@starci/eslint-canon-be 3.0.7 → 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 +13 -7
- 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/hfs.mjs +3 -3
- 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 -3
- package/project-graph.mjs +8 -5
- 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/engine/runtime-root.mjs +5 -0
- package/runtime/engine/yaml.mjs +3 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +915 -123
- package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
- package/runtime/knowledge/sonar-gate.yaml +53 -19
- 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 +17 -27
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
- package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
- package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
- 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/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
- package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
- package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
- package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
- package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
- package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
- package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
- package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
- package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
- package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
- package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
- package/runtime/scripts/{checks → 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/{checks → hfs}/architecture/source-names.mjs +27 -147
- package/runtime/scripts/{checks → 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/{checks → hfs}/architecture/surface.mjs +10 -4
- package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
- package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
- package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
- 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 +247 -0
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
- package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
- package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
- package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
- package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
- package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
- 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 +26 -7
- package/runtime/scripts/lib/line-count.mjs +9 -0
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/path-key.mjs +59 -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/{checks/common.mjs → lib/walk.mjs} +5 -15
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +3 -2
- 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/recorded-lines.mjs +0 -27
- package/runtime/scripts/lib/test-secrets.mjs +0 -116
- /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
package/lib/unit-spec.mjs
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* What is a unit spec, decided by the slot manifest and the file's role suffix, never by a path pattern.
|
|
3
3
|
*
|
|
4
4
|
* A unit spec is a `*.spec.ts` outside the folders that own the other test kinds (`src/tests/{e2e,integration,contract,world}`)
|
|
5
|
-
* only. Only a `<name>.
|
|
6
|
-
*
|
|
5
|
+
* only. Only a `<name>.<role>.spec.ts` beside `<name>.<role>.ts` is a legitimate unit spec (R47): of a spec-required unit role
|
|
6
|
+
* (`ruleParams.be.unitRoles`: a service, a cli command) anywhere its slot admits, and of any other LOGIC role (`ruleParams.be.logicRoles`: a policy,
|
|
7
|
+
* a client, a guard, a mapper, ...) inside a slot whose `coverage` is required, where the spec is optional (the file owes 100 per file, not a spec of
|
|
8
|
+
* its own). So is the spec of a door of a feature kind that has no service of its own
|
|
9
|
+
* (`<provider>.webhook.spec.ts`, `<channel>.gateway.spec.ts`, `<channel>.subscription.spec.ts` beside its door, in the kind's slot);
|
|
10
|
+
* the quality rules of R48 judge the service specs.
|
|
7
11
|
*/
|
|
8
12
|
import { basename } from "node:path"
|
|
9
13
|
|
|
@@ -29,5 +33,62 @@ export const isUnitSpecFile = (hfs, filename) => {
|
|
|
29
33
|
/** Whether a file is a `<name>.service.spec.ts` unit spec. */
|
|
30
34
|
export const isServiceSpecFile = (hfs, filename) => /\.service\.spec\.ts$/.test(baseOf(filename)) && isUnitSpecFile(hfs, filename)
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The unit-tested roles of the back end: `ruleParams.be.unitRoles` of the slot manifest, the ONE list (`[{ role, spec, slot? }]`).
|
|
38
|
+
*
|
|
39
|
+
* @param {object} hfs - The HFS view.
|
|
40
|
+
* @returns {ReadonlyArray<{ role: string, spec: string, slot?: string }>} The roles.
|
|
41
|
+
*/
|
|
42
|
+
export const unitRoles = (hfs) => hfs.ruleParams.unitRoles ?? []
|
|
43
|
+
|
|
44
|
+
/** Whether the slot that owns a file is measured at 100 per file (`coverage: required`): the home of the optional specs of the logic roles. */
|
|
45
|
+
const inCoverageSlot = (hfs, filename) => {
|
|
46
|
+
const slot = hfs.slotOf(filename)
|
|
47
|
+
return slot !== null && hfs.slot(slot)?.coverage === "required"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The roles whose spec is legal but not required: every logic role that is not a spec-required unit role. */
|
|
51
|
+
const optionalRoles = (hfs) => (hfs.ruleParams.logicRoles ?? []).filter((role) => !unitRoles(hfs).some((unit) => unit.role === role)).map((role) => ({ role, spec: `${role}.spec`, optional: true }))
|
|
52
|
+
|
|
53
|
+
/** Whether a role admits a file by its slot (a role with no slot admits any slot; an optional logic role only a coverage-required slot). */
|
|
54
|
+
const inRoleSlot = (hfs, role, filename) => (role.optional === true ? inCoverageSlot(hfs, filename) : role.slot === undefined || hfs.slotOf(filename) === role.slot)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The unit-tested role whose spec a file is (`<name>.<role>.spec.ts`, in the role's slot), or null.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} hfs - The HFS view.
|
|
60
|
+
* @param {string} filename - The linted file.
|
|
61
|
+
* @returns {object | null} The role entry.
|
|
62
|
+
*/
|
|
63
|
+
export const unitRoleOfSpec = (hfs, filename) => {
|
|
64
|
+
if (!isUnitSpecFile(hfs, filename)) return null
|
|
65
|
+
const name = baseOf(filename)
|
|
66
|
+
return [...unitRoles(hfs), ...optionalRoles(hfs)].find((role) => name.endsWith(`.${role.spec}.ts`) && inRoleSlot(hfs, role, filename)) ?? null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The unit-tested role whose subject a file is (`<name>.<role>.ts`, in the role's slot, outside the test tree), or null.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} hfs - The HFS view.
|
|
73
|
+
* @param {string} filename - The linted file.
|
|
74
|
+
* @returns {object | null} The role entry.
|
|
75
|
+
*/
|
|
76
|
+
export const unitRoleOfSubject = (hfs, filename) => {
|
|
77
|
+
const name = baseOf(filename)
|
|
78
|
+
const slot = hfs.slotOf(filename)
|
|
79
|
+
if (!slot || slot.startsWith("be.tests.")) return null
|
|
80
|
+
return unitRoles(hfs).find((role) => name.endsWith(`.${role.role}.ts`) && !name.endsWith(`.${role.spec}.ts`) && inRoleSlot(hfs, role, filename)) ?? null
|
|
81
|
+
}
|
|
82
|
+
|
|
32
83
|
/** The `<name>` of a `<name>.service.spec.ts`. */
|
|
33
84
|
export const serviceNameOfSpec = (filename) => baseOf(filename).replace(/\.service\.spec\.ts$/, "")
|
|
85
|
+
|
|
86
|
+
/** The slots of the feature kinds whose door carries its own unit spec beside it. */
|
|
87
|
+
const DOOR_SPEC_SLOTS = new Set(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket"])
|
|
88
|
+
|
|
89
|
+
/** The door a `<name>.<role>.spec.ts` of a feature kind tests, or null: `payment.webhook.spec.ts` gives `payment.webhook.ts`. */
|
|
90
|
+
export const doorOfSpec = (hfs, filename) => {
|
|
91
|
+
if (!isUnitSpecFile(hfs, filename) || !DOOR_SPEC_SLOTS.has(hfs.slotOf(filename) ?? "")) return null
|
|
92
|
+
const match = /^(.+.(?:webhook|gateway|subscription)).spec.ts$/.exec(baseOf(filename))
|
|
93
|
+
return match ? `${match[1]}.ts` : null
|
|
94
|
+
}
|
package/module-layering.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* view; none of them reads a path pattern, and an alias is resolved through the program's own `compilerOptions.paths`:
|
|
7
7
|
*
|
|
8
8
|
* - `import-owner-entry` - a cross-owner import targets the owner's `index.ts` (`@modules/domain/plan`,
|
|
9
|
-
* `@features/plan`), never a file inside it, and an alias that names a tier but no owner (`@modules/domain`) names
|
|
9
|
+
* `@features/api/plan`), never a file inside it, and an alias that names a tier but no owner (`@modules/domain`) names
|
|
10
10
|
* nothing importable. A same-owner import is relative and never goes through the owner's own `index.ts`.
|
|
11
11
|
* - `no-self-module-alias` - inside an owner, imports are relative, never the owner's own alias.
|
|
12
12
|
* - `no-relative-capability-escape` - a relative import never walks out of its own owner.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { posix } from "node:path"
|
|
21
21
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
22
22
|
import { baseName } from "./lib/ports.mjs"
|
|
23
|
-
import {
|
|
23
|
+
import { resolveAlias } from "./lib/specifier.mjs"
|
|
24
24
|
|
|
25
25
|
/** The tiers whose files belong to an owner with a root `index.ts`. */
|
|
26
26
|
const OWNER_TIERS = new Set(["feature", "domain", "platform", "integrations"])
|
|
@@ -45,27 +45,6 @@ const absolute = (hfs, rel) => `${hfs.repoRoot.replace(/\\/g, "/")}/${rel}`
|
|
|
45
45
|
/** The owner an import path (no extension, absolute) reaches: the owner of `<path>/index.ts`, which any file or folder of that owner shares. */
|
|
46
46
|
const ownerOfTarget = (hfs, target) => ownerDirOf(hfs, `${target}/index.ts`)
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
* The absolute path an alias specifier maps to under the program's `compilerOptions.paths`, or null when no pattern matches.
|
|
50
|
-
*
|
|
51
|
-
* @param {object} context - The ESLint rule context.
|
|
52
|
-
* @param {string} specifier - An import specifier.
|
|
53
|
-
* @returns {{ target: string, rest: string } | null} The absolute target without extension and the part the wildcard matched.
|
|
54
|
-
*/
|
|
55
|
-
const resolveAlias = (context, specifier) => {
|
|
56
|
-
const options = typed(context).program.getCompilerOptions()
|
|
57
|
-
const base = options.baseUrl ?? options.pathsBasePath
|
|
58
|
-
if (!options.paths || typeof base !== "string") return null
|
|
59
|
-
for (const [pattern, targets] of Object.entries(options.paths)) {
|
|
60
|
-
if (!pattern.endsWith("/*") || !targets[0]) continue
|
|
61
|
-
const prefix = pattern.slice(0, -1)
|
|
62
|
-
if (!specifier.startsWith(prefix)) continue
|
|
63
|
-
const rest = specifier.slice(prefix.length)
|
|
64
|
-
return { target: posix.normalize(`${base.replace(/\\/g, "/")}/${targets[0].replace("*", rest)}`), rest }
|
|
65
|
-
}
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
68
|
-
|
|
69
48
|
/** A specifier that names a directory by construction: `.`, `./`, `..`, `../`, or a trailing `/`. */
|
|
70
49
|
const isBareFolderSpecifier = (specifier) =>
|
|
71
50
|
specifier === "." || specifier === "./" || specifier === ".." || specifier === "../" || specifier.endsWith("/")
|
|
@@ -105,7 +84,7 @@ export const importOwnerEntry = {
|
|
|
105
84
|
schema: [],
|
|
106
85
|
messages: {
|
|
107
86
|
barrel:
|
|
108
|
-
"`{{specifier}}` names a tier, not an owner. Import an owner's public entry, for example `@modules/domain/plan` or `@features/plan`.",
|
|
87
|
+
"`{{specifier}}` names a tier, not an owner. Import an owner's public entry, for example `@modules/domain/plan` or `@features/api/plan`.",
|
|
109
88
|
deep:
|
|
110
89
|
"`{{specifier}}` reaches into a file of another owner. Import the owner's public entry (its `index.ts`) and export what you need from there.",
|
|
111
90
|
ownIndex:
|
package/module-shape.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import ts from "typescript"
|
|
30
30
|
import { decoratorName, keyName, walk } from "./lib/ast.mjs"
|
|
31
31
|
import { importsFrom } from "./lib/declared.mjs"
|
|
32
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
32
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
33
33
|
import { isDeclarationFile, isSpecFile, normalizePath } from "./lib/path.mjs"
|
|
34
34
|
import { typed } from "./lib/types.mjs"
|
|
35
35
|
|
|
@@ -70,7 +70,7 @@ const isAppModule = (hfs, filename) => (hfs.slotOf(filename) ?? "").startsWith("
|
|
|
70
70
|
* capability modules with `isGlobal: true` the way `apps/<app>/src/app.module.ts` does in production. A spec (integration,
|
|
71
71
|
* contract, e2e) does not compose and stays refused: it calls `useTestWorld(...)` only.
|
|
72
72
|
*/
|
|
73
|
-
const isWorld =
|
|
73
|
+
const isWorld = inTestWorld
|
|
74
74
|
|
|
75
75
|
const isTrue = (node) => node?.type === "Literal" && node.value === true
|
|
76
76
|
|
package/naming.mjs
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* that. Those are read by a person, which is why the law states them with the scars attached.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
33
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
34
34
|
import { normalizePath } from "./lib/path.mjs"
|
|
35
35
|
|
|
36
36
|
/** A schema generation baked into an identifier. */
|
|
@@ -203,7 +203,7 @@ export const noBareVerbExport = {
|
|
|
203
203
|
// -- no-default-export -------------------------------------------------------------------------------------------
|
|
204
204
|
|
|
205
205
|
/** The one file kind that may default-export: a Jest global setup, whose API is a default function. */
|
|
206
|
-
const isGlobalSetup = (context) => hfsOf(context)
|
|
206
|
+
const isGlobalSetup = (context) => inTestWorld(hfsOf(context), context.filename) && /\/global-(?:setup|teardown)\.ts$/.test(normalizePath(context.filename))
|
|
207
207
|
|
|
208
208
|
/** Named exports only: a default export has no name to grep, rename or import-check. */
|
|
209
209
|
export const noDefaultExport = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-canon-be",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.9",
|
|
4
4
|
"description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"*.mjs",
|
|
14
14
|
"lib/*.mjs",
|
|
15
15
|
"runtime/**",
|
|
16
|
-
"!*.
|
|
16
|
+
"!*.spec.mjs",
|
|
17
17
|
"README.md",
|
|
18
18
|
"CHANGELOG.md"
|
|
19
19
|
],
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"
|
|
31
|
+
"prepack": "node ../../hfs/prep-runtime.mjs",
|
|
32
|
+
"pretest": "node ../../hfs/prep-runtime.mjs",
|
|
33
|
+
"test": "node --test \"*.spec.mjs\""
|
|
32
34
|
},
|
|
33
35
|
"keywords": [
|
|
34
36
|
"eslint",
|
package/project-graph.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that read the project graph: the findings of the architecture machine that sit on a TypeScript file.
|
|
3
3
|
*
|
|
4
|
-
* One graph per lint run: `scripts/
|
|
4
|
+
* One graph per lint run: `scripts/hfs/project-graph.mjs` (a byte copy in this package's runtime/) builds the machine's module
|
|
5
5
|
* graph and slot manifest once for the repository, caches it, and each rule below reports the findings of its own codes on
|
|
6
6
|
* the file ESLint is visiting, on the line the machine names. There is no second graph and no second judge: the list of
|
|
7
|
-
* rules and their codes is `LINT_ENFORCERS` of the machine (scripts/
|
|
7
|
+
* rules and their codes is `LINT_ENFORCERS` of the machine (scripts/hfs/architecture/surface.mjs); `starci app check` keeps the
|
|
8
8
|
* findings that have no TypeScript file to sit on. A graph that cannot be built stops the run with the machine's message.
|
|
9
9
|
*/
|
|
10
|
-
import { LINT_ENFORCERS } from "./runtime/scripts/
|
|
11
|
-
import { projectRule } from "./runtime/scripts/
|
|
10
|
+
import { LINT_ENFORCERS } from "./runtime/scripts/hfs/architecture/surface.mjs"
|
|
11
|
+
import { projectRule } from "./runtime/scripts/hfs/project-rule.mjs"
|
|
12
12
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
13
13
|
import { dirname, join } from "node:path"
|
|
14
14
|
import { fileURLToPath } from "node:url"
|
|
@@ -17,7 +17,10 @@ const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "runtime")
|
|
|
17
17
|
const mine = LINT_ENFORCERS.filter((enforcer) => enforcer.plugins.includes("be"))
|
|
18
18
|
|
|
19
19
|
/** The rules this law contributes to the plugin: one per graph enforcer of this profile. */
|
|
20
|
-
export const rules = Object.fromEntries(mine.map((
|
|
20
|
+
export const rules = Object.fromEntries(mine.map((enforcer) => [
|
|
21
|
+
enforcer.id,
|
|
22
|
+
projectRule({ enforcer, runtimeRoot: RUNTIME, hfsOf }),
|
|
23
|
+
]))
|
|
21
24
|
|
|
22
25
|
/** Every one at error. */
|
|
23
26
|
export const recommended = Object.fromEntries(mine.map((e) => [`starci-be/${e.id}`, "error"]))
|
package/projections.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the projection pattern (catalog R161 `BE_PROJECTION_WRITE_OWNER`, R162 `BE_PROJECTION_SHAPE`).
|
|
3
|
+
*
|
|
4
|
+
* A projection is a read model: `modules/projections/<name>/` holds `<name>.projection.ts` (the only class that touches its table:
|
|
5
|
+
* `recompute*` writes it idempotently from the source facts and can replay from scratch, `get*` reads it) and
|
|
6
|
+
* `<name>.projection-entity.ts` (the table's entity). A reactor, a job or a cli command triggers a recompute; an api feature only calls `get*`.
|
|
7
|
+
* A projection publishes no event (R152 refuses `eventBus.publish` outside `modules/domain`).
|
|
8
|
+
*
|
|
9
|
+
* Entities are found by TYPE ORIGIN; the owner and the kind of a file by the slot view. The vocabulary `recompute` and `get` is the
|
|
10
|
+
* pattern's own method contract.
|
|
11
|
+
*/
|
|
12
|
+
import { writtenEntityOrigins } from "./lib/entity-writes.mjs"
|
|
13
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
14
|
+
import { baseName } from "./lib/ports.mjs"
|
|
15
|
+
import { typed } from "./lib/types.mjs"
|
|
16
|
+
|
|
17
|
+
const PROJECTION_SLOT = "be.projections"
|
|
18
|
+
const ENTITY_SUFFIX = ".projection-entity.ts"
|
|
19
|
+
const PROJECTION_SUFFIX = ".projection.ts"
|
|
20
|
+
|
|
21
|
+
/** True for the test composition, which seeds and inspects tables directly. */
|
|
22
|
+
const isTestComposition = (hfs, file) => {
|
|
23
|
+
const tier = hfs.tierOf(file)
|
|
24
|
+
return tier === "e2e" || tier === "fixtures" || inTestWorld(hfs, file)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The word a method name starts with (`recomputeUserXp` gives `recompute`), or null when the name has no camelCase boundary. */
|
|
28
|
+
const verbOf = (name) => {
|
|
29
|
+
const match = /^[a-z]+/.exec(name)
|
|
30
|
+
return match && (match[0].length === name.length || /[A-Z]/.test(name[match[0].length])) ? match[0] : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Only a projection writes its table, from its own `<name>.projection.ts`. */
|
|
34
|
+
export const projectionWriteOwner = {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: { description: "A projection's entity is written only by the `*.projection.ts` of the same projection folder." },
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
foreign: "`{{call}}` writes the read-model table of a projection from outside its `<name>.projection.ts`. A read model has one writer: its projection's `recompute*` method, which is idempotent and can replay from the source facts. Ask the projection to recompute (from a reactor or a job) instead of writing its table.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
create(context) {
|
|
44
|
+
const hfs = hfsOf(context)
|
|
45
|
+
const file = context.filename
|
|
46
|
+
if (isTestComposition(hfs, file)) return {}
|
|
47
|
+
return {
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
const entities = writtenEntityOrigins(context, node).filter((origin) => hfs.slotOf(origin.file) === PROJECTION_SLOT && baseName(origin.file).endsWith(ENTITY_SUFFIX))
|
|
50
|
+
if (entities.length === 0) return
|
|
51
|
+
const mine = hfs.slotOf(file) === PROJECTION_SLOT && baseName(file).endsWith(PROJECTION_SUFFIX)
|
|
52
|
+
if (mine && entities.every((origin) => hfs.ownerOf(origin.file) === hfs.ownerOf(file))) return
|
|
53
|
+
context.report({ node, messageId: "foreign", data: { call: context.sourceCode.getText(node.callee) } })
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The slot of an owner's root file: its kind lives there (`trigger`). */
|
|
60
|
+
const triggerOfOwner = (hfs, file) => {
|
|
61
|
+
const root = hfs.ownerOf(file)
|
|
62
|
+
const slot = root ? hfs.slotOf(`${root}/index.ts`) : null
|
|
63
|
+
return slot ? (hfs.slot(slot)?.trigger ?? null) : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The projection class speaks only `recompute*` and `get*`, never exports its entity, and an api feature never recomputes. */
|
|
67
|
+
export const projectionShape = {
|
|
68
|
+
meta: {
|
|
69
|
+
type: "problem",
|
|
70
|
+
docs: { description: "A `*.projection.ts` class has only `recompute*` and `get*` public methods, a projection's `index.ts` exports no projection entity, and an api feature calls only `get*`." },
|
|
71
|
+
schema: [],
|
|
72
|
+
messages: {
|
|
73
|
+
vocabulary: "`{{name}}` is a public method of a projection that is neither `recompute*` nor `get*`. A projection recomputes its table from the source facts (idempotent, replayable) or reads it; anything else belongs in a domain service.",
|
|
74
|
+
entityExported: "This `index.ts` exports from `{{source}}`. The entity of a projection stays inside its folder: the rest of the code reads through `get*` and never sees the table's shape.",
|
|
75
|
+
apiRecompute: "`{{call}}` recomputes a projection from an api feature. The api reads only through `get*`; a reactor, a job or a cli command keeps the read model current.",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
const hfs = hfsOf(context)
|
|
80
|
+
const file = context.filename
|
|
81
|
+
const found = hfs.classify(file)
|
|
82
|
+
const base = baseName(file)
|
|
83
|
+
const inProjection = found.slot === PROJECTION_SLOT
|
|
84
|
+
const trigger = triggerOfOwner(hfs, file)
|
|
85
|
+
return {
|
|
86
|
+
MethodDefinition(node) {
|
|
87
|
+
if (!inProjection || !base.endsWith(PROJECTION_SUFFIX) || node.kind !== "method" || node.computed || node.key.type !== "Identifier") return
|
|
88
|
+
if (node.accessibility === "private" || node.accessibility === "protected" || node.key.type === "PrivateIdentifier") return
|
|
89
|
+
const verb = verbOf(node.key.name)
|
|
90
|
+
if (verb !== "recompute" && verb !== "get") context.report({ node: node.key, messageId: "vocabulary", data: { name: node.key.name } })
|
|
91
|
+
},
|
|
92
|
+
ExportNamedDeclaration(node) {
|
|
93
|
+
if (!inProjection || base !== "index.ts" || !node.source) return
|
|
94
|
+
if (String(node.source.value).endsWith(".projection-entity")) context.report({ node, messageId: "entityExported", data: { source: node.source.value } })
|
|
95
|
+
},
|
|
96
|
+
CallExpression(node) {
|
|
97
|
+
if (trigger !== "api" || node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.property.type !== "Identifier") return
|
|
98
|
+
if (verbOf(node.callee.property.name) !== "recompute") return
|
|
99
|
+
const { checker, toTs } = typed(context)
|
|
100
|
+
const tsNode = toTs(node)
|
|
101
|
+
const declaration = tsNode ? checker.getResolvedSignature(tsNode)?.declaration : undefined
|
|
102
|
+
if (declaration && hfs.slotOf(declaration.getSourceFile().fileName) === PROJECTION_SLOT) context.report({ node, messageId: "apiRecompute", data: { call: context.sourceCode.getText(node.callee) } })
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const rules = {
|
|
109
|
+
"projection-write-owner": projectionWriteOwner,
|
|
110
|
+
"projection-shape": projectionShape,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The level these laws ask for: `error`, switched off nowhere. */
|
|
114
|
+
export const recommended = {
|
|
115
|
+
"starci-be/projection-write-owner": "error",
|
|
116
|
+
"starci-be/projection-shape": "error",
|
|
117
|
+
}
|
package/query-safety.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - `query-needs-limit` (R69 `BE_QUERY_UNBOUNDED`) - `find`, `findBy` and `findAndCount` on an `EntityManager` carry `take`.
|
|
16
16
|
* - `no-query-in-loop` (R77 `BE_QUERY_IN_LOOP`) - an `EntityManager` read does not run once per element of a loop.
|
|
17
17
|
*
|
|
18
|
-
* A migration is hand-written DDL run once by
|
|
18
|
+
* A migration is hand-written DDL run once by the cli migrate command; its `queryRunner.query(...)` takes plain text.
|
|
19
19
|
*/
|
|
20
20
|
import { keyName } from "./lib/ast.mjs"
|
|
21
21
|
import { hfsOf } from "./lib/hfs.mjs"
|
package/realtime.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the realtime kind (R184 `BE_REALTIME_SHAPE`, R185 `BE_REALTIME_WRITES`, R186 `BE_REALTIME_TOPIC_SCOPE`).
|
|
3
|
+
*
|
|
4
|
+
* A realtime door (`src/features/realtime/<channel>/transport/websocket/<channel>.gateway.ts` for a socket, `transport/graphql/<x>.subscription.ts` for a GraphQL
|
|
5
|
+
* subscription, slot `be.feature.realtime`) READS and PUSHES. It never writes: the push is fed by a reactor that calls the `RealtimeHub`
|
|
6
|
+
* of `platform/realtime`, and the door only subscribes a client to a topic of that hub.
|
|
7
|
+
*
|
|
8
|
+
* - `realtime-shape` (R184): a `.gateway.ts` holds one `@WebSocketGateway` class whose handlers are `@SubscribeMessage`; a
|
|
9
|
+
* `.subscription.ts` holds one `@Resolver` class whose operations are `@Subscription`. Each is the door its file role names, and
|
|
10
|
+
* neither carries a query, a mutation or a REST route.
|
|
11
|
+
* - `realtime-read-only` (R185): the only value injected into a realtime door is the `RealtimeHub` of `platform/realtime`. No
|
|
12
|
+
* `EntityManager`, bus, queue, event bus or domain service reaches it.
|
|
13
|
+
* - `realtime-topic-scope` (R186): the topic passed to `hub.subscribe` is built from the principal parameter of the handler
|
|
14
|
+
* (`@CurrentPrincipal()` of `domain/identity`), so a client can only listen to what the principal owns.
|
|
15
|
+
*
|
|
16
|
+
* What a receiver is comes from its TYPE, what a decorator is from the import that binds it, and where a parameter comes
|
|
17
|
+
* from from the declaration its name resolves to. No rule here tests a path pattern or a variable name.
|
|
18
|
+
*/
|
|
19
|
+
import { walk } from "./lib/ast.mjs"
|
|
20
|
+
import { aliasTarget, callsOf, hasClassDecorator, injectedMembers, methodsDecoratedBy, parametersOf } from "./lib/doors.mjs"
|
|
21
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
22
|
+
import { decoratorCallee } from "./lib/import-source.mjs"
|
|
23
|
+
import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
|
|
24
|
+
|
|
25
|
+
/** The slot of realtime doors. */
|
|
26
|
+
const DOOR_SLOTS = new Set(["be.feature.realtime.graphql", "be.feature.realtime.websocket"])
|
|
27
|
+
|
|
28
|
+
/** The `RealtimeHub` port of `platform/realtime`. */
|
|
29
|
+
const isHubType = (context, node) => isOwnedType(context, node, { name: "RealtimeHub", capability: "realtime", tier: "platform" })
|
|
30
|
+
|
|
31
|
+
/** The decorators that make a class a door of one kind of realtime file, by file role. */
|
|
32
|
+
const ROLES = {
|
|
33
|
+
gateway: { classDecorator: ["@nestjs/websockets", "WebSocketGateway"], operation: ["@nestjs/websockets", "SubscribeMessage"], foreign: [["@nestjs/graphql", ["Query", "Mutation", "ResolveField", "Subscription"]], ["@nestjs/common", ["Controller"]]] },
|
|
34
|
+
subscription: { classDecorator: ["@nestjs/graphql", "Resolver"], operation: ["@nestjs/graphql", "Subscription"], foreign: [["@nestjs/graphql", ["Query", "Mutation", "ResolveField"]], ["@nestjs/websockets", ["WebSocketGateway", "SubscribeMessage"]], ["@nestjs/common", ["Controller"]]] },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The role (`gateway` or `subscription`) a realtime file name carries, or null. */
|
|
38
|
+
const roleOf = (filename) => /\.(gateway|subscription)\.ts$/.exec(baseName(filename))?.[1] ?? null
|
|
39
|
+
|
|
40
|
+
/** The door classes of a file: every class with a class-level door decorator of the role. */
|
|
41
|
+
const doorClasses = (context, program, role) => {
|
|
42
|
+
const found = []
|
|
43
|
+
walk(program, (node) => {
|
|
44
|
+
if (node.type === "ClassDeclaration" && hasClassDecorator(context, node, ...ROLES[role].classDecorator)) found.push(node)
|
|
45
|
+
})
|
|
46
|
+
return found
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A realtime file is the one door its role names, and nothing else. */
|
|
50
|
+
export const realtimeShape = {
|
|
51
|
+
meta: {
|
|
52
|
+
type: "problem",
|
|
53
|
+
docs: { description: "A realtime file is one door: a `.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers, a `.subscription.ts` holds one `@Resolver` class with `@Subscription` operations, and neither carries a route, query or mutation." },
|
|
54
|
+
schema: [],
|
|
55
|
+
messages: {
|
|
56
|
+
door: "A `<channel>.{{role}}.ts` file declares exactly one `{{decorator}}` class (this one declares {{count}}). One file is one door.",
|
|
57
|
+
operations: "This {{role}} door has no `@{{operation}}` handler. A realtime door exists to serve a client one: it subscribes the client to a topic of the `RealtimeHub`.",
|
|
58
|
+
foreign: "`@{{name}}` has no place in a realtime {{role}} door. A realtime door only subscribes and pushes; a query, a mutation, a field resolver or a REST route belongs in an api feature, and the other realtime protocol in its own file.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
create(context) {
|
|
62
|
+
const hfs = hfsOf(context)
|
|
63
|
+
const role = roleOf(context.filename)
|
|
64
|
+
if (!DOOR_SLOTS.has(hfs.slotOf(context.filename) ?? "") || role === null) return {}
|
|
65
|
+
const spec = ROLES[role]
|
|
66
|
+
return {
|
|
67
|
+
"Program:exit"(program) {
|
|
68
|
+
const doors = doorClasses(context, program, role)
|
|
69
|
+
if (doors.length !== 1) {
|
|
70
|
+
context.report({ node: program, messageId: "door", data: { role, decorator: `@${spec.classDecorator[1]}`, count: String(doors.length) } })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (methodsDecoratedBy(context, doors[0], ...spec.operation).length === 0) context.report({ node: doors[0], messageId: "operations", data: { role, operation: spec.operation[1] } })
|
|
74
|
+
walk(program, (node) => {
|
|
75
|
+
if (node.type !== "Decorator") return
|
|
76
|
+
for (const [source, names] of spec.foreign) {
|
|
77
|
+
const hit = names.find((name) => hasDecorator(context, node, source, name))
|
|
78
|
+
if (hit !== undefined) context.report({ node, messageId: "foreign", data: { name: hit, role } })
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when one decorator node is `name` exported by `source`. */
|
|
87
|
+
const hasDecorator = (context, decorator, source, name) => hasClassDecorator(context, { decorators: [decorator] }, source, name)
|
|
88
|
+
|
|
89
|
+
/** A realtime door injects the hub and nothing else: it reads and pushes, it never writes. */
|
|
90
|
+
export const realtimeReadOnly = {
|
|
91
|
+
meta: {
|
|
92
|
+
type: "problem",
|
|
93
|
+
docs: { description: "The only value a realtime door injects is the `RealtimeHub` of `platform/realtime`: no `EntityManager`, bus, queue, event bus or domain service." },
|
|
94
|
+
schema: [],
|
|
95
|
+
messages: {
|
|
96
|
+
injects: "`{{what}}` is injected into a realtime door. A realtime door reads and pushes only: it injects the `RealtimeHub` of `platform/realtime` and nothing that can write (an `EntityManager`, a `CommandBus`, an event bus, a queue, a domain service). The push is fed by a reactor that calls the hub; a write belongs in an api feature.",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
create(context) {
|
|
100
|
+
const hfs = hfsOf(context)
|
|
101
|
+
if (!DOOR_SLOTS.has(hfs.slotOf(context.filename) ?? "") || roleOf(context.filename) === null) return {}
|
|
102
|
+
return {
|
|
103
|
+
ClassDeclaration(node) {
|
|
104
|
+
for (const { node: member, annotation } of injectedMembers(context, node)) {
|
|
105
|
+
if (!annotation || !isHubType(context, annotation)) context.report({ node: member, messageId: "injects", data: { what: context.sourceCode.getText(member) } })
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** True when the parameter carries `@CurrentPrincipal()` declared by `domain/identity`, judged by the declaration the decorator resolves to. */
|
|
113
|
+
const isPrincipalParameter = (context, hfs, param) =>
|
|
114
|
+
(param.decorators ?? []).some((decorator) => {
|
|
115
|
+
const callee = decoratorCallee(decorator)
|
|
116
|
+
if (callee?.type !== "Identifier") return false
|
|
117
|
+
const symbol = aliasTarget(context, callee)
|
|
118
|
+
return symbol?.getName() === "CurrentPrincipal" && (symbol.getDeclarations() ?? []).some((declaration) => ownerNameOf(hfs, declaration.getSourceFile().fileName) === "identity" && hfs.tierOf(declaration.getSourceFile().fileName) === "domain")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/** True when the identifier resolves to one of the `names` (parameters of the handler). */
|
|
122
|
+
const resolvesTo = (context, identifier, parameters) => {
|
|
123
|
+
for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
124
|
+
const variable = scope.set.get(identifier.name)
|
|
125
|
+
if (!variable) continue
|
|
126
|
+
return variable.defs.some((definition) => definition.type === "Parameter" && parameters.has(definition.name))
|
|
127
|
+
}
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The topic a client subscribes to is built from the principal the handler receives. */
|
|
132
|
+
export const realtimeTopicScope = {
|
|
133
|
+
meta: {
|
|
134
|
+
type: "problem",
|
|
135
|
+
docs: { description: "The topic passed to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter." },
|
|
136
|
+
schema: [],
|
|
137
|
+
messages: {
|
|
138
|
+
scope: "The topic of this `subscribe` is not built from the principal. A realtime handler takes `@CurrentPrincipal() principal` and subscribes to a topic made from `principal.id` (plus any verified id), so one client cannot listen to another's channel. A topic taken only from client input is a leak.",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
create(context) {
|
|
142
|
+
const hfs = hfsOf(context)
|
|
143
|
+
const role = roleOf(context.filename)
|
|
144
|
+
if (!DOOR_SLOTS.has(hfs.slotOf(context.filename) ?? "") || role === null) return {}
|
|
145
|
+
const [source, name] = ROLES[role].operation
|
|
146
|
+
return {
|
|
147
|
+
ClassDeclaration(node) {
|
|
148
|
+
for (const method of methodsDecoratedBy(context, node, source, name)) {
|
|
149
|
+
const principals = new Set()
|
|
150
|
+
for (const param of parametersOf(method)) {
|
|
151
|
+
if (!isPrincipalParameter(context, hfs, param)) continue
|
|
152
|
+
const target = param.type === "AssignmentPattern" ? param.left : param
|
|
153
|
+
if (target.type === "Identifier") principals.add(target)
|
|
154
|
+
}
|
|
155
|
+
for (const { call, receiver, method: called } of callsOf(method)) {
|
|
156
|
+
if (called !== "subscribe" || receiver === null || !isHubType(context, receiver)) continue
|
|
157
|
+
let scoped = false
|
|
158
|
+
walk(call.arguments[0], (child) => {
|
|
159
|
+
if (child.type === "Identifier" && resolvesTo(context, child, principals)) scoped = true
|
|
160
|
+
})
|
|
161
|
+
if (!scoped) context.report({ node: call, messageId: "scope" })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The rules this law contributes to the plugin. */
|
|
170
|
+
export const rules = {
|
|
171
|
+
"realtime-shape": realtimeShape,
|
|
172
|
+
"realtime-read-only": realtimeReadOnly,
|
|
173
|
+
"realtime-topic-scope": realtimeTopicScope,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every rule of this law at `error`. */
|
|
177
|
+
export const recommended = {
|
|
178
|
+
"starci-be/realtime-shape": "error",
|
|
179
|
+
"starci-be/realtime-read-only": "error",
|
|
180
|
+
"starci-be/realtime-topic-scope": "error",
|
|
181
|
+
}
|