@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/index.mjs
CHANGED
|
@@ -17,15 +17,21 @@
|
|
|
17
17
|
* leave two names for one rule and no way to tell which a message came from.
|
|
18
18
|
*/
|
|
19
19
|
import { buildBeConfig } from "./lib/config.mjs"
|
|
20
|
+
import { pluginForEdition } from "./lib/edition.mjs"
|
|
21
|
+
import { recommended as ambientIdRecommended, rules as ambientIdRules } from "./ambient-id.mjs"
|
|
20
22
|
import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
|
|
21
23
|
import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
|
|
22
24
|
import { recommended as buildersRecommended, rules as buildersRules } from "./builders.mjs"
|
|
23
25
|
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
24
26
|
import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
|
|
25
27
|
import { recommended as connectionsRecommended, rules as connectionsRules } from "./connections.mjs"
|
|
28
|
+
import { recommended as cookiesRecommended, rules as cookiesRules } from "./cookies.mjs"
|
|
26
29
|
import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
|
|
27
30
|
import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
|
|
28
31
|
import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
|
|
32
|
+
import { recommended as eventBusRecommended, rules as eventBusRules } from "./event-bus.mjs"
|
|
33
|
+
import { recommended as jobsRecommended, rules as jobsRules } from "./jobs.mjs"
|
|
34
|
+
import { recommended as projectionsRecommended, rules as projectionsRules } from "./projections.mjs"
|
|
29
35
|
import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
|
|
30
36
|
import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
|
|
31
37
|
import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
|
|
@@ -41,7 +47,9 @@ import { recommended as operationsRecommended, rules as operationsRules } from "
|
|
|
41
47
|
import { recommended as querySafetyRecommended, rules as querySafetyRules } from "./query-safety.mjs"
|
|
42
48
|
import { recommended as resilienceRecommended, rules as resilienceRules } from "./resilience.mjs"
|
|
43
49
|
import { recommended as schemaAuthorityRecommended, rules as schemaAuthorityRules } from "./schema-authority.mjs"
|
|
50
|
+
import { recommended as cliRecommended, rules as cliRules } from "./cli.mjs"
|
|
44
51
|
import { recommended as serviceDepsRecommended, rules as serviceDepsRules } from "./service-deps.mjs"
|
|
52
|
+
import { recommended as serviceIsolationRecommended, rules as serviceIsolationRules } from "./service-isolation.mjs"
|
|
45
53
|
import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-budget.mjs"
|
|
46
54
|
import { recommended as specNoSkipRecommended, rules as specNoSkipRules } from "./spec-no-skip.mjs"
|
|
47
55
|
import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
|
|
@@ -53,20 +61,29 @@ import { recommended as transportRecommended, rules as transportRules } from "./
|
|
|
53
61
|
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
|
|
54
62
|
import { recommended as unitSpecRecommended, rules as unitSpecRules } from "./unit-spec.mjs"
|
|
55
63
|
import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
|
|
64
|
+
import { recommended as webhooksRecommended, rules as webhooksRules } from "./webhooks.mjs"
|
|
65
|
+
import { recommended as realtimeRecommended, rules as realtimeRules } from "./realtime.mjs"
|
|
66
|
+
import { recommended as featureThinRecommended, rules as featureThinRules } from "./feature-thin.mjs"
|
|
56
67
|
import { recommended as projectGraphRecommended, rules as projectGraphRules } from "./project-graph.mjs"
|
|
57
68
|
|
|
58
69
|
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
59
70
|
const CONTRIBUTIONS = [
|
|
60
71
|
{ law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
|
|
72
|
+
{ law: "ambient-id", rules: ambientIdRules, recommended: ambientIdRecommended },
|
|
61
73
|
{ law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
|
|
62
74
|
{ law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
|
|
63
75
|
{ law: "builders", rules: buildersRules, recommended: buildersRecommended },
|
|
76
|
+
{ law: "cli", rules: cliRules, recommended: cliRecommended },
|
|
64
77
|
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
65
78
|
{ law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
|
|
66
79
|
{ law: "connections", rules: connectionsRules, recommended: connectionsRecommended },
|
|
80
|
+
{ law: "cookies", rules: cookiesRules, recommended: cookiesRecommended },
|
|
67
81
|
{ law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
|
|
68
82
|
{ law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
|
|
69
83
|
{ law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
|
|
84
|
+
{ law: "event-bus", rules: eventBusRules, recommended: eventBusRecommended },
|
|
85
|
+
{ law: "jobs", rules: jobsRules, recommended: jobsRecommended },
|
|
86
|
+
{ law: "projections", rules: projectionsRules, recommended: projectionsRecommended },
|
|
70
87
|
{ law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
|
|
71
88
|
{ law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
|
|
72
89
|
{ law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
|
|
@@ -83,6 +100,7 @@ const CONTRIBUTIONS = [
|
|
|
83
100
|
{ law: "resilience", rules: resilienceRules, recommended: resilienceRecommended },
|
|
84
101
|
{ law: "schema-authority", rules: schemaAuthorityRules, recommended: schemaAuthorityRecommended },
|
|
85
102
|
{ law: "service-deps", rules: serviceDepsRules, recommended: serviceDepsRecommended },
|
|
103
|
+
{ law: "service-isolation", rules: serviceIsolationRules, recommended: serviceIsolationRecommended },
|
|
86
104
|
{ law: "size-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
|
|
87
105
|
{ law: "spec-no-skip", rules: specNoSkipRules, recommended: specNoSkipRecommended },
|
|
88
106
|
{ law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
|
|
@@ -94,6 +112,9 @@ const CONTRIBUTIONS = [
|
|
|
94
112
|
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
95
113
|
{ law: "unit-spec", rules: unitSpecRules, recommended: unitSpecRecommended },
|
|
96
114
|
{ law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
|
|
115
|
+
{ law: "webhooks", rules: webhooksRules, recommended: webhooksRecommended },
|
|
116
|
+
{ law: "realtime", rules: realtimeRules, recommended: realtimeRecommended },
|
|
117
|
+
{ law: "feature-thin", rules: featureThinRules, recommended: featureThinRecommended },
|
|
97
118
|
]
|
|
98
119
|
|
|
99
120
|
/** Every gathered law. */
|
|
@@ -154,7 +175,7 @@ export default plugin
|
|
|
154
175
|
* @param {{ hfs: object }} input - The HFS view of the repository.
|
|
155
176
|
* @returns {Promise<Array<object>>} The flat config.
|
|
156
177
|
*/
|
|
157
|
-
export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin, recommended })
|
|
178
|
+
export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin: pluginForEdition({ plugin, hfs }), recommended })
|
|
158
179
|
|
|
159
180
|
export { linterOptions } from "./lib/config.mjs"
|
|
160
181
|
export { loadHfs } from "./lib/hfs.mjs"
|
package/infra-owner.mjs
CHANGED
|
@@ -53,7 +53,7 @@ export const infraImportOwner = {
|
|
|
53
53
|
schema: [],
|
|
54
54
|
messages: {
|
|
55
55
|
foreign: "`{{name}}` is owned by {{owners}}. Use that capability's port (injected with its `Inject*()` decorator) instead of the raw library, or move this code into the owner.",
|
|
56
|
-
nowhere: "`{{name}}` is not used in a back end: its job belongs to a platform port (config through the typed `<c>.config.ts`, events through
|
|
56
|
+
nowhere: "`{{name}}` is not used in a back end: its job belongs to a platform port (config through the typed `<c>.config.ts`, events through `eventBus.publish(event, tx)` of `platform/event-bus`, schedules and background jobs through a queue's BullMQ job scheduler of `platform/queue`). Remove it.",
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
create(context) {
|
package/injection.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { posix } from "node:path"
|
|
27
27
|
import ts from "typescript"
|
|
28
|
-
import { staticText } from "./lib/ast.mjs"
|
|
28
|
+
import { paramParts, staticText } from "./lib/ast.mjs"
|
|
29
29
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
30
30
|
import { isImportedFrom } from "./lib/import-source.mjs"
|
|
31
31
|
import { normalizePath } from "./lib/path.mjs"
|
|
@@ -133,13 +133,6 @@ const inComposition = (hfs, file) => {
|
|
|
133
133
|
return owner !== null && posix.basename(owner) === "composition"
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
|
|
137
|
-
const decoratorsOfParam = (param) => {
|
|
138
|
-
const inner = param.type === "TSParameterProperty" ? param.parameter : param
|
|
139
|
-
const target = inner.type === "AssignmentPattern" ? inner.left : inner
|
|
140
|
-
return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
136
|
/** The injected type `T` of a call typed `TypedParameterDecorator<T>`, else null. */
|
|
144
137
|
const injectedTypeOf = (context, call) => {
|
|
145
138
|
const { checker, toTs } = typed(context)
|
|
@@ -373,7 +366,7 @@ export const injectorTypeMatch = {
|
|
|
373
366
|
return {
|
|
374
367
|
MethodDefinition(node) {
|
|
375
368
|
for (const param of constructorParams(node)) {
|
|
376
|
-
const { target, decorators } =
|
|
369
|
+
const { target, decorators } = paramParts(param)
|
|
377
370
|
for (const decorator of decorators) {
|
|
378
371
|
if (decorator.expression.type !== "CallExpression") continue
|
|
379
372
|
const injected = injectedTypeOf(context, decorator.expression)
|
|
@@ -417,7 +410,25 @@ const isContainerBuilt = (context, constructor) => {
|
|
|
417
410
|
const declaration = constructor.parent?.parent
|
|
418
411
|
if (declaration?.type !== "ClassDeclaration" && declaration?.type !== "ClassExpression") return false
|
|
419
412
|
if ((declaration.decorators ?? []).some((decorator) => packageOfExport(context, decorator.expression)?.startsWith("@nestjs/"))) return true
|
|
420
|
-
return constructorParams(constructor).some((param) =>
|
|
413
|
+
return constructorParams(constructor).some((param) => paramParts(param).decorators.length > 0)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The origin that makes an annotated type infrastructure: declared by a package, or by a `platform/*` or `integrations/*` owner other
|
|
418
|
+
* than `own`; null for a domain type, a type of the same owner or a language global.
|
|
419
|
+
*/
|
|
420
|
+
const infraOriginOf = (context, hfs, own, annotation) => {
|
|
421
|
+
const origins = typeOrigins(context, annotation)
|
|
422
|
+
// A language global (`ReadonlyArray`, `Array`) is declared in the TypeScript lib and may be augmented by a package
|
|
423
|
+
// (`@types/node`): the augmentation does not make it infrastructure.
|
|
424
|
+
const isLanguageGlobal = (origin) => origins.some((other) => other.name === origin.name && /\/node_modules\/typescript\/lib\//.test(other.file))
|
|
425
|
+
return origins.find((origin) => {
|
|
426
|
+
if (isLanguageGlobal(origin)) return false
|
|
427
|
+
if (origin.module !== null) return true
|
|
428
|
+
if (own !== null && hfs.ownerOf(origin.file) === own) return false
|
|
429
|
+
const tier = hfs.tierOf(origin.file)
|
|
430
|
+
return tier === "platform" || tier === "integrations"
|
|
431
|
+
}) ?? null
|
|
421
432
|
}
|
|
422
433
|
|
|
423
434
|
/** A constructor parameter of an infrastructure type carries an injector. */
|
|
@@ -441,20 +452,10 @@ export const infraNeedsInjector = {
|
|
|
441
452
|
// metadata. A plain class (a CQRS message, a factory-built adapter, a test fake) is never DI-constructed.
|
|
442
453
|
if (!isContainerBuilt(context, node)) return
|
|
443
454
|
for (const param of constructorParams(node)) {
|
|
444
|
-
const { target, decorators } =
|
|
455
|
+
const { target, decorators } = paramParts(param)
|
|
445
456
|
const annotation = target.typeAnnotation?.typeAnnotation
|
|
446
457
|
if (!annotation) continue
|
|
447
|
-
const
|
|
448
|
-
// A language global (`ReadonlyArray`, `Array`) is declared in the TypeScript lib and may be augmented by a package
|
|
449
|
-
// (`@types/node`): the augmentation does not make it infrastructure.
|
|
450
|
-
const isLanguageGlobal = (origin) => origins.some((other) => other.name === origin.name && /\/node_modules\/typescript\/lib\//.test(other.file))
|
|
451
|
-
const infra = origins.find((origin) => {
|
|
452
|
-
if (isLanguageGlobal(origin)) return false
|
|
453
|
-
if (origin.module !== null) return true
|
|
454
|
-
if (own !== null && hfs.ownerOf(origin.file) === own) return false
|
|
455
|
-
const tier = hfs.tierOf(origin.file)
|
|
456
|
-
return tier === "platform" || tier === "integrations"
|
|
457
|
-
})
|
|
458
|
+
const infra = infraOriginOf(context, hfs, own, annotation)
|
|
458
459
|
if (!infra) continue
|
|
459
460
|
const carries = decorators.some((decorator) => {
|
|
460
461
|
const expression = decorator.expression
|
|
@@ -470,6 +471,48 @@ export const infraNeedsInjector = {
|
|
|
470
471
|
},
|
|
471
472
|
}
|
|
472
473
|
|
|
474
|
+
// -- injected-param-name --------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
/** The `ruleParams.be.paramNames` entry for an origin's declared name, or null. */
|
|
477
|
+
const paramEntryOf = (entries, name) => entries.find((entry) => (entry.type !== undefined ? entry.type === name : name.length > entry.typeSuffix.length && name.endsWith(entry.typeSuffix))) ?? null
|
|
478
|
+
|
|
479
|
+
/** An injected port of the table of `paramNames` is named the way every class names it. */
|
|
480
|
+
export const injectedParamName = {
|
|
481
|
+
meta: {
|
|
482
|
+
type: "problem",
|
|
483
|
+
docs: { description: "A constructor parameter that injects a port named in `ruleParams.be.paramNames` (`Clock`, `Logger`, `Cache`, `CommandBus`, `QueryBus`, `EntityManager`, `<Owner>Options`) carries the name that table gives it." },
|
|
484
|
+
schema: [],
|
|
485
|
+
messages: {
|
|
486
|
+
name: "`{{param}}` injects `{{type}}`, and every class names that port {{expected}}. Rename the parameter so a reader finds the same dependency under the same name in every class.",
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
create(context) {
|
|
490
|
+
const hfs = hfsOf(context)
|
|
491
|
+
const entries = hfs.ruleParams.paramNames
|
|
492
|
+
const own = hfs.ownerOf(context.filename || context.getFilename())
|
|
493
|
+
return {
|
|
494
|
+
MethodDefinition(node) {
|
|
495
|
+
if (!isContainerBuilt(context, node)) return
|
|
496
|
+
for (const param of constructorParams(node)) {
|
|
497
|
+
const { target, decorators } = paramParts(param)
|
|
498
|
+
if (target.type !== "Identifier") continue
|
|
499
|
+
const annotation = target.typeAnnotation?.typeAnnotation
|
|
500
|
+
if (!annotation) continue
|
|
501
|
+
const injected = decorators.some((decorator) => decorator.expression.type === "CallExpression" && (INJECT_NAME.test(calleeOf(context, decorator.expression).name ?? "") || injectedTypeOf(context, decorator.expression) !== null))
|
|
502
|
+
if (!injected) continue
|
|
503
|
+
const infra = infraOriginOf(context, hfs, own, annotation)
|
|
504
|
+
if (!infra) continue
|
|
505
|
+
const entry = paramEntryOf(entries, infra.name)
|
|
506
|
+
if (!entry || entry.names.includes(target.name)) continue
|
|
507
|
+
if (entry.nameSuffix !== undefined && target.name.length > entry.nameSuffix.length && target.name.endsWith(entry.nameSuffix)) continue
|
|
508
|
+
const expected = [...entry.names.map((name) => `\`${name}\``), ...(entry.nameSuffix !== undefined ? [`\`<name>${entry.nameSuffix}\``] : [])].join(" or ")
|
|
509
|
+
context.report({ node: target, messageId: "name", data: { param: target.name, type: infra.name, expected } })
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
}
|
|
515
|
+
|
|
473
516
|
// -- no-module-ref ---------------------------------------------------------------------------------
|
|
474
517
|
|
|
475
518
|
const MODULE_REF_CALLS = new Set(["get", "resolve", "create"])
|
|
@@ -585,6 +628,7 @@ export const rules = {
|
|
|
585
628
|
"injector-shape": injectorShape,
|
|
586
629
|
"injector-type-match": injectorTypeMatch,
|
|
587
630
|
"infra-needs-injector": infraNeedsInjector,
|
|
631
|
+
"injected-param-name": injectedParamName,
|
|
588
632
|
"no-module-ref": noModuleRef,
|
|
589
633
|
"no-forward-ref": noForwardRef,
|
|
590
634
|
"no-string-token": noStringToken,
|
|
@@ -596,6 +640,7 @@ export const recommended = {
|
|
|
596
640
|
"starci-be/injector-shape": "error",
|
|
597
641
|
"starci-be/injector-type-match": "error",
|
|
598
642
|
"starci-be/infra-needs-injector": "error",
|
|
643
|
+
"starci-be/injected-param-name": "error",
|
|
599
644
|
"starci-be/no-module-ref": "error",
|
|
600
645
|
"starci-be/no-forward-ref": "error",
|
|
601
646
|
"starci-be/no-string-token": "error",
|
package/input-bounds.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* argument is `RateTier.Strict` of `platform/http-security`, on the method or on its class. A sign-in, sign-up,
|
|
17
17
|
* password-reset or callback door on the default tier is the brute-force path the strict tier exists for.
|
|
18
18
|
*/
|
|
19
|
+
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
19
20
|
import ts from "typescript"
|
|
20
21
|
import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
|
|
21
22
|
import { importsFrom, isOwnedEnumMember, partsOf, presentParts, typeOf } from "./lib/declared.mjs"
|
|
@@ -26,7 +27,7 @@ import { isPackageType, typed } from "./lib/types.mjs"
|
|
|
26
27
|
/** True when the file sits in a transport slot inside a `dto/` folder, judged by the slot and the owner-relative path. */
|
|
27
28
|
const isTransportDto = (hfs, filename) => {
|
|
28
29
|
const slot = hfs.slotOf(filename) ?? ""
|
|
29
|
-
if (!slot
|
|
30
|
+
if (!isTransportSlot(slot) && !slot.startsWith("be.feature.transport.")) return false
|
|
30
31
|
return hfs.relative(filename).split("/").slice(0, -1).includes("dto")
|
|
31
32
|
}
|
|
32
33
|
|
package/jobs.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the fenced-job pattern (catalog R157 `BE_JOB_WRITE_OUTSIDE_OWNER`, R158 `BE_JOB_FENCE_REQUIRED`,
|
|
3
|
+
* R159 `BE_JOB_RUN_KEY`, R160 `BE_JOB_SHAPE`).
|
|
4
|
+
*
|
|
5
|
+
* A background job is a row whose `fencingToken` the claim bumps. A worker that lost its claim (a zombie) holds a stale token, so every
|
|
6
|
+
* write to the row is guarded by `expectedFencingToken` and an external effect carries an idempotency key that includes the token.
|
|
7
|
+
* `platform/jobs` is the ONE owner of the row: it declares the entity, the claim and the guarded writes, and nothing else writes it.
|
|
8
|
+
*
|
|
9
|
+
* Everything is found by TYPE ORIGIN (the `JobClaims`, `JobStep`, `RunKey` and `FencedProcessor` declared by `platform/jobs`, the entity
|
|
10
|
+
* class declared by that owner, typeorm receivers and query builders) and by the slot view (`be.feature.jobs.steps`, `be.feature.jobs`); the
|
|
11
|
+
* only names read are the contract's own members `runKey`, `jobId` and `expectedFencingToken`.
|
|
12
|
+
*/
|
|
13
|
+
import ts from "typescript"
|
|
14
|
+
import { walk } from "./lib/ast.mjs"
|
|
15
|
+
import { writtenEntityOrigins } from "./lib/entity-writes.mjs"
|
|
16
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
17
|
+
import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
|
|
18
|
+
import { typeOrigins, typed } from "./lib/types.mjs"
|
|
19
|
+
|
|
20
|
+
const isJobClaimsType = (context, node) => isOwnedType(context, node, { name: "JobClaims", capability: "jobs", tier: "platform" })
|
|
21
|
+
const isJobStepType = (context, node) => isOwnedType(context, node, { name: "JobStep", capability: "jobs", tier: "platform" })
|
|
22
|
+
const isFencedProcessorType = (context, node) => isOwnedType(context, node, { name: "FencedProcessor", capability: "jobs", tier: "platform" })
|
|
23
|
+
const isRunKeyType = (context, node) => isOwnedType(context, node, { name: "RunKey", capability: "jobs", tier: "platform" })
|
|
24
|
+
|
|
25
|
+
/** True for a file of the owner itself, and for the test composition. */
|
|
26
|
+
const isOwnerOrTest = (hfs, file) => {
|
|
27
|
+
const tier = hfs.tierOf(file)
|
|
28
|
+
if (tier === "e2e" || tier === "fixtures" || inTestWorld(hfs, file)) return true
|
|
29
|
+
return tier === "platform" && ownerNameOf(hfs, file) === "jobs"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A write to an entity that `platform/jobs` declares (the job row) is made only by `platform/jobs`. */
|
|
33
|
+
export const jobWriteOwner = {
|
|
34
|
+
meta: {
|
|
35
|
+
type: "problem",
|
|
36
|
+
docs: { description: "The job entity is written only by `platform/jobs`: no `update`, `increment`, `save`, `insert`, `delete` or query-builder write on it elsewhere." },
|
|
37
|
+
schema: [],
|
|
38
|
+
messages: {
|
|
39
|
+
foreign: "`{{call}}` writes the job row outside `platform/jobs`. The fencing token and the status of a job change only through `JobClaims` (`claim`, `advance`, `complete`, `fail`), each of which requires `expectedFencingToken`; a direct write lets a zombie worker overwrite the job a newer worker owns.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
create(context) {
|
|
43
|
+
const hfs = hfsOf(context)
|
|
44
|
+
if (isOwnerOrTest(hfs, context.filename)) return {}
|
|
45
|
+
return {
|
|
46
|
+
CallExpression(node) {
|
|
47
|
+
const origins = writtenEntityOrigins(context, node)
|
|
48
|
+
if (!origins.some((origin) => hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === "jobs" && hfs.slotOf(origin.file) === "be.persistence")) return
|
|
49
|
+
context.report({ node, messageId: "foreign", data: { call: context.sourceCode.getText(node.callee) } })
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The names the guarded-write contract fixes: the target job and the token the writer holds. */
|
|
56
|
+
const TARGET = "jobId"
|
|
57
|
+
const TOKEN = "expectedFencingToken"
|
|
58
|
+
/** The property of a claimed job that holds the token it carries. */
|
|
59
|
+
const CARRIED = "fencingToken"
|
|
60
|
+
|
|
61
|
+
/** A property of a TypeScript type by name, or undefined. */
|
|
62
|
+
const propertyOf = (type, name) => type.getProperty(name)
|
|
63
|
+
|
|
64
|
+
/** True when the property is a required `number`. */
|
|
65
|
+
const isRequiredNumber = (checker, property, location) => {
|
|
66
|
+
if (!property || (property.flags & ts.SymbolFlags.Optional) !== 0) return false
|
|
67
|
+
const type = checker.getTypeOfSymbolAtLocation(property, location)
|
|
68
|
+
return (type.flags & ts.TypeFlags.Number) !== 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Calls on the `JobClaims` port, and what a guarded write is: required token, no cast to get around it, and `JobFencedOut` is not swallowed. */
|
|
72
|
+
export const jobFenceRequired = {
|
|
73
|
+
meta: {
|
|
74
|
+
type: "problem",
|
|
75
|
+
docs: { description: "Every `JobClaims` method that writes an existing job declares a required `expectedFencingToken: number`, a call never casts around it, and a `catch` around a guarded write rethrows." },
|
|
76
|
+
schema: [],
|
|
77
|
+
messages: {
|
|
78
|
+
optionalToken: "`{{method}}` takes a job (`{{target}}`) but its parameter type does not require `{{token}}: number`. Every write to an existing job is guarded by the fencing token the caller holds: declare it required (non-optional, non-nullable) in the type.",
|
|
79
|
+
cast: "This argument of `{{call}}` uses a cast or a non-null assertion. The token a guarded write carries is the one the claim returned: pass it as typed, never `as`, `!` or `any` around it.",
|
|
80
|
+
swallowed: "This `catch` hides a failed guarded write: a `JobFencedOut` means a newer worker owns the job and this one must stop with no further effect. Do not catch around `JobClaims` writes in a step or processor; the `FencedProcessor` base class owns the failure path. Rethrow if you must catch.",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
create(context) {
|
|
84
|
+
const hfs = hfsOf(context)
|
|
85
|
+
const owner = isOwnerOrTest(hfs, context.filename)
|
|
86
|
+
const { checker, toTs } = typed(context)
|
|
87
|
+
const claimsCall = (node) => node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && isJobClaimsType(context, node.callee.object)
|
|
88
|
+
return {
|
|
89
|
+
// The declaration side: in the owner, each member of the port that takes a job requires the token.
|
|
90
|
+
TSMethodSignature(node) {
|
|
91
|
+
if (!owner || hfs.tierOf(context.filename) !== "platform" || !isJobClaimsType(context, node.parent.parent?.id ?? node.parent)) return
|
|
92
|
+
const first = node.params?.[0]
|
|
93
|
+
if (!first) return
|
|
94
|
+
const tsNode = toTs(first)
|
|
95
|
+
const type = tsNode ? checker.getTypeAtLocation(tsNode) : undefined
|
|
96
|
+
// A parameter that already carries the token (the claimed job itself) is a derivation, not a write.
|
|
97
|
+
if (!type || !propertyOf(type, TARGET) || propertyOf(type, CARRIED)) return
|
|
98
|
+
if (!isRequiredNumber(checker, propertyOf(type, TOKEN), tsNode)) {
|
|
99
|
+
context.report({ node: first, messageId: "optionalToken", data: { method: node.key?.name ?? "this method", target: TARGET, token: TOKEN } })
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
CallExpression(node) {
|
|
103
|
+
if (owner || !claimsCall(node)) return
|
|
104
|
+
for (const argument of node.arguments) {
|
|
105
|
+
walk(argument, (child) => {
|
|
106
|
+
if (child.type === "TSAsExpression" || child.type === "TSTypeAssertion" || child.type === "TSNonNullExpression") context.report({ node: child, messageId: "cast", data: { call: context.sourceCode.getText(node.callee) } })
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
TryStatement(node) {
|
|
111
|
+
if (owner || !node.handler) return
|
|
112
|
+
let writes = false
|
|
113
|
+
walk(node.block, (child) => { if (claimsCall(child)) writes = true })
|
|
114
|
+
if (!writes) return
|
|
115
|
+
let rethrows = false
|
|
116
|
+
walk(node.handler.body, (child) => { if (child.type === "ThrowStatement") rethrows = true })
|
|
117
|
+
if (!rethrows) context.report({ node: node.handler, messageId: "swallowed" })
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The class of an external effect of a step passes the key `JobClaims.runKey` made. */
|
|
124
|
+
export const jobRunKey = {
|
|
125
|
+
meta: {
|
|
126
|
+
type: "problem",
|
|
127
|
+
docs: { description: "In a job step every call on an integration port passes a `RunKey` (made by `JobClaims.runKey`, which includes the fencing token), never a cast or a literal." },
|
|
128
|
+
schema: [],
|
|
129
|
+
messages: {
|
|
130
|
+
missing: "`{{call}}` is an external call from a job step without a `RunKey` argument. An effect that a zombie worker may repeat must be idempotent at the provider: pass `this.claims.runKey(job, \"<step>\")` (it includes the fencing token) as the idempotency key. A read belongs in the application layer, not in a step.",
|
|
131
|
+
forged: "This `RunKey` is built with a cast. Only `JobClaims.runKey(job, step)` makes one, so the key always carries the fencing token of the claim.",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
create(context) {
|
|
135
|
+
const hfs = hfsOf(context)
|
|
136
|
+
if (hfs.slotOf(context.filename) !== "be.feature.jobs.steps") return {}
|
|
137
|
+
return {
|
|
138
|
+
CallExpression(node) {
|
|
139
|
+
const callee = node.callee
|
|
140
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
141
|
+
const origins = typeOrigins(context, callee.object)
|
|
142
|
+
if (!origins.some((origin) => origin.module === null && hfs.tierOf(origin.file) === "integrations")) return
|
|
143
|
+
const keyArguments = node.arguments.filter((argument) => isRunKeyType(context, argument))
|
|
144
|
+
if (keyArguments.length === 0) {
|
|
145
|
+
context.report({ node, messageId: "missing", data: { call: context.sourceCode.getText(callee) } })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
for (const argument of keyArguments) {
|
|
149
|
+
walk(argument, (child) => { if (child.type === "TSAsExpression" || child.type === "TSTypeAssertion") context.report({ node: child, messageId: "forged" }) })
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A processor extends the fenced base and is named after its job; a step implements `JobStep` in `steps/`. */
|
|
157
|
+
export const jobShape = {
|
|
158
|
+
meta: {
|
|
159
|
+
type: "problem",
|
|
160
|
+
docs: { description: "`<job>.processor.ts` of `features/jobs/<job>/transport/queue/` declares a class extending `FencedProcessor`; `steps/<step>.step.ts` declares a class implementing `JobStep`; neither role exists elsewhere." },
|
|
161
|
+
schema: [],
|
|
162
|
+
messages: {
|
|
163
|
+
processorPlacement: "`{{name}}` is in a `.processor.ts` file outside `features/jobs/<job>/transport/queue/`. A job processor lives only in the queue transport of its job, so the jobs kind stays one place.",
|
|
164
|
+
processorStem: "The processor of job `{{job}}` is `{{job}}.processor.ts`, but this file is `{{stem}}.processor.ts`.",
|
|
165
|
+
notFenced: "`{{name}}` is a job processor that does not extend `FencedProcessor` of `platform/jobs`. The base class claims the job with a bumped fencing token and settles it through the guarded writes; a processor without it has no fence.",
|
|
166
|
+
stepPlacement: "`{{name}}` implements `JobStep` outside `features/jobs/<job>/steps/<step>.step.ts`.",
|
|
167
|
+
notStep: "`{{name}}` is in a step file but does not implement `JobStep` of `platform/jobs`.",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
create(context) {
|
|
171
|
+
const hfs = hfsOf(context)
|
|
172
|
+
const file = context.filename
|
|
173
|
+
const found = hfs.classify(file)
|
|
174
|
+
const base = baseName(file)
|
|
175
|
+
const isProcessorFile = base.endsWith(".processor.ts")
|
|
176
|
+
const isStepFile = base.endsWith(".step.ts")
|
|
177
|
+
const inJob = found.slot === "be.feature.jobs.queue"
|
|
178
|
+
const inSteps = found.slot === "be.feature.jobs.steps"
|
|
179
|
+
const inPlatform = hfs.tierOf(file) === "platform" && ownerNameOf(hfs, file) === "jobs"
|
|
180
|
+
const check = (node) => {
|
|
181
|
+
const name = node.id?.name ?? "this class"
|
|
182
|
+
const extendsFenced = node.superClass ? isFencedProcessorType(context, node.superClass) : false
|
|
183
|
+
const implementsStep = (node.implements ?? []).some((entry) => isJobStepType(context, entry))
|
|
184
|
+
if (isProcessorFile && !inPlatform) {
|
|
185
|
+
if (!inJob) context.report({ node: node.id ?? node, messageId: "processorPlacement", data: { name } })
|
|
186
|
+
else {
|
|
187
|
+
const stem = base.slice(0, -".processor.ts".length)
|
|
188
|
+
if (found.bindings?.job !== undefined && found.bindings.job !== stem) context.report({ node: node.id ?? node, messageId: "processorStem", data: { job: found.bindings.job, stem } })
|
|
189
|
+
if (!extendsFenced) context.report({ node: node.id ?? node, messageId: "notFenced", data: { name } })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (isStepFile) {
|
|
193
|
+
if (!inSteps) context.report({ node: node.id ?? node, messageId: "stepPlacement", data: { name } })
|
|
194
|
+
else if (!implementsStep) context.report({ node: node.id ?? node, messageId: "notStep", data: { name } })
|
|
195
|
+
} else if (implementsStep && !inPlatform) context.report({ node: node.id ?? node, messageId: "stepPlacement", data: { name } })
|
|
196
|
+
}
|
|
197
|
+
return { ClassDeclaration: check, ClassExpression: check }
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const rules = {
|
|
202
|
+
"job-write-owner": jobWriteOwner,
|
|
203
|
+
"job-fence-required": jobFenceRequired,
|
|
204
|
+
"job-run-key": jobRunKey,
|
|
205
|
+
"job-shape": jobShape,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The level these laws ask for: `error`, switched off nowhere. */
|
|
209
|
+
export const recommended = {
|
|
210
|
+
"starci-be/job-write-owner": "error",
|
|
211
|
+
"starci-be/job-fence-required": "error",
|
|
212
|
+
"starci-be/job-run-key": "error",
|
|
213
|
+
"starci-be/job-shape": "error",
|
|
214
|
+
}
|
package/lib/ast.mjs
CHANGED
|
@@ -54,6 +54,13 @@ export const keyName = (node) => {
|
|
|
54
54
|
return null
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
|
|
58
|
+
export const paramParts = (param) => {
|
|
59
|
+
const inner = param.type === "TSParameterProperty" ? param.parameter : param
|
|
60
|
+
const target = inner.type === "AssignmentPattern" ? inner.left : inner
|
|
61
|
+
return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
/** The name of a decorator (`@Foo` or `@Foo(...)`), else null. */
|
|
58
65
|
export const decoratorName = (decorator) => {
|
|
59
66
|
const expression = decorator?.expression
|
package/lib/config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one flat config of a StarCi back end.
|
|
3
3
|
*
|
|
4
|
-
* A repository's `eslint.config.mjs` is a managed file (R05/R16), rendered by `
|
|
4
|
+
* A repository's `eslint.config.mjs` is a managed file (R05/R16), rendered by `starci app sync` as exactly:
|
|
5
5
|
*
|
|
6
6
|
* import { loadHfs, starciBeConfig } from "@starci/eslint-canon-be"
|
|
7
7
|
* export default starciBeConfig({ hfs: loadHfs(import.meta.url) })
|
package/lib/db-writes.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { staticText } from "./ast.mjs"
|
|
|
6
6
|
import { isPackageType } from "./types.mjs"
|
|
7
7
|
|
|
8
8
|
/** The `typeorm` types a call can write through. */
|
|
9
|
-
|
|
9
|
+
const RECEIVER_TYPES = Object.freeze(["EntityManager", "DataSource", "QueryRunner"])
|
|
10
10
|
|
|
11
11
|
/** Statement text that changes rows. */
|
|
12
12
|
export const WRITING_SQL = /(?:^|[\s;(])(?:insert\s+into|update\s+["`\w.]+\s+set|delete\s+from|truncate\b)/i
|
package/lib/doors.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the door rules of the feature kinds (webhooks, realtime) read from a class: what is injected into it, which methods
|
|
3
|
+
* the framework enters, and which expression a call is made on. Every answer comes from a decorator's import, a value's
|
|
4
|
+
* declared type or a declaration's origin, never from a spelling.
|
|
5
|
+
*/
|
|
6
|
+
import ts from "typescript"
|
|
7
|
+
import { walk } from "./ast.mjs"
|
|
8
|
+
import { decoratorCallee, importOf, isImportedFrom } from "./import-source.mjs"
|
|
9
|
+
import { typed } from "./types.mjs"
|
|
10
|
+
|
|
11
|
+
/** True when the class carries a decorator that is `name` exported by `source`. */
|
|
12
|
+
export const hasClassDecorator = (context, node, source, name) =>
|
|
13
|
+
(node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), source, name))
|
|
14
|
+
|
|
15
|
+
/** The decorators of a method or class that are one of `names` exported by `source`. */
|
|
16
|
+
export const decoratorsFrom = (context, node, source, names) =>
|
|
17
|
+
(node.decorators ?? []).filter((decorator) => isImportedFrom(context, decoratorCallee(decorator), source, names))
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every value injected into a class: a constructor parameter (plain or a parameter property) and a property that
|
|
21
|
+
* carries a decorator which injects (`Inject` of Nest, a `@nestjs/typeorm` injector or a function declared in a
|
|
22
|
+
* `*.decorators.ts` file).
|
|
23
|
+
*
|
|
24
|
+
* @param {object} context - The ESLint rule context.
|
|
25
|
+
* @param {object} classNode - A `ClassDeclaration`.
|
|
26
|
+
* @returns {Array<{ node: object, annotation: object | undefined }>} The injected members, each with its declared type annotation.
|
|
27
|
+
*/
|
|
28
|
+
export const injectedMembers = (context, classNode) => {
|
|
29
|
+
const members = []
|
|
30
|
+
const constructor = classNode.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
|
|
31
|
+
for (const original of constructor?.value.params ?? []) {
|
|
32
|
+
const param = original.type === "TSParameterProperty" ? original.parameter : original
|
|
33
|
+
members.push({ node: original, annotation: param.typeAnnotation?.typeAnnotation })
|
|
34
|
+
}
|
|
35
|
+
for (const member of classNode.body.body) {
|
|
36
|
+
if (member.type !== "PropertyDefinition" || !(member.decorators ?? []).some((decorator) => isInjectorDecorator(context, decorator))) continue
|
|
37
|
+
members.push({ node: member, annotation: member.typeAnnotation?.typeAnnotation })
|
|
38
|
+
}
|
|
39
|
+
return members
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True for a decorator that injects: `Inject` of Nest, a `@nestjs/typeorm` injector, or a function declared in a `*.decorators.ts` file. */
|
|
43
|
+
const isInjectorDecorator = (context, decorator) => {
|
|
44
|
+
const callee = decoratorCallee(decorator)
|
|
45
|
+
if (callee?.type !== "Identifier") return false
|
|
46
|
+
const found = importOf(context, callee)
|
|
47
|
+
if (found?.source === "@nestjs/common" && found.imported === "Inject") return true
|
|
48
|
+
if (found?.source === "@nestjs/typeorm") return true
|
|
49
|
+
const symbol = aliasTarget(context, callee)
|
|
50
|
+
return (symbol?.getDeclarations() ?? []).some((declaration) => declaration.getSourceFile().fileName.split("\\").join("/").endsWith(".decorators.ts"))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The symbol an identifier names, with an import alias resolved to its target. */
|
|
54
|
+
export const aliasTarget = (context, identifier) => {
|
|
55
|
+
const { checker, toTs } = typed(context)
|
|
56
|
+
const tsNode = toTs(identifier)
|
|
57
|
+
const symbol = tsNode && checker.getSymbolAtLocation(tsNode)
|
|
58
|
+
return symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The methods of a class (instance, non-constructor) that carry one of the decorators `names` of `source`. */
|
|
62
|
+
export const methodsDecoratedBy = (context, classNode, source, names) =>
|
|
63
|
+
classNode.body.body.filter((member) => member.type === "MethodDefinition" && member.kind === "method" && !member.static && decoratorsFrom(context, member, source, names).length > 0)
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every call made in a method body, closures included: `<receiver>.<method>(...)` as `{ call, receiver, method }`, and any
|
|
67
|
+
* other call as `{ call, receiver: null, method: null }`.
|
|
68
|
+
*/
|
|
69
|
+
export const callsOf = (method) => {
|
|
70
|
+
const calls = []
|
|
71
|
+
walk(method.value.body, (node) => {
|
|
72
|
+
if (node.type !== "CallExpression") return
|
|
73
|
+
const callee = node.callee
|
|
74
|
+
const member = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
|
|
75
|
+
calls.push({ call: node, receiver: member?.object ?? null, method: member?.property.name ?? null })
|
|
76
|
+
})
|
|
77
|
+
return calls
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The parameters of a handler method, with the pattern a default value or a destructuring wraps unwrapped. */
|
|
81
|
+
export const parametersOf = (method) => method.value.params.map((param) => (param.type === "TSParameterProperty" ? param.parameter : param))
|
package/lib/edition.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edition gate of the back-end plugin.
|
|
3
|
+
*
|
|
4
|
+
* Edition applicability belongs to the HFS rule catalog, beside the law and its enforcer. A rule
|
|
5
|
+
* that the catalog does not judge in this app's edition still ships in the plugin, but its `create`
|
|
6
|
+
* returns no listeners. The plugin wiring therefore has one generic gate instead of edition checks
|
|
7
|
+
* scattered through rule bodies.
|
|
8
|
+
*/
|
|
9
|
+
import { dirname, join } from "node:path"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
import { loadRuleCatalog } from "../runtime/scripts/hfs/slots.mjs"
|
|
12
|
+
|
|
13
|
+
const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
|
|
14
|
+
let catalogCache
|
|
15
|
+
|
|
16
|
+
/** The bundled rule catalog, loaded once per lint process. */
|
|
17
|
+
const bundledCatalog = () => {
|
|
18
|
+
catalogCache ??= loadRuleCatalog({ root: RUNTIME })
|
|
19
|
+
return catalogCache
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A rule which intentionally registers no listeners in this edition. */
|
|
23
|
+
const inactive = (rule) => Object.freeze({ ...rule, create: () => ({}) })
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The plugin view for one HFS edition.
|
|
27
|
+
*
|
|
28
|
+
* Full is the default edition and the catalog's superset, so it needs no runtime catalog read. This
|
|
29
|
+
* keeps the source checkout testable before `sync-runtime` refreshes the generated bundle; lite
|
|
30
|
+
* loads the package's bundled catalog in a published installation. Tests may inject a catalog to
|
|
31
|
+
* prove the generic filtering independently of generated copies.
|
|
32
|
+
*/
|
|
33
|
+
export const pluginForEdition = ({ plugin, hfs, catalog }) => {
|
|
34
|
+
const edition = hfs?.edition === undefined ? "full" : hfs.edition
|
|
35
|
+
if (edition !== "full" && edition !== "lite") {
|
|
36
|
+
throw new RangeError(`Unknown HFS edition ${JSON.stringify(edition)}; expected "full" or "lite"`)
|
|
37
|
+
}
|
|
38
|
+
if (edition === "full" && catalog === undefined) return plugin
|
|
39
|
+
const loaded = catalog ?? bundledCatalog()
|
|
40
|
+
const rules = Object.fromEntries(Object.entries(plugin.rules).map(([id, rule]) => [
|
|
41
|
+
id,
|
|
42
|
+
loaded.enforcerJudgedIn("eslint-be", id, edition) ? rule : inactive(rule),
|
|
43
|
+
]))
|
|
44
|
+
return Object.freeze({ ...plugin, rules: Object.freeze(rules) })
|
|
45
|
+
}
|