ddd-ts-kit 0.16.0 → 0.17.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 (138) hide show
  1. package/README.fr.md +1595 -0
  2. package/README.md +424 -5
  3. package/dist/check/business-names/closing-parenthesis.d.ts +3 -0
  4. package/dist/check/business-names/closing-parenthesis.d.ts.map +1 -0
  5. package/dist/check/business-names/closing-parenthesis.js +16 -0
  6. package/dist/check/business-names/closing-parenthesis.js.map +1 -0
  7. package/dist/check/business-names/declared-bindings.d.ts +4 -0
  8. package/dist/check/business-names/declared-bindings.d.ts.map +1 -0
  9. package/dist/check/business-names/declared-bindings.js +41 -0
  10. package/dist/check/business-names/declared-bindings.js.map +1 -0
  11. package/dist/check/business-names/generic-words-of.d.ts +4 -0
  12. package/dist/check/business-names/generic-words-of.d.ts.map +1 -0
  13. package/dist/check/business-names/generic-words-of.js +7 -0
  14. package/dist/check/business-names/generic-words-of.js.map +1 -0
  15. package/dist/check/business-names/generic-words.d.ts +7 -0
  16. package/dist/check/business-names/generic-words.d.ts.map +1 -0
  17. package/dist/check/business-names/generic-words.js +26 -0
  18. package/dist/check/business-names/generic-words.js.map +1 -0
  19. package/dist/check/business-names/identifier-at.d.ts +3 -0
  20. package/dist/check/business-names/identifier-at.d.ts.map +1 -0
  21. package/dist/check/business-names/identifier-at.js +6 -0
  22. package/dist/check/business-names/identifier-at.js.map +1 -0
  23. package/dist/check/business-names/judges-names.d.ts +7 -0
  24. package/dist/check/business-names/judges-names.d.ts.map +1 -0
  25. package/dist/check/business-names/judges-names.js +14 -0
  26. package/dist/check/business-names/judges-names.js.map +1 -0
  27. package/dist/check/business-names/named-binding.d.ts +12 -0
  28. package/dist/check/business-names/named-binding.d.ts.map +1 -0
  29. package/dist/check/business-names/named-binding.js +2 -0
  30. package/dist/check/business-names/named-binding.js.map +1 -0
  31. package/dist/check/business-names/named-bindings.d.ts +4 -0
  32. package/dist/check/business-names/named-bindings.d.ts.map +1 -0
  33. package/dist/check/business-names/named-bindings.js +8 -0
  34. package/dist/check/business-names/named-bindings.js.map +1 -0
  35. package/dist/check/business-names/opening-bracket.d.ts +3 -0
  36. package/dist/check/business-names/opening-bracket.d.ts.map +1 -0
  37. package/dist/check/business-names/opening-bracket.js +16 -0
  38. package/dist/check/business-names/opening-bracket.js.map +1 -0
  39. package/dist/check/business-names/parameter-bindings.d.ts +4 -0
  40. package/dist/check/business-names/parameter-bindings.d.ts.map +1 -0
  41. package/dist/check/business-names/parameter-bindings.js +28 -0
  42. package/dist/check/business-names/parameter-bindings.js.map +1 -0
  43. package/dist/check/business-names/parameter-lists.d.ts +14 -0
  44. package/dist/check/business-names/parameter-lists.d.ts.map +1 -0
  45. package/dist/check/business-names/parameter-lists.js +109 -0
  46. package/dist/check/business-names/parameter-lists.js.map +1 -0
  47. package/dist/check/business-names/pattern-bindings.d.ts +7 -0
  48. package/dist/check/business-names/pattern-bindings.d.ts.map +1 -0
  49. package/dist/check/business-names/pattern-bindings.js +31 -0
  50. package/dist/check/business-names/pattern-bindings.js.map +1 -0
  51. package/dist/check/business-names/property-bindings.d.ts +7 -0
  52. package/dist/check/business-names/property-bindings.d.ts.map +1 -0
  53. package/dist/check/business-names/property-bindings.js +31 -0
  54. package/dist/check/business-names/property-bindings.js.map +1 -0
  55. package/dist/check/business-names/suggested-name.d.ts +8 -0
  56. package/dist/check/business-names/suggested-name.d.ts.map +1 -0
  57. package/dist/check/business-names/suggested-name.js +28 -0
  58. package/dist/check/business-names/suggested-name.js.map +1 -0
  59. package/dist/check/business-names/top-level-separator.d.ts +3 -0
  60. package/dist/check/business-names/top-level-separator.d.ts.map +1 -0
  61. package/dist/check/business-names/top-level-separator.js +23 -0
  62. package/dist/check/business-names/top-level-separator.js.map +1 -0
  63. package/dist/check/catalogue/architecture-rules.d.ts.map +1 -1
  64. package/dist/check/catalogue/architecture-rules.js +2 -0
  65. package/dist/check/catalogue/architecture-rules.js.map +1 -1
  66. package/dist/check/explain/tree-docs.d.ts.map +1 -1
  67. package/dist/check/explain/tree-docs.js +11 -0
  68. package/dist/check/explain/tree-docs.js.map +1 -1
  69. package/dist/check/fixtures/business-name-fixtures.d.ts +3 -0
  70. package/dist/check/fixtures/business-name-fixtures.d.ts.map +1 -0
  71. package/dist/check/fixtures/business-name-fixtures.js +98 -0
  72. package/dist/check/fixtures/business-name-fixtures.js.map +1 -0
  73. package/dist/check/fixtures/fixtures.d.ts.map +1 -1
  74. package/dist/check/fixtures/fixtures.js +2 -0
  75. package/dist/check/fixtures/fixtures.js.map +1 -1
  76. package/dist/check/rules/naming-business-name.d.ts +3 -0
  77. package/dist/check/rules/naming-business-name.d.ts.map +1 -0
  78. package/dist/check/rules/naming-business-name.js +41 -0
  79. package/dist/check/rules/naming-business-name.js.map +1 -0
  80. package/dist/config/config.d.ts +8 -0
  81. package/dist/config/config.d.ts.map +1 -1
  82. package/dist/config/parse-business-names.d.ts +4 -0
  83. package/dist/config/parse-business-names.d.ts.map +1 -0
  84. package/dist/config/parse-business-names.js +14 -0
  85. package/dist/config/parse-business-names.js.map +1 -0
  86. package/dist/config/parse-config.d.ts.map +1 -1
  87. package/dist/config/parse-config.js +2 -0
  88. package/dist/config/parse-config.js.map +1 -1
  89. package/dist/config/serialize-config.js +9 -1
  90. package/dist/config/serialize-config.js.map +1 -1
  91. package/dist/generate/kinds/query-kind.d.ts.map +1 -1
  92. package/dist/generate/kinds/query-kind.js +2 -1
  93. package/dist/generate/kinds/query-kind.js.map +1 -1
  94. package/dist/generate/kinds/route/write-controller.d.ts.map +1 -1
  95. package/dist/generate/kinds/route/write-controller.js +2 -1
  96. package/dist/generate/kinds/route/write-controller.js.map +1 -1
  97. package/dist/generate/kinds/route-kind.d.ts.map +1 -1
  98. package/dist/generate/kinds/route-kind.js +2 -1
  99. package/dist/generate/kinds/route-kind.js.map +1 -1
  100. package/dist/generate/searched-elements.d.ts +13 -0
  101. package/dist/generate/searched-elements.d.ts.map +1 -0
  102. package/dist/generate/searched-elements.js +63 -0
  103. package/dist/generate/searched-elements.js.map +1 -0
  104. package/dist/init/manifest/versions.js +24 -24
  105. package/dist/naming/index.d.ts +2 -0
  106. package/dist/naming/index.d.ts.map +1 -1
  107. package/dist/naming/index.js +2 -0
  108. package/dist/naming/index.js.map +1 -1
  109. package/dist/naming/plural.d.ts +3 -0
  110. package/dist/naming/plural.d.ts.map +1 -0
  111. package/dist/naming/plural.js +8 -0
  112. package/dist/naming/plural.js.map +1 -0
  113. package/dist/naming/singular.d.ts +3 -0
  114. package/dist/naming/singular.d.ts.map +1 -0
  115. package/dist/naming/singular.js +11 -0
  116. package/dist/naming/singular.js.map +1 -0
  117. package/dist/naming/to-name.d.ts.map +1 -1
  118. package/dist/naming/to-name.js +1 -6
  119. package/dist/naming/to-name.js.map +1 -1
  120. package/dist/version.d.ts +1 -1
  121. package/dist/version.js +1 -1
  122. package/package.json +2 -2
  123. package/templates/command/handler.declared.ts.tpl +3 -3
  124. package/templates/command/handler.ts.tpl +3 -3
  125. package/templates/controller/controller.search.ts.tpl +1 -1
  126. package/templates/init/rules/application.md.tpl +1 -1
  127. package/templates/init/rules/domain.md.tpl +1 -1
  128. package/templates/init/scripts/events.ts.tpl +6 -6
  129. package/templates/init/scripts/openapi.ts.tpl +2 -2
  130. package/templates/pipeline/package-scripts.json +1 -1
  131. package/templates/policy/policy.declared.ts.tpl +1 -1
  132. package/templates/policy/policy.ts.tpl +1 -1
  133. package/templates/presets/identity/context/infrastructure/persistence/drizzle-search-members-reader.repository.drizzle.ts.tpl +2 -2
  134. package/templates/presets/identity/context/presentation/http/organisations/search-members/search-members.controller.ts.tpl +2 -2
  135. package/templates/presets/identity/context/test/integration/search-members-reader.spec.ts.tpl +1 -1
  136. package/templates/query/search.reader.fake.ts.tpl +1 -1
  137. package/templates/query/search.reader.in-memory.ts.tpl +20 -18
  138. package/templates/route/route.search.ts.tpl +1 -1
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # ddd-ts-kit
2
4
 
3
5
  Bootstrap, scaffold, document and verify Domain-Driven Design and Clean Architecture projects
@@ -11,6 +13,15 @@ dependency-cruiser, knip, coverage thresholds, git hooks and the CI of GitHub or
11
13
  The generator and the checker share one definition of the canonical tree, and a test generates
12
14
  every kind for every stack and requires zero violation: they cannot disagree.
13
15
 
16
+ The main commands fall into two families. The first writes the project: `init` once, `generate` for
17
+ each piece, `docs`, `sdk` and `depcruise-config` for what is derived from the code and the
18
+ config, and three migrations that move a project from an older form to the current one. The
19
+ second reads the project back without changing it: `check` judges it, `list` and `describe` state
20
+ what it holds, `status` and `todo` say what is left to do, `affected` says what a pull request has
21
+ to run.
22
+
23
+ ![The commands of the CLI around a project: on the left the ones that write it (init, generate, docs, sdk and depcruise-config, the three migrations), in the middle the project (ddd-ts-kit.config.json, the bounded contexts and the platform under src/, the generated documents), on the right the ones that read it back (check and check-file, list and describe, status and todo, affected)](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/cli-commands-and-project.svg)
24
+
14
25
  No runtime dependency. Node 22.12 or later.
15
26
 
16
27
  ```
@@ -98,7 +109,7 @@ existed keeps its spelling. See [docs/CONFIG.md](../../docs/CONFIG.md).
98
109
 
99
110
  Defaults: `express`, `zod`, `tsyringe` (`nest` with `--http nest`, and only then), `vitest`,
100
111
  `drizzle` on `postgres`, `outbox` when there is an ORM, `swagger`, `github`, `jwt`, `rbac`, no broker,
101
- the cache in the process, the audit trail `on` when there is an ORM, unexpected failures reported
112
+ the cache in the process, files in a local directory (`--storage local`), the audit trail `on` when there is an ORM, unexpected failures reported
102
113
  nowhere (`--errors none`), JSON lines on the standard output (`--logger stdout`), and the
103
114
  `declared` style with a `barrel` wherever the declarations are read: with `--di tsyringe`.
104
115
 
@@ -110,7 +121,7 @@ proven by a real install of a generated project, its build and its `verify`. The
110
121
  from the registry either way.
111
122
 
112
123
  **Pipelines.** A pull or merge request runs the unit specs, the integration specs and the lint of
113
- the contexts its change reaches through the context map (`ddd-ts-kit affected`); types,
124
+ the contexts its change reaches through the context map ([`ddd-ts-kit affected`](#affected)); types,
114
125
  architecture, import graph, dead code, end to end, build and documents always run on the whole
115
126
  project, and the default branch and the nightly schedule run everything.
116
127
 
@@ -132,6 +143,104 @@ files it imports itself are compiled by the runner. Discovery is accepted behind
132
143
  context listens on the subscriber `EventSubscribers.forContext(name)` hands it, in either assembly.
133
144
  A config written before `stack.style` existed is explicit.
134
145
 
146
+ The same use case in both styles. In the declared style, the command and its handler carry their
147
+ decorator, and the handler's constructor says what it needs. The body of the handler is the one
148
+ `generate command billing IssueInvoice --aggregate Invoice --permission invoice.issue` writes,
149
+ before its `TODO(domain)` is answered; the input of the command is filled in:
150
+
151
+ <!-- checked sample -->
152
+ ```ts
153
+ import { definePermissions } from '@ddd-ts-kit/auth';
154
+ import { type Actor, AggregateRoot, AuthorizedCommand, type Clock, type IdGenerator, Repository } from '@ddd-ts-kit/kernel';
155
+ import { CommandHandler, Requires } from '@ddd-ts-kit/kernel/declarations';
156
+
157
+ declare class Invoice extends AggregateRoot {
158
+ static create(id: string, now: Date): Invoice;
159
+ }
160
+
161
+ // domain/repositories/invoice.repository.ts
162
+ export abstract class InvoiceRepository extends Repository<Invoice> {}
163
+
164
+ // application/authorization/billing.permissions.ts
165
+ export const billingPermissions = definePermissions('billing', {
166
+ 'invoice.issue': { description: 'Issue invoice' },
167
+ });
168
+
169
+ // application/commands/issue-invoice/issue-invoice.command.ts
170
+ @Requires(billingPermissions['invoice.issue'])
171
+ export class IssueInvoiceCommand extends AuthorizedCommand<string> {
172
+ constructor(
173
+ actor: Actor,
174
+ readonly customerId: string,
175
+ ) {
176
+ super(actor);
177
+ }
178
+ }
179
+
180
+ // application/commands/issue-invoice/issue-invoice.handler.ts
181
+ @CommandHandler(IssueInvoiceCommand)
182
+ export class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> {
183
+ // each parameter is typed by one class, which is also its injection token
184
+ constructor(
185
+ private readonly invoiceRepository: InvoiceRepository,
186
+ private readonly ids: IdGenerator,
187
+ private readonly clock: Clock,
188
+ ) {}
189
+
190
+ async execute(command: IssueInvoiceCommand): Promise<string> {
191
+ const invoice = Invoice.create(this.ids.next(), this.clock.now());
192
+
193
+ await this.invoiceRepository.save(invoice);
194
+
195
+ return invoice.id;
196
+ }
197
+ }
198
+ ```
199
+
200
+ In the explicit style, the same classes carry no decorator, and the module file of the context
201
+ binds each of them, with its permission and the ports it is given:
202
+
203
+ <!-- checked sample -->
204
+ ```ts
205
+ import { definePermissions } from '@ddd-ts-kit/auth';
206
+ import { defineContextModule } from '@ddd-ts-kit/di';
207
+ import { AggregateRoot, AuthorizedCommand, Clock, type CommandHandler, IdGenerator, Repository } from '@ddd-ts-kit/kernel';
208
+
209
+ // the classes of the declared sample, without their decorators
210
+ declare class Invoice extends AggregateRoot {}
211
+ declare class IssueInvoiceCommand extends AuthorizedCommand<string> {}
212
+ declare class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> {
213
+ constructor(invoiceRepository: InvoiceRepository, ids: IdGenerator, clock: Clock);
214
+ execute(command: IssueInvoiceCommand): Promise<string>;
215
+ }
216
+ declare class InMemoryInvoiceRepository implements InvoiceRepository {
217
+ findById(id: string): Promise<Invoice | undefined>;
218
+ save(invoice: Invoice): Promise<void>;
219
+ }
220
+
221
+ export abstract class InvoiceRepository extends Repository<Invoice> {}
222
+
223
+ export const billingPermissions = definePermissions('billing', {
224
+ 'invoice.issue': { description: 'Issue invoice' },
225
+ });
226
+
227
+ // billing.module.ts: every binding of the context, one line each
228
+ export const billingModule = defineContextModule({
229
+ name: 'billing',
230
+ register(builder) {
231
+ builder.bind(InvoiceRepository).toClass(InMemoryInvoiceRepository).as('repository');
232
+ builder
233
+ .command(IssueInvoiceCommand)
234
+ .requires(billingPermissions['invoice.issue'])
235
+ .handledBy(IssueInvoiceHandler, [InvoiceRepository, IdGenerator, Clock]);
236
+ },
237
+ });
238
+ ```
239
+
240
+ `check` holds both forms: `authz/declared` reports a command that says neither who may send it
241
+ nor that it is public, `declarations/role-declared` a role file of the declared style without its
242
+ decorator.
243
+
135
244
  **A port is an abstract class and its own injection token.** `application/ports/<name>.port.ts`
136
245
  and `domain/repositories/<name>.repository.ts` hold one abstract class each. Generated code holds
137
246
  no `token()`, no UPPER_SNAKE injection constant, no `<context>.ports.ts` and no
@@ -140,6 +249,50 @@ no `token()`, no UPPER_SNAKE injection constant, no `<context>.ports.ts` and no
140
249
  list or a binding and type imports where they only type a parameter, an adapter `implements`
141
250
  its port.
142
251
 
252
+ Instead of an interface, a token constant and an injection by name:
253
+
254
+ ```ts
255
+ export interface IPaymentGateway {
256
+ charge(invoiceId: string, amountInCents: number): Promise<void>;
257
+ }
258
+
259
+ export const PAYMENT_GATEWAY = Symbol('PaymentGateway'); // ports/no-token-constant
260
+
261
+ constructor(@inject(PAYMENT_GATEWAY) private readonly payments: IPaymentGateway) {}
262
+ ```
263
+
264
+ write one abstract class, which is both the contract and the token:
265
+
266
+ <!-- checked sample -->
267
+ ```ts
268
+ import { Adapter } from '@ddd-ts-kit/kernel/declarations';
269
+
270
+ // application/ports/payment-gateway.port.ts
271
+ export abstract class PaymentGateway {
272
+ abstract charge(invoiceId: string, amountInCents: number): Promise<void>;
273
+ }
274
+
275
+ // infrastructure/external/remote-payment-gateway.adapter.ts: implements the port, never extends it
276
+ @Adapter(PaymentGateway, { kind: 'external' })
277
+ export class RemotePaymentGateway implements PaymentGateway {
278
+ async charge(invoiceId: string, amountInCents: number): Promise<void> {
279
+ // TODO(platform): call the payment provider with invoiceId and amountInCents
280
+ }
281
+ }
282
+
283
+ // a handler names the port by the type of its parameter: nothing else says what is injected
284
+ export class SettleInvoiceHandler {
285
+ constructor(private readonly payments: PaymentGateway) {}
286
+
287
+ async settle(invoiceId: string, amountInCents: number): Promise<void> {
288
+ await this.payments.charge(invoiceId, amountInCents);
289
+ }
290
+ }
291
+ ```
292
+
293
+ `ports/abstract-class`, `ports/implements-never-extends`, `ports/no-token-constant` and
294
+ `ports/value-import` hold this form, in `check` and in ESLint.
295
+
143
296
  **The constructor states the dependencies.** In the declared style a generated class lists
144
297
  nothing in its decorator (`@CommandHandler(IssueInvoiceCommand)`): each constructor parameter
145
298
  is typed by one class, its token (`declarations/constructor-injection`). `generate`, `docs` and
@@ -154,6 +307,31 @@ is absent with standard decorators and under `tsx`. `init` writes
154
307
  what each class is given. Every generated file exports one class, interface or function and is named
155
308
  after it.
156
309
 
310
+ ![How a declared context reaches the composition root: the role files carry their decorator and type their constructor parameters by classes; generate, docs and the dev script read those signatures and write the barrel, one export per role file and one injected statement per class that takes something; the composition root includes the barrel with contextModule and @ddd-ts-kit/di injects from it; check and docs --check report a barrel that no longer matches the role files](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/cli-declared-barrel.svg)
311
+
312
+ An excerpt of the barrel of the `notification` context of the example application, as the CLI
313
+ writes it (never by hand):
314
+
315
+ ```ts
316
+ // src/contexts/notification/notification.declarations.ts
317
+ import { injected } from '@ddd-ts-kit/kernel/declarations';
318
+ import { WithdrawReminderHandler } from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.handler';
319
+ import { ReminderRepository } from '@/contexts/notification/domain/repositories/reminder.repository';
320
+ import { KnexReminderRepository } from '@/contexts/notification/infrastructure/persistence/knex-reminder.repository';
321
+ import { Clock, ExecutionScope } from '@ddd-ts-kit/kernel';
322
+ import { KnexContext } from '@ddd-ts-kit/knex';
323
+
324
+ export * from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.command';
325
+ export * from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.handler';
326
+ export * from '@/contexts/notification/infrastructure/persistence/knex-reminder.repository';
327
+
328
+ injected(WithdrawReminderHandler, [ReminderRepository]);
329
+ injected(KnexReminderRepository, [KnexContext, ExecutionScope, Clock]);
330
+ ```
331
+
332
+ and the one line of the composition root that adds the whole context:
333
+ `contextModule('notification', notificationDeclarations)` in `src/platform/compose-application.ts`.
334
+
157
335
  **Domain errors are built by factories.** `init` writes `"stack.refusals": "factories"`: a
158
336
  context has one error class per concept, extending `ConceptError` of the kernel, and
159
337
  `generate error <context> <Concept> <refusal>` adds a static factory to it. The `errors/*`
@@ -161,6 +339,46 @@ rules hold that form. `--refusals classes`, or a config without the key, keeps o
161
339
  error built with `new`, as 0.1 and 0.2 wrote them; `error-factories` moves a project from one
162
340
  to the other.
163
341
 
342
+ The older form, under `"stack.refusals": "classes"`: one class per error.
343
+
344
+ <!-- checked sample -->
345
+ ```ts
346
+ import { DomainError } from '@ddd-ts-kit/kernel';
347
+
348
+ // domain/errors/slot-taken.error.ts
349
+ export class SlotTakenError extends DomainError {
350
+ readonly code = 'SLOT_TAKEN';
351
+
352
+ constructor(readonly calendarId: string) {
353
+ super(`The slot of calendar ${calendarId} is already held.`);
354
+ }
355
+ }
356
+
357
+ const refusal = new SlotTakenError('calendar-1');
358
+ ```
359
+
360
+ The form `init` writes: one class per concept of the glossary, one factory per refusal, which
361
+ `generate error scheduling Appointment slotTaken` adds.
362
+
363
+ <!-- checked sample -->
364
+ ```ts
365
+ import { ConceptError } from '@ddd-ts-kit/kernel';
366
+
367
+ // domain/errors/appointment.error.ts
368
+ export class AppointmentError extends ConceptError {
369
+ static slotTaken(calendarId: string) {
370
+ return AppointmentError.refusal('SLOT_TAKEN', `The slot of calendar ${calendarId} is already held.`, { calendarId });
371
+ }
372
+ }
373
+
374
+ const refusal = AppointmentError.slotTaken('calendar-1');
375
+
376
+ // what catches it tells the refusal by its class and its code, and reads its facts
377
+ if (AppointmentError.is(refusal, 'SLOT_TAKEN')) {
378
+ console.log(refusal.facts.calendarId);
379
+ }
380
+ ```
381
+
164
382
  **A route is a controller class.** `init` writes `"stack.routes": "controllers"`: each route is
165
383
  one class with one method, in a folder of its own, behind the guards of its route group, and a
166
384
  declared context lists no route: its controllers reach the application through its generated
@@ -188,6 +406,65 @@ the aggregate recorded are dispatched once the save resolves, inside the unit of
188
406
  command: by the kernel module on tsyringe, by `dispatchingOnSave` around each repository in
189
407
  `platform/create-platform.ts` with hand wiring (`domain-events/dispatched-on-save`).
190
408
 
409
+ Instead of pulling the events and dispatching them in the handler:
410
+
411
+ ```ts
412
+ async execute(command: IssueInvoiceCommand): Promise<void> {
413
+ const invoice = await this.invoiceRepository.findById(command.invoiceId);
414
+
415
+ invoice.issue(this.clock.now());
416
+ await this.invoiceRepository.save(invoice);
417
+ await this.dispatcher.dispatch(invoice.pullEvents()); // domain-events/dispatched-on-save
418
+ }
419
+ ```
420
+
421
+ write the save, and nothing after it:
422
+
423
+ <!-- checked sample -->
424
+ ```ts
425
+ import { type Actor, AggregateRoot, AuthorizedCommand, type Clock, type CommandHandler, ConceptError, Repository } from '@ddd-ts-kit/kernel';
426
+
427
+ declare class Invoice extends AggregateRoot {
428
+ /** Makes the invoice final and records InvoiceIssued, which waits in the aggregate. */
429
+ issue(now: Date): void;
430
+ }
431
+
432
+ declare class InvoiceError extends ConceptError {
433
+ static notFound(invoiceId: string): InvoiceError;
434
+ }
435
+
436
+ export abstract class InvoiceRepository extends Repository<Invoice> {}
437
+
438
+ export class IssueInvoiceCommand extends AuthorizedCommand<void> {
439
+ constructor(
440
+ actor: Actor,
441
+ readonly invoiceId: string,
442
+ ) {
443
+ super(actor);
444
+ }
445
+ }
446
+
447
+ export class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> {
448
+ constructor(
449
+ private readonly invoiceRepository: InvoiceRepository,
450
+ private readonly clock: Clock,
451
+ ) {}
452
+
453
+ async execute(command: IssueInvoiceCommand): Promise<void> {
454
+ const invoice = await this.invoiceRepository.findById(command.invoiceId);
455
+
456
+ if (invoice === undefined) {
457
+ throw InvoiceError.notFound(command.invoiceId);
458
+ }
459
+
460
+ invoice.issue(this.clock.now());
461
+
462
+ // InvoiceIssued is dispatched once this save resolves, inside the unit of work of the command
463
+ await this.invoiceRepository.save(invoice);
464
+ }
465
+ }
466
+ ```
467
+
191
468
  `--events in-memory` together with an ORM is refused unless `--allow-no-outbox "<reason>"` is given:
192
469
  it records a `deviations` entry (`stack.events`) and writes the decision record it points to. The generated
193
470
  persistence of the four ORMs is written for PostgreSQL; `custom` starts on in-memory adapters
@@ -212,6 +489,7 @@ config, `docsUi`, `ci` and `logger` included.
212
489
  | `--http-cache varnish` | a compose service `varnish` (Varnish 9.1.0, reaching the API on `host.docker.internal`) with its VCL in `docker/varnish/default.vcl` (only answers whose `Cache-Control` allows them by `max-age` or `s-maxage` are kept, filed under their `xkey` tags, purged by `PURGE` from the machine of Varnish or with `VARNISH_PURGE_KEY`, `x-cache: HIT\|MISS`), `VARNISH_URL` and `VARNISH_PURGE_KEY` validated, `cacheTags: { header: CacheTagHeader.xkey }` in the HTTP boundary, and the outbox relay of `main.ts` and of every `generate service` entry point publishing through `PurgingPublisher` with `VarnishCachePurger` (the end-to-end application records the purges in `purges`); on `--di manual` and `nest` the platform gains `cacheInvalidation`; with `--tenancy`, the tags of the answers and the purges are filed under the tenant (`perTenant`) and the tenant of a request is read from `x-tenant-id`. Needs an HTTP framework and `--events outbox`: a purge is a row of the outbox |
213
490
  | `--errors sentry` | unexpected failures reported to Sentry or GlitchTip (one protocol, only the DSN differs) through the kernel `ErrorReporter`, bound to `@ddd-ts-kit/sentry`. `src/platform/errors.ts` (`initSentry` on the validated configuration, then `reportProcessFailures`), the second import of `main.ts`, of `apps/<context>/main.ts` and of `apps/worker/main.ts`, right after `tracing.ts`; `errors` handed to `composeApplication`, which gives it to the kernel module on tsyringe (`errorReporter`, flushed by `container.stop()` before the logger, `errorReportingMiddleware` first on both buses, the default scheduler observing with `reportFailedRuns`) or to `createPlatform` by hand (the same middleware, `onRun: reportFailedRuns(errors)`, `platform.errors`, flushed two seconds at most when the application stops); `reportHttpErrors` in `auth/http-boundary.ts` for every request answered 5xx; `DeadLetterReports` on the `onFailure` hook of a provided broker (`createEventProvider(config, errors)`, with `observing` around each consumer) and of the outbox relay. `SENTRY_DSN`, `SENTRY_ENVIRONMENT` (default: `NODE_ENV`), `SENTRY_RELEASE` and `SENTRY_SCRUB_MESSAGES` validated in `config/load-config.ts` as `errorReporting`: without a DSN the application starts and reports nothing; a DSN that is not a URL refuses the start. Dependencies `@ddd-ts-kit/sentry` and `@sentry/node`. A compose profile `errors` running GlitchTip 6.2.6 with its own PostgreSQL and Valkey (`GLITCHTIP_PORT`, loopback only, development bootstrap), and a section of the generated README saying how to read its DSN. The tools and the tests compose the application without a reporter. It goes with every other choice of the stack; with `--errors none` nothing of this is written |
214
491
  | `--authn keycloak` | a compose service `keycloak` (Keycloak 26.8.0, `start-dev --import-realm`, profile `identity`) and `docker/keycloak/<project>-realm.json`: the API client, a public client `<project>-local` with the direct grant, its audience and `tenant_id` mappers, the realm role `admin`, and the demo users `demo-admin` and `demo-user`, for local development only |
492
+ | `--storage local\|s3` | recorded as `stack.storage`: where the content of files lives, never in the database. `src/platform/config/read-files.ts` and `src/platform/files/open-files.ts`, one per choice: `local`, a directory in development (`FILES_DIRECTORY`, `.data/files`) and the signed links answered by the application under `/files` (`FILES_PUBLIC_URL` and `FILES_SIGNING_SECRET` required in production); `s3`, a bucket of any provider of the S3 protocol through `@ddd-ts-kit/s3` (the `S3_*` variables, MinIO in the compose stack); the memory of the process in the tests and the tools, either way. With `--di tsyringe`: `files/bind-files.ts`, `files/files.module.ts` and `files/staging-uploads.ts`; with hand wiring `files/create-files.ts`; with an HTTP stack `files/file-routes.ts`. They bind `FileStorage`, `FileKeys`, `FileScanner` and `FileStaging`, register the hourly purge of staged files and stage the file a command carries before its transaction; `generate file-upload` needs them. `--storage none` writes none of this ([File storage](../../docs/concepts/file-storage.md)) |
215
493
  | `src/platform/events/` | `event-provider.ts` (`bus`, `forConsumer(name)`, `start()`, `close()`), `in-process-events.ts`, and for `--broker` `create-event-provider.ts`: `RabbitMqEventBus` (`AMQP_URL`), `KafkaEventBus` (`KAFKA_BROKERS`, with `aggregate-key-of.ts` for per-aggregate order), `NatsEventBus` (`NATS_URL`) or `RedisStreamsEventBus` of `@ddd-ts-kit/redis/event-bus` (`REDIS_URL`: Redis, Dragonfly or Valkey), each with one subscriber per consuming context (`forConsumer`), `assertTopology` at start, its address validated and a compose service with a healthcheck; or `custom-event-bus.ts` with its `eventBusContract` spec. `start-outbox-relay.ts` with the outbox. `--broker redis` with `--cache redis` is one server and one compose service, `cache`; alone, the service is `streams` |
216
494
  | `src/platform/database/` | `persistence.ts` (`interface Persistence`: unit of work, outbox and processed-event stores of the ORM package, the storage handle), `open-persistence.ts`, `persistence-over.ts`, `inert-persistence.ts`, the connection (`connect-to-database.ts`, `create-client.ts` or `create-data-source.ts`), `schema.ts`, `open-test-database.ts` (PostgreSQL in process, or the server named by a URL). The storage class a repository adapter names in its dependency list: `database.ts` with drizzle and prisma (`abstract class Database extends` the context type of the ORM package), `KnexContext` of `@ddd-ts-kit/knex`, `TypeOrmContext` of `@ddd-ts-kit/typeorm` |
217
495
  | migration tooling | `drizzle.config.ts`, `knexfile.js` and `migrations/00000000000000_initial.js`, `prisma/schema.prisma` and `prisma.config.ts`, or `data-source.ts` |
@@ -297,10 +575,42 @@ ddd-ts-kit generate <kind> ... [--term "<Term>=<meaning>"]... [--allow-name] [--
297
575
  ddd-ts-kit generate --help --json
298
576
  ```
299
577
 
578
+ One run of `generate` does five things, in this order, and writes nothing when the first one
579
+ refuses:
580
+
581
+ ![One run of generate command: the name is checked against the glossary of the context (refused with exit code 2 and the --term to add when a word is not defined), the files are written from the templates, the new pieces are registered between marker comments, the derived documents are refreshed, and the places that need a business decision are printed as file:line](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/cli-generate-one-run.svg)
582
+
583
+ In the example application, a use case named with a word its glossary does not define is refused,
584
+ and the message says how to go on:
585
+
586
+ ```
587
+ $ npx ddd-ts-kit generate command notification ArchiveNotification --permission notification.archive --aggregate Notification
588
+ error: "ArchiveNotification" uses "archive", which the glossary of src/contexts/notification/CONTEXT.md does not define.
589
+ fix: Either use an existing term (Notification, Send, Recipient, Contact, Confirmation, Reminder, Plan, Due, Withdraw, Mailer, Kind, Request, Rescheduling, Cancellation, Read, Search), or add the term in the same command: --term "Archive=<what it means for the business>". The glossary is written before the code (rule language/glossary-term).
590
+ ```
591
+
592
+ With the term given in the same command (and `--dry-run`, which says what it would write):
593
+
594
+ ```
595
+ $ npx ddd-ts-kit generate command notification ArchiveNotification --permission notification.archive --aggregate Notification \
596
+ --term "Archive=To set a sent notification aside, out of the default search" --dry-run
597
+ would create src/contexts/notification/application/commands/archive-notification/archive-notification.command.ts
598
+ would create src/contexts/notification/application/commands/archive-notification/archive-notification.handler.ts
599
+ would create src/contexts/notification/test/unit/archive-notification.handler.spec.ts
600
+ would update src/contexts/notification/CONTEXT.md
601
+ would update src/contexts/notification/application/authorization/notification.permissions.ts
602
+ would update src/contexts/notification/notification.declarations.ts
603
+
604
+ Next, in this order: the language of the context first, then the business decisions only you can make
605
+ src/contexts/notification/application/commands/archive-notification/archive-notification.command.ts:9 declare the input of "archive notification" as a constructor with the actor first, ...
606
+ src/contexts/notification/application/commands/archive-notification/archive-notification.handler.ts:16 decide what "archive notification" does to a Notification: ...
607
+ src/contexts/notification/test/unit/archive-notification.handler.spec.ts:38 replace each entry below by a test named after the business behaviour of "archive notification": ...
608
+ ```
609
+
300
610
  | Kind | Arguments | Writes |
301
611
  |---|---|---|
302
612
  | `context` | `<name> --classification <c> [--purpose "<two sentences>"] [--term ...]... [--upstream <ctx>[:relationship]]... [--preset identity]` | `CONTEXT.md` with its glossary, `published-language/index.ts`, config entry, registration in `platform/compose-application.ts` and in `test/architecture/extractable.spec.ts`; on tsyringe, with HTTP, `presentation/http/<name>.routes.ts` (a function of the two buses, in both styles); declared: the barrel `<name>.declarations.ts`; explicit and hand wiring: `<name>.module.ts`. `application/authorization/<name>.permissions.ts` is born with the first permission, or at once when `platform/auth/permissions.ts` lists the permission sets (rebac, custom). With `--preset`, the context is then written whole from that preset, its classification and purpose those of the preset unless the flags say otherwise ([Presets](#presets)) |
303
- | `aggregate` | `<context> <Name>` | aggregate, builder, spec in given / when / then |
613
+ | `aggregate` | `<context> <Name> [--event-sourced]` | aggregate, builder, spec in given / when / then; with `--event-sourced`, an aggregate on `EventSourcedAggregate` whose state is the replay of its events, born with its `Created` domain event, the codec of its stored events in `infrastructure/persistence/<name>-events.mapper.ts`, a builder that replays a history and a spec given events, and a note naming the `EventSourcedRepository` its repository adapter delegates to |
304
614
  | `builder` | `<context> <Aggregate>` | `test/builders/<name>.builder.ts`, a class extending `Builder` |
305
615
  | `entity` | `<context> <Name>` | entity, spec |
306
616
  | `value-object` (`vo`) | `<context> <Name>` | value object, spec |
@@ -316,6 +626,7 @@ ddd-ts-kit generate --help --json
316
626
  | `port` | `<context> <Name>` | `application/ports/<name>.port.ts`, one abstract class, and its fake |
317
627
  | `route` | `<context> <UseCaseName> --method <GET\|POST\|PUT\|PATCH\|DELETE> --path <p> [--command\|--query] [--group <Name>] [--mapper] [--serializer] [--cache public\|private\|no-store]` | under `"stack.routes": "controllers"`: a controller class in `presentation/http/<resource>/<use-case>/` (the resource is the first segment of the path), depending on `CommandBus` or `QueryBus`, behind the route group of its resource, written with it when it is new (`<resource>.routes.ts`: prefix, tag, `Authenticated`); a public use case stands in no group and says `guards: [Public]`; with class-validator one class per file (`<use-case>.params.ts`, `.body.ts`, `.answer.ts`, `.accepted.ts`, `.line.ts`); `--mapper` and `--serializer` add a request mapper and a serializer the controller is given; a spec; no registration in a declared project, a controller built by hand in `<context>.routes.ts` (the module file with hand wiring) in an explicit one. Under `"functions"`: a `defineRoute` function, its spec, its registration in `<context>.routes.ts`, and `<use-case>-<role>.dto.ts` classes |
318
628
  | `controller` | the same arguments as `route` | the controller class, whatever `stack.routes` says: for a project of function routes that writes its next route as a class. `--cache` writes its cache policy: `private` (a `GET` of a caller, `max-age=0`, `must-revalidate`, strong `ETag`), `public` (a `GET` of a public use case, `max-age` 1m, `s-maxage` 5m, tagged with the resources of its path), `no-store`; refused on a write, and `public` behind a route group |
629
+ | `file-upload` | `<context> <UseCaseName> --path <p> (--permission <aggregate>.<action> \| --public) [--method POST\|PUT] [--group <Name>]` | an upload: a controller that declares the file it receives (its size and media types in the contract, checked as it streams), the command that carries it as a `FileUpload`, staged by the command bus before the transaction, the handler that records it and promotes it with the commit, and their specs. Refused without `stack.storage` or without an HTTP stack, with the `init --force --storage local` to run |
319
630
  | `route-group` (`group`) | `<context> <Name> [--prefix <path>] [--group <ParentName>] [--public \| --role <role>]` | `presentation/http/<name>.routes.ts`: an empty class carrying `@RouteGroup`, behind `Authenticated` unless `--public` or `--role` says otherwise (`HasRole(Role.Admin)`, the role read from the vocabulary of the platform, `<platformDir>/auth/role.ts`, which gets it when it does not hold it); with `--group` it refines that group and inherits from it |
320
631
  | `guard` | `<context> <Name>` | `presentation/http/guards/<name>.guard.ts`: a declared guard class to fill, and its spec |
321
632
  | `representation` | `<context> <UseCase> <xml\|csv\|media/type>[,...]` | adds `produces` to the `@Controller` of the use case, and `consumes: ['application/xml']` to one that receives a body when XML is asked; says when CSV needs a page or a streamed list, and when `stack.representations` is not `negotiated` |
@@ -323,7 +634,7 @@ ddd-ts-kit generate --help --json
323
634
  | `schema` | `<context\|shared-kernel> <Name>` | `presentation/http/shared/<name>.schema.ts` of the context, or `presentation/<name>.schema.ts` of the shared kernel: one shape of the contract declared once (a zod schema with `.meta({ id })`, a class-validator class, or a schema wrapped by `sharedSchema`), to fill; the routes use it whole, nested or in part |
324
635
  | `integration-event` | `<context> <Aggregate> <Fact> [--version n]` | `defineIntegrationEvent` declaration in the published language (its payload in `<event>.v<n>.payload.ts` with class-validator or a schema of your own), index export, the publisher `application/integration/publishers/<event>.publisher.ts`, spec, registration, a line of `platform/audit/audited-events.ts` |
325
636
  | `integration-handler` (`consumer`) | `<context> <upstreamContext> <eventType>` | `application/integration/handlers/<name>.integration-handler.ts`, a class `<Name>IntegrationHandler` depending on `CommandBus`, its spec, its idempotent subscription under the consumer name `<context>.<name>`; refused unless the upstream is declared |
326
- | `event-handler` | `<context> <Aggregate> <Fact> [--phase after-commit]` | reaction to a domain event of the context, spec, subscription; before the commit of the unit of work by default, after it with `--phase after-commit` (an effect no transaction covers, at most once), and the spec runs the reaction in that phase |
637
+ | `event-handler` | `<context> <Aggregate> <Fact> [--phase before-commit\|after-commit]` | reaction to a domain event of the context, spec, subscription; before the commit of the unit of work by default, after it with `--phase after-commit` (an effect no transaction covers, at most once), and the spec runs the reaction in that phase |
327
638
  | `projection` (`projector`) | `<context> <Name> <event-type>` | a projector in `application/projections/<name>.projection.ts`, declared with `@Projection('<context>.<name>')` and one `@On(definition)` method for the integration event, which the context itself or a declared upstream publishes, and its spec; declared style only. It keeps one read model: its port is received in the constructor |
328
639
  | `job` | `<context> <Name> (--every <duration> \| --cron "<expression>" [--time-zone <zone>]) --command <UseCaseName>` | `application/scheduling/<name>.job.ts`, a class `<Name>Job implements ScheduledJob` that sends the command, as `systemActor('<context>.<name>')` when it is protected, with `execution.scheduledAt` when the command takes it; `test/unit/<name>.job.spec.ts`, which runs the job (by name on tsyringe, `application.jobs.runNow`; `job.run(occurrence)` with hand wiring) and asserts the command it sent; its registration; its switch in `platform/job-settings.ts`; the grant of its system actor (below) |
329
640
  | `background-job` | `<context> <Name> --command <UseCaseName>` | `application/background/<name>.background-job.ts`, a class `<Name>BackgroundJob implements BackgroundJob<<Name>Payload>` named by its static `jobName` `<context>.<name>`, that sends the command as `execution.actor` when it is protected, followed by one field of `<Name>Payload` per other parameter of the constructor of the command (strings, numbers, booleans, literals and lists of them as written, a `Date` as its ISO text; anything else is refused); `test/unit/<name>.background-job.spec.ts`, which enqueues it with a payload of every field as a user holding the permission and runs what is due; its registration (`@Background()`, or `builder.backgroundJob(..)` in the module file). `--di tsyringe` only |
@@ -622,6 +933,15 @@ this command rewrites, in every `.js`, `.mjs`, `.cjs`, `.d.ts`, `.d.mts` and `.d
622
933
  output directory of the tsconfig (`tsconfig.build.json` by default), each alias specifier into
623
934
  the relative path of the emitted file it names, with its `.js` extension
624
935
  (`../../platform/errors.js`), so that `node dist/src/main.js` runs with no loader and no flag.
936
+
937
+ ```js
938
+ // dist/src/contexts/billing/domain/aggregates/invoice.aggregate.js, as the compiler emits it
939
+ import { InvoiceError } from '@/contexts/billing/domain/errors/invoice.error';
940
+
941
+ // the same line after ddd-ts-kit build-aliases
942
+ import { InvoiceError } from '../errors/invoice.error.js';
943
+ ```
944
+
625
945
  It reads `importAlias` and `importExtension` of the config: `@/platform/errors` names
626
946
  `platform/errors.js` under `none`, `@/platform/errors.js` names it under `js`, and no other
627
947
  spelling is guessed.
@@ -722,6 +1042,53 @@ from `docs/architecture/slices.md`, the published languages it consumes. An exis
722
1042
  kept and only the file is written again; `--dry-run` creates and writes nothing. It needs a git
723
1043
  repository with one commit at least. The worker then generates with `--local-only`.
724
1044
 
1045
+ ## affected
1046
+
1047
+ ```
1048
+ ddd-ts-kit affected [--base <git-ref>] [--head <git-ref>] [--format text|json|github|env]
1049
+ ```
1050
+
1051
+ What a pull or merge request has to run, read from the context map and the paths that differ
1052
+ between `--base` and `--head` (`HEAD` by default; the merge base is used when the clone has it).
1053
+ Each changed path reaches something:
1054
+
1055
+ - a path under a declared context reaches that context and every context downstream of it, since
1056
+ their code may read its published language; a path under its `test/` alone reaches that
1057
+ context alone, since no other context may import its tests;
1058
+ - a document reaches nothing that runs: a Markdown file at the root, anything under `docs/`,
1059
+ `openapi/` or `.claude/` (the documents gate always runs);
1060
+ - anything else reaches everything: the platform, the shared kernel, `main.ts`, the config, the
1061
+ lockfile, the pipeline, a directory of `src/contexts/` the config does not declare.
1062
+
1063
+ Without `--base`, or when git cannot tell what changed (an unknown ref, a shallow clone without
1064
+ the base), everything is selected: that is what the default branch and the nightly run get. The
1065
+ selection is never narrower than the declared relationships, because `context/declared-relationship`
1066
+ keeps every import between contexts inside them, and that rule runs on the whole project in every
1067
+ pipeline.
1068
+
1069
+ ![A change in the scheduling context of the example application: scheduling is selected, and so are notification and analytics, which are downstream of it in the context map; identity, upstream, is left to the default branch](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/cli-affected-selection.svg)
1070
+
1071
+ In the example application, a branch that changes one aggregate of `scheduling`:
1072
+
1073
+ ```
1074
+ $ npx ddd-ts-kit affected --base main
1075
+ Affected contexts: scheduling, notification, analytics (the changed contexts and the contexts downstream of them in the context map).
1076
+ Specs: src/contexts/scheduling/**/*.spec.ts src/contexts/notification/**/*.spec.ts src/contexts/analytics/**/*.spec.ts !src/contexts/**
1077
+ Left to the default branch: src/contexts/identity/
1078
+
1079
+ $ npx ddd-ts-kit affected --base main --format github
1080
+ scope=contexts
1081
+ contexts=scheduling,notification,analytics
1082
+ args=--passWithNoTests
1083
+ lint=--ignore-pattern src/contexts/identity/
1084
+ ```
1085
+
1086
+ `--format github` writes `key=value` lines for `$GITHUB_OUTPUT`, `--format env` the same keys in
1087
+ upper case behind `AFFECTED_` for a GitLab `dotenv` report, `--json` the selection as data
1088
+ (`{ scope, reason, contexts, specs, leftOut }`). `contexts` is what the test runner reads as
1089
+ `DDD_TS_KIT_AFFECTED`, `args` what a partial run adds to it, `lint` what it adds to ESLint; the
1090
+ generated pipelines wire all three.
1091
+
725
1092
  ## check
726
1093
 
727
1094
  ```
@@ -732,7 +1099,58 @@ ddd-ts-kit check --commit-size [--staged] [--strict]
732
1099
  ```
733
1100
 
734
1101
  Each violation has a rule id, a severity, a file, a line, a message and a `fix`: one sentence
735
- saying what to do instead. Exit code 1 when there is at least one error.
1102
+ saying what to do instead. Exit code 1 when there is at least one error. Suppose that the
1103
+ `Reminder` aggregate of the example application read the system clock in its domain: an `isDue()`
1104
+ at line 63 that calls `Date.now()`, where the real one receives the instant as `isDue(asOf: Date)`.
1105
+ `check` would report:
1106
+
1107
+ ```
1108
+ $ npx ddd-ts-kit check src/contexts/notification/domain
1109
+ src/contexts/notification/domain/aggregates/reminder.aggregate.ts:64 error purity/no-system-clock
1110
+ Date.now() is used in the domain layer.
1111
+ fix: Receive the instant as a parameter in the domain, and take it from the Clock port (`this.clock.now()`) in a handler, so that a test fixes time with FixedClock.
1112
+
1113
+ 1 error, 0 warnings, 0 suppressions in 772 files.
1114
+ ```
1115
+
1116
+ The `fix` is the change to make. Instead of that hypothetical method:
1117
+
1118
+ ```ts
1119
+ isDue(): boolean {
1120
+ return this.state.status === ReminderStatus.Planned && this.state.dueAt.getTime() <= Date.now();
1121
+ }
1122
+ ```
1123
+
1124
+ write:
1125
+
1126
+ <!-- checked sample -->
1127
+ ```ts
1128
+ import { Clock, FixedClock } from '@ddd-ts-kit/kernel';
1129
+
1130
+ // domain: the instant is a parameter, as in the Reminder aggregate of the example
1131
+ export class Reminder {
1132
+ constructor(private readonly dueAt: Date) {}
1133
+
1134
+ isDue(asOf: Date): boolean {
1135
+ return this.dueAt.getTime() <= asOf.getTime();
1136
+ }
1137
+ }
1138
+
1139
+ // application: the handler takes the instant from the Clock port
1140
+ export class SendDueRemindersHandler {
1141
+ constructor(private readonly clock: Clock) {}
1142
+
1143
+ isDueNow(reminder: Reminder): boolean {
1144
+ return reminder.isDue(this.clock.now());
1145
+ }
1146
+ }
1147
+
1148
+ // a test fixes the time
1149
+ const handler = new SendDueRemindersHandler(new FixedClock(new Date('2026-03-02T09:00:00Z')));
1150
+ const due = handler.isDueNow(new Reminder(new Date('2026-03-01T09:00:00Z'))); // true
1151
+ ```
1152
+
1153
+ `ddd-ts-kit explain <rule-id>` prints the same kind of bad and good example for every rule.
736
1154
 
737
1155
  - `paths` and `--changed-since` limit what is reported, not what is analysed.
738
1156
  `--changed-since` runs `git diff --name-only --relative <ref>...` in the project.
@@ -783,6 +1201,7 @@ saying what to do instead. Exit code 1 when there is at least one error.
783
1201
  | `naming/suffix` | a file without the suffix of its role, or not in kebab-case. `application/scheduling/` holds `*.job.ts`; a use case folder accepts `*.result.ts` next to its message and its handler; `presentation/http/<context>.routes.ts`, named after its context instead of a role, is accepted in both styles |
784
1202
  | `naming/one-concept-per-file` | several exported classes in an aggregate, entity, value object, event, command, query, handler, event handler, integration event handler or job file; in the declared style, several classes carrying a declaration decorator in one role file |
785
1203
  | `naming/search-not-list` | in a context, a method named `list` or `list<Something>` in a port (`domain/repositories/*.repository.ts`, `application/ports/*.port.ts`), in a class of `infrastructure/` or in a fake of `test/fakes/`; under `application/queries/`, a folder or a file named `list`, `list-...` or `list....`, or a class named `List` or `List<Something>`. Properties and `listAccessible` (required by the `Authorizer` port) are not judged. Name them `search...`: `generate query <context> Search<Things> --search` |
1204
+ | `naming/business-name` | in `domain/` and `application/` of a context and in the shared kernel outside `presentation/`, a variable, loop variable, destructured binding, parameter or property named with a generic word (`target`, `data`, `item` and the other words of the list, which `businessNames` of the config changes); the fix names the concept an initializer builds or reads when the text says it, and `npx eslint --fix` renames it |
786
1205
  | `test/spec-required` | an aggregate, value object, domain service, handler, domain event handler, integration event handler or job without a spec (a `*.result.ts` needs none) |
787
1206
  | `test/no-mocks` | `vi.mock`, `vi.fn`, `vi.spyOn`, their `jest` twins, or a mocking library in a context's tests |
788
1207
  | `test/builder-required` | an aggregate without `test/builders/<name>.builder.ts` |
@@ -0,0 +1,3 @@
1
+ /** Offset of the `)` that closes a parenthesis opened before `from`, or the end of the text. */
2
+ export declare function closingParenthesis(text: string, from: number): number;
3
+ //# sourceMappingURL=closing-parenthesis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"closing-parenthesis.d.ts","sourceRoot":"","sources":["../../../src/check/business-names/closing-parenthesis.ts"],"names":[],"mappings":"AAEA,gGAAgG;AAChG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcrE"}
@@ -0,0 +1,16 @@
1
+ import { BRACKETS } from '../text/brackets.js';
2
+ /** Offset of the `)` that closes a parenthesis opened before `from`, or the end of the text. */
3
+ export function closingParenthesis(text, from) {
4
+ let depth = 0;
5
+ for (let i = from; i < text.length; i += 1) {
6
+ const char = text.charAt(i);
7
+ if (BRACKETS.openers.includes(char)) {
8
+ depth += 1;
9
+ }
10
+ else if (BRACKETS.closers.includes(char) && (depth -= 1) < 0) {
11
+ return i;
12
+ }
13
+ }
14
+ return text.length;
15
+ }
16
+ //# sourceMappingURL=closing-parenthesis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"closing-parenthesis.js","sourceRoot":"","sources":["../../../src/check/business-names/closing-parenthesis.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAE/C,gGAAgG;AAChG,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,IAAY;IAC3D,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,KAAK,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAE5B,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YACpC,KAAK,IAAI,CAAC,CAAC;QACb,CAAC;aAAM,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;YAC/D,OAAO,CAAC,CAAC;QACX,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC,MAAM,CAAC;AACrB,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { NamedBinding } from './named-binding.js';
2
+ /** The variables a source declares with `const`, `let` or `var`, loop variables and destructured names included. */
3
+ export declare function declaredBindings(code: string): NamedBinding[];
4
+ //# sourceMappingURL=declared-bindings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"declared-bindings.d.ts","sourceRoot":"","sources":["../../../src/check/business-names/declared-bindings.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQvD,oHAAoH;AACpH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,EAAE,CAkB7D"}
@@ -0,0 +1,41 @@
1
+ import { statementEnd } from '../text/statement-end.js';
2
+ import { closingParenthesis } from './closing-parenthesis.js';
3
+ import { identifierAt } from './identifier-at.js';
4
+ import { patternBindings } from './pattern-bindings.js';
5
+ import { topLevelSeparator } from './top-level-separator.js';
6
+ const DECLARATION = /(?<![\w$.])(?:const|let|var)\s+(?=[A-Za-z_$[{])/g;
7
+ const OPENS_A_LOOP = /(?<![\w$.])for\s*(?:await\s*)?\(\s*$/;
8
+ const WALKS = /^\s+(?:of|in)\s+/;
9
+ /** The variables a source declares with `const`, `let` or `var`, loop variables and destructured names included. */
10
+ export function declaredBindings(code) {
11
+ return [...code.matchAll(DECLARATION)].flatMap((match) => {
12
+ const at = match.index + match[0].length;
13
+ const opener = code.charAt(at);
14
+ if (opener === '{' || opener === '[') {
15
+ return patternBindings(code, at);
16
+ }
17
+ const name = identifierAt(code, at);
18
+ const after = at + name.length;
19
+ if (OPENS_A_LOOP.test(code.slice(Math.max(0, match.index - 20), match.index))) {
20
+ return [{ name, offset: at, kind: 'loop variable', source: walkedCollection(code, after) }];
21
+ }
22
+ return [{ name, offset: at, kind: 'variable', source: initializer(code, after) }];
23
+ });
24
+ }
25
+ /** `due` in `for (const target of due)`. */
26
+ function walkedCollection(code, from) {
27
+ const rest = code.slice(from, from + 400);
28
+ const walks = WALKS.exec(rest);
29
+ if (walks === null) {
30
+ return '';
31
+ }
32
+ const start = walks[0].length;
33
+ return rest.slice(start, closingParenthesis(rest, start)).trim();
34
+ }
35
+ /** What follows the `=` of a declaration, its type annotation skipped. */
36
+ function initializer(code, from) {
37
+ const declared = code.slice(from, statementEnd(code, from));
38
+ const equals = topLevelSeparator(declared, '=');
39
+ return equals === -1 ? '' : declared.slice(equals + 1).trim();
40
+ }
41
+ //# sourceMappingURL=declared-bindings.js.map