@ts-ioc-container/bundler 1.1.0 → 1.2.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/AGENTS.md CHANGED
@@ -13,8 +13,11 @@ it.
13
13
  - A namespace is a folder: relative to the config file, or a tsconfig `paths`
14
14
  alias. Generated imports use the most specific alias, else a relative path.
15
15
  - Default selection is every exported, non-abstract class. A module's
16
- `select: { export, decorators, nameGlob }` narrows it; a class must meet every
17
- criterion set. Decorators match by name, so composed ones must be listed.
16
+ `select: { export, decorators, nameGlob, excludeClasses, excludeNameGlob }`
17
+ narrows it; positive criteria must all hold and then `excludeClasses` (exact
18
+ names) and `excludeNameGlob` (glob) drop classes. Decorators match by name, so
19
+ composed ones must be listed. The build warns when two selected classes pass the
20
+ same plain-identifier first decorator argument (a same-token heuristic).
18
21
  - Never edit a `*.bundle.ts` by hand — change the classes or the config and
19
22
  rerun `tic build`.
20
23
  - Files take part when they match no `exclude` glob **and** an
package/README.md CHANGED
@@ -95,14 +95,46 @@ By default every exported, non-abstract class of a scanned file is registered.
95
95
  }
96
96
  ```
97
97
 
98
- | Criterion | Default | Meaning |
99
- | ------------ | ------- | ----------------------------------------------------------------------------------------------------------- |
100
- | `export` | `any` | `any`, `named` or `default` — which exports count |
101
- | `decorators` | — | The class must carry one of these, by name — also renamed imports and `@ioc.register()`; list composed ones |
102
- | `nameGlob` | — | Glob on the class name; an anonymous default export is named after its file (`user-service.ts` → `UserService`) |
98
+ | Criterion | Default | Meaning |
99
+ | ------------------ | ------- | ----------------------------------------------------------------------------------------------------------- |
100
+ | `export` | `any` | `any`, `named` or `default` — which exports count |
101
+ | `decorators` | — | The class must carry one of these, by name — also renamed imports and `@ioc.register()`; list composed ones |
102
+ | `nameGlob` | — | Glob on the class name; an anonymous default export is named after its file (`user-service.ts` → `UserService`) |
103
+ | `excludeClasses` | — | Class names to drop, e.g. `["MockDashboardRepository"]` |
104
+ | `excludeNameGlob` | — | Glob the class name must **not** match, e.g. `"*Mock"` |
103
105
 
104
106
  Abstract and non-exported classes are never registered.
105
107
 
108
+ ### Excluding a single class
109
+
110
+ When a real implementation and a test-only stand-in are both decorated and both
111
+ bind the same token, registration is last-wins and the stand-in silently wins. A
112
+ `tic.exports.*` predicate works, but forces a hand-maintained name list in code;
113
+ `excludeClasses` and `excludeNameGlob` drop a class right in the config:
114
+
115
+ ```json
116
+ "select": {
117
+ "decorators": ["repository", "service"],
118
+ "excludeClasses": ["MockDashboardRepository"],
119
+ "excludeNameGlob": "*Fake"
120
+ }
121
+ ```
122
+
123
+ Both apply after every other criterion, so they can be combined with
124
+ `decorators` and `nameGlob` freely. Reach for `filterExports` when the rule
125
+ needs more than the class name.
126
+
127
+ As a safety net, the build warns when two selected classes pass the same
128
+ plain-identifier first argument to a decorator — usually the binding token, as in
129
+ `@repository(IDashboardRepositoryToken)`. The check is syntactic (aliased imports
130
+ are not resolved), so it can only be a heuristic:
131
+
132
+ ```text
133
+ tic: warning: bundles[0]: decorator token "IDashboardRepositoryToken" is passed by
134
+ HttpDashboardRepository, MockDashboardRepository; registration is last-wins,
135
+ exclude one with select.excludeClasses
136
+ ```
137
+
106
138
  ## Including files with a predicate
107
139
 
108
140
  When globs aren't enough, decide in code which files take part:
package/cjm/build.js CHANGED
@@ -28,7 +28,10 @@ function build({ config = config_1.DEFAULT_CONFIG_FILE, cwd = process.cwd(), che
28
28
  fileOf: (bundle) => bundle.filterExports,
29
29
  load: exportPredicate_1.loadExportPredicate,
30
30
  });
31
- const generated = resolved.bundles.map((bundle) => generate(resolved, bundle, paths, { include: includeFor(bundle), filterExports: filterExportsFor(bundle) }));
31
+ const generated = resolved.bundles.map((bundle, i) => generate(resolved, bundle, `bundles[${i}]`, paths, {
32
+ include: includeFor(bundle),
33
+ filterExports: filterExportsFor(bundle),
34
+ }));
32
35
  const outputs = generated.map(({ bundle, content, registrations }) => {
33
36
  const current = (0, node_fs_1.existsSync)(bundle.output) ? (0, node_fs_1.readFileSync)(bundle.output, 'utf8') : undefined;
34
37
  const status = current === content ? 'unchanged' : check ? 'stale' : 'written';
@@ -38,7 +41,11 @@ function build({ config = config_1.DEFAULT_CONFIG_FILE, cwd = process.cwd(), che
38
41
  }
39
42
  return { file: bundle.output, bundle: bundle.name, status, registrations, content };
40
43
  });
41
- return { config: resolved.file, outputs, warnings: resolved.warnings };
44
+ return {
45
+ config: resolved.file,
46
+ outputs,
47
+ warnings: [...resolved.warnings, ...generated.flatMap((g) => g.warnings)],
48
+ };
42
49
  }
43
50
  function predicateResolver({ override, conventional, fileOf, load, }) {
44
51
  const loaded = new Map();
@@ -53,7 +60,7 @@ function predicateResolver({ override, conventional, fileOf, load, }) {
53
60
  return loaded.get(file);
54
61
  };
55
62
  }
56
- function generate(config, bundle, paths, { include, filterExports }) {
63
+ function generate(config, bundle, field, paths, { include, filterExports }) {
57
64
  const relative = (file) => (0, glob_1.toPosix)(node_path_1.default.relative(config.dir, file));
58
65
  const outputs = new Set(config.bundles.map((m) => m.output));
59
66
  const excludes = bundle.exclude.map(glob_1.globToRegExp);
@@ -86,5 +93,21 @@ function generate(config, bundle, paths, { include, filterExports }) {
86
93
  namespaces: bundle.namespaces.map((ns) => ns.path),
87
94
  classes,
88
95
  });
89
- return { bundle, content, registrations: classes.length };
96
+ return { bundle, content, registrations: classes.length, warnings: tokenCollisions(classes, field) };
97
+ }
98
+ function tokenCollisions(classes, field) {
99
+ const owners = new Map();
100
+ for (const { className, tokens } of classes) {
101
+ for (const token of tokens) {
102
+ const names = owners.get(token) ?? [];
103
+ if (!names.includes(className))
104
+ names.push(className);
105
+ owners.set(token, names);
106
+ }
107
+ }
108
+ return [...owners]
109
+ .filter(([, names]) => names.length > 1)
110
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
111
+ .map(([token, names]) => `${field}: decorator token "${token}" is passed by ${names.join(', ')}; ` +
112
+ `registration is last-wins, exclude one with select.excludeClasses`);
90
113
  }
package/cjm/config.js CHANGED
@@ -38,7 +38,7 @@ function toNamespace(value, field) {
38
38
  return fail(`${field}.recursive`, 'a boolean');
39
39
  return { path: value.path, recursive: value.recursive ?? true };
40
40
  }
41
- const SELECTOR_FIELDS = new Set(['export', 'decorators', 'nameGlob']);
41
+ const SELECTOR_FIELDS = new Set(['export', 'decorators', 'nameGlob', 'excludeClasses', 'excludeNameGlob']);
42
42
  function toSelector(value, field) {
43
43
  if (value === undefined)
44
44
  return { export: 'any' };
@@ -47,7 +47,7 @@ function toSelector(value, field) {
47
47
  const unknown = Object.keys(value).find((key) => !SELECTOR_FIELDS.has(key));
48
48
  if (unknown)
49
49
  throw new errors_1.TicConfigError(`${field}.${unknown}: unknown field`);
50
- const { export: kind, decorators, nameGlob } = value;
50
+ const { export: kind, decorators, nameGlob, excludeClasses, excludeNameGlob } = value;
51
51
  if (kind !== undefined && kind !== 'any' && kind !== 'named' && kind !== 'default') {
52
52
  return fail(`${field}.export`, '"any", "named" or "default"');
53
53
  }
@@ -56,7 +56,19 @@ function toSelector(value, field) {
56
56
  }
57
57
  if (nameGlob !== undefined && !isNonEmptyString(nameGlob))
58
58
  return fail(`${field}.nameGlob`, 'a non-empty string');
59
- return { export: kind ?? 'any', decorators, nameGlob: nameGlob === undefined ? undefined : (0, glob_1.globToRegExp)(nameGlob) };
59
+ if (excludeClasses !== undefined && !(isStringArray(excludeClasses) && excludeClasses.length > 0)) {
60
+ return fail(`${field}.excludeClasses`, 'a non-empty array of strings');
61
+ }
62
+ if (excludeNameGlob !== undefined && !isNonEmptyString(excludeNameGlob)) {
63
+ return fail(`${field}.excludeNameGlob`, 'a non-empty string');
64
+ }
65
+ return {
66
+ export: kind ?? 'any',
67
+ decorators,
68
+ nameGlob: nameGlob === undefined ? undefined : (0, glob_1.globToRegExp)(nameGlob),
69
+ excludeClasses,
70
+ excludeNameGlob: excludeNameGlob === undefined ? undefined : (0, glob_1.globToRegExp)(excludeNameGlob),
71
+ };
60
72
  }
61
73
  function toBundle(value, field, dir) {
62
74
  if (!isObject(value))
package/cjm/scan.js CHANGED
@@ -114,6 +114,10 @@ function findClasses(file, selector, text = (0, node_fs_1.readFileSync)(file, 'u
114
114
  }
115
115
  }
116
116
  const decoratorsOf = (node) => (ts.getDecorators(node) ?? []).map((d) => decoratorName(d, imports)).filter((n) => !!n);
117
+ const tokensOf = (node) => (ts.getDecorators(node) ?? [])
118
+ .map((d) => (ts.isCallExpression(d.expression) ? d.expression.arguments[0] : undefined))
119
+ .filter((arg) => !!arg && ts.isIdentifier(arg))
120
+ .map((arg) => arg.text);
117
121
  return exported
118
122
  .map(({ node, local, exportName }) => ({ node: node ?? (local ? classes.get(local) : undefined), exportName }))
119
123
  .filter((e) => !!e.node)
@@ -122,6 +126,7 @@ function findClasses(file, selector, text = (0, node_fs_1.readFileSync)(file, 'u
122
126
  exportName,
123
127
  className: node.name?.text ?? nameFromFile(file),
124
128
  decorators: decoratorsOf(node),
129
+ tokens: tokensOf(node),
125
130
  }))
126
131
  .filter(({ node, exportName, className, decorators }) => {
127
132
  if (hasModifier(node, ts.SyntaxKind.AbstractKeyword))
@@ -130,15 +135,22 @@ function findClasses(file, selector, text = (0, node_fs_1.readFileSync)(file, 'u
130
135
  return false;
131
136
  if (selector.decorators && !selector.decorators.some((name) => decorators.includes(name)))
132
137
  return false;
133
- return !selector.nameGlob || selector.nameGlob.test(className);
138
+ if (selector.nameGlob && !selector.nameGlob.test(className))
139
+ return false;
140
+ if (selector.excludeClasses?.includes(className))
141
+ return false;
142
+ if (selector.excludeNameGlob?.test(className))
143
+ return false;
144
+ return true;
134
145
  })
135
146
  .sort((a, b) => a.node.pos - b.node.pos)
136
- .map(({ exportName, className, decorators }) => ({
147
+ .map(({ exportName, className, decorators, tokens }) => ({
137
148
  file,
138
149
  exportName,
139
150
  localName: exportName === 'default' ? className : exportName,
140
151
  className,
141
152
  isDefault: exportName === 'default',
142
153
  decorators,
154
+ tokens,
143
155
  }));
144
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ts-ioc-container/bundler",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Bundles your dependencies into a single container module for ts-ioc-container: the `tic` CLI scans folders (by path or tsconfig alias) and generates one typed bundle that registers every class — `container.useModule(new AppBundle())`.",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/tic.schema.json CHANGED
@@ -99,6 +99,20 @@
99
99
  "type": "string",
100
100
  "minLength": 1,
101
101
  "description": "Glob the class name must match, e.g. \"*Service\". An anonymous default export is named after its file."
102
+ },
103
+ "excludeClasses": {
104
+ "type": "array",
105
+ "minItems": 1,
106
+ "items": {
107
+ "type": "string",
108
+ "minLength": 1
109
+ },
110
+ "description": "Class names to drop after selection, e.g. one test double sitting next to the real registration in the same file."
111
+ },
112
+ "excludeNameGlob": {
113
+ "type": "string",
114
+ "minLength": 1,
115
+ "description": "Glob the class name must NOT match, e.g. \"*Mock\". An anonymous default export is named after its file."
102
116
  }
103
117
  }
104
118
  },
@@ -27,7 +27,11 @@ export interface BuildResult {
27
27
  /** Absolute path of the config that was built. */
28
28
  config: string;
29
29
  outputs: OutputResult[];
30
- /** Non-fatal problems found while resolving the config, e.g. an `exclude` that drops a default test glob. */
30
+ /**
31
+ * Non-fatal problems found while building, e.g. an `exclude` that drops a default
32
+ * test glob or two selected classes passing the same decorator token. Each warning
33
+ * names the bundle field it belongs to.
34
+ */
31
35
  warnings: string[];
32
36
  }
33
37
  /**
@@ -12,12 +12,21 @@ export interface ClassSelector {
12
12
  decorators?: string[];
13
13
  /** A glob the class name must match, e.g. `*Service`. An anonymous default export is named after its file. */
14
14
  nameGlob?: string;
15
+ /**
16
+ * Class names to drop after selection, e.g. one test double sitting next to the
17
+ * real registration in the same file. Unlike `filterExports` this needs no file.
18
+ */
19
+ excludeClasses?: string[];
20
+ /** A glob the class name must NOT match, e.g. `*Mock`. An anonymous default export is named after its file. */
21
+ excludeNameGlob?: string;
15
22
  }
16
- /** A {@link ClassSelector} with its defaults filled in and its `nameGlob` compiled. */
23
+ /** A {@link ClassSelector} with its defaults filled in and its globs compiled. */
17
24
  export interface ResolvedSelector {
18
25
  export: ExportKind;
19
26
  decorators?: string[];
20
27
  nameGlob?: RegExp;
28
+ excludeClasses?: string[];
29
+ excludeNameGlob?: RegExp;
21
30
  }
22
31
  export interface NamespaceConfig {
23
32
  /** A folder relative to the config file (`./src/services`) or a tsconfig `paths` alias (`@app/services`). */
package/typings/scan.d.ts CHANGED
@@ -11,6 +11,12 @@ export interface DiscoveredClass {
11
11
  isDefault: boolean;
12
12
  /** Names of the class's decorators, renamed imports resolved. */
13
13
  decorators: string[];
14
+ /**
15
+ * Plain-identifier first arguments of the class's decorators, e.g. the token in
16
+ * `@repository(Token)`; an empty array when a decorator takes none. Syntax only:
17
+ * used to warn when two classes pass the same identifier.
18
+ */
19
+ tokens: string[];
14
20
  }
15
21
  /** Source files of `dir`, sorted by path so the generated output is stable across machines. */
16
22
  export declare function listSourceFiles(dir: string, recursive: boolean, isExcluded: (file: string) => boolean): string[];