@starci/eslint-canon-be 3.0.8 → 3.0.9
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/CHANGELOG.md +13 -1
- package/README.md +12 -6
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +18 -21
- package/config-owner.mjs +20 -4
- package/connections.mjs +12 -12
- package/cookies.mjs +106 -0
- package/cqrs.mjs +22 -26
- package/data-access.mjs +9 -28
- package/default-deny.mjs +2 -1
- package/e2e-flow.mjs +4 -4
- package/event-bus.mjs +237 -0
- package/feature-thin.mjs +237 -0
- package/idempotency.mjs +3 -1
- package/index.mjs +22 -1
- package/infra-owner.mjs +1 -1
- package/injection.mjs +67 -22
- package/input-bounds.mjs +2 -1
- package/jobs.mjs +214 -0
- package/lib/ast.mjs +7 -0
- package/lib/config.mjs +1 -1
- package/lib/db-writes.mjs +1 -1
- package/lib/doors.mjs +81 -0
- package/lib/edition.mjs +45 -0
- package/lib/entity-writes.mjs +35 -0
- package/lib/path.mjs +0 -6
- package/lib/persistence.mjs +11 -18
- package/lib/ports.mjs +5 -2
- package/lib/specifier.mjs +27 -0
- package/lib/transactions.mjs +37 -0
- package/lib/transport-slots.mjs +8 -3
- package/lib/unit-spec.mjs +63 -2
- package/module-layering.mjs +3 -24
- package/module-shape.mjs +2 -2
- package/naming.mjs +2 -2
- package/package.json +3 -1
- package/project-graph.mjs +5 -2
- package/projections.mjs +117 -0
- package/query-safety.mjs +1 -1
- package/realtime.mjs +181 -0
- package/runtime/engine/config.mjs +494 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +160 -29
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +912 -120
- package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
- package/runtime/knowledge/sonar-gate.yaml +52 -18
- package/runtime/modules/models/registry.yaml +591 -0
- package/runtime/modules/models/runtimes.yaml +701 -0
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
- package/runtime/scripts/api/git/config-get.mjs +10 -0
- package/runtime/scripts/api/git/lib.mjs +16 -26
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
- package/runtime/scripts/hfs/architecture/config.mjs +9 -6
- package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
- package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
- package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
- package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
- package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +17 -4
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
- package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
- package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
- package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
- package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
- package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
- package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
- package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
- package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
- package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
- package/runtime/scripts/hfs/edition-slots.mjs +66 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/hfs/path-findings.mjs +1 -1
- package/runtime/scripts/hfs/project-graph.mjs +3 -3
- package/runtime/scripts/hfs/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
- package/runtime/scripts/hfs/rules/read.mjs +1 -1
- package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +78 -108
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
- package/runtime/scripts/hfs/view.mjs +3 -2
- package/runtime/scripts/lib/dockerfile.mjs +124 -0
- package/runtime/scripts/lib/git.mjs +36 -0
- package/runtime/scripts/lib/i18n.mjs +86 -0
- package/runtime/scripts/lib/json.mjs +13 -3
- package/runtime/scripts/lib/language.mjs +24 -5
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/path-key.mjs +57 -2
- package/runtime/scripts/lib/read-text.mjs +34 -0
- package/runtime/scripts/lib/regex.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +4 -3
- package/runtime/scripts/lib/sops-envelope.mjs +58 -0
- package/runtime/scripts/lib/stack-services.mjs +7 -31
- package/runtime/scripts/lib/ts-ast.mjs +35 -0
- package/runtime/scripts/lib/walk.mjs +4 -3
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +1 -1
- package/test-world.mjs +5 -4
- package/testing.mjs +27 -24
- package/transport.mjs +12 -9
- package/unit-spec.mjs +1 -1
- package/user-copy.mjs +5 -4
- package/webhooks.mjs +173 -0
- package/runtime/scripts/api/sops/decrypt.mjs +0 -16
- package/runtime/scripts/lib/test-secrets.mjs +0 -116
package/schema-authority.mjs
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
* `@nestjs/typeorm` options type) with no literal `synchronize: false`; a `synchronize` that is anything but
|
|
9
9
|
* the literal `false`; `new DataSource(<not an object literal>)` whose options cannot be read; a
|
|
10
10
|
* `dataSource.synchronize()` or `.dropDatabase()` call; the `migrationsRun` option in any form and a `runMigrations()` /
|
|
11
|
-
* `undoLastMigration()` call outside
|
|
11
|
+
* `undoLastMigration()` call outside the cli (apps/cli and src/features/cli) and the test world; `dropSchema` set to anything but `false`; a schema
|
|
12
12
|
* builder (`createSchemaBuilder()`, `.build()` of typeorm's `SchemaBuilder`); a `synchronize` key in the options of typeorm's
|
|
13
13
|
* `@Entity` (a per-entity switch is a second authority); a lifecycle hook (`onModuleInit`, `onApplicationBootstrap`) that
|
|
14
|
-
* writes rows through an `EntityManager` or `DataSource` (seeding
|
|
15
|
-
* outside the migrations of `persistence/`; and an entity or migration glob. Only
|
|
14
|
+
* writes rows through an `EntityManager` or `DataSource` (seeding is a cli command); a DDL statement in any string
|
|
15
|
+
* outside the migrations of `persistence/`; and an entity or migration glob. Only the cli (its migrate and seed commands) runs migrations and seeds.
|
|
16
16
|
* - `no-entity-in-contract` (R37 `BE_ENTITY_IN_CONTRACT`) keeps ORM entities out of the types that cross a boundary:
|
|
17
17
|
* any type whose declaration carries `@Entity` (from `typeorm`), reached through the type arguments and properties of
|
|
18
18
|
* a transport signature, the `execute` of a handler, or a `*.contracts.ts`, `*.command.ts` or `*.query.ts` file; and
|
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
* never of a path pattern.
|
|
23
23
|
*/
|
|
24
24
|
import ts from "typescript"
|
|
25
|
+
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
25
26
|
import { keyName, walk } from "./lib/ast.mjs"
|
|
26
27
|
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
27
28
|
import { baseName, isMigrationFile, packageOfFile } from "./lib/ports.mjs"
|
|
29
|
+
import { inCli } from "./lib/persistence.mjs"
|
|
28
30
|
import { isPackageType, typed } from "./lib/types.mjs"
|
|
29
31
|
import { isDeclarationFile } from "./lib/path.mjs"
|
|
30
32
|
|
|
@@ -37,7 +39,7 @@ const OPTION_PACKAGES = new Set(["typeorm", "@nestjs/typeorm"])
|
|
|
37
39
|
/** The DataSource operations that change the schema at runtime. */
|
|
38
40
|
const SCHEMA_OPERATIONS = new Set(["synchronize", "dropDatabase"])
|
|
39
41
|
|
|
40
|
-
/** The DataSource operations that run migrations: allowed only in
|
|
42
|
+
/** The DataSource operations that run migrations: allowed only in the cli (apps/cli, src/features/cli) and the test world. */
|
|
41
43
|
const MIGRATION_RUNNERS = new Set(["runMigrations", "undoLastMigration"])
|
|
42
44
|
|
|
43
45
|
/** The typeorm types whose methods write rows. */
|
|
@@ -99,8 +101,8 @@ const isOperationsObject = (context, node) => {
|
|
|
99
101
|
return members.length > 0 && members.every(isMethod)
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
/** Whether a file is
|
|
103
|
-
const isMigrationHost = (hfs, filename) => hfs
|
|
104
|
+
/** Whether a file is the cli (the cli app or the cli feature root, whose migrate and seed commands run them) or the test world: the only places that run migrations and seed. */
|
|
105
|
+
const isMigrationHost = (hfs, filename) => inCli(hfs, filename) || inTestWorld(hfs, filename)
|
|
104
106
|
|
|
105
107
|
/** The SQL text of a call argument: a literal, a template, or the quasis of a tagged template. */
|
|
106
108
|
const sqlTextOf = (argument) => (argument ? stringsUnder(argument).join(" ") : "")
|
|
@@ -133,7 +135,7 @@ const bootWriteOf = (context, method, klass, visited) => {
|
|
|
133
135
|
/** Whether an object literal has a property called `name`. */
|
|
134
136
|
const hasKey = (objectNode, name) => objectNode.properties.some((property) => property.type === "Property" && !property.computed && keyName(property.key) === name)
|
|
135
137
|
|
|
136
|
-
/** The schema is decided by migrations run by
|
|
138
|
+
/** The schema is decided by migrations run by the cli migrate command, never by the running process. */
|
|
137
139
|
export const noRuntimeSchema = {
|
|
138
140
|
meta: {
|
|
139
141
|
type: "problem",
|
|
@@ -143,13 +145,13 @@ export const noRuntimeSchema = {
|
|
|
143
145
|
synchronize: "`synchronize` is set to something other than the literal `false`. The schema changes by migration only; a boot-time ORM diff decides it otherwise.",
|
|
144
146
|
synchronizeMissing: "This DataSource options object does not state `synchronize: false`. Write the literal `false` in every options object, so what the connection may do to the schema is readable where it is built.",
|
|
145
147
|
optionsNotLiteral: "`new DataSource(...)` is given options that are not an object literal, so `synchronize: false` cannot be seen. Pass the options as an object literal that states `synchronize: false`.",
|
|
146
|
-
synchronizeCall: "`.{{name}}()` changes the schema at runtime. Write a migration and let
|
|
147
|
-
migrationsRun: "`migrationsRun` is banned. Only `
|
|
148
|
-
runMigrations: "`.{{name}}()` runs migrations outside
|
|
148
|
+
synchronizeCall: "`.{{name}}()` changes the schema at runtime. Write a migration and let the cli migrate command run it.",
|
|
149
|
+
migrationsRun: "`migrationsRun` is banned. Only the cli migrate command (`cli migrate run`) runs migrations, once per connection, before api and worker start.",
|
|
150
|
+
runMigrations: "`.{{name}}()` runs migrations outside the cli. Only the cli migrate command (and the test world, over the same connections) runs them, once per connection, before api and worker start.",
|
|
149
151
|
dropSchema: "`dropSchema` is set to something other than the literal `false`. A connection never drops the schema it opens; a migration changes it.",
|
|
150
152
|
schemaBuilder: "`{{name}}` builds or logs the schema from the entity metadata at runtime. The schema changes by migration only.",
|
|
151
153
|
entitySynchronize: "`synchronize` is set in the options of `@Entity`. A per-entity switch is a second schema authority next to the connection's literal `false`; delete the key.",
|
|
152
|
-
bootSeed: "`{{hook}}` writes rows (`{{call}}`), so every process that composes this provider seeds at boot. Seeding
|
|
154
|
+
bootSeed: "`{{hook}}` writes rows (`{{call}}`), so every process that composes this provider seeds at boot. Seeding is a cli command, run once per connection.",
|
|
153
155
|
ddl: "A schema-changing statement (`CREATE|ALTER|DROP ...`) outside `persistence/migrations/`. Schema-changing SQL belongs in a migration.",
|
|
154
156
|
glob: "`{{key}}` is found by glob. List the entities and migrations explicitly from each capability's `index.ts` so what runs is what was reviewed.",
|
|
155
157
|
},
|
|
@@ -269,7 +271,7 @@ const PARAMETER_HOLDERS = new Set(["Identifier", "ObjectPattern", "ArrayPattern"
|
|
|
269
271
|
const boundaryOf = (hfs, filename) => {
|
|
270
272
|
const slot = hfs.slotOf(filename) ?? ""
|
|
271
273
|
const name = baseName(filename)
|
|
272
|
-
if (
|
|
274
|
+
if (isTransportSlot(slot)) return "file"
|
|
273
275
|
if (/\.(?:contracts|command|query)\.ts$/.test(name)) return "file"
|
|
274
276
|
if (name.endsWith(".handler.ts")) return "execute"
|
|
275
277
|
return null
|
package/service-deps.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* token: it is either a broken provider or a raw string/number injection.
|
|
12
12
|
* - `no-repository-class` (R83): a class that receives or returns an `EntityManager`, a typeorm `Repository`, a
|
|
13
13
|
* `DataSource` or a `QueryRunner` in any member lives only where the manager may be held (an application handler,
|
|
14
|
-
* a domain service, a platform persistence capability), in the
|
|
14
|
+
* a domain service, a platform persistence capability), in the cli app, in the test world or in a migration.
|
|
15
15
|
* A per-entity persistence class anywhere else is a repository under another name.
|
|
16
16
|
* - `no-test-double-in-source` (R89): a class, function or constant of production source is not named as a test
|
|
17
17
|
* double (`Mock`, `Fake`, `Stub` as a PascalCase or camelCase word). A double lives in a spec or `src/tests`;
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* slot view. A name is read only for the fixed vocabulary of the double markers, as whole words.
|
|
22
22
|
*/
|
|
23
23
|
import ts from "typescript"
|
|
24
|
-
import {
|
|
24
|
+
import { paramParts } from "./lib/ast.mjs"
|
|
25
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
25
26
|
import { isSpecFile, normalizePath } from "./lib/path.mjs"
|
|
26
|
-
import { implementsMigration,
|
|
27
|
+
import { implementsMigration, inCli, isMigrationFile, mayHoldEntityManager } from "./lib/persistence.mjs"
|
|
27
28
|
import { typed, typeOrigins } from "./lib/types.mjs"
|
|
28
29
|
|
|
29
30
|
// -- provider-param-token --------------------------------------------------------------------------
|
|
@@ -31,13 +32,6 @@ import { typed, typeOrigins } from "./lib/types.mjs"
|
|
|
31
32
|
/** The convention's injector vocabulary: `Inject` or `Inject<Thing>`. */
|
|
32
33
|
const INJECT_NAME = /^Inject(?:[A-Z][A-Za-z0-9]*)?$/
|
|
33
34
|
|
|
34
|
-
/** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
|
|
35
|
-
const paramParts = (param) => {
|
|
36
|
-
const inner = param.type === "TSParameterProperty" ? param.parameter : param
|
|
37
|
-
const target = inner.type === "AssignmentPattern" ? inner.left : inner
|
|
38
|
-
return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
35
|
/** True when a decorator expression is a call of an `Inject*` function, or of anything typed `TypedParameterDecorator<T>`. */
|
|
42
36
|
const isInjector = (context, decorator) => {
|
|
43
37
|
const expression = decorator.expression
|
|
@@ -140,11 +134,11 @@ const persistenceMember = (context, classNode) => {
|
|
|
140
134
|
export const noRepositoryClass = {
|
|
141
135
|
meta: {
|
|
142
136
|
type: "problem",
|
|
143
|
-
docs: { description: "A class with a member typed `EntityManager`, `Repository`, `DataSource` or `QueryRunner` lives only in an application handler, a domain service, a platform persistence capability, the
|
|
137
|
+
docs: { description: "A class with a member typed `EntityManager`, `Repository`, `DataSource` or `QueryRunner` lives only in an application handler, a domain service, a platform persistence capability, the cli app, the test world or a migration." },
|
|
144
138
|
schema: [],
|
|
145
139
|
messages: {
|
|
146
140
|
statements:
|
|
147
|
-
"`{{name}}` is exported and takes an `EntityManager` as its first parameter: a statement module is a repository without a class. Put the SQL text in `<name>.sql.ts` and run it with `this.entityManager.query(...)` from the capability `*.service.ts` or the application `*.handler.ts`; only `platform/database`, `apps/
|
|
141
|
+
"`{{name}}` is exported and takes an `EntityManager` as its first parameter: a statement module is a repository without a class. Put the SQL text in `<name>.sql.ts` and run it with `this.entityManager.query(...)` from the capability `*.service.ts` or the application `*.handler.ts`; only `platform/database`, the cli (`apps/cli`, `src/features/cli`) and `src/tests` take a manager in a free function.",
|
|
148
142
|
repository:
|
|
149
143
|
"`{{name}}` takes or holds a `{{type}}` in `{{member}}` but is not an application handler, a domain service or a platform persistence capability. A class wrapping the manager per entity is a repository whatever it is called: it hides which connection and transaction a call runs in and cannot be unit-tested with a token double. Move the queries into the handler or the domain service that owns the use, and call the injected EntityManager directly.",
|
|
150
144
|
},
|
|
@@ -153,7 +147,7 @@ export const noRepositoryClass = {
|
|
|
153
147
|
const hfs = hfsOf(context)
|
|
154
148
|
const filename = context.filename || context.getFilename()
|
|
155
149
|
const file = normalizePath(filename)
|
|
156
|
-
if (mayHoldEntityManager(hfs, file) ||
|
|
150
|
+
if (mayHoldEntityManager(hfs, file) || inCli(hfs, file) || inTestWorld(hfs, file) || isMigrationFile(hfs, file) || (hfs.slotOf(file) ?? "").startsWith("be.tests.")) return {}
|
|
157
151
|
const check = (node) => {
|
|
158
152
|
if (implementsMigration(context, node)) return
|
|
159
153
|
const found = persistenceMember(context, node)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that keeps the services of one product apart (catalog R168 `BE_SERVICE_ISOLATION`).
|
|
3
|
+
*
|
|
4
|
+
* A back-end service is a Nest app at `be/apps/<service>/` of the one repository; services share the `be/src` libraries and talk
|
|
5
|
+
* to each other only through the wire (a client of the sibling's API, or the events of its vendored contract under
|
|
6
|
+
* `be/contracts/<service>/`). One service importing a file of another service's `apps/<other>/` folder reaches into a sibling's
|
|
7
|
+
* composition and options, which makes the two one deployable. The check asks the HFS slot view which app owns a file (the owner of
|
|
8
|
+
* an `apps/<app>/src/` slot) for both the importing file and the file the specifier resolves to, through a relative path or the
|
|
9
|
+
* program's own `compilerOptions.paths`; it never matches a path by name.
|
|
10
|
+
*/
|
|
11
|
+
import { posix } from "node:path"
|
|
12
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
13
|
+
import { resolveAlias } from "./lib/specifier.mjs"
|
|
14
|
+
|
|
15
|
+
/** The app slots: the slot ids of a service app's `apps/<app>/src/` folder. */
|
|
16
|
+
const isAppSlot = (slotId) => typeof slotId === "string" && slotId.startsWith("be.app.")
|
|
17
|
+
|
|
18
|
+
const isRelative = (specifier) => specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../")
|
|
19
|
+
|
|
20
|
+
/** Calls `check(node, specifier)` for every import and re-export that names a module. */
|
|
21
|
+
const onSpecifiers = (check) => ({
|
|
22
|
+
ImportDeclaration(node) {
|
|
23
|
+
check(node.source, node.source.value)
|
|
24
|
+
},
|
|
25
|
+
ExportNamedDeclaration(node) {
|
|
26
|
+
if (node.source) check(node.source, node.source.value)
|
|
27
|
+
},
|
|
28
|
+
ExportAllDeclaration(node) {
|
|
29
|
+
if (node.source) check(node.source, node.source.value)
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** A file of one service app imports no file of a sibling service app. */
|
|
34
|
+
export const serviceIsolation = {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: { description: "A service app (`apps/<service>/`) imports no file of a sibling service app; services share only the `src` libraries and meet through the wire." },
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
sibling:
|
|
41
|
+
"`{{specifier}}` reaches into the service app `{{other}}` from `{{self}}`. Services share only the `src` libraries; call the sibling through its API client or consume its events through the vendored contract (`be/contracts/{{other}}/`).",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
create(context) {
|
|
45
|
+
const filename = context.filename || context.getFilename()
|
|
46
|
+
const hfs = hfsOf(context)
|
|
47
|
+
if (!isAppSlot(hfs.slotOf(filename))) return {}
|
|
48
|
+
const self = hfs.ownerOf(filename)
|
|
49
|
+
if (!self) return {}
|
|
50
|
+
return onSpecifiers((node, specifier) => {
|
|
51
|
+
if (typeof specifier !== "string") return
|
|
52
|
+
const target = isRelative(specifier)
|
|
53
|
+
? posix.normalize(`${posix.dirname(filename.replaceAll("\\", "/"))}/${specifier}`)
|
|
54
|
+
: resolveAlias(context, specifier)?.target
|
|
55
|
+
if (!target) return
|
|
56
|
+
const reached = [`${target}.ts`, `${target}/index.ts`].find((candidate) => isAppSlot(hfs.slotOf(candidate)))
|
|
57
|
+
if (!reached) return
|
|
58
|
+
const other = hfs.ownerOf(reached)
|
|
59
|
+
if (!other || other === self) return
|
|
60
|
+
context.report({ node, messageId: "sibling", data: { specifier, self: posix.basename(posix.dirname(self)), other: posix.basename(posix.dirname(other)) } })
|
|
61
|
+
})
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const rules = {
|
|
66
|
+
"service-isolation": serviceIsolation,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The level this law asks for: `error`, switched off nowhere. */
|
|
70
|
+
export const recommended = {
|
|
71
|
+
"starci-be/service-isolation": "error",
|
|
72
|
+
}
|
package/size-budget.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* baseline is git's.
|
|
10
10
|
*
|
|
11
11
|
* There is no soft-limit lint rule: a warning under a zero-warning gate is an exception in disguise. Listing the
|
|
12
|
-
* files over the soft budget (`fileLines.soft`) is a report item of the
|
|
12
|
+
* files over the soft budget (`fileLines.soft`) is a report item of the starci app check, never a block.
|
|
13
13
|
* There is no baseline file and no allowlist: the record is the repository's own history. A spec, a fixture and an e2e
|
|
14
14
|
* file are source files like any other. A migration is exempt (a file of the `be.persistence` slot named
|
|
15
15
|
* `<timestamp>-<name>.ts`: append-only, never edited), and so is a declaration file.
|
package/test-world.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Unit specs sit beside their subject. Everything else lives under `src/tests/`, one folder per kind:
|
|
5
5
|
* - `world/` the ONLY test infrastructure (slot `be.tests.world`): `global-setup.ts` starts (or attaches to) the stack
|
|
6
6
|
* the repository declares in `.starcistacks/<env>` (the test-world library) - every service of it runs
|
|
7
|
-
* REAL behind toxiproxy - and runs
|
|
7
|
+
* REAL behind toxiproxy - and runs the migrations once over the cli app's connections; `use-test-world.ts` exports
|
|
8
8
|
* `useTestWorld({ apps } | { modules })` -> `world.apps.<name>.api`, `world.db.<connection>`,
|
|
9
9
|
* `world.infra.<service>` (`latency(ms)`, `cut()`, `restore()` on the real service),
|
|
10
10
|
* `world.fake.<provider>` (network-edge fakes of external SaaS with failNext/replayWebhook/delay),
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Receivers and origins are judged by type and by the slot of their declaring file, never by name.
|
|
19
19
|
*/
|
|
20
20
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
21
|
+
import { inCli } from "./lib/persistence.mjs"
|
|
21
22
|
import { isPackageType, typeOrigins } from "./lib/types.mjs"
|
|
22
23
|
|
|
23
24
|
/** Every test slot under `src/tests/` except the world, where the infrastructure lives. */
|
|
@@ -36,7 +37,7 @@ const isEnvMember = (node) => node?.type === "MemberExpression" && isProcessEnv(
|
|
|
36
37
|
/**
|
|
37
38
|
* The world is the only infrastructure location. Refused in fixtures, integration, e2e and contract specs: importing a
|
|
38
39
|
* migration (a class declared in a `be.persistence` `migrations/` file, or a value whose type is an array of them) or
|
|
39
|
-
* anything the
|
|
40
|
+
* anything the cli app declares; importing typeorm's `DataSource` or a testcontainers package; calling
|
|
40
41
|
* `runMigrations`/`synchronize`/`dropDatabase`/`createSchema`... on a typeorm receiver; constructing a container; writing
|
|
41
42
|
* `process.env`. Migration behaviour is tested by the e2e world.
|
|
42
43
|
*/
|
|
@@ -46,7 +47,7 @@ export const testsInfraOnlyInWorld = {
|
|
|
46
47
|
docs: { description: "Only src/tests/world starts infrastructure, migrates, holds a DataSource or writes process.env." },
|
|
47
48
|
schema: [],
|
|
48
49
|
messages: {
|
|
49
|
-
migration: "This test imports a migration or the
|
|
50
|
+
migration: "This test imports a migration or the cli. The schema is prepared once by `src/tests/world/global-setup.ts`, which runs the migrations over the cli app's connections; migration behaviour is tested through the e2e world.",
|
|
50
51
|
call: "`{{name}}` builds or changes the schema outside `src/tests/world`. The world migrates once; a test uses `world.db.<connection>`.",
|
|
51
52
|
infra: "This test imports or starts infrastructure (testcontainers, typeorm's DataSource). It belongs to `src/tests/world`; a test uses `useTestWorld(...)` and `world.db.<connection>`.",
|
|
52
53
|
env: "This test writes `process.env`. The environment is set once by `src/tests/world`; a test takes the world as it is.",
|
|
@@ -57,7 +58,7 @@ export const testsInfraOnlyInWorld = {
|
|
|
57
58
|
const filename = context.filename || context.getFilename()
|
|
58
59
|
if (!OUTSIDE_WORLD.has(hfs.slotOf(filename))) return {}
|
|
59
60
|
const isMigrationDecl = (file) => hfs.slotOf(file) === "be.persistence" && /\/migrations\/[^/]+$/.test(hfs.relative(file))
|
|
60
|
-
const fromMigrate = (file) => hfs
|
|
61
|
+
const fromMigrate = (file) => inCli(hfs, file)
|
|
61
62
|
const migrationTyped = (node) => {
|
|
62
63
|
const services = context.sourceCode.parserServices
|
|
63
64
|
const checker = services.program.getTypeChecker()
|
package/testing.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that hold `
|
|
2
|
+
* The rules that hold `knowledge/patterns/be/test.yaml` (catalog R47 `BE_TEST_TOPOLOGY` and R48 `BE_SPEC_QUALITY`).
|
|
3
3
|
*
|
|
4
4
|
* MOST OF THAT LAW IS NOT MACHINE-CHECKABLE, and pretending otherwise would be worse than checking nothing. No rule
|
|
5
5
|
* can tell whether a file represents a business flow, whether the unhappy path it covers drags a critical flow
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* - a spec whose every assertion is about a call rather than a result (`no-call-only-spec`);
|
|
10
10
|
* - a unit spec of anything but a service, a service with no `<name>.service.spec.ts` beside it, a service spec with no
|
|
11
|
-
* service beside it, and a file of a kind the convention bans (`.test.ts`,
|
|
11
|
+
* service beside it, and a file of a kind the convention bans (`.test.ts`, `.int-spec.ts`, `.harness-spec.ts`)
|
|
12
12
|
* (`unit-test-colocated`);
|
|
13
13
|
* - an e2e that never reads persisted state back, and an e2e that reaches a model provider
|
|
14
14
|
* (`e2e-asserts-persisted-state`, `no-model-call-in-e2e`);
|
|
@@ -24,17 +24,17 @@
|
|
|
24
24
|
|
|
25
25
|
import { statSync } from "node:fs"
|
|
26
26
|
import { basename, dirname, join } from "node:path"
|
|
27
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
27
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
28
28
|
import { isPackageType, typeOrigins } from "./lib/types.mjs"
|
|
29
|
-
import {
|
|
29
|
+
import { doorOfSpec, isUnitSpecFile, unitRoleOfSpec, unitRoleOfSubject } from "./lib/unit-spec.mjs"
|
|
30
30
|
|
|
31
31
|
/** The file name of a linted path, in forward-slash form. */
|
|
32
32
|
const baseOf = (filename) => basename(String(filename || "").replace(/\\/g, "/"))
|
|
33
33
|
|
|
34
|
-
/** Test kind
|
|
34
|
+
/** Test kind `spec`: a unit spec, `<name>.service.spec.ts` beside its service. */
|
|
35
35
|
const isUnitSpec = (filename) => /\.spec\.ts$/.test(baseOf(filename))
|
|
36
36
|
|
|
37
|
-
/** Test kind
|
|
37
|
+
/** Test kind `e2e-spec`: `*.e2e-spec.ts` under `src/tests/e2e/` (`integration-spec` and `contract-spec` are the other two kinds of the tests slots). */
|
|
38
38
|
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(baseOf(filename))
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -121,23 +121,23 @@ const exists = (path) => {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/** Kinds of test file the convention bans: `.test.ts`, `*.int-spec.ts`, `*.harness-spec.ts` (any spec kind other than `spec` and `
|
|
124
|
+
/** Kinds of test file the convention bans: `.test.ts`, `*.int-spec.ts`, `*.harness-spec.ts` (any spec kind other than `spec`, `e2e-spec`, `integration-spec` and `contract-spec`). */
|
|
125
125
|
const BANNED_TEST_FILE = /(?:\.test|\.int-spec|\.harness-spec)\.[cm]?[jt]sx?$/
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
128
|
* Unit specs are for services only: every `<name>.service.ts` has its `<name>.service.spec.ts` beside it, every unit spec is
|
|
129
|
-
* one of those, and there are no
|
|
129
|
+
* one of those, and there are no kinds of test file beyond `spec`, `e2e-spec`, `integration-spec` and `contract-spec`.
|
|
130
130
|
*/
|
|
131
131
|
export const unitTestColocated = {
|
|
132
132
|
meta: {
|
|
133
133
|
type: "problem",
|
|
134
|
-
docs: { description: "Only a `<name>.service.ts`
|
|
134
|
+
docs: { description: "Only the unit-tested roles (`ruleParams.be.unitRoles`: a `<name>.service.ts`, a cli command `<name>.cli.ts` of `src/features/cli`) own a REQUIRED unit spec, each beside it as `<name>.<role>.spec.ts`; any other logic role (`ruleParams.be.logicRoles`: policy, client, guard, mapper, ...) inside a slot whose coverage is required may have one; any other unit spec is a finding, and there are only the spec kinds `spec`, `e2e-spec`, `integration-spec` and `contract-spec`." },
|
|
135
135
|
schema: [],
|
|
136
136
|
messages: {
|
|
137
|
-
suffix: "`{{name}}` is a banned kind of test file. A test is a `<name>.service.spec.ts` unit beside its service or a `*.e2e-spec.ts`
|
|
138
|
-
notService: "`{{name}}` is a unit spec of something that
|
|
139
|
-
orphan: "`{{name}}` has
|
|
140
|
-
missing: "This
|
|
137
|
+
suffix: "`{{name}}` is a banned kind of test file. A test is a `<name>.service.spec.ts` unit beside its service, or a `*.e2e-spec.ts`, `*.integration-spec.ts` or `*.contract-spec.ts` under its `src/tests/` folder; there is no `.test.ts`, `.int-spec.ts` or `.harness-spec.ts`.",
|
|
138
|
+
notService: "`{{name}}` is a unit spec of something that carries no unit-tested logic here. A unit spec sits beside a `<name>.<role>.ts` of a spec-required role (a service, a cli command of `src/features/cli`) or of a logic role (`ruleParams.be.logicRoles`: policy, client, guard, mapper, ...) inside the logic of `src/modules/**`, where every file owes 100 per file; a feature file (handler, resolver, controller, consumer, processor, step, ...) is thin (BE_FEATURE_THIN) and is proven by the integration and e2e flows, and an entity, module or composition is declaration. Delete this spec and move any business rule it checks into a service, policy or mapper of `src/modules/**`.",
|
|
139
|
+
orphan: "`{{name}}` has nothing beside it to test. A unit spec is `<name>.<role>.spec.ts` next to the `<name>.<role>.ts` it tests (a service, or a cli command of `src/features/cli`; the roles are `ruleParams.be.unitRoles`); move it beside its subject, or rename it after the subject it tests.",
|
|
140
|
+
missing: "This unit-tested subject has no spec. Add `{{spec}}` beside it: it builds the subject with `Test.createTestingModule`, provides only its constructor dependencies as typed doubles and asserts results or state.",
|
|
141
141
|
},
|
|
142
142
|
},
|
|
143
143
|
create(context) {
|
|
@@ -149,17 +149,20 @@ export const unitTestColocated = {
|
|
|
149
149
|
return { Program(node) { context.report({ node, messageId: "suffix", data: { name } }) } }
|
|
150
150
|
}
|
|
151
151
|
if (isUnitSpecFile(hfs, filename)) {
|
|
152
|
-
|
|
153
|
-
if (exists(join(dirname(filename),
|
|
152
|
+
const door = doorOfSpec(hfs, filename)
|
|
153
|
+
if (door !== null) return exists(join(dirname(filename), door)) ? {} : { Program(node) { context.report({ node, messageId: "orphan", data: { name } }) } }
|
|
154
|
+
const role = unitRoleOfSpec(hfs, filename)
|
|
155
|
+
if (!role) return { Program(node) { context.report({ node, messageId: "notService", data: { name } }) } }
|
|
156
|
+
const subject = `${name.slice(0, -`.${role.spec}.ts`.length)}.${role.role}.ts`
|
|
157
|
+
if (exists(join(dirname(filename), subject))) return {}
|
|
154
158
|
return { Program(node) { context.report({ node, messageId: "orphan", data: { name } }) } }
|
|
155
159
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return { Program(node) { context.report({ node, messageId: "missing", data: { spec: `${name.slice(0, -".ts".length)}.spec.ts` } }) } }
|
|
160
|
+
// a unit-tested subject outside the test tree (ruleParams.be.unitRoles): its spec sits beside it
|
|
161
|
+
const role = unitRoleOfSubject(hfs, filename)
|
|
162
|
+
if (!role) return {}
|
|
163
|
+
const spec = `${name.slice(0, -`.${role.role}.ts`.length)}.${role.spec}.ts`
|
|
164
|
+
if (exists(join(dirname(filename), spec))) return {}
|
|
165
|
+
return { Program(node) { context.report({ node, messageId: "missing", data: { spec } }) } }
|
|
163
166
|
},
|
|
164
167
|
}
|
|
165
168
|
|
|
@@ -175,7 +178,7 @@ const isStateReader = (context, node) => STATE_TYPES.some((name) => isPackageTyp
|
|
|
175
178
|
/** The handle `useTestWorld` answers, by where its type is declared: the `@starci/test-world` package or the repository's world slot. */
|
|
176
179
|
const isWorldHandle = (context, node) => {
|
|
177
180
|
const hfs = hfsOf(context)
|
|
178
|
-
return typeOrigins(context, node).some((origin) => origin.name === "TestWorld" && (origin.module === "@starci/test-world" || hfs
|
|
181
|
+
return typeOrigins(context, node).some((origin) => origin.name === "TestWorld" && (origin.module === "@starci/test-world" || inTestWorld(hfs, origin.file)))
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
/** The name a non-computed member access reads, or null. */
|
|
@@ -302,7 +305,7 @@ export const noApiShapedE2eFilename = {
|
|
|
302
305
|
// -- TESTING-7 -------------------------------------------------------------------------------------
|
|
303
306
|
|
|
304
307
|
/** Test infrastructure: the fixtures and the test world, where the shared model stub lives (slots `be.tests.fixtures`, `be.tests.world`). */
|
|
305
|
-
const isTestInfrastructure = (hfs, filename) =>
|
|
308
|
+
const isTestInfrastructure = (hfs, filename) => hfs.slotOf(filename) === "be.tests.fixtures" || inTestWorld(hfs, filename)
|
|
306
309
|
|
|
307
310
|
/** Bare markers a stub returns when nobody gave it a real answer to stand in for. */
|
|
308
311
|
const MARKER_STRINGS = new Set(["stubbed", "stub", "ok", "test", "mock", "fake", "todo", "tbd", "n/a", "pending", ""])
|
package/transport.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import ts from "typescript"
|
|
|
13
13
|
import { walk } from "./lib/ast.mjs"
|
|
14
14
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
15
15
|
import { decoratorArguments, decoratorCallee, importOf, isImportedFrom, moduleReferences } from "./lib/import-source.mjs"
|
|
16
|
-
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
16
|
+
import { isTransportSlot, KIND_DOOR_SLOTS } from "./lib/transport-slots.mjs"
|
|
17
17
|
import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
|
|
18
18
|
|
|
19
19
|
/** The HTTP route decorators of `@nestjs/common`. */
|
|
@@ -25,15 +25,17 @@ const GRAPHQL_OPERATIONS = ["Query", "Mutation", "Subscription", "ResolveField"]
|
|
|
25
25
|
/** The slot of REST doors. */
|
|
26
26
|
const HTTP_SLOT = "be.transport.http"
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
const
|
|
28
|
+
/** The slot of provider webhook doors (the REST doors of the webhook kind). */
|
|
29
|
+
const WEBHOOK_SLOT = "be.feature.webhooks.http"
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/** Slots whose classes are entered through a method the framework calls on a queue. */
|
|
32
|
+
const PLAIN_ENTRY_SLOTS = new Set(["be.transport.message"])
|
|
33
|
+
|
|
34
|
+
/** True for the decorators that make a method a transport handler (a socket handler belongs to the realtime kind, which has its own laws). */
|
|
32
35
|
const isRouteDecorator = (context, decorator) => {
|
|
33
36
|
const callee = decoratorCallee(decorator)
|
|
34
37
|
return isImportedFrom(context, callee, "@nestjs/common", HTTP_ROUTES)
|
|
35
38
|
|| isImportedFrom(context, callee, "@nestjs/graphql", GRAPHQL_OPERATIONS)
|
|
36
|
-
|| isImportedFrom(context, callee, "@nestjs/websockets", "SubscribeMessage")
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** True when the class declares (transitively through its members' decorators) nothing but is a transport door: it has handler methods. */
|
|
@@ -115,7 +117,8 @@ export const transportIsThin = {
|
|
|
115
117
|
create(context) {
|
|
116
118
|
const hfs = hfsOf(context)
|
|
117
119
|
const slot = hfs.slotOf(context.filename)
|
|
118
|
-
|
|
120
|
+
// a webhook or realtime door answers to its own kind laws (webhooks.mjs, realtime.mjs), not to the one-dispatch shape
|
|
121
|
+
if (!isTransportSlot(slot) || KIND_DOOR_SLOTS.includes(slot)) return {}
|
|
119
122
|
const sourceCode = context.sourceCode
|
|
120
123
|
const isMapper = (callee) => {
|
|
121
124
|
if (callee.type !== "Identifier") return false
|
|
@@ -345,19 +348,19 @@ export const noGraphqlJson = {
|
|
|
345
348
|
/** True for a `@Controller` decorator of `@nestjs/common`. */
|
|
346
349
|
const isController = (context, decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Controller")
|
|
347
350
|
|
|
348
|
-
/** A door is a door whatever its protocol, and every REST door lives in the `transport/http` slot of a feature. */
|
|
351
|
+
/** A door is a door whatever its protocol, and every REST door lives in the `transport/http` slot of a feature or in the door slot of the webhook kind. */
|
|
349
352
|
export const doorLivesInFeatures = {
|
|
350
353
|
meta: {
|
|
351
354
|
type: "problem",
|
|
352
355
|
docs: { description: "A `@Controller` is declared in the `transport/http` slot of a feature." },
|
|
353
356
|
schema: [],
|
|
354
357
|
messages: {
|
|
355
|
-
wrongSlot: "A `@Controller` belongs in `src/features/<feature>/transport/http/` (slot `be.transport.http`); this file is in slot `{{slot}}`. A door parked among the capabilities it calls reads as one and gets imported like one.",
|
|
358
|
+
wrongSlot: "A `@Controller` belongs in `src/features/api/<feature>/transport/http/` (slot `be.transport.http`) or, for a provider webhook, in `src/features/webhooks/<provider>/transport/http/` (slot `be.feature.webhooks.http`); this file is in slot `{{slot}}`. A door parked among the capabilities it calls reads as one and gets imported like one.",
|
|
356
359
|
},
|
|
357
360
|
},
|
|
358
361
|
create(context) {
|
|
359
362
|
const slot = hfsOf(context).slotOf(context.filename)
|
|
360
|
-
if (slot === HTTP_SLOT) return {}
|
|
363
|
+
if (slot === HTTP_SLOT || slot === WEBHOOK_SLOT) return {}
|
|
361
364
|
return {
|
|
362
365
|
Decorator(node) {
|
|
363
366
|
if (isController(context, node)) context.report({ node, messageId: "wrongSlot", data: { slot: slot ?? "none" } })
|
package/unit-spec.mjs
CHANGED
|
@@ -374,7 +374,7 @@ const formOf = (context, node, depth = 0) => {
|
|
|
374
374
|
}
|
|
375
375
|
|
|
376
376
|
/** The kit type each double produces, by declared name: what a parameter or a property typed so is a double of. */
|
|
377
|
-
const KIT_TYPES = Object.freeze({ MockEntityManager: "mockEntityManager", FakeCache: "fakeCache", FakeLock: "fakeLock",
|
|
377
|
+
const KIT_TYPES = Object.freeze({ MockEntityManager: "mockEntityManager", FakeCache: "fakeCache", FakeLock: "fakeLock", RecordingEventBus: "recordingEventBus", RecordingQueueOutbox: "recordingQueueOutbox", FakeClock: "FakeClock", FakeIds: "fakeIds", FakeTransaction: "fakeTransaction" })
|
|
378
378
|
|
|
379
379
|
/** The kit double a value's TYPE says it is (a helper parameter typed `MockEntityManager`, `tx.em` of a `fakeTransaction`), or null. */
|
|
380
380
|
const kitDoubleOfType = (context, node, kit) => {
|
package/user-copy.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* (the constructed TYPE decides, not a `*Error` name); the built-in `Error` family itself is left for
|
|
12
12
|
* internal invariants;
|
|
13
13
|
* 2. a `subject`/`title`/`body`/`text`/`message` property of an object handed to an outbound port - a call on a
|
|
14
|
-
* receiver whose type is declared by an `integrations` owner or by `platform/
|
|
14
|
+
* receiver whose type is declared by an `integrations` owner or by `platform/event-bus` or `platform/queue` (the receiver's type
|
|
15
15
|
* decides, not a method name such as `send`);
|
|
16
16
|
* 3. a `message`/`description`/`title` property of an object literal built in a transport slot.
|
|
17
17
|
*
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* file's syntax for and cannot split into its static and interpolated halves without guessing. Specs get the same
|
|
21
21
|
* law: fixture text is a `messages` catalog key or lives in a fixture.
|
|
22
22
|
*/
|
|
23
|
+
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
23
24
|
import { keyName, staticText } from "./lib/ast.mjs"
|
|
24
25
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
25
26
|
import { ownerNameOf } from "./lib/ports.mjs"
|
|
@@ -52,9 +53,9 @@ const isCustomError = (program, checker, type) => {
|
|
|
52
53
|
return extendsError(type)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/** Whether a receiver's type is declared by an `integrations` owner or by `platform/
|
|
56
|
+
/** Whether a receiver's type is declared by an `integrations` owner or by `platform/event-bus` or `platform/queue`: an outbound port. */
|
|
56
57
|
const isOutboundPort = (context, hfs, node) =>
|
|
57
|
-
typeOrigins(context, node).some((origin) => origin.module === null && (hfs.tierOf(origin.file) === "integrations" || (hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file)
|
|
58
|
+
typeOrigins(context, node).some((origin) => origin.module === null && (hfs.tierOf(origin.file) === "integrations" || (hfs.tierOf(origin.file) === "platform" && ["event-bus", "queue"].includes(ownerNameOf(hfs, origin.file) ?? ""))))
|
|
58
59
|
|
|
59
60
|
/** User-facing text is looked up in the catalog by key, never written where it is used. */
|
|
60
61
|
export const userCopyThroughCatalog = {
|
|
@@ -73,7 +74,7 @@ export const userCopyThroughCatalog = {
|
|
|
73
74
|
if (isDeclarationFile(filename)) return {}
|
|
74
75
|
const hfs = hfsOf(context)
|
|
75
76
|
const slot = hfs.slotOf(filename) ?? ""
|
|
76
|
-
const inTransport = slot
|
|
77
|
+
const inTransport = isTransportSlot(slot)
|
|
77
78
|
const { program, checker, toTs } = typed(context)
|
|
78
79
|
const isOutboundCall = (node) =>
|
|
79
80
|
node?.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && isOutboundPort(context, hfs, node.callee.object)
|