@ts-ioc-container/bundler 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -13,11 +13,14 @@ 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, excludeClasses, excludeNameGlob }`
16
+ `select: { export, decorators, nameGlob, excludeClasses, excludeNameGlob, excludeAliases }`
17
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
+ names), `excludeNameGlob` (glob) and `excludeAliases` (tsconfig `paths` aliases
19
+ the file resolves through, e.g. `services` for `@services/*`) drop classes.
20
+ Decorators match by name, so composed ones must be listed. The build warns when
21
+ two selected classes pass the same plain-identifier first decorator argument (a
22
+ same-token heuristic), unless a decorator they share has differing arguments
23
+ (scope-gated, e.g. `@perPage('a')` vs `@perPage('b')`).
21
24
  - Never edit a `*.bundle.ts` by hand — change the classes or the config and
22
25
  rerun `tic build`.
23
26
  - Files take part when they match no `exclude` glob **and** an
package/README.md CHANGED
@@ -102,6 +102,7 @@ By default every exported, non-abstract class of a scanned file is registered.
102
102
  | `nameGlob` | — | Glob on the class name; an anonymous default export is named after its file (`user-service.ts` → `UserService`) |
103
103
  | `excludeClasses` | — | Class names to drop, e.g. `["MockDashboardRepository"]` |
104
104
  | `excludeNameGlob` | — | Glob the class name must **not** match, e.g. `"*Mock"` |
105
+ | `excludeAliases` | — | tsconfig `paths` aliases to drop, e.g. `["services"]` for a `services/*` or `@services/*` alias |
105
106
 
106
107
  Abstract and non-exported classes are never registered.
107
108
 
@@ -124,6 +125,18 @@ Both apply after every other criterion, so they can be combined with
124
125
  `decorators` and `nameGlob` freely. Reach for `filterExports` when the rule
125
126
  needs more than the class name.
126
127
 
128
+ `excludeAliases` drops classes by the tsconfig `paths` alias their file resolves
129
+ through — handy for excluding a whole namespace without naming its classes:
130
+
131
+ ```json
132
+ "select": {
133
+ "excludeAliases": ["services"]
134
+ }
135
+ ```
136
+
137
+ An entry matches a `paths` pattern such as `services/*` or `@services/*`; a
138
+ leading `@` and a trailing `/*` may be omitted.
139
+
127
140
  As a safety net, the build warns when two selected classes pass the same
128
141
  plain-identifier first argument to a decorator — usually the binding token, as in
129
142
  `@repository(IDashboardRepositoryToken)`. The check is syntactic (aliased imports
@@ -135,6 +148,11 @@ HttpDashboardRepository, MockDashboardRepository; registration is last-wins,
135
148
  exclude one with select.excludeClasses
136
149
  ```
137
150
 
151
+ Classes that are scope-gated are not last-wins, so they do not warn: when the
152
+ colliding classes share a decorator called with different arguments, e.g.
153
+ `@perPage('stations')` vs `@perPage('sessions')`, each scope registers its own
154
+ class and nothing is lost.
155
+
138
156
  ## Including files with a predicate
139
157
 
140
158
  When globs aren't enough, decide in code which files take part:
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.ImportPaths = void 0;
40
+ exports.normalizeAliasName = normalizeAliasName;
40
41
  const node_fs_1 = require("node:fs");
41
42
  const node_path_1 = __importDefault(require("node:path"));
42
43
  const ts = __importStar(require("typescript"));
@@ -92,6 +93,19 @@ class ImportPaths {
92
93
  specifier(fromFile, toFile) {
93
94
  const target = (0, glob_1.toPosix)(toFile);
94
95
  const bare = target.replace(SOURCE_EXTENSION, '');
96
+ const best = this.bestAlias(target, bare);
97
+ if (best)
98
+ return best.spec;
99
+ const relative = (0, glob_1.toPosix)(node_path_1.default.relative(node_path_1.default.dirname(fromFile), bare));
100
+ return `${relative.startsWith('.') ? relative : `./${relative}`}${this.extension}`;
101
+ }
102
+ aliasName(file) {
103
+ const target = (0, glob_1.toPosix)(file);
104
+ const bare = target.replace(SOURCE_EXTENSION, '');
105
+ const best = this.bestAlias(target, bare);
106
+ return best && normalizeAliasName(best.pattern);
107
+ }
108
+ bestAlias(target, bare) {
95
109
  let best;
96
110
  for (const { pattern, targets } of this.aliases) {
97
111
  for (const aliasTarget of targets) {
@@ -100,10 +114,7 @@ class ImportPaths {
100
114
  best = match;
101
115
  }
102
116
  }
103
- if (best)
104
- return best.spec;
105
- const relative = (0, glob_1.toPosix)(node_path_1.default.relative(node_path_1.default.dirname(fromFile), bare));
106
- return `${relative.startsWith('.') ? relative : `./${relative}`}${this.extension}`;
117
+ return best;
107
118
  }
108
119
  aliasTargets(spec) {
109
120
  return this.aliases.flatMap(({ pattern, targets }) => {
@@ -121,14 +132,17 @@ class ImportPaths {
121
132
  const wildcard = splitWildcard(aliasTarget);
122
133
  if (!wildcard) {
123
134
  const exact = aliasTarget === file || aliasTarget.replace(SOURCE_EXTENSION, '') === bare;
124
- return exact && !splitWildcard(pattern) ? { spec: pattern, score: Infinity } : undefined;
135
+ return exact && !splitWildcard(pattern) ? { spec: pattern, score: Infinity, pattern } : undefined;
125
136
  }
126
137
  const [prefix, suffix] = wildcard;
127
138
  if (!bare.startsWith(prefix) || !bare.endsWith(suffix) || bare.length < prefix.length + suffix.length) {
128
139
  return undefined;
129
140
  }
130
141
  const captured = bare.slice(prefix.length, bare.length - suffix.length);
131
- return { spec: `${pattern.replace('*', captured)}${this.extension}`, score: prefix.length };
142
+ return { spec: `${pattern.replace('*', captured)}${this.extension}`, score: prefix.length, pattern };
132
143
  }
133
144
  }
134
145
  exports.ImportPaths = ImportPaths;
146
+ function normalizeAliasName(pattern) {
147
+ return pattern.replace(/^@/, '').replace(/\/?\*$/, '');
148
+ }
package/cjm/build.js CHANGED
@@ -76,10 +76,19 @@ function generate(config, bundle, field, paths, { include, filterExports }) {
76
76
  for (const file of (0, scan_1.listSourceFiles)(dir, namespace.recursive, isExcluded))
77
77
  files.add(file);
78
78
  }
79
+ const excludedAliases = new Set((bundle.select.excludeAliases ?? []).map(ImportPaths_1.normalizeAliasName));
80
+ const isAliasExcluded = (file) => {
81
+ if (excludedAliases.size === 0)
82
+ return false;
83
+ const alias = paths.aliasName(file);
84
+ return alias !== undefined && excludedAliases.has(alias);
85
+ };
79
86
  const classes = [...files]
80
87
  .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
81
88
  .flatMap((file) => (0, scan_1.findClasses)(file, bundle.select))
82
89
  .filter((cls) => {
90
+ if (isAliasExcluded(cls.file))
91
+ return false;
83
92
  if (!filterExports)
84
93
  return true;
85
94
  const filename = relative(cls.file);
@@ -97,17 +106,27 @@ function generate(config, bundle, field, paths, { include, filterExports }) {
97
106
  }
98
107
  function tokenCollisions(classes, field) {
99
108
  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);
109
+ for (const cls of classes) {
110
+ for (const token of cls.tokens) {
111
+ const group = owners.get(token) ?? [];
112
+ if (!group.some((owner) => owner.className === cls.className))
113
+ group.push(cls);
114
+ owners.set(token, group);
106
115
  }
107
116
  }
108
117
  return [...owners]
109
- .filter(([, names]) => names.length > 1)
118
+ .filter(([, group]) => group.length > 1 && !scopeDisjoint(group))
110
119
  .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`);
120
+ .map(([token, group]) => {
121
+ const names = group.map((cls) => cls.className);
122
+ return (`${field}: decorator token "${token}" is passed by ${names.join(', ')}; ` +
123
+ `registration is last-wins, exclude one with select.excludeClasses`);
124
+ });
125
+ }
126
+ function scopeDisjoint(group) {
127
+ const [first, ...rest] = group;
128
+ return first.decoratorCalls.some(({ name, args }) => rest.every((cls) => {
129
+ const same = cls.decoratorCalls.find((call) => call.name === name);
130
+ return same !== undefined && same.args.join('\u0000') !== args.join('\u0000');
131
+ }));
113
132
  }
package/cjm/config.js CHANGED
@@ -38,7 +38,14 @@ 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', 'excludeClasses', 'excludeNameGlob']);
41
+ const SELECTOR_FIELDS = new Set([
42
+ 'export',
43
+ 'decorators',
44
+ 'nameGlob',
45
+ 'excludeClasses',
46
+ 'excludeNameGlob',
47
+ 'excludeAliases',
48
+ ]);
42
49
  function toSelector(value, field) {
43
50
  if (value === undefined)
44
51
  return { export: 'any' };
@@ -47,7 +54,7 @@ function toSelector(value, field) {
47
54
  const unknown = Object.keys(value).find((key) => !SELECTOR_FIELDS.has(key));
48
55
  if (unknown)
49
56
  throw new errors_1.TicConfigError(`${field}.${unknown}: unknown field`);
50
- const { export: kind, decorators, nameGlob, excludeClasses, excludeNameGlob } = value;
57
+ const { export: kind, decorators, nameGlob, excludeClasses, excludeNameGlob, excludeAliases } = value;
51
58
  if (kind !== undefined && kind !== 'any' && kind !== 'named' && kind !== 'default') {
52
59
  return fail(`${field}.export`, '"any", "named" or "default"');
53
60
  }
@@ -62,12 +69,16 @@ function toSelector(value, field) {
62
69
  if (excludeNameGlob !== undefined && !isNonEmptyString(excludeNameGlob)) {
63
70
  return fail(`${field}.excludeNameGlob`, 'a non-empty string');
64
71
  }
72
+ if (excludeAliases !== undefined && !(isStringArray(excludeAliases) && excludeAliases.length > 0)) {
73
+ return fail(`${field}.excludeAliases`, 'a non-empty array of strings');
74
+ }
65
75
  return {
66
76
  export: kind ?? 'any',
67
77
  decorators,
68
78
  nameGlob: nameGlob === undefined ? undefined : (0, glob_1.globToRegExp)(nameGlob),
69
79
  excludeClasses,
70
80
  excludeNameGlob: excludeNameGlob === undefined ? undefined : (0, glob_1.globToRegExp)(excludeNameGlob),
81
+ excludeAliases,
71
82
  };
72
83
  }
73
84
  function toBundle(value, field, dir) {
package/cjm/scan.js CHANGED
@@ -114,6 +114,13 @@ 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 decoratorCallsOf = (node) => (ts.getDecorators(node) ?? []).flatMap((d) => {
118
+ const name = decoratorName(d, imports);
119
+ if (!name)
120
+ return [];
121
+ const args = ts.isCallExpression(d.expression) ? d.expression.arguments.map((arg) => arg.getText(source)) : [];
122
+ return [{ name, args }];
123
+ });
117
124
  const tokensOf = (node) => (ts.getDecorators(node) ?? [])
118
125
  .map((d) => (ts.isCallExpression(d.expression) ? d.expression.arguments[0] : undefined))
119
126
  .filter((arg) => !!arg && ts.isIdentifier(arg))
@@ -126,6 +133,7 @@ function findClasses(file, selector, text = (0, node_fs_1.readFileSync)(file, 'u
126
133
  exportName,
127
134
  className: node.name?.text ?? nameFromFile(file),
128
135
  decorators: decoratorsOf(node),
136
+ decoratorCalls: decoratorCallsOf(node),
129
137
  tokens: tokensOf(node),
130
138
  }))
131
139
  .filter(({ node, exportName, className, decorators }) => {
@@ -144,13 +152,14 @@ function findClasses(file, selector, text = (0, node_fs_1.readFileSync)(file, 'u
144
152
  return true;
145
153
  })
146
154
  .sort((a, b) => a.node.pos - b.node.pos)
147
- .map(({ exportName, className, decorators, tokens }) => ({
155
+ .map(({ exportName, className, decorators, decoratorCalls, tokens }) => ({
148
156
  file,
149
157
  exportName,
150
158
  localName: exportName === 'default' ? className : exportName,
151
159
  className,
152
160
  isDefault: exportName === 'default',
153
161
  decorators,
162
+ decoratorCalls,
154
163
  tokens,
155
164
  }));
156
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ts-ioc-container/bundler",
3
- "version": "1.2.0",
3
+ "version": "1.3.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
@@ -113,6 +113,15 @@
113
113
  "type": "string",
114
114
  "minLength": 1,
115
115
  "description": "Glob the class name must NOT match, e.g. \"*Mock\". An anonymous default export is named after its file."
116
+ },
117
+ "excludeAliases": {
118
+ "type": "array",
119
+ "minItems": 1,
120
+ "items": {
121
+ "type": "string",
122
+ "minLength": 1
123
+ },
124
+ "description": "tsconfig paths aliases to drop after selection, e.g. [\"services\"] for a `services/*` (or `@services/*`) alias. A class whose file resolves through one of these aliases is dropped. Names may omit a leading `@` and a trailing `/*`."
116
125
  }
117
126
  }
118
127
  },
@@ -29,9 +29,18 @@ export declare class ImportPaths {
29
29
  resolveNamespace(namespace: string, baseDir: string): string;
30
30
  /** The specifier `fromFile` imports `toFile` by: its most specific alias, else a relative path. */
31
31
  specifier(fromFile: string, toFile: string): string;
32
+ /**
33
+ * The tsconfig `paths` alias `file` is covered by, normalized to its name
34
+ * (`@app/services/*` -> `app/services`), or `undefined` when no alias covers it.
35
+ * Used to drop classes by alias (`select.excludeAliases`).
36
+ */
37
+ aliasName(file: string): string | undefined;
38
+ private bestAlias;
32
39
  private aliasTargets;
33
40
  /** A longer matched target prefix is a more specific alias; an exact file alias beats every wildcard. */
34
41
  private matchAlias;
35
42
  }
43
+ /** The name of a `paths` alias: `@app/services/*` and `@app/services` both become `app/services`. */
44
+ export declare function normalizeAliasName(pattern: string): string;
36
45
  export {};
37
46
  //# sourceMappingURL=ImportPaths.d.ts.map
@@ -19,6 +19,12 @@ export interface ClassSelector {
19
19
  excludeClasses?: string[];
20
20
  /** A glob the class name must NOT match, e.g. `*Mock`. An anonymous default export is named after its file. */
21
21
  excludeNameGlob?: string;
22
+ /**
23
+ * tsconfig `paths` aliases to drop after selection: a class whose file resolves
24
+ * through one of these aliases is dropped, e.g. `["services"]` for a `services/*`
25
+ * (or `@services/*`) alias. Names may omit a leading `@` and a trailing `/*`.
26
+ */
27
+ excludeAliases?: string[];
22
28
  }
23
29
  /** A {@link ClassSelector} with its defaults filled in and its globs compiled. */
24
30
  export interface ResolvedSelector {
@@ -27,6 +33,7 @@ export interface ResolvedSelector {
27
33
  nameGlob?: RegExp;
28
34
  excludeClasses?: string[];
29
35
  excludeNameGlob?: RegExp;
36
+ excludeAliases?: string[];
30
37
  }
31
38
  export interface NamespaceConfig {
32
39
  /** A folder relative to the config file (`./src/services`) or a tsconfig `paths` alias (`@app/services`). */
package/typings/scan.d.ts CHANGED
@@ -17,6 +17,15 @@ export interface DiscoveredClass {
17
17
  * used to warn when two classes pass the same identifier.
18
18
  */
19
19
  tokens: string[];
20
+ /**
21
+ * Each class decorator's name and the source text of its arguments, e.g.
22
+ * `perPage` with `["'stations'"]`. Syntax only: lets the token-collision check
23
+ * tell scope-gated classes (`@perPage('a')` vs `@perPage('b')`) apart.
24
+ */
25
+ decoratorCalls: {
26
+ name: string;
27
+ args: string[];
28
+ }[];
20
29
  }
21
30
  /** Source files of `dir`, sorted by path so the generated output is stable across machines. */
22
31
  export declare function listSourceFiles(dir: string, recursive: boolean, isExcluded: (file: string) => boolean): string[];