@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.
Files changed (151) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/LICENSE +21 -0
  3. package/README.md +12 -6
  4. package/ambient-id.mjs +90 -0
  5. package/cli.mjs +181 -0
  6. package/comments.mjs +24 -36
  7. package/config-owner.mjs +20 -4
  8. package/connections.mjs +12 -12
  9. package/cookies.mjs +106 -0
  10. package/cqrs.mjs +22 -26
  11. package/data-access.mjs +9 -28
  12. package/default-deny.mjs +2 -1
  13. package/e2e-flow.mjs +4 -4
  14. package/event-bus.mjs +237 -0
  15. package/feature-thin.mjs +237 -0
  16. package/idempotency.mjs +57 -6
  17. package/index.mjs +22 -1
  18. package/infra-owner.mjs +1 -1
  19. package/injection.mjs +67 -22
  20. package/input-bounds.mjs +2 -1
  21. package/jobs.mjs +214 -0
  22. package/lib/ast.mjs +7 -0
  23. package/lib/config.mjs +1 -1
  24. package/lib/db-writes.mjs +1 -1
  25. package/lib/doors.mjs +81 -0
  26. package/lib/edition.mjs +45 -0
  27. package/lib/entity-writes.mjs +35 -0
  28. package/lib/path.mjs +0 -6
  29. package/lib/persistence.mjs +11 -18
  30. package/lib/ports.mjs +5 -2
  31. package/lib/specifier.mjs +27 -0
  32. package/lib/transactions.mjs +37 -0
  33. package/lib/transport-slots.mjs +8 -3
  34. package/lib/unit-spec.mjs +63 -2
  35. package/module-layering.mjs +3 -24
  36. package/module-shape.mjs +2 -2
  37. package/naming.mjs +2 -2
  38. package/package.json +5 -2
  39. package/project-graph.mjs +5 -2
  40. package/projections.mjs +117 -0
  41. package/query-safety.mjs +1 -1
  42. package/realtime.mjs +181 -0
  43. package/runtime/engine/config.mjs +486 -0
  44. package/runtime/engine/invalid-config.mjs +15 -0
  45. package/runtime/engine/orca-config.mjs +27 -0
  46. package/runtime/engine/secrets.mjs +130 -0
  47. package/runtime/knowledge/hfs/canon-pins.yaml +162 -31
  48. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  49. package/runtime/knowledge/hfs/slots.yaml +912 -120
  50. package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
  51. package/runtime/knowledge/sonar-gate.yaml +52 -18
  52. package/runtime/modules/models/registry.yaml +553 -0
  53. package/runtime/modules/models/runtimes.yaml +695 -0
  54. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  55. package/runtime/scripts/api/git/config-get.mjs +10 -0
  56. package/runtime/scripts/api/git/lib.mjs +18 -26
  57. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  58. package/runtime/scripts/api/git/remote.mjs +5 -0
  59. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  60. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  61. package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
  62. package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
  63. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
  64. package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
  65. package/runtime/scripts/hfs/architecture/config.mjs +9 -6
  66. package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
  67. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  68. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  69. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  70. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  71. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  72. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
  73. package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
  74. package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
  75. package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
  76. package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
  77. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -2
  78. package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
  79. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  80. package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
  81. package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
  82. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
  83. package/runtime/scripts/hfs/architecture/index.mjs +17 -4
  84. package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
  85. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  86. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
  88. package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
  89. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
  90. package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
  91. package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
  92. package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
  93. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  94. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  95. package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
  96. package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
  97. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  98. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  99. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  100. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  101. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  102. package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
  103. package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
  104. package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
  105. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
  106. package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
  107. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  108. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  109. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  110. package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
  111. package/runtime/scripts/hfs/param-names.mjs +18 -0
  112. package/runtime/scripts/hfs/path-findings.mjs +1 -1
  113. package/runtime/scripts/hfs/project-graph.mjs +3 -3
  114. package/runtime/scripts/hfs/repo-identity.mjs +9 -6
  115. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  116. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
  117. package/runtime/scripts/hfs/rules/read.mjs +1 -1
  118. package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
  119. package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
  120. package/runtime/scripts/hfs/slots.mjs +78 -108
  121. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  122. package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
  123. package/runtime/scripts/hfs/view.mjs +3 -2
  124. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  125. package/runtime/scripts/lib/git.mjs +36 -0
  126. package/runtime/scripts/lib/i18n.mjs +86 -0
  127. package/runtime/scripts/lib/jsdoc.mjs +35 -0
  128. package/runtime/scripts/lib/json.mjs +13 -3
  129. package/runtime/scripts/lib/language.mjs +24 -5
  130. package/runtime/scripts/lib/list.mjs +20 -0
  131. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  132. package/runtime/scripts/lib/path-key.mjs +57 -2
  133. package/runtime/scripts/lib/read-text.mjs +34 -0
  134. package/runtime/scripts/lib/regex.mjs +4 -0
  135. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  136. package/runtime/scripts/lib/sops-envelope.mjs +112 -0
  137. package/runtime/scripts/lib/stack-services.mjs +7 -31
  138. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  139. package/runtime/scripts/lib/walk.mjs +4 -3
  140. package/schema-authority.mjs +14 -12
  141. package/service-deps.mjs +7 -13
  142. package/service-isolation.mjs +72 -0
  143. package/size-budget.mjs +1 -1
  144. package/test-world.mjs +5 -4
  145. package/testing.mjs +27 -24
  146. package/transport.mjs +12 -9
  147. package/unit-spec.mjs +1 -1
  148. package/user-copy.mjs +5 -4
  149. package/webhooks.mjs +173 -0
  150. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  151. package/runtime/scripts/lib/test-secrets.mjs +0 -116
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Which entities a database write call targets, found by TYPE: a write method called on a typeorm database receiver (`EntityManager`,
3
+ * `DataSource`, `QueryRunner`) or on a typeorm query builder, whose arguments (or, for a query builder, the arguments of any call
4
+ * of the same chain) are the entity class or an instance of it. No variable, class or table name is read.
5
+ */
6
+ import { isDatabaseReceiver } from "./db-writes.mjs"
7
+ import { typeOrigins } from "./types.mjs"
8
+
9
+ /** The methods of a manager or query builder that change rows. */
10
+ export const WRITE_METHODS = Object.freeze(["update", "save", "insert", "upsert", "delete", "remove", "increment", "decrement", "softDelete", "softRemove", "recover", "restore", "clear"])
11
+
12
+ /** True when the node's type is a query builder of `typeorm` (`SelectQueryBuilder`, `UpdateQueryBuilder` ...). */
13
+ const isQueryBuilder = (context, node) => typeOrigins(context, node).some((origin) => origin.module === "typeorm" && origin.name.endsWith("QueryBuilder"))
14
+
15
+ /** The declarations (class origins) of the entities a database write call targets, or `[]` when the node is not such a call. */
16
+ export const writtenEntityOrigins = (context, node) => {
17
+ const callee = node.callee
18
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || !WRITE_METHODS.includes(callee.property.name)) return []
19
+ if (!isDatabaseReceiver(context, callee.object) && !isQueryBuilder(context, callee.object)) return []
20
+ const candidates = [...node.arguments]
21
+ for (let link = callee.object; link; ) {
22
+ if (link.type === "CallExpression") {
23
+ candidates.push(...link.arguments)
24
+ link = link.callee.type === "MemberExpression" ? link.callee.object : null
25
+ } else if (link.type === "MemberExpression") link = link.object
26
+ else if (link.type === "TSNonNullExpression" || link.type === "AwaitExpression") link = link.expression ?? link.argument
27
+ else link = null
28
+ }
29
+ // The entity of a builder write may come later in the same chain (`.delete().from(Entity)`, `.insert().into(Entity)`).
30
+ for (let outer = node; outer.parent?.type === "MemberExpression" && outer.parent.object === outer && outer.parent.parent?.type === "CallExpression" && outer.parent.parent.callee === outer.parent; ) {
31
+ outer = outer.parent.parent
32
+ candidates.push(...outer.arguments)
33
+ }
34
+ return candidates.flatMap((candidate) => typeOrigins(context, candidate)).filter((origin) => origin.module === null)
35
+ }
package/lib/path.mjs CHANGED
@@ -4,12 +4,6 @@ export const normalizePath = (filename) => String(filename || "").replace(/\\/g,
4
4
  /** A spec file: the unit lane, where a fixture may build a deliberately wrong value. */
5
5
  export const isSpecFile = (filename) => /\.(?:spec|test)\.[cm]?[jt]sx?$|-spec\.[cm]?[jt]sx?$/.test(normalizePath(filename))
6
6
 
7
- /** Any file of the test lanes: a spec, or anything under `src/tests/`. */
8
- export const isTestLane = (filename) => {
9
- const file = normalizePath(filename)
10
- return isSpecFile(file) || file.includes("/src/tests/")
11
- }
12
-
13
7
  /** A migration: the one place schema-changing SQL is allowed to be written. */
14
8
  export const isMigrationFile = (filename) => /\/persistence\/migrations\/[^/]+\.[cm]?[jt]s$/.test(normalizePath(filename))
15
9
 
@@ -6,6 +6,7 @@
6
6
  * `typeorm`'s; a file is the database capability's because the HFS slot manifest says so.
7
7
  */
8
8
  import { normalizePath } from "./path.mjs"
9
+ import { ownerNameOf } from "./ports.mjs"
9
10
  import { typeOrigins } from "./types.mjs"
10
11
 
11
12
  /** The `typeorm` types a class must never receive except through the platform database capability. */
@@ -65,24 +66,14 @@ export const inDatabaseCapability = (hfs, file) => {
65
66
  }
66
67
 
67
68
  /**
68
- * Whether a file belongs to the migrate app.
69
+ * Whether a file is the command line of the back end: the cli app (slot `be.app.cli`) or the cli feature root (slot `be.cli`,
70
+ * `src/features/cli/`), where every one-off action runs, the migrate and seed commands among them.
69
71
  *
70
72
  * @param {object} hfs - The HFS view of the repository.
71
73
  * @param {string} file - A file path.
72
- * @returns {boolean} True for `be.app.migrate` files.
74
+ * @returns {boolean} True for the files of the cli app and the cli feature root.
73
75
  */
74
- export const inMigrateApp = (hfs, file) => hfs.slotOf(file) === "be.app.migrate"
75
-
76
- /**
77
- * Whether a file is the test world: slot `be.tests.world` (`src/tests/world/**`), the one test location that owns
78
- * the shared infrastructure (containers, apps/migrate bootstrap, teardown) and so may build and hold a `DataSource`.
79
- * Specs (unit and e2e) take `world.db.<connection>` instead (owner ruling on R83/R47, 2026-09-30).
80
- *
81
- * @param {object} hfs - The HFS view of the repository.
82
- * @param {string} file - A file path.
83
- * @returns {boolean} True for `be.tests.world` files.
84
- */
85
- export const inTestBootstrap = (hfs, file) => hfs.slotOf(file) === "be.tests.world"
76
+ export const inCli = (hfs, file) => hfs.slotOf(file) === "be.app.cli" || hfs.slotOf(file) === "be.cli"
86
77
 
87
78
  /**
88
79
  * Whether a file is a migration: a `be.persistence` file below its `migrations/` folder.
@@ -113,11 +104,11 @@ export const connectionFileOf = (hfs, file, role) => {
113
104
  return connectionsOf(hfs).find((connection) => connection.name === name) ?? null
114
105
  }
115
106
 
116
- /** The platform capabilities that own a `persistence/` and so may hold an `EntityManager`. */
117
- const PERSISTENCE_CAPABILITIES = ["database", "inbox", "outbox", "lease"]
107
+ /** The platform capabilities that own a `persistence/` and so may hold an `EntityManager` (a pattern capability is named by its folder). */
108
+ const PERSISTENCE_CAPABILITIES = ["database", "inbox", "event-bus", "queue", "jobs", "saga", "outbox"]
118
109
 
119
110
  /**
120
- * Whether a class in this file may receive an `EntityManager`: an application handler, a domain service, a platform persistence capability.
111
+ * Whether a class in this file may receive an `EntityManager`: an application handler, a domain service, a projection, a typed queue producer (it takes the transaction of its caller), a platform persistence capability.
121
112
  *
122
113
  * @param {object} hfs - The HFS view of the repository.
123
114
  * @param {string} file - A file path.
@@ -128,7 +119,9 @@ export const mayHoldEntityManager = (hfs, file) => {
128
119
  const base = baseNameOf(file)
129
120
  if (found.slot === "be.feature.application") return base.endsWith(".handler.ts")
130
121
  if (found.slot === "be.domain") return base.endsWith(".service.ts")
131
- return found.slot === "be.platform" && PERSISTENCE_CAPABILITIES.includes(found.bindings?.capability)
122
+ if (found.slot === "be.projections") return base.endsWith(".projection.ts")
123
+ if (found.slot === "be.queues") return base.endsWith(".queue.ts")
124
+ return typeof found.slot === "string" && found.slot.startsWith("be.platform") && PERSISTENCE_CAPABILITIES.includes(ownerNameOf(hfs, file))
132
125
  }
133
126
 
134
127
  /**
package/lib/ports.mjs CHANGED
@@ -23,7 +23,10 @@ export const baseName = (file) => String(file).replace(/\\/g, "/").split("/").po
23
23
  */
24
24
  export const ownerNameOf = (hfs, file) => {
25
25
  const bindings = hfs.classify(file).bindings ?? {}
26
- return bindings.capability ?? bindings.feature ?? null
26
+ if (bindings.capability ?? bindings.feature) return bindings.capability ?? bindings.feature
27
+ // A pattern slot names its capability literally (`src/modules/platform/event-bus/`), so no variable binds it: the owner folder is its name.
28
+ const root = hfs.ownerOf(file)
29
+ return root === null || root === undefined ? null : root.split("/").pop()
27
30
  }
28
31
 
29
32
  /**
@@ -33,7 +36,7 @@ export const ownerNameOf = (hfs, file) => {
33
36
  * @param {string} file - A file path.
34
37
  * @returns {string | null} The slot-relative path.
35
38
  */
36
- export const pathInSlot = (hfs, file) => {
39
+ const pathInSlot = (hfs, file) => {
37
40
  const found = hfs.classify(file)
38
41
  if (!found.slot) return null
39
42
  const rel = hfs.relative(file)
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Where an import specifier points. A `compilerOptions.paths` alias resolves through the program's own options, so a
3
+ * rule asks the same question TypeScript asks instead of maintaining a second alias table.
4
+ */
5
+ import { posix } from "node:path"
6
+ import { typed } from "./types.mjs"
7
+
8
+ /**
9
+ * The absolute path an alias specifier maps to under the program's `compilerOptions.paths`, or null when no pattern matches.
10
+ *
11
+ * @param {object} context - The ESLint rule context.
12
+ * @param {string} specifier - An import specifier.
13
+ * @returns {{ target: string, rest: string } | null} The absolute target without extension and the part the wildcard matched.
14
+ */
15
+ export const resolveAlias = (context, specifier) => {
16
+ const options = typed(context).program.getCompilerOptions()
17
+ const base = options.baseUrl ?? options.pathsBasePath
18
+ if (!options.paths || typeof base !== "string") return null
19
+ for (const [pattern, targets] of Object.entries(options.paths)) {
20
+ if (!pattern.endsWith("/*") || !targets[0]) continue
21
+ const prefix = pattern.slice(0, -1)
22
+ if (!specifier.startsWith(prefix)) continue
23
+ const rest = specifier.slice(prefix.length)
24
+ return { target: posix.normalize(`${base.replace(/\\/g, "/")}/${targets[0].replace("*", rest)}`), rest }
25
+ }
26
+ return null
27
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Questions a rule asks about database transactions: the callback of a `.transaction(...)` call and the variable an identifier
3
+ * resolves to. The receiver of `.transaction` is judged by its TYPE (typeorm's `EntityManager` or `DataSource`), never by its name.
4
+ */
5
+ import { infraTypeOf } from "./persistence.mjs"
6
+
7
+ /**
8
+ * The callback of a `.transaction(...)` call on a manager or data source, or null.
9
+ *
10
+ * @param {object} context - The ESLint rule context.
11
+ * @param {object} node - A CallExpression.
12
+ * @returns {object | null} The arrow or function expression that receives the transactional manager.
13
+ */
14
+ export const transactionCallback = (context, node) => {
15
+ const callee = node.callee
16
+ if (callee.type !== "MemberExpression" || callee.computed) return null
17
+ if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return null
18
+ const receiver = infraTypeOf(context, callee.object)
19
+ if (receiver !== "EntityManager" && receiver !== "DataSource") return null
20
+ const callback = node.arguments[node.arguments.length - 1]
21
+ return callback && (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") ? callback : null
22
+ }
23
+
24
+ /**
25
+ * The variable an identifier resolves to, looking outward from the identifier's own scope.
26
+ *
27
+ * @param {object} sourceCode - The ESLint source code.
28
+ * @param {object} node - An Identifier.
29
+ * @returns {object | null} The scope variable.
30
+ */
31
+ export const resolveVariable = (sourceCode, node) => {
32
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
33
+ const variable = scope.set.get(node.name)
34
+ if (variable) return variable
35
+ }
36
+ return null
37
+ }
@@ -2,13 +2,18 @@
2
2
  * The transport slots of the HFS manifest, in one place.
3
3
  *
4
4
  * A slot id is manifest vocabulary, not a path: every rule that judges "a transport file" asks this instead of spelling
5
- * the ids itself. The command-line slot carries its feature prefix in the manifest (`be.feature.transport.cli`).
5
+ * the ids itself. A cli command is no transport: it is an action runner of the cli feature root (slot `be.cli`). The
6
+ * webhook and realtime kinds hold their doors in slots of their own (`be.feature.webhooks.http`, `be.feature.realtime.graphql`, `be.feature.realtime.websocket`): a provider webhook and a
7
+ * push channel are doors like any other and answer to the door laws (default deny, bounded input, no envelope).
6
8
  */
7
9
 
10
+ /** The slots of the feature kinds that are doors but are not an api's `transport/<protocol>/` folders. */
11
+ export const KIND_DOOR_SLOTS = Object.freeze(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket"])
12
+
8
13
  /**
9
- * True for a slot that holds transport code (`be.transport.<protocol>` and the command-line slot).
14
+ * True for a slot that holds transport code (`be.transport.<protocol>` and the door slots of the webhook and realtime kinds).
10
15
  *
11
16
  * @param {string | null} slot - A slot id from `hfs.slotOf`.
12
17
  * @returns {boolean} Whether files of that slot are transport files.
13
18
  */
14
- export const isTransportSlot = (slot) => typeof slot === "string" && (slot.startsWith("be.transport.") || slot === "be.feature.transport.cli")
19
+ export const isTransportSlot = (slot) => typeof slot === "string" && (slot.startsWith("be.transport.") || KIND_DOOR_SLOTS.includes(slot))
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>.service.spec.ts` beside `<name>.service.ts` is a
6
- * legitimate unit spec (R47); the quality rules of R48 judge exactly those.
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
+ }
@@ -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 { typed } from "./lib/types.mjs"
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 = (hfs, filename) => hfs.slotOf(filename) === "be.tests.world"
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).slotOf(context.filename) === "be.tests.world" && /\/global-(?:setup|teardown)\.ts$/.test(normalizePath(context.filename))
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,15 +1,16 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "3.0.8",
3
+ "version": "3.0.10",
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
- "license": "UNLICENSED",
6
+ "license": "MIT",
7
7
  "private": false,
8
8
  "exports": {
9
9
  ".": "./index.mjs"
10
10
  },
11
11
  "main": "./index.mjs",
12
12
  "files": [
13
+ "LICENSE",
13
14
  "*.mjs",
14
15
  "lib/*.mjs",
15
16
  "runtime/**",
@@ -28,6 +29,8 @@
28
29
  "access": "public"
29
30
  },
30
31
  "scripts": {
32
+ "prepack": "node ../../hfs/prep-runtime.mjs",
33
+ "pretest": "node ../../hfs/prep-runtime.mjs",
31
34
  "test": "node --test \"*.spec.mjs\""
32
35
  },
33
36
  "keywords": [
package/project-graph.mjs CHANGED
@@ -4,7 +4,7 @@
4
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/hfs/architecture/surface.mjs); `hfs check` keeps the
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
10
  import { LINT_ENFORCERS } from "./runtime/scripts/hfs/architecture/surface.mjs"
@@ -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((e) => [e.id, projectRule({ enforcer: e, runtimeRoot: RUNTIME, hfsOf })]))
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"]))
@@ -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 `apps/migrate`; its `queryRunner.query(...)` takes plain text.
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"