alloy-di 1.2.2 → 1.3.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.
@@ -89,8 +89,12 @@ declare class Container {
89
89
  */
90
90
  private resolveParam;
91
91
  /**
92
- * Execute a lazy importer with optional retry/backoff semantics.
93
- * Implements exponential backoff for transient network failures.
92
+ * Execute a lazy importer with optional retry/backoff semantics, then
93
+ * validate that the imported value is a constructor.
94
+ *
95
+ * Only the dynamic import itself is retried; a successful import that does
96
+ * not yield a constructor is deterministic and fails immediately without
97
+ * backoff or re-wrapping.
94
98
  *
95
99
  * @param lazyDep - Lazy dependency wrapper with importer function and retry config
96
100
  * @param target - Service being resolved (for error messages)
@@ -99,6 +103,10 @@ declare class Container {
99
103
  * @throws Error if all retry attempts exhausted or import returns non-constructor
100
104
  */
101
105
  private importWithRetry;
106
+ /**
107
+ * Run a lazy importer, retrying failed imports with exponential backoff.
108
+ */
109
+ private runImporterWithRetry;
102
110
  /**
103
111
  * Resolve a token dependency via registered value providers.
104
112
  */
@@ -184,8 +184,12 @@ var Container = class {
184
184
  return classification;
185
185
  }
186
186
  /**
187
- * Execute a lazy importer with optional retry/backoff semantics.
188
- * Implements exponential backoff for transient network failures.
187
+ * Execute a lazy importer with optional retry/backoff semantics, then
188
+ * validate that the imported value is a constructor.
189
+ *
190
+ * Only the dynamic import itself is retried; a successful import that does
191
+ * not yield a constructor is deterministic and fails immediately without
192
+ * backoff or re-wrapping.
189
193
  *
190
194
  * @param lazyDep - Lazy dependency wrapper with importer function and retry config
191
195
  * @param target - Service being resolved (for error messages)
@@ -194,23 +198,28 @@ var Container = class {
194
198
  * @throws Error if all retry attempts exhausted or import returns non-constructor
195
199
  */
196
200
  async importWithRetry(lazyDep, target, resolutionStack) {
197
- const runImport = async () => await lazyDep.importer();
201
+ const module = await this.runImporterWithRetry(lazyDep, target, resolutionStack);
202
+ const depClass = typeof module === "object" && module !== null && "default" in module ? module.default : module;
203
+ if (!isConstructor(depClass)) {
204
+ const stackPath = this.formatStackPath(target, resolutionStack);
205
+ throw new DependencyResolutionError(`Lazy importer did not return a class for dependency while resolving ${target.name}. Resolution stack: ${stackPath}. Received type: ${typeof depClass}`, {
206
+ target,
207
+ resolutionStack,
208
+ failedDependency: depClass
209
+ });
210
+ }
211
+ return depClass;
212
+ }
213
+ /**
214
+ * Run a lazy importer, retrying failed imports with exponential backoff.
215
+ */
216
+ async runImporterWithRetry(lazyDep, target, resolutionStack) {
198
217
  const retries = lazyDep.retry?.retries ?? 0;
199
218
  const baseDelay = lazyDep.retry?.backoffMs ?? 0;
200
219
  const factor = lazyDep.retry?.factor ?? 2;
201
220
  let attempt = 0;
202
221
  while (true) try {
203
- const module = await runImport();
204
- const depClass = typeof module === "object" && module !== null && "default" in module ? module.default : module;
205
- if (!isConstructor(depClass)) {
206
- const stackPath = this.formatStackPath(target, resolutionStack);
207
- throw new DependencyResolutionError(`Lazy importer did not return a class for dependency while resolving ${target.name}. Resolution stack: ${stackPath}. Received type: ${typeof depClass}`, {
208
- target,
209
- resolutionStack,
210
- failedDependency: depClass
211
- });
212
- }
213
- return depClass;
222
+ return await lazyDep.importer();
214
223
  } catch (err) {
215
224
  if (attempt >= retries) {
216
225
  const stackPath = this.formatStackPath(target, resolutionStack);
@@ -251,10 +260,14 @@ var Container = class {
251
260
  const cached = this.metadataCache.get(target);
252
261
  if (cached) return cached;
253
262
  const registryEntry = dependenciesRegistry.get(target);
263
+ if (!registryEntry) return {
264
+ scope: ServiceScope.TRANSIENT,
265
+ dependencies: []
266
+ };
254
267
  const metadata = {
255
- scope: registryEntry?.scope ?? ServiceScope.TRANSIENT,
256
- dependencies: (registryEntry?.dependencies ?? (() => []))(),
257
- factory: registryEntry?.factory
268
+ scope: registryEntry.scope ?? ServiceScope.TRANSIENT,
269
+ dependencies: (registryEntry.dependencies ?? (() => []))(),
270
+ factory: registryEntry.factory
258
271
  };
259
272
  this.metadataCache.set(target, metadata);
260
273
  return metadata;
@@ -65,11 +65,44 @@ function createDecoratorWithoutDeps(scope) {
65
65
  function isDependenciesArg(value) {
66
66
  return typeof value === "function" || Array.isArray(value);
67
67
  }
68
+ /**
69
+ * Detect a class constructor passed where dependency metadata was expected.
70
+ *
71
+ * Class constructors have a non-writable `prototype`; dependency thunks
72
+ * (arrow functions or plain functions) either lack a `prototype` descriptor
73
+ * or have a writable one.
74
+ *
75
+ * @param value - The argument supplied to `@Injectable`/`@Singleton`.
76
+ * @returns True if the value is a class constructor.
77
+ * @internal
78
+ */
79
+ function isClassConstructor(value) {
80
+ if (typeof value !== "function") return false;
81
+ const prototypeDescriptor = Object.getOwnPropertyDescriptor(value, "prototype");
82
+ return prototypeDescriptor !== void 0 && !prototypeDescriptor.writable;
83
+ }
84
+ /**
85
+ * Reject the bare-decorator misuse `@Injectable` / `@Singleton` (no parens).
86
+ *
87
+ * Applied bare, the decorator factory itself receives the class, mistakes it
88
+ * for a dependencies thunk, and returns a decorator function — which legacy
89
+ * decorator semantics then substitute for the class. Throwing here turns that
90
+ * silent class replacement into an immediate, actionable error.
91
+ *
92
+ * @param value - The first argument received by the decorator factory.
93
+ * @param decoratorName - Public decorator name for the error message.
94
+ * @internal
95
+ */
96
+ function assertNotBareDecorator(value, decoratorName) {
97
+ if (isClassConstructor(value)) throw new TypeError(`@${decoratorName} must be called — use @${decoratorName}() instead of @${decoratorName}`);
98
+ }
68
99
  function Injectable(depsOrScope, scopeOverride) {
100
+ assertNotBareDecorator(depsOrScope, "Injectable");
69
101
  if (isDependenciesArg(depsOrScope)) return createDecoratorWithDeps(scopeOverride ?? ServiceScope.TRANSIENT, depsOrScope);
70
102
  return createDecoratorWithoutDeps((typeof depsOrScope === "string" ? depsOrScope : void 0) ?? scopeOverride ?? ServiceScope.TRANSIENT);
71
103
  }
72
104
  function Singleton(dependencies) {
105
+ assertNotBareDecorator(dependencies, "Singleton");
73
106
  if (isDependenciesArg(dependencies)) return createDecoratorWithDeps(ServiceScope.SINGLETON, dependencies);
74
107
  return createDecoratorWithoutDeps(ServiceScope.SINGLETON);
75
108
  }
@@ -0,0 +1,24 @@
1
+ //#region src/lib/env-detection.d.ts
2
+ type ImportMetaEnvShape = {
3
+ MODE?: string;
4
+ PROD?: boolean;
5
+ NODE_ENV?: string;
6
+ [key: string]: unknown;
7
+ };
8
+ type EnvDetectionOverrides = {
9
+ /**
10
+ * Explicit import.meta.env replacement. Use `null` to force "no env" behavior.
11
+ */
12
+ importMetaEnv?: ImportMetaEnvShape | null;
13
+ /**
14
+ * Explicit NODE_ENV replacement. Use `null` to ignore process.env.
15
+ */
16
+ nodeEnv?: string | null;
17
+ /**
18
+ * Short-circuit the entire detection logic with a predetermined boolean.
19
+ */
20
+ isDev?: boolean;
21
+ };
22
+ declare function setEnvDetectionOverrides(overrides: EnvDetectionOverrides | undefined): void;
23
+ //#endregion
24
+ export { EnvDetectionOverrides, setEnvDetectionOverrides };
@@ -2,11 +2,20 @@
2
2
  function isRecord(value) {
3
3
  return typeof value === "object" && value !== null;
4
4
  }
5
+ /**
6
+ * Build-time-injected detection overrides.
7
+ *
8
+ * The Vite plugin emits a `setEnvDetectionOverrides({ isDev: ... })` call
9
+ * into the generated container module so detection in plugin-driven setups
10
+ * uses the bundler's authoritative mode instead of runtime sniffing.
11
+ */
12
+ let injectedOverrides;
13
+ function setEnvDetectionOverrides(overrides) {
14
+ injectedOverrides = overrides;
15
+ }
5
16
  function readImportMetaEnvFromRuntime() {
6
17
  if (typeof import.meta === "undefined") return;
7
- const candidate = import.meta;
8
- if (!isRecord(candidate) || !("env" in candidate)) return;
9
- const envValue = candidate.env;
18
+ const envValue = import.meta.env;
10
19
  if (!isRecord(envValue)) return;
11
20
  const env = {};
12
21
  if (typeof envValue.MODE === "string") env.MODE = envValue.MODE;
@@ -15,8 +24,11 @@ function readImportMetaEnvFromRuntime() {
15
24
  return env;
16
25
  }
17
26
  function readProcessNodeEnv() {
18
- if (typeof process === "undefined") return;
19
- return "development";
27
+ try {
28
+ return "development";
29
+ } catch {
30
+ return;
31
+ }
20
32
  }
21
33
  function getImportMetaEnv(overrides) {
22
34
  if (overrides?.importMetaEnv === null) return;
@@ -29,14 +41,15 @@ function getNodeEnv(overrides) {
29
41
  return readProcessNodeEnv();
30
42
  }
31
43
  function isDevEnvironment(overrides) {
32
- if (typeof overrides?.isDev === "boolean") return overrides.isDev;
33
- const nodeEnv = getNodeEnv(overrides);
44
+ const effective = overrides ?? injectedOverrides;
45
+ if (typeof effective?.isDev === "boolean") return effective.isDev;
46
+ const nodeEnv = getNodeEnv(effective);
34
47
  if (typeof nodeEnv === "string") return nodeEnv !== "production";
35
- const importMetaEnv = getImportMetaEnv(overrides);
48
+ const importMetaEnv = getImportMetaEnv(effective);
36
49
  if (typeof importMetaEnv?.PROD === "boolean") return !importMetaEnv.PROD;
37
50
  if (typeof importMetaEnv?.MODE === "string") return importMetaEnv.MODE !== "production";
38
51
  if (typeof importMetaEnv?.NODE_ENV === "string") return importMetaEnv.NODE_ENV !== "production";
39
52
  return true;
40
53
  }
41
54
  //#endregion
42
- export { isDevEnvironment };
55
+ export { isDevEnvironment, setEnvDetectionOverrides };
@@ -13,8 +13,11 @@ type ServiceIdentifier<T = unknown> = symbol & {
13
13
  /**
14
14
  * Associates a constructor with a stable identifier. When an explicit identifier
15
15
  * is provided (e.g., by generated metadata), it becomes canonical for the
16
- * constructor. Attempting to reuse an identifier with a different constructor
17
- * throws to surface manifest/config mismatches early.
16
+ * constructor when the constructor is first registered. If the constructor was
17
+ * already assigned an auto-generated identifier, later explicit identifiers are
18
+ * recorded as aliases so both symbols resolve to the same constructor.
19
+ * Attempting to reuse an identifier with a different constructor throws to
20
+ * surface manifest/config mismatches early.
18
21
  */
19
22
  declare function registerServiceIdentifier<T>(ctor: Constructor, explicitIdentifier?: ServiceIdentifier<T>): ServiceIdentifier<T>;
20
23
  /**
@@ -11,8 +11,11 @@ function createServiceIdentifier(ctor) {
11
11
  /**
12
12
  * Associates a constructor with a stable identifier. When an explicit identifier
13
13
  * is provided (e.g., by generated metadata), it becomes canonical for the
14
- * constructor. Attempting to reuse an identifier with a different constructor
15
- * throws to surface manifest/config mismatches early.
14
+ * constructor when the constructor is first registered. If the constructor was
15
+ * already assigned an auto-generated identifier, later explicit identifiers are
16
+ * recorded as aliases so both symbols resolve to the same constructor.
17
+ * Attempting to reuse an identifier with a different constructor throws to
18
+ * surface manifest/config mismatches early.
16
19
  */
17
20
  function registerServiceIdentifier(ctor, explicitIdentifier) {
18
21
  const current = ctorToIdentifier.get(ctor);
@@ -20,6 +23,7 @@ function registerServiceIdentifier(ctor, explicitIdentifier) {
20
23
  if (explicitIdentifier && explicitIdentifier !== current) {
21
24
  const owner = identifierToCtor.get(explicitIdentifier);
22
25
  if (owner && owner !== ctor) throw new Error("Attempted to reassign an existing ServiceIdentifier to a different constructor.");
26
+ identifierToCtor.set(explicitIdentifier, ctor);
23
27
  }
24
28
  return current;
25
29
  }
@@ -1,6 +1,7 @@
1
1
  import { createClassKey, createSymbolKey, hashString, normalizeImportPath } from "./utils.js";
2
2
  import { IdentifierResolver } from "./identifier-resolver.js";
3
3
  import path from "node:path";
4
+ import ts from "typescript";
4
5
  //#region src/plugins/core/codegen.ts
5
6
  function escapeSingleQuotes(value) {
6
7
  return value.replaceAll("'", "\\'");
@@ -18,49 +19,152 @@ function createSymbolDescription(meta) {
18
19
  return createSymbolKey(meta.filePath, meta.className);
19
20
  }
20
21
  /**
22
+ * Allocates module-local names from a single shared pool so the generated
23
+ * module's independent naming domains (runtime helpers, service imports,
24
+ * factory-lazy stubs, dependency imports, identifier consts, generated
25
+ * locals) can never collide.
26
+ */
27
+ function createNamePool(reservedNames) {
28
+ const used = new Set(reservedNames);
29
+ return { claim(base) {
30
+ let candidate = base;
31
+ let suffix = 1;
32
+ while (used.has(candidate)) {
33
+ candidate = `${base}_${suffix}`;
34
+ suffix++;
35
+ }
36
+ used.add(candidate);
37
+ return candidate;
38
+ } };
39
+ }
40
+ function stripModuleExtension(importPath) {
41
+ return importPath.replace(/\.[cm]?[jt]sx?$/i, "");
42
+ }
43
+ /**
44
+ * Identity of an imported binding: the module (extension-insensitive, since
45
+ * references may resolve extensionless while service paths keep theirs) plus
46
+ * the export name.
47
+ */
48
+ function createBindingKey(importPath, exportName) {
49
+ return `${stripModuleExtension(importPath)}::${exportName}`;
50
+ }
51
+ /** Resolves a referenced import's specifier to a normalized module path. */
52
+ function resolveReferencePath(ref, meta) {
53
+ return normalizeImportPath(ref.path.startsWith(".") ? path.resolve(path.dirname(meta.filePath), ref.path) : ref.path);
54
+ }
55
+ function resolveDependencyImport(ref, normalizedPath, pool, serviceBindings, runtimeImports) {
56
+ if (normalizedPath === "alloy-di/runtime" && ref.originalName && ref.name === ref.originalName && runtimeImports.has(ref.originalName)) return {
57
+ localName: ref.originalName,
58
+ importPath: normalizedPath,
59
+ originalName: ref.originalName,
60
+ reusesExistingBinding: true
61
+ };
62
+ const serviceLocalName = serviceBindings.get(createBindingKey(normalizedPath, ref.originalName ?? "default"));
63
+ if (serviceLocalName) return {
64
+ localName: serviceLocalName,
65
+ importPath: normalizedPath,
66
+ originalName: ref.originalName,
67
+ reusesExistingBinding: true
68
+ };
69
+ return {
70
+ localName: pool.claim(ref.name),
71
+ importPath: normalizedPath,
72
+ originalName: ref.originalName
73
+ };
74
+ }
75
+ /**
21
76
  * Analyzes dependencies across all discovered services and resolves imports.
22
- * Deduplicates imports and handles naming collisions by generating unique local names.
77
+ * Deduplicates imports by binding identity, reuses bindings the module
78
+ * already declares (runtime helpers, service imports, factory-lazy stubs),
79
+ * and claims fresh local names from the shared pool otherwise.
23
80
  */
24
- function resolveDependencyImports(metas) {
81
+ function resolveDependencyImports(metas, pool, serviceBindings, runtimeImports) {
25
82
  const importMap = /* @__PURE__ */ new Map();
26
- const nameCounts = /* @__PURE__ */ new Map();
27
- const getUniqueName = (name) => {
28
- const count = nameCounts.get(name) ?? 0;
29
- nameCounts.set(name, count + 1);
30
- return count === 0 ? name : `${name}_${count}`;
31
- };
32
- for (const meta of metas) {
33
- if (!meta.referencedImports?.length) continue;
34
- for (const ref of meta.referencedImports) {
35
- if (ref.isTypeOnly) continue;
36
- const normalizedPath = normalizeImportPath(ref.path.startsWith(".") ? path.resolve(path.dirname(meta.filePath), ref.path) : ref.path);
37
- const key = `${normalizedPath}::${ref.originalName ?? "default"}`;
38
- if (importMap.has(key)) continue;
39
- importMap.set(key, {
40
- localName: getUniqueName(ref.name),
41
- importPath: normalizedPath,
42
- originalName: ref.originalName
43
- });
44
- }
83
+ for (const meta of metas) for (const ref of meta.referencedImports ?? []) {
84
+ if (ref.isTypeOnly) continue;
85
+ const normalizedPath = resolveReferencePath(ref, meta);
86
+ const key = createBindingKey(normalizedPath, ref.originalName ?? "default");
87
+ if (importMap.has(key)) continue;
88
+ importMap.set(key, resolveDependencyImport(ref, normalizedPath, pool, serviceBindings, runtimeImports));
45
89
  }
46
90
  return {
47
91
  dependencyImports: Array.from(importMap.values()),
48
92
  importMap
49
93
  };
50
94
  }
95
+ /**
96
+ * True when an identifier occupies a name position rather than referencing a
97
+ * binding: property-access names (`ns.Api`), object keys — including method
98
+ * and accessor keys (`{ Api() {} }`, `{ get Api() {} }`) — class member
99
+ * names, qualified names, and destructuring property names.
100
+ */
101
+ function isNonReferencePosition(node) {
102
+ const parent = node.parent;
103
+ if (ts.isPropertyAccessExpression(parent)) return parent.name === node;
104
+ if (ts.isQualifiedName(parent)) return parent.right === node;
105
+ if (ts.isBindingElement(parent)) return parent.propertyName === node;
106
+ if (ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent) || ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodSignature(parent) || ts.isPropertySignature(parent)) return parent.name === node;
107
+ return false;
108
+ }
109
+ /**
110
+ * Decide how to rewrite one identifier node, or skip it.
111
+ *
112
+ * Only binding references are rewritten: name positions and string/comment
113
+ * content keep their text. Shorthand properties expand
114
+ * (`{ Api }` -> `{ Api: Api_1 }`) so the key survives the rename.
115
+ */
116
+ function createIdentifierEdit(node, source, replacement) {
117
+ const parent = node.parent;
118
+ if (isNonReferencePosition(node)) return;
119
+ const start = node.getStart(source);
120
+ if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) return {
121
+ start,
122
+ end: node.end,
123
+ text: `${node.text}: ${replacement}`
124
+ };
125
+ return {
126
+ start,
127
+ end: node.end,
128
+ text: replacement
129
+ };
130
+ }
131
+ /**
132
+ * Rewrite identifier references inside a reconstructed dependency expression.
133
+ *
134
+ * The expression is parsed and identifier nodes are replaced by position, so
135
+ * `$`-prefixed names rewrite correctly and occurrences inside string literals
136
+ * (e.g. lazy `import('/src/Api')` specifiers) and comments are untouched —
137
+ * the previous `\b`-regex text replacement got both wrong.
138
+ */
139
+ function rewriteReferencedIdentifiers(expression, referenced, rewriter) {
140
+ const wrapped = `(${expression});`;
141
+ const source = ts.createSourceFile("alloy-dependency-expression.ts", wrapped, ts.ScriptTarget.ESNext, true);
142
+ const edits = [];
143
+ const visit = (node) => {
144
+ if (ts.isIdentifier(node) && referenced.has(node.text)) {
145
+ const replacement = rewriter(node.text);
146
+ if (replacement && replacement !== node.text) {
147
+ const edit = createIdentifierEdit(node, source, replacement);
148
+ if (edit) edits.push(edit);
149
+ }
150
+ }
151
+ ts.forEachChild(node, visit);
152
+ };
153
+ visit(source);
154
+ let result = wrapped;
155
+ for (const edit of edits.toSorted((a, b) => b.start - a.start)) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
156
+ return result.slice(1, -2);
157
+ }
51
158
  function reconstructDependencyExpression(dep, rewriter, contextDir) {
52
159
  let expr = dep.expression;
53
- for (const ident of dep.referencedIdentifiers) {
54
- const replacement = rewriter(ident);
55
- if (replacement && replacement !== ident) expr = expr.replaceAll(new RegExp(`\\b${ident}\\b`, "g"), replacement);
56
- }
160
+ if (dep.referencedIdentifiers.length > 0) expr = rewriteReferencedIdentifiers(expr, new Set(dep.referencedIdentifiers), rewriter);
57
161
  if (dep.isLazy) expr = expr.replaceAll(/import\s*\(\s*(['"])(.+?)\1\s*\)/g, (match, quote, importPath) => {
58
162
  if (importPath.startsWith(".")) return `import(${quote}${normalizeImportPath(path.resolve(contextDir, importPath))}${quote})`;
59
163
  return match;
60
164
  });
61
165
  return expr;
62
166
  }
63
- function reconstructOptionsText(meta, importMap) {
167
+ function reconstructOptionsText(meta, importMap, serviceRenames) {
64
168
  const { scope, dependencies, factory } = meta.metadata;
65
169
  const parts = [];
66
170
  if (factory) {
@@ -73,12 +177,11 @@ function reconstructOptionsText(meta, importMap) {
73
177
  return reconstructDependencyExpression(dep, (ident) => {
74
178
  const ref = meta.referencedImports?.find((r) => r.name === ident && !r.isTypeOnly);
75
179
  if (ref) {
76
- const dir = path.dirname(meta.filePath);
77
- const key = `${normalizeImportPath(ref.path.startsWith(".") ? path.resolve(dir, ref.path) : ref.path)}::${ref.originalName ?? "default"}`;
180
+ const key = createBindingKey(resolveReferencePath(ref, meta), ref.originalName ?? "default");
78
181
  const resolved = importMap.get(key);
79
182
  return resolved ? resolved.localName : ident;
80
183
  }
81
- return ident;
184
+ return serviceRenames.get(ident) ?? ident;
82
185
  }, path.dirname(meta.filePath));
83
186
  });
84
187
  parts.push(`dependencies: () => [${depExprs.join(", ")}]`);
@@ -86,13 +189,38 @@ function reconstructOptionsText(meta, importMap) {
86
189
  if (parts.length === 0) return "{}";
87
190
  return `{ ${parts.join(", ")} }`;
88
191
  }
89
- function buildImportsAndRegistrations(metas, lazyReferencedClassKeys, hasProviderModules) {
192
+ function buildImportsAndRegistrations(metas, lazyReferencedClassKeys, providerModulePaths, options) {
193
+ const hasProviderModules = providerModulePaths.length > 0;
90
194
  const activeMetas = filterActiveMetas(metas, lazyReferencedClassKeys);
91
- const { dependencyImports, importMap } = resolveDependencyImports(activeMetas);
92
- const resolvedRegistrations = enrichRegistrations(activeMetas, new IdentifierResolver(activeMetas), importMap);
93
- const runtimeImports = computeRuntimeImports(resolvedRegistrations, hasProviderModules);
195
+ const resolver = new IdentifierResolver(activeMetas);
196
+ const runtimeImports = computeRuntimeImports(activeMetas, hasProviderModules, options?.isDev !== void 0);
197
+ const pool = createNamePool([
198
+ ...runtimeImports,
199
+ "registrations",
200
+ "container",
201
+ "providerDefinitions",
202
+ ...providerModulePaths.map((_, idx) => `providers_${idx}`)
203
+ ]);
204
+ const serviceNames = /* @__PURE__ */ new Map();
205
+ const serviceRenames = /* @__PURE__ */ new Map();
206
+ const serviceBindings = /* @__PURE__ */ new Map();
207
+ for (const meta of activeMetas) {
208
+ const baseName = resolver.resolve(meta.className, meta.filePath);
209
+ const name = pool.claim(baseName);
210
+ serviceNames.set(meta, name);
211
+ if (name !== baseName) serviceRenames.set(baseName, name);
212
+ serviceBindings.set(createBindingKey(getServiceImportPath(meta), meta.className), name);
213
+ }
214
+ const { dependencyImports, importMap } = resolveDependencyImports(activeMetas, pool, serviceBindings, runtimeImports);
215
+ const resolvedRegistrations = enrichRegistrations(activeMetas, {
216
+ resolver,
217
+ serviceNames,
218
+ serviceRenames,
219
+ importMap,
220
+ pool
221
+ });
94
222
  const runtimeImportStatement = formatRuntimeImportStatement(runtimeImports);
95
- const stubsBlock = createStubBlock(dependencyImports, resolvedRegistrations, runtimeImports);
223
+ const stubsBlock = createStubBlock(dependencyImports, resolvedRegistrations);
96
224
  return {
97
225
  runtimeImportStatement,
98
226
  registrationsBlock: createRegistrationsBlock(buildRegistrationEntries(resolvedRegistrations)),
@@ -103,13 +231,14 @@ function buildImportsAndRegistrations(metas, lazyReferencedClassKeys, hasProvide
103
231
  function filterActiveMetas(metas, lazyReferencedClassKeys) {
104
232
  return metas.filter((meta) => !lazyReferencedClassKeys.has(createClassKey(meta.filePath, meta.className)));
105
233
  }
106
- function enrichRegistrations(activeMetas, resolver, importMap) {
234
+ function enrichRegistrations(activeMetas, naming) {
235
+ const { resolver, serviceNames, serviceRenames, importMap, pool } = naming;
107
236
  return activeMetas.map((meta) => {
108
- const importName = resolver.resolve(meta.className, meta.filePath);
109
- const identifierConst = `${importName}Identifier`;
237
+ const importName = serviceNames.get(meta) ?? meta.className;
238
+ const identifierConst = pool.claim(`${importName}Identifier`);
110
239
  const exportKey = createIdentifierExportKey(meta, resolver);
111
240
  const symbolDescription = meta.identifierKey ?? createSymbolDescription(meta);
112
- const optionsText = reconstructOptionsText(meta, importMap);
241
+ const optionsText = reconstructOptionsText(meta, importMap, serviceRenames);
113
242
  return {
114
243
  ...meta,
115
244
  importName,
@@ -121,34 +250,30 @@ function enrichRegistrations(activeMetas, resolver, importMap) {
121
250
  };
122
251
  });
123
252
  }
124
- function computeRuntimeImports(registrations, hasProviderModules) {
253
+ function computeRuntimeImports(activeMetas, hasProviderModules, hasEnvOverrides = false) {
125
254
  const imports = new Set(["Container", "dependenciesRegistry"]);
126
- const needsLazyImport = registrations.some((m) => m.metadata.dependencies.some((d) => d.isLazy) || !!m.metadata.factory);
255
+ if (hasEnvOverrides) imports.add("setEnvDetectionOverrides");
256
+ const needsLazyImport = activeMetas.some((m) => m.metadata.dependencies.some((d) => d.isLazy) || !!m.metadata.factory);
127
257
  if (hasProviderModules) imports.add("applyProviders");
128
258
  if (needsLazyImport) imports.add("Lazy");
129
- if (registrations.length) imports.add("registerServiceIdentifier");
259
+ if (activeMetas.length) imports.add("registerServiceIdentifier");
130
260
  return imports;
131
261
  }
132
262
  function formatRuntimeImportStatement(imports) {
133
263
  return `\nimport { ${Array.from(imports).join(", ")} } from 'alloy-di/runtime';\n`;
134
264
  }
135
- function createStubBlock(dependencyImports, registrations, runtimeImports) {
265
+ function createStubBlock(dependencyImports, registrations) {
136
266
  const statements = [];
137
- const importedNames = new Set(runtimeImports);
138
267
  for (const dep of dependencyImports) {
139
- if (dep.importPath === "alloy-di/runtime" && dep.originalName && dep.localName === dep.originalName && runtimeImports.has(dep.originalName)) continue;
140
- if (importedNames.has(dep.localName)) continue;
268
+ if (dep.reusesExistingBinding) continue;
141
269
  statements.push(createDependencyImportStatement(dep));
142
- importedNames.add(dep.localName);
143
270
  }
144
271
  for (const meta of registrations) {
145
272
  if (meta.isFactoryLazy) {
146
273
  statements.push(`class ${meta.importName} {}`);
147
274
  continue;
148
275
  }
149
- if (importedNames.has(meta.importName)) continue;
150
276
  statements.push(createServiceImportStatement(meta));
151
- importedNames.add(meta.importName);
152
277
  }
153
278
  return statements.length ? `${statements.join("\n")}\n` : "";
154
279
  }
@@ -158,8 +283,11 @@ function createDependencyImportStatement(dep) {
158
283
  if (dep.originalName && dep.originalName !== dep.localName) return `import { ${dep.originalName} as ${dep.localName} } from '${dep.importPath}';`;
159
284
  return `import { ${dep.localName} } from '${dep.importPath}';`;
160
285
  }
286
+ function getServiceImportPath(meta) {
287
+ return !/^(\/|[A-Za-z]:\\|\.|~)/.test(meta.filePath) && !meta.filePath.includes("\\") ? meta.filePath : normalizeImportPath(meta.filePath);
288
+ }
161
289
  function createServiceImportStatement(meta) {
162
- const importPath = !/^(\/|[A-Za-z]:\\|\.|~)/.test(meta.filePath) && !meta.filePath.includes("\\") ? meta.filePath : normalizeImportPath(meta.filePath);
290
+ const importPath = getServiceImportPath(meta);
163
291
  if (meta.importName === meta.className) return `import { ${meta.className} } from '${importPath}';`;
164
292
  return `import { ${meta.className} as ${meta.importName} } from '${importPath}';`;
165
293
  }
@@ -191,9 +319,10 @@ function createIdentifierExports(registrations) {
191
319
  * @param lazyReferencedClassKeys - Set of service keys that are referenced ONLY lazily (and thus should not be imported/registered eagerly in this bundle).
192
320
  * @param providerModulePaths - List of provider modules to import and apply.
193
321
  */
194
- function generateContainerModule(metas, lazyReferencedClassKeys, providerModulePaths) {
322
+ function generateContainerModule(metas, lazyReferencedClassKeys, providerModulePaths, options) {
195
323
  const hasProviderModules = providerModulePaths.length > 0;
196
- const { runtimeImportStatement, registrationsBlock, stubsBlock, identifierExportBlock } = buildImportsAndRegistrations(metas, lazyReferencedClassKeys, hasProviderModules);
324
+ const { runtimeImportStatement, registrationsBlock, stubsBlock, identifierExportBlock } = buildImportsAndRegistrations(metas, lazyReferencedClassKeys, providerModulePaths, options);
325
+ const envOverridesBlock = options?.isDev === void 0 ? "" : `\nsetEnvDetectionOverrides({ isDev: ${options.isDev} });\n`;
197
326
  let providerImportBlock = "";
198
327
  let providerInvocationBlock = "";
199
328
  if (hasProviderModules) {
@@ -202,7 +331,7 @@ function generateContainerModule(metas, lazyReferencedClassKeys, providerModuleP
202
331
  providerInvocationBlock = `\nconst providerDefinitions = [${aliasNames.join(", ")}];\nfor (const definition of providerDefinitions) {\n applyProviders(container, definition);\n}\n`;
203
332
  }
204
333
  return `
205
- ${runtimeImportStatement}${stubsBlock}
334
+ ${runtimeImportStatement}${envOverridesBlock}${stubsBlock}
206
335
  ${providerImportBlock}
207
336
  ${registrationsBlock}
208
337
 
@@ -215,6 +344,11 @@ ${providerInvocationBlock}${identifierExportBlock}
215
344
  export default container;
216
345
  `;
217
346
  }
347
+ const GENERATED_FILE_NOTICE = "This file was auto-generated by Alloy. Manual changes will be overwritten.";
348
+ const GENERATED_FILE_HEADER = `/**
349
+ * ${GENERATED_FILE_NOTICE}
350
+ */
351
+ `;
218
352
  /**
219
353
  * Generates the TypeScript declaration definition (`.d.ts`) for the virtual container module.
220
354
  * It exports the `ServiceIdentifiers` interface matching the runtime exports.
@@ -224,17 +358,22 @@ export default container;
224
358
  */
225
359
  function generateContainerTypeDefinition(metas, pathResolver) {
226
360
  const resolver = new IdentifierResolver(metas);
361
+ const pool = createNamePool([
362
+ "Container",
363
+ "ServiceIdentifier",
364
+ "container"
365
+ ]);
227
366
  const imports = [];
228
367
  const interfaceMembers = [];
229
368
  for (const meta of metas) {
230
- const importName = resolver.resolve(meta.className, meta.filePath);
369
+ const importName = pool.claim(resolver.resolve(meta.className, meta.filePath));
231
370
  const importPath = pathResolver(meta.filePath);
232
371
  if (importName === meta.className) imports.push(`import { ${meta.className} } from '${importPath}';`);
233
372
  else imports.push(`import { ${meta.className} as ${importName} } from '${importPath}';`);
234
373
  const exportKey = createIdentifierExportKey(meta, resolver);
235
374
  interfaceMembers.push(`${exportKey}: ServiceIdentifier<${importName}>;`);
236
375
  }
237
- return `
376
+ return `${GENERATED_FILE_HEADER}
238
377
  declare module "virtual:alloy-container" {
239
378
  import { Container, ServiceIdentifier } from "alloy-di/runtime";
240
379
  ${imports.length ? imports.join("\n") + "\n" : ""}
@@ -258,7 +397,7 @@ declare module "virtual:alloy-container" {
258
397
  * @param manifests - List of loaded manifest info (packageName and services).
259
398
  */
260
399
  function generateManifestTypeDefinition(manifests) {
261
- return manifests.map((m) => {
400
+ return GENERATED_FILE_HEADER + manifests.map((m) => {
262
401
  const serviceIdentifiers = m.services.map((s) => ` export const ${s.exportName}Identifier: ServiceIdentifier;`).join("\n");
263
402
  return `
264
403
  declare module "${m.packageName}/manifest" {
@@ -349,4 +488,4 @@ ${serviceIdentifiers}
349
488
  }).join("\n");
350
489
  }
351
490
  //#endregion
352
- export { generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition };
491
+ export { GENERATED_FILE_NOTICE, generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition };
@@ -1,14 +1,18 @@
1
- import { normalizeImportPath } from "../core/utils.js";
2
- import { IdentifierResolver } from "../core/identifier-resolver.js";
3
- import { generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition } from "../core/codegen.js";
1
+ import { normalizeImportPath, writeFileIfChanged } from "./utils.js";
2
+ import { IdentifierResolver } from "./identifier-resolver.js";
3
+ import { GENERATED_FILE_NOTICE, generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition } from "./codegen.js";
4
4
  import { augmentFactoryLazyServices, collectEagerReferencedNames, findDuplicateManifestServices, groupMetasByName, readManifests, reconcileLazySet, toMetaFromManifest } from "./manifest-utils.js";
5
5
  import { generateMermaidDiagram } from "./visualizer.js";
6
6
  import { ensureDirectoryForFile } from "./visualization-utils.js";
7
7
  import path from "node:path";
8
8
  import fs from "node:fs";
9
- //#region src/plugins/vite-plugin/container-loader.ts
9
+ //#region src/plugins/core/container-loader.ts
10
10
  async function loadVirtualContainerModule(options) {
11
- const metas = options.localMetas;
11
+ const metas = options.localMetas.map((meta) => ({
12
+ ...meta,
13
+ metadata: { ...meta.metadata }
14
+ }));
15
+ const lazyClassKeys = new Set(options.lazyReferencedClassKeys);
12
16
  assignIdentifierKeys(metas, options.packageName, options.resolvedRoot);
13
17
  const manifestData = await readManifests(options.manifests);
14
18
  const manifestServices = manifestData.services;
@@ -24,14 +28,13 @@ async function loadVirtualContainerModule(options) {
24
28
  }))];
25
29
  const resolver = new IdentifierResolver(combinedMetas);
26
30
  const metasByName = groupMetasByName(combinedMetas);
27
- for (const svc of manifestServices) metas.push(toMetaFromManifest(svc, metasByName, resolver, options.lazyReferencedClassKeys));
31
+ for (const svc of manifestServices) metas.push(toMetaFromManifest(svc, metasByName, resolver, lazyClassKeys));
28
32
  const providerImports = Array.from(new Set([...options.providerImportPaths, ...manifestData.providers]));
29
- const eagerReferencedNames = collectEagerReferencedNames(metas);
30
- reconcileLazySet(metas, options.lazyReferencedClassKeys, eagerReferencedNames);
33
+ reconcileLazySet(metas, lazyClassKeys, collectEagerReferencedNames(metas));
31
34
  augmentFactoryLazyServices(metas, options.lazyServiceKeys);
32
- const code = generateContainerModule(metas, new Set(options.lazyReferencedClassKeys), providerImports);
35
+ const code = generateContainerModule(metas, lazyClassKeys, providerImports, { isDev: options.isDevMode });
33
36
  writeTypeDefinitions(metas, loadedManifests, options.resolvedRoot, options.containerDeclarationDir);
34
- writeVisualizationArtifact(metas, options.lazyReferencedClassKeys, options.resolvedVisualization);
37
+ writeVisualizationArtifact(metas, lazyClassKeys, options.resolvedVisualization);
35
38
  return {
36
39
  code,
37
40
  moduleType: "js"
@@ -63,13 +66,13 @@ function writeTypeDefinitions(metas, loadedManifests, resolvedRoot, containerDec
63
66
  const dtsDir = path.resolve(resolvedRoot, containerDeclarationDir ?? "./src");
64
67
  const dtsContent = generateContainerTypeDefinition(metas, (filePath) => resolveDeclarationImportPath(dtsDir, filePath));
65
68
  if (!fs.existsSync(dtsDir)) fs.mkdirSync(dtsDir, { recursive: true });
66
- fs.writeFileSync(path.join(dtsDir, "alloy-container.d.ts"), dtsContent);
69
+ writeFileIfChanged(path.join(dtsDir, "alloy-container.d.ts"), dtsContent);
67
70
  if (loadedManifests.length === 0) return;
68
71
  const manifestsDts = generateManifestTypeDefinition(loadedManifests.map((m) => ({
69
72
  packageName: m.packageName,
70
73
  services: m.services
71
74
  })));
72
- fs.writeFileSync(path.join(dtsDir, "alloy-manifests.d.ts"), manifestsDts);
75
+ writeFileIfChanged(path.join(dtsDir, "alloy-manifests.d.ts"), manifestsDts);
73
76
  }
74
77
  function resolveDeclarationImportPath(dtsDir, filePath) {
75
78
  if (!path.isAbsolute(filePath)) return filePath;
@@ -86,7 +89,7 @@ function writeVisualizationArtifact(metas, lazyReferencedClassKeys, resolvedVisu
86
89
  options: resolvedVisualization.mermaidOptions
87
90
  });
88
91
  ensureDirectoryForFile(resolvedVisualization.outputPath);
89
- fs.writeFileSync(resolvedVisualization.outputPath, `${artifact.diagram}\n`);
92
+ writeFileIfChanged(resolvedVisualization.outputPath, `%% ${GENERATED_FILE_NOTICE}\n${artifact.diagram}\n`);
90
93
  }
91
94
  //#endregion
92
95
  export { loadVirtualContainerModule };
@@ -1,6 +1,6 @@
1
- import { createClassKey } from "../core/utils.js";
2
- import { createDiscoveryStore } from "../core/discovery-store.js";
3
- //#region src/plugins/vite-plugin/discovery-runtime.ts
1
+ import { createClassKey } from "./utils.js";
2
+ import { createDiscoveryStore } from "./discovery-store.js";
3
+ //#region src/plugins/core/discovery-runtime.ts
4
4
  /** Files the discovery scanner processes (mirrors the transform hook filter). */
5
5
  function isDiscoverableFile(file) {
6
6
  return /\.tsx?$/i.test(file) && !/\.d\.ts$/i.test(file) && !file.includes("node_modules");
@@ -62,15 +62,5 @@ function createDiscoveryRuntime() {
62
62
  }
63
63
  };
64
64
  }
65
- /**
66
- * Invalidate the generated container module in every environment's module
67
- * graph so its `load` hook re-runs and regenerates from current discovery.
68
- */
69
- function invalidateContainerModule(server, resolvedVirtualModuleId) {
70
- for (const environment of Object.values(server.environments)) {
71
- const mod = environment.moduleGraph.getModuleById(resolvedVirtualModuleId);
72
- if (mod) environment.moduleGraph.invalidateModule(mod);
73
- }
74
- }
75
65
  //#endregion
76
- export { createDiscoveryRuntime, invalidateContainerModule, isDiscoverableFile };
66
+ export { createDiscoveryRuntime, isDiscoverableFile };
@@ -1,10 +1,14 @@
1
1
  import { scanSource } from "./scanner.js";
2
+ import crypto from "node:crypto";
2
3
  //#region src/plugins/core/discovery-store.ts
3
4
  /**
4
5
  * Maintains a per-file cache of discovered DI metadata, lazy references, and
5
6
  * optionally source snapshots to drive incremental recompilation inside the
6
7
  * Alloy discovery pipeline.
7
8
  */
9
+ function hashContent(code) {
10
+ return crypto.createHash("sha1").update(code).digest("hex");
11
+ }
8
12
  /**
9
13
  * Creates a file-scoped discovery store that caches scanner output and
10
14
  * optionally the original source for diagnostics or incremental rebuilds.
@@ -15,6 +19,7 @@ import { scanSource } from "./scanner.js";
15
19
  function createDiscoveryStore(options = {}) {
16
20
  const fileMetas = /* @__PURE__ */ new Map();
17
21
  const fileLazyRefs = /* @__PURE__ */ new Map();
22
+ const fileContentHashes = /* @__PURE__ */ new Map();
18
23
  const fileSources = options.trackSources ? /* @__PURE__ */ new Map() : void 0;
19
24
  /**
20
25
  * Scan and cache the latest metadata for a file, returning both the fresh
@@ -26,6 +31,14 @@ function createDiscoveryStore(options = {}) {
26
31
  function updateFile(id, code) {
27
32
  const previousMetas = fileMetas.get(id);
28
33
  const previousLazyClassKeys = fileLazyRefs.get(id);
34
+ const contentHash = hashContent(code);
35
+ if (fileContentHashes.get(id) === contentHash) return {
36
+ metas: previousMetas ?? [],
37
+ lazyClassKeys: new Set(previousLazyClassKeys),
38
+ previousMetas,
39
+ previousLazyClassKeys
40
+ };
41
+ fileContentHashes.set(id, contentHash);
29
42
  if (fileSources) fileSources.set(id, code);
30
43
  const { metas, lazyClassKeys } = scanSource(code, id);
31
44
  if (metas.length) fileMetas.set(id, metas);
@@ -49,6 +62,7 @@ function createDiscoveryStore(options = {}) {
49
62
  const previousLazyClassKeys = fileLazyRefs.get(id);
50
63
  fileMetas.delete(id);
51
64
  fileLazyRefs.delete(id);
65
+ fileContentHashes.delete(id);
52
66
  if (fileSources) fileSources.delete(id);
53
67
  return {
54
68
  previousMetas,
@@ -61,6 +75,7 @@ function createDiscoveryStore(options = {}) {
61
75
  function clear() {
62
76
  fileMetas.clear();
63
77
  fileLazyRefs.clear();
78
+ fileContentHashes.clear();
64
79
  fileSources?.clear();
65
80
  }
66
81
  return {
@@ -1,6 +1,6 @@
1
- import { createClassKey, createSymbolKey, normalizeImportPath } from "../core/utils.js";
1
+ import { createClassKey, createSymbolKey, normalizeImportPath } from "./utils.js";
2
2
  import { z } from "zod";
3
- //#region src/plugins/vite-plugin/manifest-utils.ts
3
+ //#region src/plugins/core/manifest-utils.ts
4
4
  /**
5
5
  * Reads a list of manifest objects and returns aggregated service + provider module specifiers.
6
6
  *
@@ -83,9 +83,16 @@ async function readManifests(inputs) {
83
83
  function readManifestByVersion(manifest) {
84
84
  return (manifest.schemaVersion ?? 1) === 2 ? readManifestV2(manifest) : readManifestV1(manifest);
85
85
  }
86
+ function warnInvalidManifest(manifest, error) {
87
+ const packageName = typeof manifest.packageName === "string" && manifest.packageName ? manifest.packageName : "<unknown package>";
88
+ console.warn(`[alloy] Ignoring invalid manifest "${packageName}" — its services and providers will not be registered:\n${z.prettifyError(error)}`);
89
+ }
86
90
  function readManifestV1(manifest) {
87
91
  const parsed = manifestSchemaV1.safeParse(manifest);
88
- if (!parsed.success) return null;
92
+ if (!parsed.success) {
93
+ warnInvalidManifest(manifest, parsed.error);
94
+ return null;
95
+ }
89
96
  return {
90
97
  schemaVersion: 1,
91
98
  packageName: parsed.data.packageName,
@@ -98,7 +105,10 @@ function readManifestV1(manifest) {
98
105
  }
99
106
  function readManifestV2(manifest) {
100
107
  const parsed = manifestSchemaV2.safeParse(manifest);
101
- if (!parsed.success) return null;
108
+ if (!parsed.success) {
109
+ warnInvalidManifest(manifest, parsed.error);
110
+ return null;
111
+ }
102
112
  return {
103
113
  schemaVersion: 2,
104
114
  packageName: parsed.data.packageName,
@@ -36,7 +36,19 @@ function collectFileImports(sourceFile) {
36
36
  }
37
37
  return imports;
38
38
  }
39
+ /**
40
+ * Cheap pre-filter that avoids the TS parse for files that cannot contribute
41
+ * discovery results: decorators require an `@` and lazy references require
42
+ * the `Lazy` identifier.
43
+ */
44
+ function mayContainDiscoverableSyntax(code) {
45
+ return code.includes("@") || code.includes("Lazy");
46
+ }
39
47
  function scanSource(code, id) {
48
+ if (!mayContainDiscoverableSyntax(code)) return {
49
+ metas: [],
50
+ lazyClassKeys: /* @__PURE__ */ new Set()
51
+ };
40
52
  const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.ESNext, true);
41
53
  const discovered = /* @__PURE__ */ new Map();
42
54
  const lazyRefs = /* @__PURE__ */ new Set();
@@ -80,7 +92,10 @@ function findServiceDecorator(node, sourceFile, fileImports, id, resolutionCache
80
92
  const decorators = ts.getDecorators ? ts.getDecorators(node) : void 0;
81
93
  if (!decorators?.length) return;
82
94
  for (const decorator of decorators) {
83
- if (!ts.isCallExpression(decorator.expression)) continue;
95
+ if (!ts.isCallExpression(decorator.expression)) {
96
+ warnOnBareAlloyDecorator(decorator, sourceFile, fileImports, id, resolutionCache);
97
+ continue;
98
+ }
84
99
  const decoratorName = resolveDecoratorName(decorator.expression.expression, fileImports, id, new Set([id]), resolutionCache);
85
100
  if (decoratorName) return {
86
101
  decoratorCall: decorator.expression,
@@ -88,6 +103,20 @@ function findServiceDecorator(node, sourceFile, fileImports, id, resolutionCache
88
103
  };
89
104
  }
90
105
  }
106
+ /**
107
+ * Warn when an alloy decorator is applied bare (`@Injectable` instead of
108
+ * `@Injectable()`). The scanner only registers call-expression decorators, so
109
+ * the service would silently vanish from the container — and at runtime the
110
+ * factory throws. Surfacing the location here makes the misuse findable at
111
+ * build time.
112
+ */
113
+ function warnOnBareAlloyDecorator(decorator, sourceFile, fileImports, id, resolutionCache) {
114
+ if (!ts.isIdentifier(decorator.expression) && !ts.isPropertyAccessExpression(decorator.expression)) return;
115
+ if (!resolveDecoratorName(decorator.expression, fileImports, id, new Set([id]), resolutionCache)) return;
116
+ const { line } = sourceFile.getLineAndCharacterOfPosition(decorator.getStart(sourceFile));
117
+ const appliedText = decorator.expression.getText(sourceFile);
118
+ console.warn(`[alloy] ${id}:${line + 1} applies @${appliedText} without calling it — use @${appliedText}(). The class will not be registered.`);
119
+ }
91
120
  function resolveDecoratorName(expression, fileImports, id, visitedModules, resolutionCache) {
92
121
  if (ts.isIdentifier(expression)) {
93
122
  const importInfo = fileImports.get(expression.text);
@@ -104,11 +133,11 @@ function resolveImportedDecorator(importPath, requestedName, fromId, visitedModu
104
133
  if (importPath === ALLOY_RUNTIME_MODULE) return isAlloyDecoratorName(requestedName) ? requestedName : void 0;
105
134
  if (!importPath.startsWith(".")) return;
106
135
  for (const candidate of resolveModuleSpecifierCandidates(fromId, importPath)) {
107
- if (visitedModules.has(candidate) || !fs.existsSync(candidate)) continue;
108
136
  const cacheKey = `${candidate}:${requestedName}`;
109
137
  const cached = resolutionCache.get(cacheKey);
110
138
  if (cached) return cached;
111
139
  if (cached === null) continue;
140
+ if (visitedModules.has(candidate) || !fs.existsSync(candidate)) continue;
112
141
  visitedModules.add(candidate);
113
142
  try {
114
143
  const source = fs.readFileSync(candidate, "utf8");
@@ -18,7 +18,7 @@ function ensureLeadingSlash(value) {
18
18
  }
19
19
  function hashString(value) {
20
20
  let hash = 0;
21
- for (let i = 0; i < value.length; i++) hash = Math.trunc(hash * 31 + value.charCodeAt(i));
21
+ for (let i = 0; i < value.length; i++) hash = hash * 31 + value.charCodeAt(i) | 0;
22
22
  return Math.abs(hash).toString(36);
23
23
  }
24
24
  function createClassKey(filePath, className) {
@@ -30,14 +30,51 @@ function createAliasName(className, filePath) {
30
30
  function createSymbolKey(filePath, className) {
31
31
  return `alloy:${normalizeImportPath(filePath)}#${className}`;
32
32
  }
33
- function walkSync(dir, fileList = []) {
34
- if (!fs.existsSync(dir)) return fileList;
35
- fs.readdirSync(dir).forEach((file) => {
36
- const filePath = path.join(dir, file);
37
- if (fs.statSync(filePath).isDirectory()) walkSync(filePath, fileList);
38
- else fileList.push(filePath);
39
- });
33
+ const lastWrittenContent = /* @__PURE__ */ new Map();
34
+ /**
35
+ * Write a generated artifact only when its content actually changed.
36
+ *
37
+ * @returns true when the file was written, false when it already matched.
38
+ */
39
+ function writeFileIfChanged(filePath, content) {
40
+ if (lastWrittenContent.get(filePath) === content) return false;
41
+ try {
42
+ if (fs.readFileSync(filePath, "utf-8") === content) {
43
+ lastWrittenContent.set(filePath, content);
44
+ return false;
45
+ }
46
+ } catch {}
47
+ fs.writeFileSync(filePath, content);
48
+ lastWrittenContent.set(filePath, content);
49
+ return true;
50
+ }
51
+ function walkSync(dir, fileList = [], visitedDirs) {
52
+ const visited = visitedDirs ?? /* @__PURE__ */ new Set();
53
+ let realDir;
54
+ try {
55
+ realDir = fs.realpathSync(dir);
56
+ } catch {
57
+ return fileList;
58
+ }
59
+ if (visited.has(realDir)) return fileList;
60
+ visited.add(realDir);
61
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
62
+ if (entry.name.startsWith(".")) continue;
63
+ const filePath = path.join(dir, entry.name);
64
+ if (entry.isDirectory()) walkSync(filePath, fileList, visited);
65
+ else if (entry.isFile()) fileList.push(filePath);
66
+ else if (entry.isSymbolicLink()) {
67
+ let stat;
68
+ try {
69
+ stat = fs.statSync(filePath);
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (stat.isDirectory()) walkSync(filePath, fileList, visited);
74
+ else if (stat.isFile()) fileList.push(filePath);
75
+ }
76
+ }
40
77
  return fileList;
41
78
  }
42
79
  //#endregion
43
- export { createAliasName, createClassKey, createSymbolKey, hashString, normalizeImportPath, walkSync };
80
+ export { createAliasName, createClassKey, createSymbolKey, hashString, normalizeImportPath, walkSync, writeFileIfChanged };
@@ -1,6 +1,6 @@
1
1
  import { MermaidDiagramOptions } from "./visualizer.js";
2
2
 
3
- //#region src/plugins/vite-plugin/visualization-utils.d.ts
3
+ //#region src/plugins/core/visualization-utils.d.ts
4
4
  interface AlloyMermaidVisualizerOptions extends MermaidDiagramOptions {
5
5
  outputPath?: string;
6
6
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
- //#region src/plugins/vite-plugin/visualization-utils.ts
3
+ //#region src/plugins/core/visualization-utils.ts
4
4
  const DEFAULT_MERMAID_FILENAME = "alloy-di.mmd";
5
5
  function resolveVisualizationOptions(input, projectRoot) {
6
6
  if (!input) return null;
@@ -1,6 +1,6 @@
1
1
  import { ServiceScope } from "../../lib/scope.js";
2
2
 
3
- //#region src/plugins/vite-plugin/visualizer.d.ts
3
+ //#region src/plugins/core/visualizer.d.ts
4
4
  interface MermaidDiagramOptions {
5
5
  direction?: "LR" | "TB" | "BT" | "RL";
6
6
  includeLegend?: boolean;
@@ -1,22 +1,22 @@
1
- import { createClassKey, createSymbolKey, hashString, normalizeImportPath } from "../core/utils.js";
1
+ import { createClassKey, createSymbolKey, hashString, normalizeImportPath } from "./utils.js";
2
2
  import path from "node:path";
3
- //#region src/plugins/vite-plugin/visualizer.ts
3
+ //#region src/plugins/core/visualizer.ts
4
4
  const DEFAULT_SCOPE_COLORS = {
5
- singleton: "#f6c14a",
6
- transient: "#58a6ff"
5
+ singleton: "#3b6ea5",
6
+ transient: "#2a7d73"
7
7
  };
8
8
  const DEFAULT_OPTIONS = {
9
9
  direction: "LR",
10
10
  includeLegend: true,
11
11
  scopeColors: DEFAULT_SCOPE_COLORS,
12
- lazyNodeFill: "#e8def8",
13
- factoryNodeFill: "#ffe0b2",
14
- tokenNodeFill: "#d1d5db",
15
- nodeStrokeColor: "#1f2937",
16
- nodeTextColor: "#111827",
17
- lazyEdgeColor: "#a855f7",
18
- eagerEdgeColor: "#6b7280",
19
- factoryEdgeColor: "#ef6c00"
12
+ lazyNodeFill: "#6c5cb8",
13
+ factoryNodeFill: "#9c6516",
14
+ tokenNodeFill: "#4b5c6b",
15
+ nodeStrokeColor: "#5a7488",
16
+ nodeTextColor: "#ffffff",
17
+ lazyEdgeColor: "#9385d6",
18
+ eagerEdgeColor: "#7c93a6",
19
+ factoryEdgeColor: "#c2922e"
20
20
  };
21
21
  const RESERVED_IDENTIFIERS = new Set([
22
22
  "Lazy",
@@ -95,7 +95,7 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options }) {
95
95
  edges.push({
96
96
  from: sourceNode,
97
97
  to: target,
98
- label: describeEdge(sourceNode, target, dep.isLazy),
98
+ label: describeEdge(sourceNode, target),
99
99
  isLazy: dep.isLazy,
100
100
  stroke: selectEdgeColor(dep.isLazy, target, mergedOptions)
101
101
  });
@@ -106,6 +106,7 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options }) {
106
106
  if (mergedOptions.includeLegend) {
107
107
  lines.push(` %% Legend: singleton=${mergedOptions.scopeColors.singleton}, transient=${mergedOptions.scopeColors.transient}, lazy-only=${mergedOptions.lazyNodeFill}, factory=${mergedOptions.factoryNodeFill}, token=${mergedOptions.tokenNodeFill}`);
108
108
  lines.push(` %% Edge colors: eager=${mergedOptions.eagerEdgeColor}, lazy=${mergedOptions.lazyEdgeColor}, factory=${mergedOptions.factoryEdgeColor}`);
109
+ lines.push(` %% Edge labels: Si=singleton, Tr=transient, Tk=token; solid=eager, dotted=lazy`);
109
110
  }
110
111
  const allNodes = [...serviceNodes, ...Array.from(tokenNodes.values())];
111
112
  for (const node of allNodes) {
@@ -155,11 +156,18 @@ function sanitizeMermaidId(source, fallbackIndex) {
155
156
  function escapeMermaidLabel(label) {
156
157
  return label.replaceAll("\"", "\\\"").replaceAll("|", "/");
157
158
  }
159
+ /** Single-token code for a node's scope/kind: Si, Tr, or Tk (token). */
160
+ function scopeCode(node) {
161
+ if (node.type === "token") return "Tk";
162
+ return node.scope === "transient" ? "Tr" : "Si";
163
+ }
158
164
  /**
159
- * Builds a human-readable label describing the nature of an edge between two nodes.
165
+ * Builds a compact edge label as a `source→target` scope transition (e.g.
166
+ * `Si→Tr`). Eager/lazy is conveyed by the arrow style and the target kind by
167
+ * node color, so they are intentionally omitted from the text. See the legend.
160
168
  */
161
- function describeEdge(from, to, depIsLazy) {
162
- return `${depIsLazy ? "Lazy" : "Eager"} · ${from.scope ?? "unknown"}→${to.type === "token" ? "token" : to.scope ?? "unknown"} · ${to.type === "token" ? "Token" : to.hasFactory ? "Factory" : "Class"}`;
169
+ function describeEdge(from, to) {
170
+ return `${scopeCode(from)}→${scopeCode(to)}`;
163
171
  }
164
172
  /**
165
173
  * Determines the fill color for a node based on its type, scope, and lazy/factory flags.
@@ -1,6 +1,6 @@
1
1
  import { ServiceIdentifier } from "../../lib/service-identifiers.js";
2
2
  import { AlloyManifest } from "../core/types.js";
3
- import { AlloyMermaidVisualizerOptions, AlloyVisualizationOptions } from "./visualization-utils.js";
3
+ import { AlloyMermaidVisualizerOptions, AlloyVisualizationOptions } from "../core/visualization-utils.js";
4
4
  import { Plugin } from "vite";
5
5
 
6
6
  //#region src/plugins/vite-plugin/index.d.ts
@@ -1,7 +1,8 @@
1
1
  import { normalizeImportPath, walkSync } from "../core/utils.js";
2
- import { resolveVisualizationOptions } from "./visualization-utils.js";
3
- import { loadVirtualContainerModule } from "./container-loader.js";
4
- import { createDiscoveryRuntime, invalidateContainerModule, isDiscoverableFile } from "./discovery-runtime.js";
2
+ import { resolveVisualizationOptions } from "../core/visualization-utils.js";
3
+ import { loadVirtualContainerModule } from "../core/container-loader.js";
4
+ import { createDiscoveryRuntime, isDiscoverableFile } from "../core/discovery-runtime.js";
5
+ import { invalidateContainerModule } from "./module-invalidation.js";
5
6
  import path from "node:path";
6
7
  import fs from "node:fs";
7
8
  //#region src/plugins/vite-plugin/index.ts
@@ -22,6 +23,7 @@ function alloy(options = {}) {
22
23
  let resolvedRoot = process.cwd();
23
24
  let packageName = "UNKNOWN_PACKAGE";
24
25
  let resolvedVisualization = null;
26
+ let isDevMode;
25
27
  const lazyServiceKeys = new Set((options.lazyServices ?? []).map(toLazyServiceKey));
26
28
  const discoveryRuntime = createDiscoveryRuntime();
27
29
  return {
@@ -29,6 +31,7 @@ function alloy(options = {}) {
29
31
  enforce: "pre",
30
32
  configResolved(config) {
31
33
  resolvedRoot = config.root ?? process.cwd();
34
+ isDevMode = !config.isProduction;
32
35
  try {
33
36
  const pkgPath = path.resolve(resolvedRoot, "package.json");
34
37
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
@@ -102,7 +105,8 @@ function alloy(options = {}) {
102
105
  packageName,
103
106
  resolvedRoot,
104
107
  containerDeclarationDir: options.containerDeclarationDir,
105
- resolvedVisualization
108
+ resolvedVisualization,
109
+ isDevMode
106
110
  });
107
111
  }
108
112
  }
@@ -0,0 +1,13 @@
1
+ //#region src/plugins/vite-plugin/module-invalidation.ts
2
+ /**
3
+ * Invalidate the generated container module in every environment's module
4
+ * graph so its `load` hook re-runs and regenerates from current discovery.
5
+ */
6
+ function invalidateContainerModule(server, resolvedVirtualModuleId) {
7
+ for (const environment of Object.values(server.environments)) {
8
+ const mod = environment.moduleGraph.getModuleById(resolvedVirtualModuleId);
9
+ if (mod) environment.moduleGraph.invalidateModule(mod);
10
+ }
11
+ }
12
+ //#endregion
13
+ export { invalidateContainerModule };
package/dist/runtime.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Newable, Token, createToken } from "./lib/types.js";
2
2
  import { ServiceIdentifier, clearServiceIdentifierRegistry, getConstructorByIdentifier, getServiceIdentifier, registerServiceIdentifier } from "./lib/service-identifiers.js";
3
3
  import { Container } from "./lib/container.js";
4
+ import { EnvDetectionOverrides, setEnvDetectionOverrides } from "./lib/env-detection.js";
4
5
  import { LAZY_IDENTIFIER, Lazy } from "./lib/lazy.js";
5
6
  import { Injectable, Singleton, assertDeps, dependenciesRegistry, deps } from "./lib/decorators.js";
6
7
  import { ProviderDefinitions, applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
7
- export { Container, Injectable, LAZY_IDENTIFIER, Lazy, type Lazy as LazyInterface, type Newable, type ProviderDefinitions, type ServiceIdentifier, Singleton, type Token, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier };
8
+ export { Container, type EnvDetectionOverrides, Injectable, LAZY_IDENTIFIER, Lazy, type Lazy as LazyInterface, type Newable, type ProviderDefinitions, type ServiceIdentifier, Singleton, type Token, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
package/dist/runtime.js CHANGED
@@ -2,6 +2,7 @@ import { createToken } from "./lib/types.js";
2
2
  import { LAZY_IDENTIFIER, Lazy } from "./lib/lazy.js";
3
3
  import { Injectable, Singleton, assertDeps, dependenciesRegistry, deps } from "./lib/decorators.js";
4
4
  import { clearServiceIdentifierRegistry, getConstructorByIdentifier, getServiceIdentifier, registerServiceIdentifier } from "./lib/service-identifiers.js";
5
+ import { setEnvDetectionOverrides } from "./lib/env-detection.js";
5
6
  import { Container } from "./lib/container.js";
6
7
  import { applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
7
- export { Container, Injectable, LAZY_IDENTIFIER, Lazy, Singleton, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier };
8
+ export { Container, Injectable, LAZY_IDENTIFIER, Lazy, Singleton, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
@@ -1 +1 @@
1
- {"root":["../src/entry-points.test.ts","../src/rollup.ts","../src/runtime.ts","../src/test.ts","../src/vite.ts","../src/lib/container.identifiers.test.ts","../src/lib/container.internals.test.ts","../src/lib/container.test.ts","../src/lib/container.testing-features.test.ts","../src/lib/container.ts","../src/lib/decorators.runtime.test.ts","../src/lib/decorators.test-d.ts","../src/lib/decorators.ts","../src/lib/dependency-error.ts","../src/lib/env-detection.test.ts","../src/lib/env-detection.ts","../src/lib/lazy-retry.test.ts","../src/lib/lazy.ts","../src/lib/providers.test.ts","../src/lib/providers.ts","../src/lib/scope.ts","../src/lib/service-identifiers.test.ts","../src/lib/service-identifiers.ts","../src/lib/tokens.test.ts","../src/lib/types.ts","../src/lib/testing/mocking.test.ts","../src/lib/testing/mocking.ts","../src/lib/testing/registry.test.ts","../src/lib/testing/registry.ts","../src/plugins/core/codegen.test.ts","../src/plugins/core/codegen.ts","../src/plugins/core/decorators.helpers.test.ts","../src/plugins/core/decorators.ts","../src/plugins/core/discovery-store.ts","../src/plugins/core/identifier-resolver.test.ts","../src/plugins/core/identifier-resolver.ts","../src/plugins/core/lazy-utils.ts","../src/plugins/core/lazy.helpers.test.ts","../src/plugins/core/lazy.ts","../src/plugins/core/scanner.test.ts","../src/plugins/core/scanner.ts","../src/plugins/core/types.ts","../src/plugins/core/utils.ts","../src/plugins/rollup-plugin/build-utils.ts","../src/plugins/rollup-plugin/index.ts","../src/plugins/rollup-plugin/rollup-plugin.test.ts","../src/plugins/vite-plugin/codegen.specifier.test.ts","../src/plugins/vite-plugin/container-loader.ts","../src/plugins/vite-plugin/discovery-runtime.ts","../src/plugins/vite-plugin/duplicate-registrations.test.ts","../src/plugins/vite-plugin/fixture-subpaths.test.ts","../src/plugins/vite-plugin/index.ts","../src/plugins/vite-plugin/lazy-services.test.ts","../src/plugins/vite-plugin/lifecycle-and-hmr.test.ts","../src/plugins/vite-plugin/manifest-utils.ts","../src/plugins/vite-plugin/manifest-utils.validation.test.ts","../src/plugins/vite-plugin/module-generation.test.ts","../src/plugins/vite-plugin/test-utils.ts","../src/plugins/vite-plugin/transform-guards.test.ts","../src/plugins/vite-plugin/visualization-option.test.ts","../src/plugins/vite-plugin/visualization-utils.ts","../src/plugins/vite-plugin/visualizer.test.ts","../src/plugins/vite-plugin/visualizer.ts"],"version":"6.0.3"}
1
+ {"root":["../src/entry-points.test.ts","../src/fixture-subpaths.test.ts","../src/rollup.ts","../src/runtime.ts","../src/test.ts","../src/vite.ts","../src/lib/container.identifiers.test.ts","../src/lib/container.internals.test.ts","../src/lib/container.test.ts","../src/lib/container.testing-features.test.ts","../src/lib/container.ts","../src/lib/decorators.runtime.test.ts","../src/lib/decorators.test-d.ts","../src/lib/decorators.ts","../src/lib/dependency-error.ts","../src/lib/env-detection.test.ts","../src/lib/env-detection.ts","../src/lib/lazy-retry.test.ts","../src/lib/lazy.ts","../src/lib/providers.test.ts","../src/lib/providers.ts","../src/lib/scope.ts","../src/lib/service-identifiers.test.ts","../src/lib/service-identifiers.ts","../src/lib/tokens.test.ts","../src/lib/types.ts","../src/lib/testing/mocking.test.ts","../src/lib/testing/mocking.ts","../src/lib/testing/registry.test.ts","../src/lib/testing/registry.ts","../src/plugins/core/codegen.test.ts","../src/plugins/core/codegen.ts","../src/plugins/core/container-loader.test.ts","../src/plugins/core/container-loader.ts","../src/plugins/core/decorators.helpers.test.ts","../src/plugins/core/decorators.ts","../src/plugins/core/discovery-runtime.ts","../src/plugins/core/discovery-store.test.ts","../src/plugins/core/discovery-store.ts","../src/plugins/core/identifier-resolver.test.ts","../src/plugins/core/identifier-resolver.ts","../src/plugins/core/lazy-utils.ts","../src/plugins/core/lazy.helpers.test.ts","../src/plugins/core/lazy.ts","../src/plugins/core/manifest-utils.ts","../src/plugins/core/manifest-utils.validation.test.ts","../src/plugins/core/scanner.test.ts","../src/plugins/core/scanner.ts","../src/plugins/core/types.ts","../src/plugins/core/utils.ts","../src/plugins/core/utils.walk.test.ts","../src/plugins/core/utils.write.test.ts","../src/plugins/core/visualization-utils.ts","../src/plugins/core/visualizer.test.ts","../src/plugins/core/visualizer.ts","../src/plugins/rollup-plugin/build-utils.ts","../src/plugins/rollup-plugin/index.ts","../src/plugins/rollup-plugin/rollup-plugin.test.ts","../src/plugins/vite-plugin/duplicate-registrations.test.ts","../src/plugins/vite-plugin/index.ts","../src/plugins/vite-plugin/lazy-services.test.ts","../src/plugins/vite-plugin/lifecycle-and-hmr.test.ts","../src/plugins/vite-plugin/module-generation.test.ts","../src/plugins/vite-plugin/module-invalidation.ts","../src/plugins/vite-plugin/test-utils.ts","../src/plugins/vite-plugin/transform-guards.test.ts","../src/plugins/vite-plugin/visualization-option.test.ts"],"version":"6.0.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alloy-di",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "A compile-time dependency injection plugin for Vite",
5
5
  "keywords": [
6
6
  "dependency-injection",