@starci/eslint-canon-be 3.0.8 → 3.0.10
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/LICENSE +21 -0
- package/README.md +12 -6
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +24 -36
- 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 +57 -6
- 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 +5 -2
- 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 +486 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/engine/secrets.mjs +130 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +162 -31
- 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 +553 -0
- package/runtime/modules/models/runtimes.yaml +695 -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 +18 -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 +2 -2
- 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/jsdoc.mjs +35 -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/mutation-fence.mjs +15 -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 +112 -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/connections.mjs
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - `one-connection-per-database` (R84 `BE_CONNECTION_DUPLICATE`) closes the ways a second door to a database appears:
|
|
6
6
|
* `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in the
|
|
7
|
-
* `<conn>.decorators.ts` of the platform database capability, in the
|
|
7
|
+
* `<conn>.decorators.ts` of the platform database capability, in the cli app and in the test database fixture; any
|
|
8
8
|
* exported injector of an `EntityManager` (its declared type is `TypedParameterDecorator<EntityManager>`, whatever it
|
|
9
9
|
* is called) is `Inject<Pascal(conn)>EntityManager` for a declared connection and lives in that connection's
|
|
10
10
|
* `<conn>.decorators.ts`; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and
|
|
11
|
-
* the
|
|
11
|
+
* the cli app; the literal connection name is written once, in `<conn>.connection.ts`; and a `<conn>.config.ts`
|
|
12
12
|
* reads only keys that start with that connection's `envPrefix`.
|
|
13
13
|
* - `em-injection-slots` (R88 `BE_TRANSPORT_SHAPE`) lets a class receive the `EntityManager` only in an application
|
|
14
14
|
* handler, a domain service and the platform capabilities that own a `persistence/` - never in transport,
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
* to the HFS slot view.
|
|
19
19
|
*/
|
|
20
20
|
import ts from "typescript"
|
|
21
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
21
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
22
22
|
import {
|
|
23
23
|
baseNameOf,
|
|
24
24
|
connectionFileOf,
|
|
25
25
|
connectionsOf,
|
|
26
26
|
inDatabaseCapability,
|
|
27
|
-
|
|
27
|
+
inCli,
|
|
28
28
|
infraTypeOf,
|
|
29
29
|
injectionSites,
|
|
30
30
|
injectorNameOf,
|
|
@@ -38,11 +38,11 @@ const RAW_CONNECTION_CALLS = new Set(["InjectEntityManager", "InjectDataSource",
|
|
|
38
38
|
/** The initializers that can be an injector factory; a plain value is never typed here. */
|
|
39
39
|
const FUNCTION_LIKE = new Set(["ArrowFunctionExpression", "FunctionExpression", "CallExpression"])
|
|
40
40
|
|
|
41
|
-
/** Whether a file may build connections: the platform database capability and the
|
|
42
|
-
const mayBuildConnections = (hfs, file) => inDatabaseCapability(hfs, file) ||
|
|
41
|
+
/** Whether a file may build connections: the platform database capability and the cli app. */
|
|
42
|
+
const mayBuildConnections = (hfs, file) => inDatabaseCapability(hfs, file) || inCli(hfs, file)
|
|
43
43
|
|
|
44
44
|
/** `new DataSource(` also in the test world (`src/tests/world`), which owns the shared test infrastructure (owner ruling 2026-09-30). */
|
|
45
|
-
const mayConstructDataSource = (hfs, file) => mayBuildConnections(hfs, file) ||
|
|
45
|
+
const mayConstructDataSource = (hfs, file) => mayBuildConnections(hfs, file) || inTestWorld(hfs, file)
|
|
46
46
|
|
|
47
47
|
/** The connection whose name a string literal spells, or undefined. */
|
|
48
48
|
const connectionNamed = (hfs, node) =>
|
|
@@ -120,11 +120,11 @@ export const oneConnectionPerDatabase = {
|
|
|
120
120
|
schema: [],
|
|
121
121
|
messages: {
|
|
122
122
|
rawInjector:
|
|
123
|
-
"`{{name}}` is called here. A connection is reached only through its own injector: `{{name}}` is written in `platform/database/<connection>.decorators.ts`, the
|
|
123
|
+
"`{{name}}` is called here. A connection is reached only through its own injector: `{{name}}` is written in `platform/database/<connection>.decorators.ts`, the cli app and the test database fixture, and nowhere else. Inject the shared EntityManager with `Inject<Connection>EntityManager()`.",
|
|
124
124
|
injectorHome:
|
|
125
125
|
"`{{name}}` is an EntityManager injector that is not the one injector of a declared connection. The only allowed injector is `Inject<Pascal(connection)>EntityManager`, exported from `platform/database/<connection>.decorators.ts` of a connection declared in `hfs.json`. A capability- or feature-specific injector, a second injector for one database and an alias are all a second door to the same database: use the connection's own injector.",
|
|
126
126
|
registration:
|
|
127
|
-
"`{{what}}` builds a connection outside the platform database capability and the
|
|
127
|
+
"`{{what}}` builds a connection outside the platform database capability and the cli app. A connection is registered once, in `platform/database`; the cli app builds its `DataSource` from the same `<connection>.config.ts`.",
|
|
128
128
|
literal:
|
|
129
129
|
"The connection name `{{name}}` is written as a string here. It is written once, as `export const {{constant}} = \"{{name}}\"` in `platform/database/{{name}}.connection.ts`; import that constant.",
|
|
130
130
|
envKey:
|
|
@@ -138,7 +138,7 @@ export const oneConnectionPerDatabase = {
|
|
|
138
138
|
const connectionFile = connectionFileOf(hfs, filename, "connection")
|
|
139
139
|
const configFile = connectionFileOf(hfs, filename, "config")
|
|
140
140
|
const fixtureDatabase = hfs.slotOf(filename) === "be.tests.fixtures" && baseNameOf(filename) === "database.ts"
|
|
141
|
-
const mayRawInject = Boolean(decoratorsFile) ||
|
|
141
|
+
const mayRawInject = Boolean(decoratorsFile) || inCli(hfs, filename) || fixtureDatabase
|
|
142
142
|
const declaredNames = new Set(connectionsOf(hfs).map((connection) => injectorNameOf(connection.name)))
|
|
143
143
|
|
|
144
144
|
/** Reports each injector-typed value written at `node`, unless it is the declared injector of this file's own connection. */
|
|
@@ -211,11 +211,11 @@ export const oneConnectionPerDatabase = {
|
|
|
211
211
|
export const emInjectionSlots = {
|
|
212
212
|
meta: {
|
|
213
213
|
type: "problem",
|
|
214
|
-
docs: { description: "An `EntityManager` is injected only in application handlers, domain services and the platform capabilities that own a `persistence/`." },
|
|
214
|
+
docs: { description: "An `EntityManager` is injected only in application handlers, domain services, projections and the platform capabilities that own a `persistence/`." },
|
|
215
215
|
schema: [],
|
|
216
216
|
messages: {
|
|
217
217
|
slot:
|
|
218
|
-
"`EntityManager` is injected in a file of slot `{{slot}}`. Only an application `*.handler.ts`, a domain `*.service.ts` and the platform database, inbox,
|
|
218
|
+
"`EntityManager` is injected in a file of slot `{{slot}}`. Only an application `*.handler.ts`, a domain `*.service.ts`, a projection's `*.projection.ts` and the platform database, inbox, event-bus, queue and jobs capabilities reach the database. Transport dispatches a command or query, an integration calls its provider, an app composes: move the data access into a handler or a domain service.",
|
|
219
219
|
},
|
|
220
220
|
},
|
|
221
221
|
create(context) {
|
package/cookies.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds the cookie half of the back-end transport law (catalog R141 `BE_COOKIE_ATTRIBUTES`).
|
|
3
|
+
*
|
|
4
|
+
* No back-end door writes a cookie under the canon today (the session travels as a bearer or as the front end's own httpOnly
|
|
5
|
+
* cookie), so this law is built ahead of the first one: a cookie a door writes through the response of Express or Fastify
|
|
6
|
+
* states `httpOnly: true`, carries `secure` and has `sameSite` `lax` or `strict`, judged on the TYPE of its options (a spread
|
|
7
|
+
* `as const` constant keeps its literals), and a `Set-Cookie` header is never written by hand, because a hand-written header
|
|
8
|
+
* states none of the three. The call is recognised by where its signature is declared (the `express` or `fastify` family of
|
|
9
|
+
* packages), never by the name of the receiver.
|
|
10
|
+
*/
|
|
11
|
+
import ts from "typescript"
|
|
12
|
+
import { moduleOf, typed } from "./lib/types.mjs"
|
|
13
|
+
|
|
14
|
+
/** The packages whose response types declare the cookie writers: Express with its typings, and Fastify with its cookie plugin. */
|
|
15
|
+
const RESPONSE_PACKAGES = new Set([
|
|
16
|
+
"express",
|
|
17
|
+
"express-serve-static-core",
|
|
18
|
+
"@types/express",
|
|
19
|
+
"@types/express-serve-static-core",
|
|
20
|
+
"fastify",
|
|
21
|
+
"@fastify/cookie",
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
/** The response methods that write a cookie by name, value and options, and the ones that write a header by name. */
|
|
25
|
+
const COOKIE_WRITERS = new Set(["cookie", "setCookie"])
|
|
26
|
+
const HEADER_WRITERS = new Set(["setHeader", "append", "header", "set", "appendHeader"])
|
|
27
|
+
const SAME_SITE = new Set(["lax", "strict"])
|
|
28
|
+
|
|
29
|
+
/** The declaration a call resolves to, when it is a method of a response type of the express or fastify family; else null. */
|
|
30
|
+
const responseMethodOf = (checker, toTs, call) => {
|
|
31
|
+
const tsCall = toTs(call)
|
|
32
|
+
const declaration = tsCall ? checker.getResolvedSignature(tsCall)?.declaration : undefined
|
|
33
|
+
if (!declaration || !(ts.isMethodSignature(declaration) || ts.isMethodDeclaration(declaration)) || !declaration.name) return null
|
|
34
|
+
const file = String(declaration.getSourceFile().fileName).split("\\").join("/")
|
|
35
|
+
const pkg = moduleOf(declaration, file)
|
|
36
|
+
return pkg !== null && RESPONSE_PACKAGES.has(pkg) ? declaration.name.getText() : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The present (non-nullish) members of a property's type on an options type, or [] when the property is absent or only nullish. */
|
|
40
|
+
const presentParts = (checker, type, name, at) => {
|
|
41
|
+
const symbol = type.getProperty(name)
|
|
42
|
+
if (!symbol) return []
|
|
43
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(symbol, at)
|
|
44
|
+
const parts = propertyType.isUnion() ? propertyType.types : [propertyType]
|
|
45
|
+
return parts.filter((part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isBooleanLiteral = (part, value) => Boolean(part.flags & ts.TypeFlags.BooleanLiteral) && part.intrinsicName === String(value)
|
|
49
|
+
|
|
50
|
+
/** The attributes a written cookie is missing, judged on the type of its options. */
|
|
51
|
+
const missingAttributes = (checker, type, at) => {
|
|
52
|
+
const missing = []
|
|
53
|
+
const httpOnly = presentParts(checker, type, "httpOnly", at)
|
|
54
|
+
if (httpOnly.length === 0 || !httpOnly.every((part) => isBooleanLiteral(part, true))) missing.push("`httpOnly: true`")
|
|
55
|
+
const secure = presentParts(checker, type, "secure", at)
|
|
56
|
+
if (secure.length === 0 || !secure.every((part) => isBooleanLiteral(part, true))) missing.push("`secure: true`")
|
|
57
|
+
const sameSite = presentParts(checker, type, "sameSite", at)
|
|
58
|
+
if (sameSite.length === 0 || !sameSite.every((part) => part.isStringLiteral() && SAME_SITE.has(part.value))) missing.push('`sameSite: "lax"` or `"strict"`')
|
|
59
|
+
return missing
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A cookie a door writes states httpOnly, secure and sameSite, and no door writes `Set-Cookie` by hand. */
|
|
63
|
+
export const responseCookieAttributes = {
|
|
64
|
+
meta: {
|
|
65
|
+
type: "problem",
|
|
66
|
+
docs: { description: "A cookie written through an Express or Fastify response states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict`; `Set-Cookie` is never written as a header." },
|
|
67
|
+
schema: [],
|
|
68
|
+
messages: {
|
|
69
|
+
attributes:
|
|
70
|
+
"This cookie is written without {{missing}}. A cookie left to the defaults is readable by page script, travels over plain HTTP and is sent on cross-site requests: pass one options constant, declared `as const` by the owner of the cookie, that states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict`.",
|
|
71
|
+
raw: "`Set-Cookie` is written here as a header, which states none of `httpOnly`, `secure` and `sameSite`. Write the cookie through the response's `cookie(name, value, options)` with an options constant that states all three.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
create(context) {
|
|
75
|
+
const { checker, toTs } = typed(context)
|
|
76
|
+
return {
|
|
77
|
+
CallExpression(node) {
|
|
78
|
+
if (node.callee.type !== "MemberExpression") return
|
|
79
|
+
const method = responseMethodOf(checker, toTs, node)
|
|
80
|
+
if (method === null) return
|
|
81
|
+
if (COOKIE_WRITERS.has(method)) {
|
|
82
|
+
const options = node.arguments[2]
|
|
83
|
+
const tsOptions = options ? toTs(options) : undefined
|
|
84
|
+
const missing = tsOptions ? missingAttributes(checker, checker.getTypeAtLocation(tsOptions), tsOptions) : ["`httpOnly: true`", "`secure: true`", '`sameSite: "lax"` or `"strict"`']
|
|
85
|
+
if (missing.length > 0) context.report({ node, messageId: "attributes", data: { missing: missing.join(", ") } })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (!HEADER_WRITERS.has(method)) return
|
|
89
|
+
const name = node.arguments[0]
|
|
90
|
+
const tsName = name ? toTs(name) : undefined
|
|
91
|
+
const type = tsName ? checker.getTypeAtLocation(tsName) : undefined
|
|
92
|
+
if (type?.isStringLiteral() && type.value.toLowerCase() === "set-cookie") context.report({ node, messageId: "raw" })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every rule this module ships, by the name a config switches it on under. */
|
|
99
|
+
export const rules = {
|
|
100
|
+
"response-cookie-attributes": responseCookieAttributes,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The level a consuming repository switches this on at. */
|
|
104
|
+
export const recommended = {
|
|
105
|
+
"starci-be/response-cookie-attributes": "error",
|
|
106
|
+
}
|
package/cqrs.mjs
CHANGED
|
@@ -118,8 +118,9 @@ export const handlerOverridesProcess = {
|
|
|
118
118
|
/** Statements and expressions that are a decision or a repetition: none of them belongs in a handler. */
|
|
119
119
|
const DECISION_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement"])
|
|
120
120
|
|
|
121
|
-
/**
|
|
122
|
-
const isServiceType = (context, annotation) => typeOrigins(context, annotation).some((origin) => origin.module === null
|
|
121
|
+
/** The one collaborator a handler may hold: a domain service (a class named `*Service` declared in a `*.service.ts` file) or a projection (a class declared in a `*.projection.ts` file of slot `be.projections`). */
|
|
122
|
+
const isServiceType = (context, hfs, annotation) => typeOrigins(context, annotation).some((origin) => origin.module === null
|
|
123
|
+
&& ((origin.name.endsWith("Service") && origin.file.endsWith(".service.ts")) || (origin.file.endsWith(".projection.ts") && hfs.slotOf(origin.file) === "be.projections")))
|
|
123
124
|
|
|
124
125
|
/** The injected members of a class: constructor parameters and decorated properties, with the type annotation of each. */
|
|
125
126
|
const injectedMembers = (node) => {
|
|
@@ -140,10 +141,10 @@ const injectedMembers = (node) => {
|
|
|
140
141
|
export const handlerIsThin = {
|
|
141
142
|
meta: {
|
|
142
143
|
type: "problem",
|
|
143
|
-
docs: { description: "A CQRS handler injects only `*Service` classes (and the template's Logger) and its `process` is one `return this.<service>.<method>(...)`." },
|
|
144
|
+
docs: { description: "A CQRS handler injects only `*Service` classes or projections (and the template's Logger) and its `process` is one `return this.<service>.<method>(...)`." },
|
|
144
145
|
schema: [],
|
|
145
146
|
messages: {
|
|
146
|
-
dependency: "`{{what}}` is injected into a handler. A handler holds services (and the Logger the `ICQRSHandler` template needs) and nothing else: an `EntityManager`, a bus, a client or any other infrastructure is the business of a `*.service.ts`, which is the one file that is unit-tested. Move the work into a service and inject that.",
|
|
147
|
+
dependency: "`{{what}}` is injected into a handler. A handler holds services or projections (and the Logger the `ICQRSHandler` template needs) and nothing else: an `EntityManager`, a bus, a client or any other infrastructure is the business of a `*.service.ts`, which is the one file that is unit-tested. Move the work into a service and inject that.",
|
|
147
148
|
branch: "`{{what}}` is a decision or a loop in a handler. The `process` of a handler only maps its input and returns what one service method answers; every branch belongs in the service, where it is covered by the service spec.",
|
|
148
149
|
body: "`process` of `{{name}}` must be the single statement `return this.<service>.<method>(<mapped input>)` (with or without `await`). Any other statement is logic that no unit spec covers: move it into the service.",
|
|
149
150
|
call: "`process` of `{{name}}` must call exactly one method of an injected `*Service` and return its result: this makes {{count}} call(s), or calls something that is not an injected service.",
|
|
@@ -158,7 +159,7 @@ export const handlerIsThin = {
|
|
|
158
159
|
const name = node.id?.name ?? "this handler"
|
|
159
160
|
const services = new Set()
|
|
160
161
|
for (const injected of injectedMembers(node)) {
|
|
161
|
-
if (injected.annotation && isServiceType(context, injected.annotation)) {
|
|
162
|
+
if (injected.annotation && isServiceType(context, hfs, injected.annotation)) {
|
|
162
163
|
if (injected.name !== null) services.add(injected.name)
|
|
163
164
|
} else if (!injected.annotation || !isLoggerType(context, injected.annotation)) {
|
|
164
165
|
context.report({ node: injected.report, messageId: "dependency", data: { what: sourceCode.getText(injected.param) } })
|
|
@@ -405,18 +406,9 @@ export const noUseCase = {
|
|
|
405
406
|
|
|
406
407
|
// -- no-event-bus ------------------------------------------------------------------------------------------------
|
|
407
408
|
|
|
408
|
-
/** The
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
/** The package of the second in-process event system. */
|
|
412
|
-
const EVENT_EMITTER_PACKAGE = "@nestjs/event-emitter"
|
|
413
|
-
|
|
414
|
-
/** The Node and RxJS classes that are an in-process event bus when constructed or extended: package -> class names. */
|
|
415
|
-
const BUS_CLASSES = { events: new Set(["EventEmitter"]), "node:events": new Set(["EventEmitter"]), rxjs: new Set(["Subject", "BehaviorSubject", "ReplaySubject", "AsyncSubject"]) }
|
|
416
|
-
|
|
417
|
-
/** The bus class a constructed or extended expression is declared as (by declaration origin), else null. */
|
|
418
|
-
const busClassOf = (context, node) => {
|
|
419
|
-
const origin = typeOrigins(context, node).find((entry) => entry.module !== null && BUS_CLASSES[entry.module]?.has(entry.name))
|
|
409
|
+
/** The bus class a constructed or extended expression is declared as (by declaration origin), else null. `classes` is the `ruleParams.be.eventBus.classes` map: module specifier -> class names. */
|
|
410
|
+
const busClassOf = (context, node, classes) => {
|
|
411
|
+
const origin = typeOrigins(context, node).find((entry) => entry.module !== null && classes[entry.module]?.includes(entry.name))
|
|
420
412
|
return origin ? origin.name : null
|
|
421
413
|
}
|
|
422
414
|
|
|
@@ -434,20 +426,22 @@ const holdsFunctions = (checker, type) => {
|
|
|
434
426
|
return args.length > 0 && isFunctionType(args[args.length - 1])
|
|
435
427
|
}
|
|
436
428
|
|
|
437
|
-
/** No in-process events: a side effect that must happen anyway is an
|
|
429
|
+
/** No in-process events: a side effect that must happen anyway is an event published through the bus. */
|
|
438
430
|
export const noEventBus = {
|
|
439
431
|
meta: {
|
|
440
432
|
type: "problem",
|
|
441
433
|
docs: { description: "`EventBus`, `@EventsHandler`, `IEventHandler` and `@nestjs/event-emitter` are not used." },
|
|
442
434
|
schema: [],
|
|
443
435
|
messages: {
|
|
444
|
-
event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction:
|
|
445
|
-
bus: "`{{name}}` is constructed or extended here as an in-process event bus. A `*TransitionEmitter`, a Subject used as a channel or an EventEmitter subclass has the same failure as `EventBus`: listeners live in one process and run outside the transaction.
|
|
446
|
-
registry: "`{{name}}` stores callbacks that other code registers and this class later calls: a hand-rolled listener list is an in-process event bus.
|
|
436
|
+
event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction: publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/` instead.",
|
|
437
|
+
bus: "`{{name}}` is constructed or extended here as an in-process event bus. A `*TransitionEmitter`, a Subject used as a channel or an EventEmitter subclass has the same failure as `EventBus`: listeners live in one process and run outside the transaction. Publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/`; a state a caller waits for is read from the database.",
|
|
438
|
+
registry: "`{{name}}` stores callbacks that other code registers and this class later calls: a hand-rolled listener list is an in-process event bus. Publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/` instead.",
|
|
447
439
|
},
|
|
448
440
|
},
|
|
449
441
|
create(context) {
|
|
450
442
|
const report = (node, name) => context.report({ node, messageId: "event", data: { name } })
|
|
443
|
+
const eventBus = hfsOf(context).ruleParams.eventBus
|
|
444
|
+
const eventNames = new Set(eventBus.imports[CQRS_PACKAGE] ?? [])
|
|
451
445
|
const { checker, toTs } = typed(context)
|
|
452
446
|
/** Collects, per class, the functions-collection properties and the registrations made on them. */
|
|
453
447
|
const checkClass = (classNode) => {
|
|
@@ -483,21 +477,23 @@ export const noEventBus = {
|
|
|
483
477
|
ClassDeclaration(node) {
|
|
484
478
|
checkClass(node)
|
|
485
479
|
if (node.superClass) {
|
|
486
|
-
const name = busClassOf(context, node.superClass)
|
|
480
|
+
const name = busClassOf(context, node.superClass, eventBus.classes)
|
|
487
481
|
if (name) context.report({ node: node.superClass, messageId: "bus", data: { name } })
|
|
488
482
|
}
|
|
489
483
|
},
|
|
490
484
|
ClassExpression(node) { checkClass(node) },
|
|
491
485
|
NewExpression(node) {
|
|
492
|
-
const name = busClassOf(context, node.callee)
|
|
486
|
+
const name = busClassOf(context, node.callee, eventBus.classes)
|
|
493
487
|
if (name) context.report({ node, messageId: "bus", data: { name } })
|
|
494
488
|
},
|
|
495
489
|
...moduleReferences(({ node, source, value, names }) => {
|
|
496
|
-
|
|
497
|
-
|
|
490
|
+
const members = eventBus.imports[value]
|
|
491
|
+
if (members === undefined) return
|
|
492
|
+
if (members.length === 0) report(source, value)
|
|
493
|
+
else for (const name of names) if (members.includes(name)) report(node, name)
|
|
498
494
|
}),
|
|
499
495
|
MemberExpression(node) {
|
|
500
|
-
if (node.computed || node.property.type !== "Identifier" || !
|
|
496
|
+
if (node.computed || node.property.type !== "Identifier" || !eventNames.has(node.property.name)) return
|
|
501
497
|
if (node.object.type === "Identifier" && importOf(context, node.object)?.source === CQRS_PACKAGE && importOf(context, node.object).imported === "*") report(node, node.property.name)
|
|
502
498
|
},
|
|
503
499
|
}
|
package/data-access.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - `must-inject-entity-manager` - a constructor parameter typed `EntityManager` carries the named injector of a declared
|
|
10
10
|
* connection. A lookalike (`InjectFooEntityManager`) or a bare `InjectEntityManager()` is not one.
|
|
11
11
|
* - `named-entity-manager-only` - no property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a
|
|
12
|
-
* `DataSource` or `QueryRunner` is injected only in the platform database capability and the
|
|
12
|
+
* `DataSource` or `QueryRunner` is injected only in the platform database capability and the cli app; no
|
|
13
13
|
* `getRepository(...)` on a manager or data source; and outside those places no type reference, awaited value,
|
|
14
14
|
* or `provide:` token is a `DataSource` or `QueryRunner` (a wrapper service that exposes one is the same door), and no
|
|
15
15
|
* class receives a wrapper class that exposes one.
|
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
|
|
25
25
|
import ts from "typescript"
|
|
26
26
|
import { walk } from "./lib/ast.mjs"
|
|
27
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
27
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
28
|
+
import { resolveVariable, transactionCallback } from "./lib/transactions.mjs"
|
|
28
29
|
import {
|
|
29
30
|
declaredInjectorNames,
|
|
30
31
|
inDatabaseCapability,
|
|
31
|
-
|
|
32
|
+
inCli,
|
|
32
33
|
implementsMigration,
|
|
33
34
|
infraTypeOf,
|
|
34
35
|
injectionSites,
|
|
@@ -69,18 +70,18 @@ export const namedEntityManagerOnly = {
|
|
|
69
70
|
type: "problem",
|
|
70
71
|
docs: {
|
|
71
72
|
description:
|
|
72
|
-
"No property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a `DataSource` or `QueryRunner` only in the platform database capability, the
|
|
73
|
+
"No property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a `DataSource` or `QueryRunner` only in the platform database capability, the cli app and the test world (`src/tests/world`); no `getRepository`.",
|
|
73
74
|
},
|
|
74
75
|
schema: [],
|
|
75
76
|
messages: {
|
|
76
77
|
property:
|
|
77
78
|
"`{{type}}` is injected as a class property. Property injection hides the dependency from the constructor and the app module. Receive the named-injector `EntityManager` as a constructor parameter.",
|
|
78
79
|
infra:
|
|
79
|
-
"`{{type}}` is injected here. A `DataSource` or `QueryRunner` is built and held only by the platform database capability, the
|
|
80
|
+
"`{{type}}` is injected here. A `DataSource` or `QueryRunner` is built and held only by the platform database capability, the cli app and the test world in `src/tests/world`; specs take `world.db.<connection>`; everything else injects the shared `EntityManager` through its named injector and calls it directly.",
|
|
80
81
|
exposed:
|
|
81
|
-
"`{{type}}` is written here outside the platform database capability, the
|
|
82
|
+
"`{{type}}` is written here outside the platform database capability, the cli app and the test world. A wrapper service that returns, holds or hands out a connection is the same door as injecting it: the connection is opened and held only by `platform/database`, and everything else calls the shared EntityManager through its named injector.",
|
|
82
83
|
token:
|
|
83
|
-
"This provider is bound to a `{{type}}`. A token that resolves to a connection object is a second door to the database: only `platform/database`, the
|
|
84
|
+
"This provider is bound to a `{{type}}`. A token that resolves to a connection object is a second door to the database: only `platform/database`, the cli app and the test world provide one.",
|
|
84
85
|
wrapper:
|
|
85
86
|
"`{{wrapper}}` exposes a `{{type}}` through `{{member}}`, and this class receives it. A connection wrapper is not a service dependency: inject the shared EntityManager with its named injector and call it directly.",
|
|
86
87
|
getRepository:
|
|
@@ -90,7 +91,7 @@ export const namedEntityManagerOnly = {
|
|
|
90
91
|
create(context) {
|
|
91
92
|
const hfs = hfsOf(context)
|
|
92
93
|
const filename = context.filename || context.getFilename()
|
|
93
|
-
const mayHoldConnections = inDatabaseCapability(hfs, filename) ||
|
|
94
|
+
const mayHoldConnections = inDatabaseCapability(hfs, filename) || inCli(hfs, filename) || inTestWorld(hfs, filename) || isMigrationFile(hfs, filename)
|
|
94
95
|
const CONNECTION_TYPES = new Set(["DataSource", "QueryRunner"])
|
|
95
96
|
/** The connection type (DataSource / QueryRunner) a value or written type is declared as, else null. */
|
|
96
97
|
const connectionTypeOf = (node) => {
|
|
@@ -286,26 +287,6 @@ export const requireEntityTableName = {
|
|
|
286
287
|
|
|
287
288
|
// -- transactions ----------------------------------------------------------------------------------
|
|
288
289
|
|
|
289
|
-
/** The callback of a `.transaction(...)` call on a manager or data source, or null. */
|
|
290
|
-
const transactionCallback = (context, node) => {
|
|
291
|
-
const callee = node.callee
|
|
292
|
-
if (callee.type !== "MemberExpression" || callee.computed) return null
|
|
293
|
-
if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return null
|
|
294
|
-
const receiver = infraTypeOf(context, callee.object)
|
|
295
|
-
if (receiver !== "EntityManager" && receiver !== "DataSource") return null
|
|
296
|
-
const callback = node.arguments[node.arguments.length - 1]
|
|
297
|
-
return callback && (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") ? callback : null
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** The variable an identifier resolves to, looking outward from the identifier's own scope. */
|
|
301
|
-
const resolveVariable = (sourceCode, node) => {
|
|
302
|
-
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
|
|
303
|
-
const variable = scope.set.get(node.name)
|
|
304
|
-
if (variable) return variable
|
|
305
|
-
}
|
|
306
|
-
return null
|
|
307
|
-
}
|
|
308
|
-
|
|
309
290
|
/** The leftmost expression of a member chain: `this.deps.em` gives the `this`. */
|
|
310
291
|
const rootOf = (expression) => {
|
|
311
292
|
let current = expression
|
package/default-deny.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
|
|
14
14
|
* one file cannot see which module the app registered.
|
|
15
15
|
*/
|
|
16
|
+
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
16
17
|
import { decoratorName, keyName } from "./lib/ast.mjs"
|
|
17
18
|
import { isOwnedEnumMember } from "./lib/declared.mjs"
|
|
18
19
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
@@ -49,7 +50,7 @@ export const noUntypedBody = {
|
|
|
49
50
|
const filename = normalizePath(context.filename || context.getFilename())
|
|
50
51
|
if (isDeclarationFile(filename)) return {}
|
|
51
52
|
const slot = hfsOf(context).slotOf(filename) ?? ""
|
|
52
|
-
const inTransport = slot
|
|
53
|
+
const inTransport = isTransportSlot(slot) || slot.startsWith("be.feature.transport.")
|
|
53
54
|
return {
|
|
54
55
|
Identifier(node) {
|
|
55
56
|
if (node.name === "GraphQLJSON" && !/^Import.*Specifier$/.test(node.parent?.type ?? "")) {
|
package/e2e-flow.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that hold `
|
|
2
|
+
* The rules that hold the e2e half of `knowledge/patterns/be/test.yaml` (catalog R48 `BE_SPEC_QUALITY`).
|
|
3
3
|
*
|
|
4
4
|
* A rule earns its place by being exact: it fires on a syntactic or typed shape, never on a judgement, or it becomes
|
|
5
|
-
* something authors learn to work around. What these four enforce is transport bypass (a bus or an actor called
|
|
5
|
+
* something authors learn to work around. What these four rules enforce is transport bypass (a bus or an actor called
|
|
6
6
|
* directly), a sleep instead of a poll, a branch inside a step, and a testing module built inside a spec instead of
|
|
7
7
|
* booted by the test world (`useTestWorld({ apps })`, `src/tests/world/`). They do not pretend to judge business meaning: whether a file is one flow, whether
|
|
8
8
|
* its steps are named, and what the absence of an effect is proved by are read by a person.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { basename } from "node:path"
|
|
15
15
|
import ts from "typescript"
|
|
16
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
16
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
17
17
|
import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
|
|
18
18
|
|
|
19
19
|
/** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
|
|
@@ -172,7 +172,7 @@ export const noBranchInFlowStep = {
|
|
|
172
172
|
!call.callee.computed &&
|
|
173
173
|
call.callee.property.type === "Identifier" &&
|
|
174
174
|
call.callee.property.name === "waitFor" &&
|
|
175
|
-
typeOrigins(context, call.callee.object).some((origin) => hfs
|
|
175
|
+
typeOrigins(context, call.callee.object).some((origin) => inTestWorld(hfs, origin.file))
|
|
176
176
|
|
|
177
177
|
/** True when this node sits inside the callback of an `it(...)` or `test(...)`, and not inside a `waitFor` probe. */
|
|
178
178
|
const insideStep = (node) => {
|