@djodjonx/neo-syringe 1.1.5 → 1.2.2

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 (61) hide show
  1. package/.github/workflows/ci.yml +6 -5
  2. package/.github/workflows/docs.yml +59 -0
  3. package/CHANGELOG.md +27 -0
  4. package/README.md +74 -740
  5. package/dist/{GraphValidator-G0F4QiLk.cjs → GraphValidator-CV4VoJl0.cjs} +18 -10
  6. package/dist/{GraphValidator-C8ldJtNp.mjs → GraphValidator-DXqqkNdS.mjs} +18 -10
  7. package/dist/cli/index.cjs +16 -1
  8. package/dist/cli/index.mjs +16 -1
  9. package/dist/index.d.cts +31 -5
  10. package/dist/index.d.mts +31 -5
  11. package/dist/lsp/index.cjs +1 -1
  12. package/dist/lsp/index.mjs +1 -1
  13. package/dist/unplugin/index.cjs +33 -9
  14. package/dist/unplugin/index.d.cts +7 -5
  15. package/dist/unplugin/index.d.mts +7 -5
  16. package/dist/unplugin/index.mjs +33 -9
  17. package/docs/.vitepress/config.ts +109 -0
  18. package/docs/.vitepress/theme/custom.css +150 -0
  19. package/docs/.vitepress/theme/index.ts +17 -0
  20. package/docs/api/configuration.md +274 -0
  21. package/docs/api/functions.md +291 -0
  22. package/docs/api/types.md +158 -0
  23. package/docs/guide/basic-usage.md +267 -0
  24. package/docs/guide/cli.md +174 -0
  25. package/docs/guide/generated-code.md +284 -0
  26. package/docs/guide/getting-started.md +171 -0
  27. package/docs/guide/ide-plugin.md +203 -0
  28. package/docs/guide/injection-types.md +287 -0
  29. package/docs/guide/legacy-migration.md +333 -0
  30. package/docs/guide/lifecycle.md +223 -0
  31. package/docs/guide/parent-container.md +321 -0
  32. package/docs/guide/scoped-injections.md +271 -0
  33. package/docs/guide/what-is-neo-syringe.md +162 -0
  34. package/docs/guide/why-neo-syringe.md +219 -0
  35. package/docs/index.md +138 -0
  36. package/docs/public/logo.png +0 -0
  37. package/package.json +15 -12
  38. package/src/analyzer/Analyzer.ts +20 -10
  39. package/src/analyzer/types.ts +55 -49
  40. package/src/cli/index.ts +15 -0
  41. package/src/generator/Generator.ts +24 -2
  42. package/src/generator/GraphValidator.ts +6 -2
  43. package/src/types.ts +30 -4
  44. package/src/unplugin/index.ts +13 -41
  45. package/tests/analyzer/Analyzer.test.ts +4 -4
  46. package/tests/analyzer/AnalyzerDeclarative.test.ts +1 -1
  47. package/tests/analyzer/Factory.test.ts +2 -2
  48. package/tests/analyzer/Scoped.test.ts +434 -0
  49. package/tests/cli/cli.test.ts +91 -0
  50. package/tests/e2e/container-integration.test.ts +21 -21
  51. package/tests/e2e/generated-code.test.ts +7 -7
  52. package/tests/e2e/scoped.test.ts +370 -0
  53. package/tests/e2e/snapshots.test.ts +2 -2
  54. package/tests/e2e/standalone.test.ts +2 -2
  55. package/tests/generator/ExternalGenerator.test.ts +1 -1
  56. package/tests/generator/FactoryGenerator.test.ts +6 -6
  57. package/tests/generator/Generator.test.ts +2 -2
  58. package/tests/generator/GeneratorDeclarative.test.ts +1 -1
  59. package/tests/generator/GraphValidator.test.ts +1 -1
  60. package/tsconfig.json +2 -1
  61. package/typedoc.json +0 -5
@@ -1,87 +1,93 @@
1
1
  import type { Symbol, Node } from 'typescript';
2
2
 
3
- export type TokenId = string; // Unique identifier for the token (Interface Name or Class Name)
3
+ /**
4
+ * Unique identifier for a token (interface name or class name).
5
+ */
6
+ export type TokenId = string;
4
7
 
8
+ /**
9
+ * How a service was registered in the container.
10
+ * - `explicit`: Provider was explicitly specified.
11
+ * - `autowire`: Token is both the token and provider (self-binding).
12
+ * - `parent`: Inherited from parent container.
13
+ * - `factory`: Provider is a factory function.
14
+ */
5
15
  export type RegistrationType = 'explicit' | 'autowire' | 'parent' | 'factory';
6
16
 
17
+ /**
18
+ * Represents a single service definition in the dependency graph.
19
+ */
7
20
  export interface ServiceDefinition {
21
+ /** Unique identifier for this service token. */
8
22
  tokenId: TokenId;
23
+
9
24
  /**
10
- * The symbol of the concrete class implementation.
25
+ * The TypeScript symbol of the concrete class implementation.
11
26
  * Undefined if the provider is a factory function.
12
27
  */
13
28
  implementationSymbol?: Symbol;
14
29
 
15
30
  /**
16
- * The symbol of the token (if it is a Class/Value).
17
- * Undefined if the token is a Virtual Interface ID.
31
+ * The TypeScript symbol of the token (if it is a Class/Value).
32
+ * Undefined if the token is a virtual interface ID.
18
33
  */
19
34
  tokenSymbol?: Symbol;
20
35
 
21
- /**
22
- * The source node where the registration happened (for error reporting).
23
- */
36
+ /** The source node where the registration happened (for error reporting). */
24
37
  registrationNode: Node;
25
38
 
39
+ /** How this service was registered. */
26
40
  type: RegistrationType;
27
- scope: 'singleton' | 'transient';
28
41
 
29
- /**
30
- * True if the token is an Interface (requires string literal key).
31
- * False if the token is a Class (requires reference key).
32
- */
42
+ /** Lifecycle of the service instance. */
43
+ lifecycle: 'singleton' | 'transient';
44
+
45
+ /** True if the token is an interface (requires string literal key). */
33
46
  isInterfaceToken?: boolean;
34
47
 
35
- /**
36
- * True if the token is a Value Token (useToken<T>('name')).
37
- * Used for primitive values like string, number, boolean.
38
- */
48
+ /** True if the token is a value token for primitives. */
39
49
  isValueToken?: boolean;
40
50
 
41
- /**
42
- * True if the provider is a factory function.
43
- */
51
+ /** True if the provider is a factory function. */
44
52
  isFactory?: boolean;
45
53
 
46
- /**
47
- * The raw source text of the factory function (for code generation).
48
- */
54
+ /** The raw source text of the factory function (for code generation). */
49
55
  factorySource?: string;
56
+
57
+ /** True if this injection is scoped to the local container. */
58
+ isScoped?: boolean;
50
59
  }
51
60
 
61
+ /**
62
+ * A node in the dependency graph representing a service and its dependencies.
63
+ */
52
64
  export interface DependencyNode {
65
+ /** The service definition. */
53
66
  service: ServiceDefinition;
54
- /**
55
- * The dependencies required by this service's constructor.
56
- * Maps parameter index to the TokenId it depends on.
57
- */
67
+
68
+ /** Token IDs of dependencies required by this service's constructor. */
58
69
  dependencies: TokenId[];
59
70
  }
60
71
 
72
+ /**
73
+ * Complete dependency graph for a container configuration.
74
+ */
61
75
  export interface DependencyGraph {
76
+ /** All service nodes indexed by their token ID. */
62
77
  nodes: Map<TokenId, DependencyNode>;
63
- /**
64
- * The root services that are explicitly requested or exported.
65
- */
78
+
79
+ /** Root services that are explicitly requested or exported. */
66
80
  roots: TokenId[];
67
- /**
68
- * Arguments passed to the .build() method call.
69
- * Captured as raw source text to be injected into the generated constructor.
70
- */
81
+
82
+ /** Arguments passed to the .build() method call (raw source text). */
71
83
  buildArguments?: string[];
72
- /**
73
- * The optional name of the container for debugging.
74
- */
75
- containerName?: string;
76
-
77
- /**
78
- * List of legacy container variable names to delegate to.
79
- */
80
- legacyContainers?: string[];
81
-
82
- /**
83
- * Tokens provided by the parent container (useContainer).
84
- * Used for validation - these tokens don't need local bindings.
85
- */
86
- parentProvidedTokens?: Set<TokenId>;
87
- }
84
+
85
+ /** Optional container name for debugging. */
86
+ containerName?: string;
87
+
88
+ /** Legacy container variable names to delegate to. */
89
+ legacyContainers?: string[];
90
+
91
+ /** Tokens provided by the parent container (used for validation). */
92
+ parentProvidedTokens?: Set<TokenId>;
93
+ }
package/src/cli/index.ts CHANGED
@@ -1,8 +1,23 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * Neo-Syringe CLI
4
+ *
5
+ * Validates the dependency graph for a TypeScript project.
6
+ * Detects circular dependencies, missing bindings, and duplicate registrations.
7
+ *
8
+ * @example
9
+ * ```bash
10
+ * npx neo-syringe
11
+ * ```
12
+ */
2
13
  import * as ts from 'typescript';
3
14
  import { Analyzer } from '../analyzer/Analyzer';
4
15
  import { GraphValidator } from '../generator/GraphValidator';
5
16
 
17
+ /**
18
+ * CLI entry point.
19
+ * Reads tsconfig.json, analyzes the project, and validates the dependency graph.
20
+ */
6
21
  function main() {
7
22
  const cwd = process.cwd();
8
23
  const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, 'tsconfig.json');
@@ -2,11 +2,24 @@ import * as ts from 'typescript';
2
2
  import { DependencyGraph, TokenId } from '../analyzer/types';
3
3
 
4
4
  /**
5
- * Generates the TypeScript code for the dependency injection container.
5
+ * Generates TypeScript code for the dependency injection container.
6
+ *
7
+ * Takes a validated dependency graph and produces:
8
+ * - Import statements for all dependencies
9
+ * - Factory functions for each service
10
+ * - A NeoContainer class with resolve logic
6
11
  */
7
12
  export class Generator {
13
+ /**
14
+ * Creates a new Generator.
15
+ * @param graph - The validated dependency graph to generate code from.
16
+ */
8
17
  constructor(private graph: DependencyGraph) {}
9
18
 
19
+ /**
20
+ * Generates the complete container code as a string.
21
+ * @returns TypeScript source code for the generated container.
22
+ */
10
23
  public generate(): string {
11
24
  const sorted = this.topologicalSort();
12
25
  const imports = new Map<string, string>(); // filePath -> importAliasPrefix
@@ -78,7 +91,7 @@ function ${factoryId}(container: NeoContainer) {
78
91
  }
79
92
 
80
93
  // 2. Generate Resolve Switch Case
81
- const isTransient = node.service.scope === 'transient';
94
+ const isTransient = node.service.lifecycle === 'transient';
82
95
 
83
96
  // Determine key for instances Map and token check
84
97
  let tokenKey: string;
@@ -196,6 +209,10 @@ export const container = new NeoContainer(${containerArgs});
196
209
  `;
197
210
  }
198
211
 
212
+ /**
213
+ * Sorts services in topological order (dependencies before dependents).
214
+ * @returns Array of TokenIds in dependency order.
215
+ */
199
216
  private topologicalSort(): TokenId[] {
200
217
  const visited = new Set<TokenId>();
201
218
  const sorted: TokenId[] = [];
@@ -220,6 +237,11 @@ export const container = new NeoContainer(${containerArgs});
220
237
  return sorted;
221
238
  }
222
239
 
240
+ /**
241
+ * Creates a valid JavaScript function name from a token ID.
242
+ * @param tokenId - The token identifier.
243
+ * @returns A sanitized factory function name.
244
+ */
223
245
  private getFactoryName(tokenId: TokenId): string {
224
246
  return `create_${tokenId.replace(/[^a-zA-Z0-9]/g, '_')}`;
225
247
  }
@@ -19,11 +19,15 @@ export class GraphValidator {
19
19
  const parentTokens = graph.parentProvidedTokens ?? new Set<TokenId>();
20
20
 
21
21
  // 1. Check for Duplicate Registrations (local token already in parent)
22
- for (const nodeId of graph.nodes.keys()) {
22
+ for (const [nodeId, node] of graph.nodes) {
23
23
  if (parentTokens.has(nodeId)) {
24
+ // Allow if scoped: true (intentional override)
25
+ if (node.service.isScoped) {
26
+ continue;
27
+ }
24
28
  throw new Error(
25
29
  `Duplicate registration: '${nodeId}' is already registered in the parent container. ` +
26
- `Remove the local registration or use a different token.`
30
+ `Use 'scoped: true' to override the parent's registration intentionally.`
27
31
  );
28
32
  }
29
33
  }
package/src/types.ts CHANGED
@@ -2,14 +2,14 @@
2
2
  * Represents a generic class constructor.
3
3
  * @template T - The type of the instance created by the constructor.
4
4
  */
5
- export type Constructor<T = unknown> = new (...args: unknown[]) => T;
5
+ export type Constructor<T = any> = new (...args: any[]) => T;
6
6
 
7
7
  /**
8
- * Defines the lifecycle scope of a service.
8
+ * Defines the lifecycle of a service.
9
9
  * - `singleton`: One instance per container.
10
10
  * - `transient`: A new instance every time it is resolved.
11
11
  */
12
- export type Scope = 'singleton' | 'transient';
12
+ export type Lifecycle = 'singleton' | 'transient';
13
13
 
14
14
  /**
15
15
  * The dependency injection container interface.
@@ -86,7 +86,33 @@ export interface Injection<T = any> {
86
86
  * Required when provider is a function, not a class.
87
87
  */
88
88
  useFactory?: boolean;
89
- scope?: Scope;
89
+ /**
90
+ * Lifecycle of the service.
91
+ * - `singleton`: One instance per container (default).
92
+ * - `transient`: A new instance every time it is resolved.
93
+ */
94
+ lifecycle?: Lifecycle;
95
+ /**
96
+ * If true, this injection is scoped to this container only.
97
+ * Allows overriding a token from a parent container without causing a duplicate error.
98
+ * The local instance will be used instead of delegating to the parent.
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * const parent = defineBuilderConfig({
103
+ * injections: [{ token: useInterface<ILogger>(), provider: ConsoleLogger }]
104
+ * });
105
+ *
106
+ * const child = defineBuilderConfig({
107
+ * useContainer: parent,
108
+ * injections: [
109
+ * // Override parent's ILogger with a local FileLogger
110
+ * { token: useInterface<ILogger>(), provider: FileLogger, scoped: true }
111
+ * ]
112
+ * });
113
+ * ```
114
+ */
115
+ scoped?: boolean;
90
116
  }
91
117
 
92
118
  /**
@@ -5,46 +5,35 @@ import { GraphValidator } from '../generator/GraphValidator';
5
5
  import { Generator } from '../generator/Generator';
6
6
 
7
7
  /**
8
- * The Unplugin factory for Neo-Syringe.
8
+ * Neo-Syringe build plugin for Vite, Rollup, Webpack, and other bundlers.
9
9
  *
10
- * This plugin integrates with Vite, Rollup, Webpack, etc.
11
- * It intercepts files containing `createContainer` calls, analyzes them,
12
- * and replaces the runtime configuration code with the generated dependency graph.
10
+ * Intercepts files containing `defineBuilderConfig` calls, analyzes the
11
+ * dependency graph, validates it, and replaces the configuration with
12
+ * generated factory code.
13
13
  *
14
14
  * @example
15
+ * ```typescript
15
16
  * // vite.config.ts
16
17
  * import { neoSyringePlugin } from '@djodjonx/neo-syringe/plugin';
17
18
  *
18
19
  * export default defineConfig({
19
- * plugins: [neoSyringePlugin.vite()],
20
+ * plugins: [neoSyringePlugin.vite()]
20
21
  * });
22
+ * ```
21
23
  */
22
- export const neoSyringePlugin = createUnplugin((_options) => {
24
+ export const neoSyringePlugin = createUnplugin(() => {
23
25
  return {
24
26
  name: 'neo-syringe-plugin',
25
- /**
26
- * Includes .ts and .tsx files for transformation.
27
- * @param id - The file path.
28
- */
27
+
29
28
  transformInclude(id) {
30
29
  return id.endsWith('.ts') || id.endsWith('.tsx');
31
30
  },
32
- /**
33
- * Transforms the code by replacing container definitions with generated factories.
34
- * @param code - The source code.
35
- * @param id - The file path.
36
- */
31
+
37
32
  transform(code, id) {
38
33
  if (!code.includes('defineBuilderConfig')) return;
39
34
 
40
- // naive check, but saves performance.
41
-
42
- // We need to parse this file to see if it really defines a container.
43
- // We need a full Program to resolve types across files.
44
- // Using existing tsconfig if possible.
45
-
46
35
  const configFile = ts.findConfigFile(process.cwd(), ts.sys.fileExists, 'tsconfig.json');
47
- if (!configFile) return; // Cannot work without tsconfig
36
+ if (!configFile) return;
48
37
 
49
38
  const { config } = ts.readConfigFile(configFile, ts.sys.readFile);
50
39
  const { options: compilerOptions } = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd());
@@ -53,30 +42,13 @@ export const neoSyringePlugin = createUnplugin((_options) => {
53
42
  const analyzer = new Analyzer(program);
54
43
  const graph = analyzer.extract();
55
44
 
56
- if (graph.nodes.size === 0) return; // No container found
45
+ if (graph.nodes.size === 0) return;
57
46
 
58
- // Validate
59
47
  const validator = new GraphValidator();
60
48
  validator.validate(graph);
61
49
 
62
- // Generate
63
50
  const generator = new Generator(graph);
64
- const generatedCode = generator.generate();
65
-
66
- // We need to replace the export.
67
- // Strategy: Use the generated code AS the content of this file?
68
- // If the file *only* exports the container, yes.
69
- // But if it exports other things, we might break them.
70
-
71
- // Safer strategy:
72
- // Replace the `createContainer()...build()` expression with the object literal from generated code.
73
- // But the generated code includes imports. Imports must be at top level.
74
-
75
- // So we must REPLACE the whole file content with the generated code.
76
- // This implies the user should put the container in a dedicated file.
77
- // We can warn or document this.
78
-
79
- return generatedCode;
51
+ return generator.generate();
80
52
  },
81
53
  };
82
54
  });
@@ -49,8 +49,8 @@ describe('Analyzer', () => {
49
49
  class B {}
50
50
  export const container = defineBuilderConfig({
51
51
  injections: [
52
- { token: A, scope: 'transient' },
53
- { token: B, scope: 'singleton' }
52
+ { token: A, lifecycle: 'transient' },
53
+ { token: B, lifecycle: 'singleton' }
54
54
  ]
55
55
  });
56
56
  `;
@@ -69,7 +69,7 @@ describe('Analyzer', () => {
69
69
  const analyzer = new Analyzer(program);
70
70
  const graph = analyzer.extract();
71
71
 
72
- expect(graph.nodes.get('A')?.service.scope).toBe('transient');
73
- expect(graph.nodes.get('B')?.service.scope).toBe('singleton');
72
+ expect(graph.nodes.get('A')?.service.lifecycle).toBe('transient');
73
+ expect(graph.nodes.get('B')?.service.lifecycle).toBe('singleton');
74
74
  });
75
75
  });
@@ -80,7 +80,7 @@ describe('Analyzer - Declarative Config (defineBuilderConfig)', () => {
80
80
 
81
81
  // Since we don't know the ID yet, let's iterate.
82
82
  const nodes = Array.from(graph.nodes.values());
83
- const loggerNode = nodes.find(n => n.service.implementationSymbol.getName() === 'ConsoleLogger');
83
+ const loggerNode = nodes.find(n => n.service.implementationSymbol?.getName() === 'ConsoleLogger');
84
84
 
85
85
  expect(loggerNode).toBeDefined();
86
86
  expect(loggerNode?.service.type).toBe('explicit');
@@ -151,7 +151,7 @@ describe('Analyzer - Factory Support', () => {
151
151
  {
152
152
  token: useInterface<IRequest>(),
153
153
  provider: () => ({ id: ++counter }),
154
- scope: 'transient'
154
+ lifecycle: 'transient'
155
155
  }
156
156
  ]
157
157
  });
@@ -163,7 +163,7 @@ describe('Analyzer - Factory Support', () => {
163
163
 
164
164
  const node = findNodeByName(graph, 'IRequest');
165
165
  expect(node?.service.isFactory).toBe(true);
166
- expect(node?.service.scope).toBe('transient');
166
+ expect(node?.service.lifecycle).toBe('transient');
167
167
  });
168
168
 
169
169
  it('should not treat class as factory', () => {