alloy-di 1.2.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/container.d.ts +22 -5
  3. package/dist/lib/container.js +85 -25
  4. package/dist/lib/decorators.d.ts +1 -1
  5. package/dist/lib/env-detection.d.ts +24 -0
  6. package/dist/lib/env-detection.js +22 -9
  7. package/dist/lib/providers.d.ts +1 -1
  8. package/dist/lib/scope.d.ts +19 -2
  9. package/dist/plugins/core/codegen.js +118 -14
  10. package/dist/plugins/{vite-plugin → core}/container-loader.js +26 -13
  11. package/dist/plugins/core/decorators.js +12 -22
  12. package/dist/plugins/{vite-plugin → core}/discovery-runtime.js +4 -14
  13. package/dist/plugins/core/discovery-store.js +15 -0
  14. package/dist/plugins/{vite-plugin → core}/manifest-utils.js +14 -4
  15. package/dist/plugins/core/scanner.js +12 -0
  16. package/dist/plugins/core/scopes-validation.d.ts +19 -0
  17. package/dist/plugins/core/scopes-validation.js +225 -0
  18. package/dist/plugins/core/types.d.ts +9 -4
  19. package/dist/plugins/core/utils.js +45 -8
  20. package/dist/plugins/{vite-plugin → core}/visualization-utils.d.ts +1 -1
  21. package/dist/plugins/{vite-plugin → core}/visualization-utils.js +1 -1
  22. package/dist/plugins/{vite-plugin → core}/visualizer.d.ts +3 -3
  23. package/dist/plugins/{vite-plugin → core}/visualizer.js +92 -20
  24. package/dist/plugins/rollup-plugin/barrel-exports.js +51 -0
  25. package/dist/plugins/rollup-plugin/dependency-resolution.js +42 -0
  26. package/dist/plugins/rollup-plugin/index.d.ts +2 -23
  27. package/dist/plugins/rollup-plugin/index.js +15 -128
  28. package/dist/plugins/rollup-plugin/manifest-deps.js +48 -0
  29. package/dist/plugins/rollup-plugin/package-exports.js +20 -0
  30. package/dist/plugins/rollup-plugin/types.d.ts +36 -0
  31. package/dist/plugins/vite-plugin/index.d.ts +10 -2
  32. package/dist/plugins/vite-plugin/index.js +9 -4
  33. package/dist/plugins/vite-plugin/module-invalidation.js +13 -0
  34. package/dist/runtime.d.ts +3 -1
  35. package/dist/runtime.js +3 -1
  36. package/dist/scopes.d.ts +66 -0
  37. package/dist/scopes.js +141 -0
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +7 -2
@@ -1,12 +1,13 @@
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, generateScopeAugmentationDefinition } from "./codegen.js";
4
4
  import { augmentFactoryLazyServices, collectEagerReferencedNames, findDuplicateManifestServices, groupMetasByName, readManifests, reconcileLazySet, toMetaFromManifest } from "./manifest-utils.js";
5
+ import { validateScopeStability, validateScopesConfig } from "./scopes-validation.js";
5
6
  import { generateMermaidDiagram } from "./visualizer.js";
6
7
  import { ensureDirectoryForFile } from "./visualization-utils.js";
7
8
  import path from "node:path";
8
9
  import fs from "node:fs";
9
- //#region src/plugins/vite-plugin/container-loader.ts
10
+ //#region src/plugins/core/container-loader.ts
10
11
  async function loadVirtualContainerModule(options) {
11
12
  const metas = options.localMetas.map((meta) => ({
12
13
  ...meta,
@@ -32,9 +33,16 @@ async function loadVirtualContainerModule(options) {
32
33
  const providerImports = Array.from(new Set([...options.providerImportPaths, ...manifestData.providers]));
33
34
  reconcileLazySet(metas, lazyClassKeys, collectEagerReferencedNames(metas));
34
35
  augmentFactoryLazyServices(metas, options.lazyServiceKeys);
35
- const code = generateContainerModule(metas, lazyClassKeys, providerImports);
36
- writeTypeDefinitions(metas, loadedManifests, options.resolvedRoot, options.containerDeclarationDir);
37
- writeVisualizationArtifact(metas, lazyClassKeys, options.resolvedVisualization);
36
+ if (options.scopes && Object.keys(options.scopes).length > 0) {
37
+ validateScopesConfig(options.scopes);
38
+ validateScopeStability(metas, options.scopes);
39
+ }
40
+ const code = generateContainerModule(metas, lazyClassKeys, providerImports, {
41
+ isDev: options.isDevMode,
42
+ scopes: options.scopes
43
+ });
44
+ writeTypeDefinitions(metas, loadedManifests, options.resolvedRoot, options.containerDeclarationDir, options.scopes);
45
+ writeVisualizationArtifact(metas, lazyClassKeys, options.resolvedVisualization, options.scopes);
38
46
  return {
39
47
  code,
40
48
  moduleType: "js"
@@ -62,17 +70,21 @@ function assertNoDuplicateManifestServices(metas, manifestServices) {
62
70
  "Resolve by removing one source (local or manifest) to avoid ambiguous DI keys."
63
71
  ].join("\n"));
64
72
  }
65
- function writeTypeDefinitions(metas, loadedManifests, resolvedRoot, containerDeclarationDir) {
73
+ function writeTypeDefinitions(metas, loadedManifests, resolvedRoot, containerDeclarationDir, scopes) {
66
74
  const dtsDir = path.resolve(resolvedRoot, containerDeclarationDir ?? "./src");
67
75
  const dtsContent = generateContainerTypeDefinition(metas, (filePath) => resolveDeclarationImportPath(dtsDir, filePath));
68
76
  if (!fs.existsSync(dtsDir)) fs.mkdirSync(dtsDir, { recursive: true });
69
- fs.writeFileSync(path.join(dtsDir, "alloy-container.d.ts"), dtsContent);
77
+ writeFileIfChanged(path.join(dtsDir, "alloy-container.d.ts"), dtsContent);
78
+ const scopeAugmentationPath = path.join(dtsDir, "alloy-scopes.d.ts");
79
+ const scopeAugmentation = generateScopeAugmentationDefinition(scopes ? Object.keys(scopes) : []);
80
+ if (scopeAugmentation) writeFileIfChanged(scopeAugmentationPath, scopeAugmentation);
81
+ else if (fs.existsSync(scopeAugmentationPath)) fs.rmSync(scopeAugmentationPath);
70
82
  if (loadedManifests.length === 0) return;
71
83
  const manifestsDts = generateManifestTypeDefinition(loadedManifests.map((m) => ({
72
84
  packageName: m.packageName,
73
85
  services: m.services
74
86
  })));
75
- fs.writeFileSync(path.join(dtsDir, "alloy-manifests.d.ts"), manifestsDts);
87
+ writeFileIfChanged(path.join(dtsDir, "alloy-manifests.d.ts"), manifestsDts);
76
88
  }
77
89
  function resolveDeclarationImportPath(dtsDir, filePath) {
78
90
  if (!path.isAbsolute(filePath)) return filePath;
@@ -81,15 +93,16 @@ function resolveDeclarationImportPath(dtsDir, filePath) {
81
93
  if (!rel.startsWith(".")) rel = "./" + rel;
82
94
  return rel;
83
95
  }
84
- function writeVisualizationArtifact(metas, lazyReferencedClassKeys, resolvedVisualization) {
96
+ function writeVisualizationArtifact(metas, lazyReferencedClassKeys, resolvedVisualization, scopes) {
85
97
  if (!resolvedVisualization) return;
86
98
  const artifact = generateMermaidDiagram({
87
99
  metas,
88
100
  lazyClassKeys: new Set(lazyReferencedClassKeys),
89
- options: resolvedVisualization.mermaidOptions
101
+ options: resolvedVisualization.mermaidOptions,
102
+ scopes
90
103
  });
91
104
  ensureDirectoryForFile(resolvedVisualization.outputPath);
92
- fs.writeFileSync(resolvedVisualization.outputPath, `${artifact.diagram}\n`);
105
+ writeFileIfChanged(resolvedVisualization.outputPath, `%% ${GENERATED_FILE_NOTICE}\n${artifact.diagram}\n`);
93
106
  }
94
107
  //#endregion
95
108
  export { loadVirtualContainerModule };
@@ -66,9 +66,9 @@ function parseDependencies(node, sourceFile) {
66
66
  return [];
67
67
  }
68
68
  function extractServiceMetadata(decoratorName, callExpression, sourceFile) {
69
- let scope = ServiceScope.TRANSIENT;
69
+ const isSingletonDecorator = decoratorName.endsWith("Singleton");
70
+ let scope = isSingletonDecorator ? ServiceScope.SINGLETON : ServiceScope.TRANSIENT;
70
71
  let dependencies = [];
71
- if (decoratorName.endsWith("Singleton")) scope = ServiceScope.SINGLETON;
72
72
  const args = callExpression.arguments;
73
73
  if (args.length === 0) return {
74
74
  scope,
@@ -78,30 +78,20 @@ function extractServiceMetadata(decoratorName, callExpression, sourceFile) {
78
78
  if (ts.isObjectLiteralExpression(firstArg)) {
79
79
  for (const prop of firstArg.properties) if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
80
80
  if (prop.name.text === "scope") {
81
- if (ts.isStringLiteral(prop.initializer)) {
82
- const val = prop.initializer.text;
83
- if (val === "singleton") scope = ServiceScope.SINGLETON;
84
- else if (val === "transient") scope = ServiceScope.TRANSIENT;
85
- }
81
+ if (ts.isStringLiteralLike(prop.initializer)) scope = prop.initializer.text;
86
82
  } else if (prop.name.text === "dependencies") dependencies = parseDependencies(prop.initializer, sourceFile);
87
83
  }
88
- if (decoratorName.endsWith("Singleton")) scope = ServiceScope.SINGLETON;
89
- return {
90
- scope,
91
- dependencies
92
- };
93
- }
94
- let depsNode;
95
- if (ts.isStringLiteralLike(firstArg)) {
96
- if (firstArg.text === "singleton") scope = ServiceScope.SINGLETON;
97
- } else depsNode = firstArg;
98
- if (args.length > 1) {
99
- const secondArg = args[1];
100
- if (ts.isStringLiteralLike(secondArg)) {
101
- if (secondArg.text === "singleton") scope = ServiceScope.SINGLETON;
84
+ } else {
85
+ let depsNode;
86
+ if (ts.isStringLiteralLike(firstArg)) scope = firstArg.text;
87
+ else depsNode = firstArg;
88
+ if (args.length > 1) {
89
+ const secondArg = args[1];
90
+ if (ts.isStringLiteralLike(secondArg)) scope = secondArg.text;
102
91
  }
92
+ if (depsNode) dependencies = parseDependencies(depsNode, sourceFile);
103
93
  }
104
- if (depsNode) dependencies = parseDependencies(depsNode, sourceFile);
94
+ if (isSingletonDecorator) scope = ServiceScope.SINGLETON;
105
95
  return {
106
96
  scope,
107
97
  dependencies
@@ -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();
@@ -0,0 +1,19 @@
1
+ //#region src/plugins/core/scopes-validation.d.ts
2
+ /** Declared parent relationship for a single custom scope. */
3
+ interface AlloyScopeConfig {
4
+ /**
5
+ * The next-longer-lived scope: either `"singleton"` or another custom scope.
6
+ * Defaults to `"singleton"` when omitted.
7
+ */
8
+ parent?: string;
9
+ }
10
+ /**
11
+ * Hierarchy of custom, application-defined scopes keyed by scope name. The two
12
+ * built-in lifecycles (`singleton` as the implicit root, `transient` as the
13
+ * implicit leaf) are never declared here.
14
+ */
15
+ interface AlloyScopesConfig {
16
+ [scopeName: string]: AlloyScopeConfig;
17
+ }
18
+ //#endregion
19
+ export { AlloyScopesConfig };
@@ -0,0 +1,225 @@
1
+ import { normalizeImportPath } from "./utils.js";
2
+ import path from "node:path";
3
+ //#region src/plugins/core/scopes-validation.ts
4
+ const SINGLETON = "singleton";
5
+ const TRANSIENT = "transient";
6
+ /**
7
+ * Resolves a declared scope's parent, defaulting to the implicit `singleton`
8
+ * root when omitted.
9
+ */
10
+ function parentOf(config, name) {
11
+ return config[name]?.parent ?? SINGLETON;
12
+ }
13
+ /** Identifiers that never refer to a discoverable service in a dependency expression. */
14
+ const RESERVED_IDENTIFIERS = new Set([
15
+ "Lazy",
16
+ "Symbol",
17
+ "Promise",
18
+ "import",
19
+ "this",
20
+ "arguments"
21
+ ]);
22
+ /**
23
+ * Validates the `scopes` plugin configuration in isolation (independent of any
24
+ * discovered services). Catches authoring mistakes — redeclaring a built-in
25
+ * scope, pointing at an unknown or illegal parent, or forming a cycle — and
26
+ * throws a single actionable error. Returns silently when the config is empty
27
+ * or valid.
28
+ */
29
+ function validateScopesConfig(config) {
30
+ if (!config) return;
31
+ const names = Object.keys(config);
32
+ if (names.length === 0) return;
33
+ const errors = [];
34
+ for (const name of names) if (name === SINGLETON || name === TRANSIENT) errors.push(`- '${name}' is a built-in lifecycle and cannot be declared in 'scopes'.`);
35
+ for (const name of names) {
36
+ const declaredParent = parentOf(config, name);
37
+ if (declaredParent === TRANSIENT) errors.push(`- '${name}' declares 'transient' as its parent, but 'transient' is the implicit leaf and can never be a parent.`);
38
+ else if (declaredParent !== SINGLETON && !(declaredParent in config)) errors.push(`- '${name}' declares unknown parent '${declaredParent}'. Parents must be 'singleton' or another scope declared in 'scopes'.`);
39
+ }
40
+ for (const start of names) {
41
+ const seen = /* @__PURE__ */ new Set();
42
+ let current = start;
43
+ while (current && current !== SINGLETON) {
44
+ if (seen.has(current)) {
45
+ errors.push(`- Cyclic scope hierarchy detected: ${[...seen, current].join(" -> ")}.`);
46
+ break;
47
+ }
48
+ seen.add(current);
49
+ current = current in config ? parentOf(config, current) : void 0;
50
+ }
51
+ }
52
+ if (errors.length) throw new Error(["[alloy] Invalid 'scopes' configuration:", ...dedupe(errors)].join("\n"));
53
+ }
54
+ /**
55
+ * Returns the ancestor scopes of `scope` (longer-lived scopes it bubbles up
56
+ * to), ordered nearest-first and always terminating with `singleton`. The
57
+ * implicit `singleton` root has no ancestors; `transient` is the leaf and is
58
+ * never an ancestor of anything.
59
+ */
60
+ function getAncestorScopes(scope, config) {
61
+ if (scope === SINGLETON || scope === TRANSIENT) return [];
62
+ if (!(scope in config)) return [SINGLETON];
63
+ const ancestors = [];
64
+ let current = parentOf(config, scope);
65
+ const guard = /* @__PURE__ */ new Set();
66
+ while (current) {
67
+ if (guard.has(current)) break;
68
+ guard.add(current);
69
+ ancestors.push(current);
70
+ if (current === SINGLETON) break;
71
+ current = parentOf(config, current);
72
+ }
73
+ return ancestors;
74
+ }
75
+ /**
76
+ * Decides whether a service in `hostScope` may depend on a service in
77
+ * `depScope`. A service may depend only on services in its own scope or a
78
+ * longer-lived (ancestor) scope. `transient` (the leaf) may depend on anything;
79
+ * nothing longer-lived may depend on a `transient`.
80
+ */
81
+ function isDependencyAllowed(hostScope, depScope, config) {
82
+ if (hostScope === TRANSIENT) return true;
83
+ if (depScope === hostScope) return true;
84
+ return getAncestorScopes(hostScope, config).includes(depScope);
85
+ }
86
+ /**
87
+ * Validates the discovered service graph against the declared scope hierarchy:
88
+ *
89
+ * 1. **Unknown scope usage** — every service must declare a scope that is
90
+ * `singleton`, `transient`, or present in the `scopes` config.
91
+ * 2. **Scope stability** — no service may depend on a shorter-lived
92
+ * (descendant) scope; such a captive dependency leaks the short-lived
93
+ * instance into its longer-lived host.
94
+ *
95
+ * All issues are gathered and reported together, mirroring the build's
96
+ * circular-dependency diagnostics. Assumes `config` has already passed
97
+ * {@link validateScopesConfig}.
98
+ */
99
+ function validateScopeStability(metas, config) {
100
+ const scopesConfig = config ?? {};
101
+ const known = new Set([
102
+ SINGLETON,
103
+ TRANSIENT,
104
+ ...Object.keys(scopesConfig)
105
+ ]);
106
+ const { byClassName, byFilePath } = buildMetaIndexes(metas);
107
+ const unknownScopeErrors = [];
108
+ const violations = [];
109
+ for (const host of metas) {
110
+ const hostScope = host.metadata.scope;
111
+ if (!known.has(hostScope)) {
112
+ unknownScopeErrors.push(`- '${host.className}' declares unknown scope '${hostScope}'. Declare it in the 'scopes' plugin option or use a built-in lifecycle.`);
113
+ continue;
114
+ }
115
+ const visited = /* @__PURE__ */ new Set();
116
+ for (const dep of host.metadata.dependencies) for (const identifier of gatherIdentifiers(dep)) for (const target of resolveServiceMetas(identifier, host, byClassName, byFilePath)) {
117
+ if (target === host || visited.has(target)) continue;
118
+ visited.add(target);
119
+ const depScope = target.metadata.scope;
120
+ if (!known.has(depScope)) continue;
121
+ if (!isDependencyAllowed(hostScope, depScope, scopesConfig)) violations.push({
122
+ hostClassName: host.className,
123
+ hostScope,
124
+ hostFilePath: host.filePath,
125
+ depClassName: target.className,
126
+ depScope
127
+ });
128
+ }
129
+ }
130
+ if (!unknownScopeErrors.length && !violations.length) return;
131
+ const sections = [];
132
+ if (unknownScopeErrors.length) sections.push("[alloy] Unknown scope(s) used by discovered services:", ...dedupe(unknownScopeErrors));
133
+ if (violations.length) sections.push("[alloy] Scope stability violation(s) detected — a longer-lived service may only depend on equal- or longer-lived scopes:", ...dedupe(violations.map((v) => `- '${v.hostClassName}' (${v.hostScope}) depends on '${v.depClassName}' (${v.depScope}), a shorter-lived scope. The dependency would be captured and leak.`)));
134
+ throw new Error(sections.join("\n"));
135
+ }
136
+ function dedupe(values) {
137
+ return Array.from(new Set(values));
138
+ }
139
+ function buildMetaIndexes(metas) {
140
+ const byClassName = /* @__PURE__ */ new Map();
141
+ const byFilePath = /* @__PURE__ */ new Map();
142
+ for (const meta of metas) {
143
+ const classBucket = byClassName.get(meta.className) ?? [];
144
+ classBucket.push(meta);
145
+ byClassName.set(meta.className, classBucket);
146
+ const normalizedPath = normalizeImportPath(meta.filePath);
147
+ const pathBucket = byFilePath.get(normalizedPath) ?? [];
148
+ pathBucket.push(meta);
149
+ byFilePath.set(normalizedPath, pathBucket);
150
+ }
151
+ return {
152
+ byClassName,
153
+ byFilePath
154
+ };
155
+ }
156
+ /**
157
+ * Collects the candidate service identifiers a dependency expression refers to.
158
+ * Mirrors the visualizer's resolution so stability edges match the rendered
159
+ * graph: prefer recorded references, fall back to inferring from the raw
160
+ * expression for lazy imports.
161
+ */
162
+ function gatherIdentifiers(dep) {
163
+ const identifiers = /* @__PURE__ */ new Set();
164
+ const ignored = new Set(dep.ignoredIdentifiers ?? []);
165
+ for (const ident of dep.referencedIdentifiers ?? []) {
166
+ const trimmed = ident.trim();
167
+ if (!trimmed || RESERVED_IDENTIFIERS.has(trimmed) || ignored.has(trimmed)) continue;
168
+ identifiers.add(trimmed);
169
+ }
170
+ if (!identifiers.size) for (const inferred of inferIdentifiersFromExpression(dep.expression)) {
171
+ const trimmed = inferred.trim();
172
+ if (!trimmed || RESERVED_IDENTIFIERS.has(trimmed) || ignored.has(trimmed)) continue;
173
+ identifiers.add(trimmed);
174
+ }
175
+ return Array.from(identifiers);
176
+ }
177
+ function inferIdentifiersFromExpression(expression) {
178
+ const matches = /* @__PURE__ */ new Set();
179
+ const thenPattern = /\.then\(\s*(?:\w+)\s*=>\s*\w+\.([A-Za-z_][A-Za-z0-9_]*)/g;
180
+ let match;
181
+ while ((match = thenPattern.exec(expression)) !== null) matches.add(match[1]);
182
+ if (!matches.size) {
183
+ const simple = expression.match(/([A-Za-z_][A-Za-z0-9_]*)$/);
184
+ if (simple) matches.add(simple[1]);
185
+ }
186
+ return Array.from(matches);
187
+ }
188
+ /**
189
+ * Resolves a dependency identifier to the service metas it targets, using
190
+ * recorded import references (by path + export name) with a class-name
191
+ * fallback. Identifiers that resolve to no service (e.g. tokens) yield nothing.
192
+ */
193
+ function resolveServiceMetas(identifier, meta, byClassName, byFilePath) {
194
+ const matches = [];
195
+ const importRef = meta.referencedImports?.find((ref) => !ref.isTypeOnly && ref.name === identifier);
196
+ if (importRef) {
197
+ const normalizedPath = resolveImportSpecifierPath(meta.filePath, importRef.path);
198
+ if (normalizedPath) {
199
+ const byPath = byFilePath.get(normalizedPath);
200
+ if (byPath?.length) {
201
+ if (importRef.originalName && importRef.originalName !== "*" && importRef.originalName !== "default") {
202
+ for (const candidate of byPath) if (candidate.className === importRef.originalName) matches.push(candidate);
203
+ }
204
+ if (!matches.length) matches.push(...byPath);
205
+ }
206
+ }
207
+ const fallbackName = importRef.originalName && importRef.originalName !== "*" && importRef.originalName !== "default" ? importRef.originalName : identifier;
208
+ const byName = byClassName.get(fallbackName);
209
+ if (byName) matches.push(...byName);
210
+ } else {
211
+ const byName = byClassName.get(identifier);
212
+ if (byName) matches.push(...byName);
213
+ }
214
+ return dedupeMetas(matches);
215
+ }
216
+ function dedupeMetas(metas) {
217
+ return Array.from(new Set(metas));
218
+ }
219
+ function resolveImportSpecifierPath(sourceFilePath, specifier) {
220
+ if (!specifier) return;
221
+ if (specifier.startsWith(".")) return normalizeImportPath(path.resolve(path.dirname(sourceFilePath), specifier));
222
+ return normalizeImportPath(specifier);
223
+ }
224
+ //#endregion
225
+ export { isDependencyAllowed, validateScopeStability, validateScopesConfig };
@@ -1,6 +1,11 @@
1
- import { ServiceScope } from "../../lib/scope.js";
2
-
3
1
  //#region src/plugins/core/types.d.ts
2
+ /**
3
+ * Build-time scope name. Unlike the runtime `ServiceScope` (a closed union of
4
+ * the two built-ins within the library's own compilation), discovered metadata
5
+ * may carry arbitrary, application-defined custom scope names (e.g. `session`,
6
+ * `request`) which are validated against the plugin `scopes` config.
7
+ */
8
+ type BuildScope = string;
4
9
  interface ManifestTokenDependency {
5
10
  exportName: string;
6
11
  importPath: string;
@@ -33,7 +38,7 @@ interface ManifestServiceDescriptorBase {
33
38
  * Format: `alloy:<package-name>/<relative-path>#<ClassName>`
34
39
  */
35
40
  symbolKey: string;
36
- scope: ServiceScope;
41
+ scope: BuildScope;
37
42
  }
38
43
  interface ManifestServiceDescriptorV1 extends ManifestServiceDescriptorBase {
39
44
  deps: string[];
@@ -59,4 +64,4 @@ interface AlloyManifest {
59
64
  };
60
65
  }
61
66
  //#endregion
62
- export { AlloyManifest };
67
+ export { AlloyManifest, BuildScope };
@@ -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,10 +1,10 @@
1
- import { ServiceScope } from "../../lib/scope.js";
1
+ import { BuildScope } from "./types.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;
7
- scopeColors?: Partial<Record<ServiceScope, string>>;
7
+ scopeColors?: Partial<Record<BuildScope, string>>;
8
8
  lazyNodeFill?: string;
9
9
  factoryNodeFill?: string;
10
10
  tokenNodeFill?: string;