ioc-manifest 0.3.2 → 1.0.1

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 (187) hide show
  1. package/README.md +399 -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 +16 -1
  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/generator/analyzeDemandSupply/emitTypeReference.d.ts +15 -0
  34. package/dist/generator/analyzeDemandSupply/emitTypeReference.d.ts.map +1 -0
  35. package/dist/generator/analyzeDemandSupply/emitTypeReference.js +137 -0
  36. package/dist/generator/analyzeDemandSupply/emitTypeReference.js.map +1 -0
  37. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.d.ts +17 -0
  38. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.d.ts.map +1 -0
  39. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.js +108 -0
  40. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.js.map +1 -0
  41. package/dist/generator/analyzeDemandSupply/index.d.ts +17 -0
  42. package/dist/generator/analyzeDemandSupply/index.d.ts.map +1 -0
  43. package/dist/generator/analyzeDemandSupply/index.js +162 -0
  44. package/dist/generator/analyzeDemandSupply/index.js.map +1 -0
  45. package/dist/generator/analyzeDemandSupply/types.d.ts +24 -0
  46. package/dist/generator/analyzeDemandSupply/types.d.ts.map +1 -0
  47. package/dist/generator/analyzeDemandSupply/types.js +2 -0
  48. package/dist/generator/analyzeDemandSupply/types.js.map +1 -0
  49. package/dist/generator/buildComposedRegistrationOverrides.d.ts +7 -0
  50. package/dist/generator/buildComposedRegistrationOverrides.d.ts.map +1 -0
  51. package/dist/generator/buildComposedRegistrationOverrides.js +59 -0
  52. package/dist/generator/buildComposedRegistrationOverrides.js.map +1 -0
  53. package/dist/generator/contractTypeSourceFile.d.ts +2 -3
  54. package/dist/generator/contractTypeSourceFile.d.ts.map +1 -1
  55. package/dist/generator/contractTypeSourceFile.js +2 -85
  56. package/dist/generator/contractTypeSourceFile.js.map +1 -1
  57. package/dist/generator/discoverFactories/discoverFactories.d.ts.map +1 -1
  58. package/dist/generator/discoverFactories/discoverFactories.js +0 -1
  59. package/dist/generator/discoverFactories/discoverFactories.js.map +1 -1
  60. package/dist/generator/discoverFactories/scanFactoryFile.d.ts.map +1 -1
  61. package/dist/generator/discoverFactories/scanFactoryFile.js +0 -1
  62. package/dist/generator/discoverFactories/scanFactoryFile.js.map +1 -1
  63. package/dist/generator/generateManifest.d.ts.map +1 -1
  64. package/dist/generator/generateManifest.js +91 -21
  65. package/dist/generator/generateManifest.js.map +1 -1
  66. package/dist/generator/iocProgramContext.d.ts +14 -1
  67. package/dist/generator/iocProgramContext.d.ts.map +1 -1
  68. package/dist/generator/iocProgramContext.js +41 -16
  69. package/dist/generator/iocProgramContext.js.map +1 -1
  70. package/dist/generator/loadComposedManifestContracts.d.ts +9 -0
  71. package/dist/generator/loadComposedManifestContracts.d.ts.map +1 -0
  72. package/dist/generator/loadComposedManifestContracts.js +74 -0
  73. package/dist/generator/loadComposedManifestContracts.js.map +1 -0
  74. package/dist/generator/loadComposedManifestGroups.d.ts +15 -0
  75. package/dist/generator/loadComposedManifestGroups.d.ts.map +1 -0
  76. package/dist/generator/loadComposedManifestGroups.js +82 -0
  77. package/dist/generator/loadComposedManifestGroups.js.map +1 -0
  78. package/dist/generator/manifestOptions.d.ts.map +1 -1
  79. package/dist/generator/manifestOptions.js +1 -6
  80. package/dist/generator/manifestOptions.js.map +1 -1
  81. package/dist/generator/manifestPaths.d.ts +10 -35
  82. package/dist/generator/manifestPaths.d.ts.map +1 -1
  83. package/dist/generator/manifestPaths.js +25 -185
  84. package/dist/generator/manifestPaths.js.map +1 -1
  85. package/dist/generator/resolveComposedPackageExport.d.ts +11 -0
  86. package/dist/generator/resolveComposedPackageExport.d.ts.map +1 -0
  87. package/dist/generator/resolveComposedPackageExport.js +54 -0
  88. package/dist/generator/resolveComposedPackageExport.js.map +1 -0
  89. package/dist/generator/resolveRegistrationPlan.d.ts +4 -1
  90. package/dist/generator/resolveRegistrationPlan.d.ts.map +1 -1
  91. package/dist/generator/resolveRegistrationPlan.js +74 -7
  92. package/dist/generator/resolveRegistrationPlan.js.map +1 -1
  93. package/dist/generator/writeComposedManifest.d.ts +15 -0
  94. package/dist/generator/writeComposedManifest.d.ts.map +1 -0
  95. package/dist/generator/writeComposedManifest.js +183 -0
  96. package/dist/generator/writeComposedManifest.js.map +1 -0
  97. package/dist/generator/writeManifest.d.ts +16 -0
  98. package/dist/generator/writeManifest.d.ts.map +1 -1
  99. package/dist/generator/writeManifest.js +146 -74
  100. package/dist/generator/writeManifest.js.map +1 -1
  101. package/dist/groups/canonicalBaseTypeId.d.ts +27 -0
  102. package/dist/groups/canonicalBaseTypeId.d.ts.map +1 -0
  103. package/dist/groups/canonicalBaseTypeId.js +198 -0
  104. package/dist/groups/canonicalBaseTypeId.js.map +1 -0
  105. package/dist/groups/resolveGroupPlan.d.ts +4 -1
  106. package/dist/groups/resolveGroupPlan.d.ts.map +1 -1
  107. package/dist/groups/resolveGroupPlan.js +27 -6
  108. package/dist/groups/resolveGroupPlan.js.map +1 -1
  109. package/dist/index.d.ts +6 -3
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +4 -2
  112. package/dist/index.js.map +1 -1
  113. package/dist/inspection/runDiscoveryAnalysis.d.ts.map +1 -1
  114. package/dist/inspection/runDiscoveryAnalysis.js +19 -11
  115. package/dist/inspection/runDiscoveryAnalysis.js.map +1 -1
  116. package/dist/runtime/bootstrap.d.ts +2 -1
  117. package/dist/runtime/bootstrap.d.ts.map +1 -1
  118. package/dist/runtime/bootstrap.js +6 -4
  119. package/dist/runtime/bootstrap.js.map +1 -1
  120. package/dist/runtime/composeManifests.d.ts +52 -0
  121. package/dist/runtime/composeManifests.d.ts.map +1 -0
  122. package/dist/runtime/composeManifests.js +427 -0
  123. package/dist/runtime/composeManifests.js.map +1 -0
  124. package/dist/runtime/composedOverrides.d.ts +22 -0
  125. package/dist/runtime/composedOverrides.d.ts.map +1 -0
  126. package/dist/runtime/composedOverrides.js +2 -0
  127. package/dist/runtime/composedOverrides.js.map +1 -0
  128. package/dist/runtime/groupBaseTypeEquivalence.d.ts +9 -0
  129. package/dist/runtime/groupBaseTypeEquivalence.d.ts.map +1 -0
  130. package/dist/runtime/groupBaseTypeEquivalence.js +18 -0
  131. package/dist/runtime/groupBaseTypeEquivalence.js.map +1 -0
  132. package/dist/runtime/index.d.ts +1 -0
  133. package/dist/runtime/index.d.ts.map +1 -1
  134. package/dist/runtime/index.js.map +1 -1
  135. package/dist/schemaVersion.d.ts +7 -0
  136. package/dist/schemaVersion.d.ts.map +1 -0
  137. package/dist/schemaVersion.js +6 -0
  138. package/dist/schemaVersion.js.map +1 -0
  139. package/dist/test-support/manifestFixtures.d.ts +20 -0
  140. package/dist/test-support/manifestFixtures.d.ts.map +1 -0
  141. package/dist/test-support/manifestFixtures.js +49 -0
  142. package/dist/test-support/manifestFixtures.js.map +1 -0
  143. package/dist/validate/checks/appConfig.d.ts +4 -0
  144. package/dist/validate/checks/appConfig.d.ts.map +1 -0
  145. package/dist/validate/checks/appConfig.js +92 -0
  146. package/dist/validate/checks/appConfig.js.map +1 -0
  147. package/dist/validate/checks/defaultAmbiguity.d.ts +3 -0
  148. package/dist/validate/checks/defaultAmbiguity.d.ts.map +1 -0
  149. package/dist/validate/checks/defaultAmbiguity.js +81 -0
  150. package/dist/validate/checks/defaultAmbiguity.js.map +1 -0
  151. package/dist/validate/checks/externals.d.ts +3 -0
  152. package/dist/validate/checks/externals.d.ts.map +1 -0
  153. package/dist/validate/checks/externals.js +27 -0
  154. package/dist/validate/checks/externals.js.map +1 -0
  155. package/dist/validate/checks/groups.d.ts +3 -0
  156. package/dist/validate/checks/groups.d.ts.map +1 -0
  157. package/dist/validate/checks/groups.js +136 -0
  158. package/dist/validate/checks/groups.js.map +1 -0
  159. package/dist/validate/checks/sameKeyConflict.d.ts +3 -0
  160. package/dist/validate/checks/sameKeyConflict.d.ts.map +1 -0
  161. package/dist/validate/checks/sameKeyConflict.js +78 -0
  162. package/dist/validate/checks/sameKeyConflict.js.map +1 -0
  163. package/dist/validate/checks/schemaVersion.d.ts +3 -0
  164. package/dist/validate/checks/schemaVersion.d.ts.map +1 -0
  165. package/dist/validate/checks/schemaVersion.js +21 -0
  166. package/dist/validate/checks/schemaVersion.js.map +1 -0
  167. package/dist/validate/formatValidationReport.d.ts +11 -0
  168. package/dist/validate/formatValidationReport.d.ts.map +1 -0
  169. package/dist/validate/formatValidationReport.js +36 -0
  170. package/dist/validate/formatValidationReport.js.map +1 -0
  171. package/dist/validate/loadParsedManifests.d.ts +12 -0
  172. package/dist/validate/loadParsedManifests.d.ts.map +1 -0
  173. package/dist/validate/loadParsedManifests.js +109 -0
  174. package/dist/validate/loadParsedManifests.js.map +1 -0
  175. package/dist/validate/parseGeneratedSource.d.ts +9 -0
  176. package/dist/validate/parseGeneratedSource.d.ts.map +1 -0
  177. package/dist/validate/parseGeneratedSource.js +258 -0
  178. package/dist/validate/parseGeneratedSource.js.map +1 -0
  179. package/dist/validate/runValidate.d.ts +30 -0
  180. package/dist/validate/runValidate.d.ts.map +1 -0
  181. package/dist/validate/runValidate.js +61 -0
  182. package/dist/validate/runValidate.js.map +1 -0
  183. package/dist/validate/types.d.ts +63 -0
  184. package/dist/validate/types.d.ts.map +1 -0
  185. package/dist/validate/types.js +5 -0
  186. package/dist/validate/types.js.map +1 -0
  187. 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,33 @@ 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`, and inline object literals (`({ foo, bar }: { foo: Foo; bar: Bar })`) aren't allowed either — codegen will reject both. The rule is: the first parameter must be a named `interface` or `type` alias.
101
+
102
+ Three reasons:
103
+
104
+ 1. **The cradle is generated from your factories' declarations.** A factory declaring its inputs by referencing the cradle would be a chicken-and-egg loop — the cradle doesn't exist yet at the moment codegen reads the factory.
105
+
106
+ 2. **The deps type is the factory's testable contract.** Exporting `type UserServiceDeps = { ... }` means tests can `import type { UserServiceDeps }`, build a literal satisfying it, and call the factory directly with no container at all (see [Testing](#testing) below). Inline literals aren't importable — tests would have to reconstruct the same shape by hand in every file, and that drifts.
107
+
108
+ 3. **The deps type is documentation.** When someone opens the file, the named declaration sits at the top and says exactly what the factory consumes. Inline literals bury the contract inside the function signature, where it competes for attention with parameter names and the return type.
109
+
110
+ The cost is one extra line per factory. That's the deal.
70
111
 
71
112
  ### 2. Configure
72
113
 
@@ -111,39 +152,46 @@ const container = createContainer<IocGeneratedCradle>({
111
152
  injectionMode: InjectionMode.PROXY,
112
153
  });
113
154
 
114
- registerIocFromManifest(container, iocManifest);
155
+ registerIocFromManifest(container, [iocManifest]);
115
156
 
116
157
  // Fully typed — no 'any', no string guessing
117
158
  const userService = container.resolve("userService");
118
159
  ```
119
160
 
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.
161
+ 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.
162
+
163
+ 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
164
 
122
165
  ---
123
166
 
124
167
  ## What gets generated
125
168
 
126
- Here's what the output looks like for a small app. You never edit these files — they're regenerated from source.
169
+ Here's what library-mode output looks like for a small app. You never edit these files — they're regenerated from source.
127
170
 
128
- **`ioc-registry.types.ts`** — the typed cradle:
171
+ **`ioc-registry.types.ts`** — the typed cradle and externals:
129
172
 
130
173
  ```ts
131
174
  /* AUTO-GENERATED. DO NOT EDIT. */
132
175
  import type { Logger } from "../services/buildConsoleLogger.js";
133
176
  import type { MediaStorage } from "../services/buildLocalMediaStorage.js";
134
177
  import type { UserService } from "../services/buildUserService.js";
178
+ import type { Database } from "../types/Database.js";
135
179
 
136
- export interface IocGeneratedTypes {
180
+ export interface IocGeneratedCradle {
137
181
  logger: Logger;
138
182
  mediaStorage: MediaStorage;
139
183
  mediaStorages: ReadonlyArray<MediaStorage>;
140
184
  userService: UserService;
141
185
  }
142
186
 
143
- export type IocGeneratedCradle = IocGeneratedTypes;
187
+ export interface IocExternals {
188
+ database: Database;
189
+ }
144
190
  ```
145
191
 
146
- Notice `mediaStorages` (plural) that appeared automatically because there are multiple `MediaStorage` implementations.
192
+ `mediaStorages` (plural) appears automatically because there are multiple `MediaStorage` implementations.
193
+
194
+ `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
195
 
148
196
  **`ioc-manifest.ts`** — the registration data:
149
197
 
@@ -159,6 +207,7 @@ import * as ioc_services_buildLocalMediaStorage from "../services/buildLocalMedi
159
207
  // ... more imports ...
160
208
 
161
209
  export const iocManifest = {
210
+ manifestSchemaVersion: 2,
162
211
  moduleImports: [
163
212
  /* ... */
164
213
  ] as const satisfies readonly IocModuleNamespace[],
@@ -201,9 +250,10 @@ The contract type must be a named type (interface or type alias) that is importe
201
250
 
202
251
  When a contract has only one implementation, it is the default. When there are multiple, the default is selected by this precedence:
203
252
 
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
253
+ 1. **App override** — `default: true` in an app-mode `ioc.config` (highest precedence; only relevant when composing)
254
+ 2. **Explicit** — `default: true` on exactly one implementation in the local `ioc.config`
255
+ 3. **Convention** — the implementation whose registration key equals the camel-cased contract name (e.g. `mediaStorage` for `MediaStorage`)
256
+ 4. **Single** — if only one implementation exists, it's the default
207
257
 
208
258
  If the choice is ambiguous, generation fails with a clear error telling you what to do.
209
259
 
@@ -222,7 +272,9 @@ This is the same fundamental idea behind having multiple implementations of a si
222
272
 
223
273
  ### Dependency inference
224
274
 
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.
275
+ 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`).
276
+
277
+ 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
278
 
227
279
  ---
228
280
 
@@ -243,19 +295,22 @@ export default defineIocConfig({
243
295
  groups: {
244
296
  /* cross-contract grouping by base type (advanced) */
245
297
  },
298
+ // app mode only:
299
+ composedManifests: [
300
+ /* package names to compose */
301
+ ],
246
302
  });
247
303
  ```
248
304
 
249
305
  ### `discovery`
250
306
 
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)). | — |
307
+ | Field | Purpose | Default |
308
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
309
+ | `scanDirs` | **Required.** Directories to scan. String, string array, or array of `{ path, scope? }` objects. Paths must resolve within the package root. | — |
310
+ | `includes` | Glob patterns for files to include. | `["**/*.{ts,tsx,js,mjs,cjs}"]` |
311
+ | `excludes` | Glob patterns for files to exclude. | `["**/*.d.ts", "**/*.test.ts", ...]` |
312
+ | `factoryPrefix` | Export name prefix for factory discovery. | `"build"` |
313
+ | `generatedDir` | Output directory for generated files. | `"generated"` |
259
314
 
260
315
  ### `registrations`
261
316
 
@@ -276,16 +331,201 @@ registrations: {
276
331
 
277
332
  Under each contract name, keys are implementation names from discovery (`buildFoo` → `foo`). The reserved `$contract` key holds contract-level options.
278
333
 
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 |
334
+ | Per-implementation field | Effect |
335
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
336
+ | `name` | Overrides the Awilix registration key |
337
+ | `lifetime` | `"singleton"` \| `"scoped"` \| `"transient"` |
338
+ | `default` | `true` to select this implementation as the contract default |
339
+ | `source` | (app mode only) Resolve same-key conflicts across composed manifests. See [Cross-package composition](#cross-package-composition). |
284
340
 
285
341
  | `$contract` field | Effect |
286
342
  | ----------------- | ----------------------------------------------------------------------------------------------- |
287
343
  | `accessKey` | Overrides the cradle property name for the default slot (e.g. `"database"` instead of `"knex"`) |
288
344
 
345
+ ### App-mode fields
346
+
347
+ These only apply in app mode (a package that composes manifests from other packages):
348
+
349
+ | Field | Purpose |
350
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
351
+ | `composedManifests` | Array of package names whose manifests this app composes. Setting this turns on app mode. |
352
+ | `packageName` | The local package's npm name. Used for self-reference detection. Falls back to `package.json` `name`; required if neither is available. |
353
+ | `groupBaseTypeAliases` | Equivalence sets for canonical base type identifiers when hoisting produces mismatches. See [Cross-package composition](#cross-package-composition). |
354
+
355
+ | Library-mode-only field | Purpose |
356
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
357
+ | `manifestExportPath` | Informational. The path your `package.json` `exports` points at for the manifest. Default `./generated/ioc-manifest`. |
358
+
359
+ `composedManifests` and `manifestExportPath` are mutually exclusive — a config is either library or app mode.
360
+
361
+ ---
362
+
363
+ ## Cross-package composition
364
+
365
+ 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.
366
+
367
+ ### The model
368
+
369
+ 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.
370
+
371
+ 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.
372
+
373
+ ### A monorepo example
374
+
375
+ ```
376
+ packages/
377
+ lib-storage/ # library mode
378
+ src/
379
+ ioc.config.ts
380
+ factories/
381
+ types/
382
+ lib-services/ # library mode
383
+ src/
384
+ ioc.config.ts
385
+ factories/
386
+ types/
387
+ app/ # app mode
388
+ src/
389
+ ioc.config.ts
390
+ bootstrap.ts
391
+ factories/
392
+ ```
393
+
394
+ `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.
395
+
396
+ ### App config
397
+
398
+ ```ts
399
+ // packages/app/src/ioc.config.ts
400
+ import { defineIocConfig } from "ioc-manifest";
401
+
402
+ export default defineIocConfig({
403
+ discovery: {
404
+ scanDirs: "src",
405
+ generatedDir: "generated",
406
+ },
407
+ composedManifests: ["@example/lib-storage", "@example/lib-services"],
408
+ registrations: {
409
+ Storage: {
410
+ s3Storage: { default: true },
411
+ },
412
+ },
413
+ });
414
+ ```
415
+
416
+ ### Required package exports
417
+
418
+ Each composed package's `package.json` must expose two subpath exports:
419
+
420
+ ```jsonc
421
+ {
422
+ "exports": {
423
+ ".": "./src/index.ts",
424
+ "./iocManifest": "./src/generated/ioc-manifest.js",
425
+ "./iocTypes": "./src/generated/ioc-registry.types.js",
426
+ },
427
+ }
428
+ ```
429
+
430
+ (Substitute `./dist/...` for published packages with a build step.)
431
+
432
+ ### Generated `ioc-composed.ts`
433
+
434
+ ```ts
435
+ /* AUTO-GENERATED. DO NOT EDIT. */
436
+ import { iocManifest as localManifest } from "./ioc-manifest.js";
437
+ import { iocManifest as libStorageManifest } from "@example/lib-storage/iocManifest";
438
+ import { iocManifest as libServicesManifest } from "@example/lib-services/iocManifest";
439
+
440
+ import type { IocGeneratedCradle as LocalCradle } from "./ioc-registry.types.js";
441
+ import type { IocGeneratedCradle as LibStorageCradle } from "@example/lib-storage/iocTypes";
442
+ import type { IocGeneratedCradle as LibServicesCradle } from "@example/lib-services/iocTypes";
443
+ import type { IocExternals as LibStorageExternals } from "@example/lib-storage/iocTypes";
444
+ import type { IocExternals as LibServicesExternals } from "@example/lib-services/iocTypes";
445
+
446
+ export const composedManifests = [
447
+ localManifest,
448
+ libStorageManifest,
449
+ libServicesManifest,
450
+ ] as const;
451
+
452
+ export type AppCradle = LocalCradle & LibStorageCradle & LibServicesCradle;
453
+
454
+ // Compile-time externals satisfaction assertions
455
+ type _IocExpect<T extends true> = T;
456
+ type _LibStorageExternalsSatisfied =
457
+ LibStorageExternals extends Pick<AppCradle, keyof LibStorageExternals>
458
+ ? true
459
+ : false;
460
+ type _LibStorageExternalsAssert = _IocExpect<_LibStorageExternalsSatisfied>;
461
+ type _LibServicesExternalsSatisfied =
462
+ LibServicesExternals extends Pick<AppCradle, keyof LibServicesExternals>
463
+ ? true
464
+ : false;
465
+ type _LibServicesExternalsAssert = _IocExpect<_LibServicesExternalsSatisfied>;
466
+
467
+ export const composedRegistrationOverrides = {
468
+ /* ... */
469
+ };
470
+ ```
471
+
472
+ 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.
473
+
474
+ ### App bootstrap
475
+
476
+ ```ts
477
+ import { createContainer } from "awilix";
478
+ import { registerIocFromManifest } from "ioc-manifest";
479
+ import {
480
+ composedManifests,
481
+ composedRegistrationOverrides,
482
+ type AppCradle,
483
+ } from "./generated/ioc-composed.js";
484
+
485
+ const container = createContainer<AppCradle>();
486
+ registerIocFromManifest(
487
+ container,
488
+ composedManifests,
489
+ composedRegistrationOverrides,
490
+ );
491
+
492
+ const uploadService = container.resolve("uploadService");
493
+ ```
494
+
495
+ ### Resolving same-key conflicts
496
+
497
+ 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:
498
+
499
+ ```ts
500
+ registrations: {
501
+ AlbumRepository: {
502
+ albumRepository: { source: "local" }, // or "@example/lib-services"
503
+ },
504
+ }
505
+ ```
506
+
507
+ `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.
508
+
509
+ ### Groups across manifests
510
+
511
+ 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.
512
+
513
+ 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.
514
+
515
+ 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:
516
+
517
+ ```ts
518
+ // in the app's ioc.config.ts
519
+ groupBaseTypeAliases: {
520
+ discountStrategies: [
521
+ "/path/to/a.ts:DiscountStrategy",
522
+ "/path/to/b.ts:DiscountStrategy",
523
+ ],
524
+ }
525
+ ```
526
+
527
+ The library treats the listed identifiers as equivalent. This is an escape hatch, not a normal-path mechanism.
528
+
289
529
  ---
290
530
 
291
531
  ## Dev and production builds
@@ -304,21 +544,106 @@ This was a deliberate design choice (and a painful one to get right). There's no
304
544
 
305
545
  ```bash
306
546
  npx ioc # prints help
307
- npx ioc generate # discover factories, emit manifest + types
547
+ npx ioc generate # discover factories, emit manifest + types (and ioc-composed.ts in app mode)
308
548
  npx ioc generate -c ./ioc.config.test.ts # generate with a specific config
309
549
  npx ioc inspect # loads the generated manifest and prints a summary
310
550
  npx ioc inspect --discovery # re-runs discovery without reading the manifest
311
- npx ioc inspect --config ./src/ioc.config.ts --project ./packages/api
551
+ npx ioc validate # app mode: cross-manifest checks against composedManifests
552
+ npx ioc validate --json # machine-readable issue list
312
553
  ```
313
554
 
314
555
  | Flag | Purpose |
315
556
  | -------------------------- | --------------------------------------------------------------------------------------- |
316
557
  | `--discovery` | (inspect only) Re-run factory discovery and planning; don't read the generated manifest |
558
+ | `--json` | (validate only) Emit issues as JSON |
317
559
  | `--config PATH`, `-c PATH` | Explicit path to `ioc.config.ts` |
318
560
  | `--project PATH` | Project directory for config resolution (default: cwd) |
319
561
 
320
562
  Set `IOC_DEBUG=1` for full stack traces on errors.
321
563
 
564
+ ### `ioc validate`
565
+
566
+ 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.
567
+
568
+ `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.
569
+
570
+ Typical output for a failing run:
571
+
572
+ ```
573
+ [app-config] registrations references unknown contract "Storge"
574
+ Known local contracts: Logger.
575
+ Known composed contracts: Logger, LoggingService, Storage, UploadService.
576
+ Did you mean: "Storage"?
577
+ Suggested fix: Fix the contract name in ioc.config.ts registrations, or add a factory for "Storge".
578
+
579
+ Validation failed: 1 error, 0 warnings.
580
+ ```
581
+
582
+ Library-mode invocations print an informational message and exit 0 — there's nothing cross-manifest to validate.
583
+
584
+ Recommended workflow: `ioc generate` → `ioc validate` → `tsc --noEmit` → deploy.
585
+
586
+ ---
587
+
588
+ ## Testing
589
+
590
+ The named-deps-type pattern at the factory site enables three levels of testing, each with the ergonomics that fit:
591
+
592
+ ### Factory-level (no container)
593
+
594
+ Most unit tests don't need a container. Import the factory, import its deps type, hand-build a stub, call the factory:
595
+
596
+ ```ts
597
+ import { buildValidateOperationService } from "../src/...";
598
+ import type { ValidateOperationServiceDeps } from "../src/...";
599
+
600
+ const deps: ValidateOperationServiceDeps = {
601
+ mediaItemReadRepository: {
602
+ /* stub */
603
+ },
604
+ grantReadRepository: {
605
+ /* stub */
606
+ },
607
+ albumMemberReadRepository: {
608
+ /* stub */
609
+ },
610
+ };
611
+ const svc = buildValidateOperationService(deps);
612
+ ```
613
+
614
+ No container, no manifest, no awilix. TypeScript enforces what must be provided.
615
+
616
+ ### Container-level with mocked externals
617
+
618
+ 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:
619
+
620
+ ```ts
621
+ import { createContainer, asValue } from "awilix";
622
+ import { registerIocFromManifest } from "ioc-manifest";
623
+ import { iocManifest } from "../src/generated/ioc-manifest.js";
624
+ import type {
625
+ IocGeneratedCradle,
626
+ IocExternals,
627
+ } from "../src/generated/ioc-registry.types.js";
628
+
629
+ const container = createContainer<IocGeneratedCradle>();
630
+ registerIocFromManifest(container, [iocManifest]);
631
+
632
+ const externals: IocExternals = {
633
+ database: mockKnex,
634
+ logger: silentLogger,
635
+ };
636
+ for (const [k, v] of Object.entries(externals)) {
637
+ container.register({ [k]: asValue(v) });
638
+ }
639
+ ```
640
+
641
+ 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.
642
+
643
+ ### Test-specific manifest
644
+
645
+ 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`.
646
+
322
647
  ---
323
648
 
324
649
  ## Error handling
@@ -327,7 +652,9 @@ Errors are designed to tell you exactly what went wrong and what to do about it.
327
652
 
328
653
  **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
654
 
330
- **Discovery errors** are prefixed `[ioc]` — duplicate registration keys, unresolvable contract types, overlapping scan directories with conflicting scopes.
655
+ **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).
656
+
657
+ **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
658
 
332
659
  **Runtime resolution errors** use `IocResolutionError` with structured dependency chains:
333
660
 
@@ -402,9 +729,13 @@ groups: {
402
729
  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
730
 
404
731
  ```ts
732
+ type PricingEngineDeps = {
733
+ discountStrategies: ReadonlyArray<DiscountStrategy>;
734
+ };
735
+
405
736
  export const buildPricingEngine = ({
406
737
  discountStrategies,
407
- }: IocGeneratedCradle): PricingEngine => ({
738
+ }: PricingEngineDeps): PricingEngine => ({
408
739
  applyDiscounts: (order) => {
409
740
  for (const strategy of discountStrategies) {
410
741
  if (strategy.applies(order)) {
@@ -418,6 +749,8 @@ export const buildPricingEngine = ({
418
749
 
419
750
  Add a sixth strategy? Just create the factory. It shows up in the group automatically — no registration changes.
420
751
 
752
+ 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.
753
+
421
754
  #### Object groups: bundling related services
422
755
 
423
756
  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 +778,29 @@ Now `container.resolve("readServices")` returns an object keyed by each contract
445
778
 
446
779
  #### Group validation
447
780
 
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.
449
-
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.
781
+ 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).
453
782
 
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
- ```
783
+ ### Environment-specific configs
472
784
 
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`.
785
+ 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
786
 
475
- For contract type imports (the `import type` lines in `ioc-registry.types.ts`), use `workspacePackageImportBases` to achieve the same mapping:
787
+ For a single-package app, point the generator at a different config:
476
788
 
477
- ```ts
478
- discovery: {
479
- scanDirs: "packages/api/src",
480
- workspacePackageImportBases: [
481
- { root: "packages/shared", importBase: "@acme/shared" },
482
- ],
483
- },
789
+ ```bash
790
+ npx ioc generate --config ./ioc.config.test.ts
484
791
  ```
485
792
 
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:
793
+ For a monorepo app, you can swap `composedManifests` entries to compose with mock packages in tests:
491
794
 
492
795
  ```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
- },
796
+ // ioc.config.test.ts
797
+ composedManifests: [
798
+ "@example/lib-storage-mock", // a sibling test-only package
799
+ "@example/lib-services",
800
+ ],
502
801
  ```
503
802
 
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.
803
+ Either way, factory source code doesn't change.
517
804
 
518
805
  ---
519
806
 
@@ -523,13 +810,23 @@ Point the generator at a different config with `npx ioc generate --config ./ioc.
523
810
 
524
811
  **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
812
 
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.
813
+ **Factory destructures `IocGeneratedCradle`**not allowed. Use a named local deps type instead. The error message names the factory and shows the correct pattern.
814
+
815
+ **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.
816
+
817
+ **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
818
 
528
819
  **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
820
 
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.
821
+ **`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.
822
+
823
+ **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.
824
+
825
+ **`_<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.
826
+
827
+ **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
828
 
532
- **`inspect` shows `lifetimeSource: factory-config`** — this means the lifetime came from `ioc.config`, not from the factory source file (the label is historical).
829
+ **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
830
 
534
831
  ---
535
832
 
@@ -537,10 +834,12 @@ Point the generator at a different config with `npx ioc generate --config ./ioc.
537
834
 
538
835
  This package is **not** an IoC container. It is a codegen layer over Awilix that trades manual registration for convention.
539
836
 
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.
837
+ - **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
838
  - **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
839
  - **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.
840
+ - **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.
841
+ - **App-mode composition is set-like.** `registerIocFromManifest(container, [a, b, c])` is order-independent. Conflicts are hard errors with explicit resolution, never silent override.
842
+ - **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
843
  - **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
844
 
546
845
  ---