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.
- package/README.md +1 -1
- package/dist/lib/container.d.ts +22 -5
- package/dist/lib/container.js +85 -25
- package/dist/lib/decorators.d.ts +1 -1
- package/dist/lib/env-detection.d.ts +24 -0
- package/dist/lib/env-detection.js +22 -9
- package/dist/lib/providers.d.ts +1 -1
- package/dist/lib/scope.d.ts +19 -2
- package/dist/plugins/core/codegen.js +118 -14
- package/dist/plugins/{vite-plugin → core}/container-loader.js +26 -13
- package/dist/plugins/core/decorators.js +12 -22
- package/dist/plugins/{vite-plugin → core}/discovery-runtime.js +4 -14
- package/dist/plugins/core/discovery-store.js +15 -0
- package/dist/plugins/{vite-plugin → core}/manifest-utils.js +14 -4
- package/dist/plugins/core/scanner.js +12 -0
- package/dist/plugins/core/scopes-validation.d.ts +19 -0
- package/dist/plugins/core/scopes-validation.js +225 -0
- package/dist/plugins/core/types.d.ts +9 -4
- package/dist/plugins/core/utils.js +45 -8
- package/dist/plugins/{vite-plugin → core}/visualization-utils.d.ts +1 -1
- package/dist/plugins/{vite-plugin → core}/visualization-utils.js +1 -1
- package/dist/plugins/{vite-plugin → core}/visualizer.d.ts +3 -3
- package/dist/plugins/{vite-plugin → core}/visualizer.js +92 -20
- package/dist/plugins/rollup-plugin/barrel-exports.js +51 -0
- package/dist/plugins/rollup-plugin/dependency-resolution.js +42 -0
- package/dist/plugins/rollup-plugin/index.d.ts +2 -23
- package/dist/plugins/rollup-plugin/index.js +15 -128
- package/dist/plugins/rollup-plugin/manifest-deps.js +48 -0
- package/dist/plugins/rollup-plugin/package-exports.js +20 -0
- package/dist/plugins/rollup-plugin/types.d.ts +36 -0
- package/dist/plugins/vite-plugin/index.d.ts +10 -2
- package/dist/plugins/vite-plugin/index.js +9 -4
- package/dist/plugins/vite-plugin/module-invalidation.js +13 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +3 -1
- package/dist/scopes.d.ts +66 -0
- package/dist/scopes.js +141 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -2
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { normalizeImportPath } from "
|
|
2
|
-
import { IdentifierResolver } from "
|
|
3
|
-
import { generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition } from "
|
|
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/
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 (
|
|
94
|
+
if (isSingletonDecorator) scope = ServiceScope.SINGLETON;
|
|
105
95
|
return {
|
|
106
96
|
scope,
|
|
107
97
|
dependencies
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createClassKey } from "
|
|
2
|
-
import { createDiscoveryStore } from "
|
|
3
|
-
//#region src/plugins/
|
|
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,
|
|
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 "
|
|
1
|
+
import { createClassKey, createSymbolKey, normalizeImportPath } from "./utils.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
//#region src/plugins/
|
|
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)
|
|
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)
|
|
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:
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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/
|
|
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/
|
|
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 {
|
|
1
|
+
import { BuildScope } from "./types.js";
|
|
2
2
|
|
|
3
|
-
//#region src/plugins/
|
|
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<
|
|
7
|
+
scopeColors?: Partial<Record<BuildScope, string>>;
|
|
8
8
|
lazyNodeFill?: string;
|
|
9
9
|
factoryNodeFill?: string;
|
|
10
10
|
tokenNodeFill?: string;
|