@starci/eslint-canon-be 1.8.0 → 2.0.0

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 (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
@@ -1,24 +1,59 @@
1
1
  /**
2
- * The rules that hold HFS schema authority and the persistence boundary (catalog R34, R36, R37).
2
+ * The rules that hold HFS schema authority and the persistence boundary (catalog R34, R37, R74).
3
3
  *
4
4
  * The schema changes by migration and by nothing else, and the SQL that touches it lives in one place:
5
5
  *
6
- * - `no-runtime-schema` (R34 `BE_SCHEMA_AUTHORITY`) refuses everything that lets the running process decide
7
- * the schema: a `synchronize` that is not the literal `false`, a `dataSource.synchronize()` call, a
8
- * `migrationsRun` that is not the literal `false`, a `CREATE|ALTER|DROP TABLE` string outside
9
- * `persistence/migrations/`, and an entity or migration glob. Only `apps/migrate` runs migrations.
10
- * - `sql-text-only` (R36) keeps raw SQL text inside a `<name>.sql.ts` (the owning capability's `persistence/`,
11
- * or a test builder's `src/tests/fixtures/builders/<area>.sql.ts`): `.query(...)` elsewhere takes an imported
12
- * text constant, never a literal or template; `createQueryBuilder` is not used.
13
- * - `no-repository-file` (R36) refuses a `*.repository.ts` file: repositories are banned (owner rule); SQL text lives
14
- * in `<name>.sql.ts` and the capability's service runs it on the shared EntityManager.
15
- * - `no-entity-in-contract` (R37 `BE_ENTITY_IN_CONTRACT`) keeps ORM entities out of the types that cross a
16
- * boundary: `*.contracts.ts`, DTOs, resolver and controller signatures. A contract returns a projection.
6
+ * - `no-runtime-schema` (R34 `BE_SCHEMA_AUTHORITY`) refuses everything that lets the running process decide the
7
+ * schema: a DataSource / TypeORM options object (an object literal whose contextual TYPE is a `typeorm` or
8
+ * `@nestjs/typeorm` options type) with no literal `synchronize: false`; a `synchronize` that is anything but
9
+ * the literal `false`; `new DataSource(<not an object literal>)` whose options cannot be read; a
10
+ * `dataSource.synchronize()` or `.dropDatabase()` call; the `migrationsRun` option in any form and a `runMigrations()` /
11
+ * `undoLastMigration()` call outside `apps/migrate` and the test world; `dropSchema` set to anything but `false`; a schema
12
+ * builder (`createSchemaBuilder()`, `.build()` of typeorm's `SchemaBuilder`); a `synchronize` key in the options of typeorm's
13
+ * `@Entity` (a per-entity switch is a second authority); a lifecycle hook (`onModuleInit`, `onApplicationBootstrap`) that
14
+ * writes rows through an `EntityManager` or `DataSource` (seeding runs in `apps/migrate`); a DDL statement in any string
15
+ * outside the migrations of `persistence/`; and an entity or migration glob. Only `apps/migrate` runs migrations and seeds.
16
+ * - `no-entity-in-contract` (R37 `BE_ENTITY_IN_CONTRACT`) keeps ORM entities out of the types that cross a boundary:
17
+ * any type whose declaration carries `@Entity` (from `typeorm`), reached through the type arguments and properties of
18
+ * a transport signature, the `execute` of a handler, or a `*.contracts.ts`, `*.command.ts` or `*.query.ts` file; and
19
+ * entity files that import `@nestjs/graphql` or `class-validator` (an entity never reaches a contract).
20
+ *
21
+ * Where a file lives is asked of the slot view (`be.persistence` and its `migrations/` folder, the transport slots),
22
+ * never of a path pattern.
17
23
  */
18
- import { keyName, staticText, walk } from "./lib/ast.mjs"
19
- import { isDeclarationFile, isMigrationFile, normalizePath } from "./lib/path.mjs"
24
+ import ts from "typescript"
25
+ import { keyName, walk } from "./lib/ast.mjs"
26
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
27
+ import { baseName, isMigrationFile, packageOfFile } from "./lib/ports.mjs"
28
+ import { isPackageType, typed } from "./lib/types.mjs"
29
+ import { isDeclarationFile } from "./lib/path.mjs"
30
+
31
+ /** Schema-changing statements: `CREATE|ALTER|DROP` of a structure, `TRUNCATE TABLE`, `ADD|DROP COLUMN`. */
32
+ const DDL = /\b(?:(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:UNIQUE\s+)?(?:TEMP(?:ORARY)?\s+)?(?:TABLE|INDEX|TYPE|SCHEMA|VIEW|MATERIALIZED\s+VIEW|SEQUENCE|EXTENSION|DATABASE|TRIGGER|FUNCTION|PROCEDURE|DOMAIN|POLICY|PUBLICATION|COLLATION|ROLE)|TRUNCATE\s+TABLE|(?:ADD|DROP)\s+(?:COLUMN|CONSTRAINT))\b/i
33
+
34
+ /** The packages whose option types describe a DataSource. */
35
+ const OPTION_PACKAGES = new Set(["typeorm", "@nestjs/typeorm"])
36
+
37
+ /** The DataSource operations that change the schema at runtime. */
38
+ const SCHEMA_OPERATIONS = new Set(["synchronize", "dropDatabase"])
39
+
40
+ /** The DataSource operations that run migrations: allowed only in `apps/migrate` and the test world that runs its bootstrap. */
41
+ const MIGRATION_RUNNERS = new Set(["runMigrations", "undoLastMigration"])
42
+
43
+ /** The typeorm types whose methods write rows. */
44
+ const WRITER_TYPES = ["EntityManager", "DataSource", "QueryRunner"]
45
+
46
+ /** The typeorm schema builder types. */
47
+ const SCHEMA_BUILDER_TYPES = ["SchemaBuilder", "RdbmsSchemaBuilder"]
48
+
49
+ /** The writes of an `EntityManager` that need no SQL text to be recognised. */
50
+ const WRITE_METHODS = new Set(["insert", "save", "upsert", "update", "delete", "remove", "softRemove", "softDelete", "recover", "increment", "decrement", "clear"])
51
+
52
+ /** A statement that writes rows. */
53
+ const WRITE_SQL = /\b(?:INSERT\s+INTO|UPDATE\s+\S+\s+SET|DELETE\s+FROM|MERGE\s+INTO)\b/i
20
54
 
21
- const DDL = /\b(?:CREATE|ALTER|DROP)\s+TABLE\b/i
55
+ /** The Nest lifecycle hooks that run at boot in every process that composes the provider. */
56
+ const BOOT_HOOKS = new Set(["onModuleInit", "onApplicationBootstrap"])
22
57
 
23
58
  const isFalse = (node) => node?.type === "Literal" && node.value === false
24
59
 
@@ -32,39 +67,143 @@ const stringsUnder = (node) => {
32
67
  return found
33
68
  }
34
69
 
70
+ /** The non-union, non-intersection constituents of a TypeScript type. */
71
+ const leavesOf = (type) => (type.isUnionOrIntersection() ? type.types.flatMap(leavesOf) : [type])
72
+
73
+ /** The `synchronize` members that typeorm declares on the contextual type of an object literal, as TypeScript declarations. */
74
+ const synchronizeMembers = (context, node) => {
75
+ const { checker, toTs } = typed(context)
76
+ const tsNode = toTs(node)
77
+ const expected = tsNode ? checker.getContextualType(tsNode) : undefined
78
+ if (!expected) return []
79
+ return leavesOf(expected)
80
+ .flatMap((part) => checker.getPropertyOfType(part, "synchronize")?.declarations ?? [])
81
+ .filter((declaration) => OPTION_PACKAGES.has(packageOfFile(declaration.getSourceFile().fileName)))
82
+ }
83
+
84
+ /** A `synchronize` declared as a method is the DataSource operation, not the options flag. */
85
+ const isMethod = (declaration) => ts.isMethodSignature(declaration) || ts.isMethodDeclaration(declaration)
86
+
87
+ /** A `synchronize` declared on typeorm's `EntityOptions` is the per-entity switch, not the connection's. */
88
+ const isEntityOption = (declaration) => ts.isInterfaceDeclaration(declaration.parent) && declaration.parent.name.text === "EntityOptions"
89
+
90
+ /** Whether an object literal is contextually a DataSource options object: its expected type has a `synchronize` option (a property, not the `synchronize()` method, not an entity's) declared by `typeorm`. */
91
+ const isOptionsObject = (context, node) => synchronizeMembers(context, node).some((declaration) => !isMethod(declaration) && !isEntityOption(declaration))
92
+
93
+ /** Whether an object literal is the options of typeorm's `@Entity(...)`. */
94
+ const isEntityOptions = (context, node) => synchronizeMembers(context, node).some(isEntityOption)
95
+
96
+ /** Whether an object literal is contextually a DataSource (or a partial of one) whose `synchronize` is the method: its keys are overrides of operations, not configuration. */
97
+ const isOperationsObject = (context, node) => {
98
+ const members = synchronizeMembers(context, node)
99
+ return members.length > 0 && members.every(isMethod)
100
+ }
101
+
102
+ /** Whether a file is `apps/migrate` (slot `be.app.migrate`) or the test world that runs its bootstrap: the only places that run migrations and seed. */
103
+ const isMigrationHost = (hfs, filename) => hfs.slotOf(filename) === "be.app.migrate" || inTestWorld(hfs, filename)
104
+
105
+ /** The SQL text of a call argument: a literal, a template, or the quasis of a tagged template. */
106
+ const sqlTextOf = (argument) => (argument ? stringsUnder(argument).join(" ") : "")
107
+
108
+ /**
109
+ * The first call under a lifecycle hook that writes rows: an `EntityManager`/`DataSource`/`QueryRunner` write method, a `.query(...)` whose
110
+ * text writes, or a call of another method of the same class (`this.seed()`) that does.
111
+ */
112
+ const bootWriteOf = (context, method, klass, visited) => {
113
+ let found = null
114
+ walk(method.value.body, (node) => {
115
+ if (found || node.type !== "CallExpression") return
116
+ const callee = node.callee
117
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
118
+ const name = callee.property.name
119
+ if (callee.object.type === "ThisExpression") {
120
+ const target = klass.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "method" && !member.computed && keyName(member.key) === name)
121
+ if (target && !visited.has(target)) {
122
+ visited.add(target)
123
+ found = bootWriteOf(context, target, klass, visited)
124
+ }
125
+ return
126
+ }
127
+ if (!WRITER_TYPES.some((type) => isPackageType(context, callee.object, type, "typeorm"))) return
128
+ if (WRITE_METHODS.has(name) || (name === "query" && WRITE_SQL.test(sqlTextOf(node.arguments[0])))) found = node
129
+ })
130
+ return found
131
+ }
132
+
133
+ /** Whether an object literal has a property called `name`. */
134
+ const hasKey = (objectNode, name) => objectNode.properties.some((property) => property.type === "Property" && !property.computed && keyName(property.key) === name)
135
+
35
136
  /** The schema is decided by migrations run by `apps/migrate`, never by the running process. */
36
137
  export const noRuntimeSchema = {
37
138
  meta: {
38
139
  type: "problem",
39
- docs: { description: "No synchronize, no migrationsRun in a service, no runtime DDL, no entity or migration glob." },
140
+ docs: { description: "Every DataSource options object states `synchronize: false`; no `migrationsRun`, no runtime DDL, no entity or migration glob." },
40
141
  schema: [],
41
142
  messages: {
42
143
  synchronize: "`synchronize` is set to something other than the literal `false`. The schema changes by migration only; a boot-time ORM diff decides it otherwise.",
43
- synchronizeCall: "`.synchronize()` builds the schema at runtime. Write a migration and let `apps/migrate` run it.",
44
- migrationsRun: "`migrationsRun` is not the literal `false`. Only `apps/migrate` runs migrations, once per connection, before api and worker start.",
45
- ddl: "A `CREATE|ALTER|DROP TABLE` string outside `persistence/migrations/`. Schema-changing SQL belongs in a migration.",
144
+ synchronizeMissing: "This DataSource options object does not state `synchronize: false`. Write the literal `false` in every options object, so what the connection may do to the schema is readable where it is built.",
145
+ optionsNotLiteral: "`new DataSource(...)` is given options that are not an object literal, so `synchronize: false` cannot be seen. Pass the options as an object literal that states `synchronize: false`.",
146
+ synchronizeCall: "`.{{name}}()` changes the schema at runtime. Write a migration and let `apps/migrate` run it.",
147
+ migrationsRun: "`migrationsRun` is banned. Only `apps/migrate` runs migrations, once per connection, before api and worker start.",
148
+ runMigrations: "`.{{name}}()` runs migrations outside `apps/migrate`. Only `apps/migrate` (and the test world, which runs its bootstrap) runs them, once per connection, before api and worker start.",
149
+ dropSchema: "`dropSchema` is set to something other than the literal `false`. A connection never drops the schema it opens; a migration changes it.",
150
+ schemaBuilder: "`{{name}}` builds or logs the schema from the entity metadata at runtime. The schema changes by migration only.",
151
+ entitySynchronize: "`synchronize` is set in the options of `@Entity`. A per-entity switch is a second schema authority next to the connection's literal `false`; delete the key.",
152
+ bootSeed: "`{{hook}}` writes rows (`{{call}}`), so every process that composes this provider seeds at boot. Seeding runs in `apps/migrate` only, once per connection.",
153
+ ddl: "A schema-changing statement (`CREATE|ALTER|DROP ...`) outside `persistence/migrations/`. Schema-changing SQL belongs in a migration.",
46
154
  glob: "`{{key}}` is found by glob. List the entities and migrations explicitly from each capability's `index.ts` so what runs is what was reviewed.",
47
155
  },
48
156
  },
49
157
  create(context) {
50
- const filename = normalizePath(context.filename || context.getFilename())
158
+ const filename = context.filename || context.getFilename()
51
159
  if (isDeclarationFile(filename)) return {}
52
- const migration = isMigrationFile(filename)
160
+ const hfs = hfsOf(context)
161
+ const migration = isMigrationFile(hfs, filename)
162
+ const migrationHost = isMigrationHost(hfs, filename)
53
163
  return {
164
+ ObjectExpression(node) {
165
+ if (isOptionsObject(context, node) && !hasKey(node, "synchronize")) context.report({ node, messageId: "synchronizeMissing" })
166
+ },
167
+ MethodDefinition(node) {
168
+ if (node.kind !== "method" || node.computed || !BOOT_HOOKS.has(keyName(node.key) ?? "") || migrationHost) return
169
+ const klass = node.parent?.parent
170
+ if (klass?.type !== "ClassDeclaration" && klass?.type !== "ClassExpression") return
171
+ const write = bootWriteOf(context, node, klass, new Set([node]))
172
+ if (write) context.report({ node: write, messageId: "bootSeed", data: { hook: keyName(node.key), call: context.sourceCode.getText(write.callee) } })
173
+ },
54
174
  Property(node) {
175
+ if (node.computed) return
55
176
  const key = keyName(node.key)
56
- if (key === "synchronize" && !isFalse(node.value)) {
177
+ if (key === "synchronize" && node.parent?.type === "ObjectExpression" && isEntityOptions(context, node.parent)) {
178
+ context.report({ node, messageId: "entitySynchronize" })
179
+ } else if (key === "dropSchema" && !isFalse(node.value)) {
180
+ context.report({ node, messageId: "dropSchema" })
181
+ } else if (key === "synchronize" && !isFalse(node.value)) {
182
+ if (node.parent?.type === "ObjectExpression" && isOperationsObject(context, node.parent)) return
57
183
  context.report({ node, messageId: "synchronize" })
58
- } else if (key === "migrationsRun" && !isFalse(node.value)) {
184
+ } else if (key === "migrationsRun") {
59
185
  context.report({ node, messageId: "migrationsRun" })
60
186
  } else if ((key === "entities" || key === "migrations") && stringsUnder(node.value).some((text) => text.includes("*"))) {
61
187
  context.report({ node, messageId: "glob", data: { key } })
62
188
  }
63
189
  },
190
+ NewExpression(node) {
191
+ if (!isPackageType(context, node, "DataSource", "typeorm")) return
192
+ const options = node.arguments[0]
193
+ if (options && options.type !== "ObjectExpression") context.report({ node: options, messageId: "optionsNotLiteral" })
194
+ },
64
195
  CallExpression(node) {
65
- if (node.callee.type !== "MemberExpression" || node.callee.computed) return
66
- if (node.callee.property.type === "Identifier" && node.callee.property.name === "synchronize") {
67
- context.report({ node, messageId: "synchronizeCall" })
196
+ const callee = node.callee
197
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
198
+ const name = callee.property.name
199
+ if (SCHEMA_OPERATIONS.has(name)) {
200
+ if (isPackageType(context, callee.object, "DataSource", "typeorm")) context.report({ node, messageId: "synchronizeCall", data: { name } })
201
+ } else if (MIGRATION_RUNNERS.has(name)) {
202
+ if (!migrationHost && isPackageType(context, callee.object, "DataSource", "typeorm")) context.report({ node, messageId: "runMigrations", data: { name } })
203
+ } else if (name === "createSchemaBuilder") {
204
+ if (isPackageType(context, callee.object, "DataSource", "typeorm")) context.report({ node, messageId: "schemaBuilder", data: { name: "createSchemaBuilder()" } })
205
+ } else if (name === "build" || name === "log") {
206
+ if (SCHEMA_BUILDER_TYPES.some((type) => isPackageType(context, callee.object, type, "typeorm"))) context.report({ node, messageId: "schemaBuilder", data: { name: `${name}()` } })
68
207
  }
69
208
  },
70
209
  Literal(node) {
@@ -77,87 +216,134 @@ export const noRuntimeSchema = {
77
216
  },
78
217
  }
79
218
 
80
- /** A file that holds SQL text: `<name>.sql.ts`. */
81
- const SQL_FILE = /\.sql\.[cm]?ts$/
82
- /** A repository file, banned. */
83
- const REPOSITORY_FILE = /\.repository\.[cm]?ts$/
219
+ // -- R37 -------------------------------------------------------------------------------------------
84
220
 
85
- /** Raw SQL text is written only in a `<name>.sql.ts`; everywhere else `.query(...)` takes an imported constant. */
86
- export const sqlTextOnly = {
87
- meta: {
88
- type: "problem",
89
- docs: { description: "Raw SQL text only in a `<name>.sql.ts`; `.query(...)` elsewhere takes an imported text constant; no `createQueryBuilder`." },
90
- schema: [],
91
- messages: {
92
- sql: "Raw SQL text outside a `<name>.sql.ts`. Write the statement as an exported constant in the owning capability's `persistence/<name>.sql.ts` (a test builder's in `src/tests/fixtures/builders/<area>.sql.ts`), import it, and pass values as numbered parameters.",
93
- builder: "`createQueryBuilder(...)` is banned. Use `manager.find(Entity, { where, take })`, or a SQL text constant from a `<name>.sql.ts` run with `manager.query(...)`.",
94
- },
95
- },
96
- create(context) {
97
- const filename = normalizePath(context.filename || context.getFilename())
98
- if (isDeclarationFile(filename) || isMigrationFile(filename)) return {}
99
- const sqlFile = SQL_FILE.test(filename)
100
- return {
101
- CallExpression(node) {
102
- const callee = node.callee
103
- if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
104
- const name = callee.property.name
105
- if (name === "createQueryBuilder") context.report({ node, messageId: "builder" })
106
- else if (!sqlFile && name === "query" && (staticText(node.arguments[0]) !== null || node.arguments[0]?.type === "TemplateLiteral")) {
107
- context.report({ node, messageId: "sql" })
108
- }
109
- },
221
+ /** Whether a decorator is `typeorm`'s `Entity`, read through the checker so an aliased import counts. */
222
+ const isEntityDecorator = (checker, decorator) => {
223
+ const callee = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
224
+ let symbol = checker.getSymbolAtLocation(callee)
225
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
226
+ return symbol?.name === "Entity" && (symbol.declarations ?? []).some((declaration) => packageOfFile(declaration.getSourceFile().fileName) === "typeorm")
227
+ }
228
+
229
+ /** Whether a TypeScript declaration is a class carrying `@Entity`. */
230
+ const carriesEntity = (checker, declaration) => ts.canHaveDecorators(declaration) && (ts.getDecorators(declaration) ?? []).some((decorator) => isEntityDecorator(checker, decorator))
231
+
232
+ /**
233
+ * The name of an ORM entity a type reaches, or null: the type itself, a union or intersection member, a type argument
234
+ * (`Array<OrderEntity>`, `Promise<...>`), or a property of a type the repository declares. Types declared by a package
235
+ * or the language are not walked into, only through their type arguments.
236
+ */
237
+ const entityReached = (checker, type, seen, depth) => {
238
+ if (!type || depth > 6 || seen.has(type)) return null
239
+ seen.add(type)
240
+ if (type.isUnionOrIntersection()) {
241
+ for (const part of type.types) {
242
+ const found = entityReached(checker, part, seen, depth + 1)
243
+ if (found) return found
110
244
  }
111
- },
245
+ return null
246
+ }
247
+ const declarations = type.getSymbol()?.getDeclarations() ?? []
248
+ const entity = declarations.find((declaration) => carriesEntity(checker, declaration))
249
+ if (entity) return entity.name?.text ?? "an entity"
250
+ for (const argument of checker.getTypeArguments(type)) {
251
+ const found = entityReached(checker, argument, seen, depth + 1)
252
+ if (found) return found
253
+ }
254
+ const own = declarations.length > 0 && declarations.every((declaration) => !declaration.getSourceFile().isDeclarationFile)
255
+ if (!own) return null
256
+ for (const property of checker.getPropertiesOfType(type)) {
257
+ const declaration = property.valueDeclaration ?? property.declarations?.[0]
258
+ if (!declaration) continue
259
+ const found = entityReached(checker, checker.getTypeOfSymbolAtLocation(property, declaration), seen, depth + 1)
260
+ if (found) return found
261
+ }
262
+ return null
112
263
  }
113
264
 
114
- /** Repositories are banned: a `*.repository.ts` file is a finding. */
115
- export const noRepositoryFile = {
116
- meta: {
117
- type: "problem",
118
- docs: { description: "No `*.repository.ts` file." },
119
- schema: [],
120
- messages: {
121
- file: "Repository files are banned. Move the SQL text into the capability's `persistence/<name>.sql.ts` and run it from the capability's `*.service.ts` on the shared EntityManager.",
122
- },
123
- },
124
- create(context) {
125
- const filename = normalizePath(context.filename || context.getFilename())
126
- if (isDeclarationFile(filename) || !REPOSITORY_FILE.test(filename)) return {}
127
- return { Program(node) { context.report({ node, loc: { line: 1, column: 0 }, messageId: "file" }) } }
128
- },
265
+ const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", "TSDeclareFunction", "TSEmptyBodyFunctionExpression", "TSMethodSignature"])
266
+ const PARAMETER_HOLDERS = new Set(["Identifier", "ObjectPattern", "ArrayPattern", "AssignmentPattern", "RestElement"])
267
+
268
+ /** Which files carry boundary types, and how much of each: the whole file, or only the `execute` of a handler. */
269
+ const boundaryOf = (hfs, filename) => {
270
+ const slot = hfs.slotOf(filename) ?? ""
271
+ const name = baseName(filename)
272
+ if (slot.startsWith("be.transport.") || slot === "be.feature.transport.cli") return "file"
273
+ if (/\.(?:contracts|command|query)\.ts$/.test(name)) return "file"
274
+ if (name.endsWith(".handler.ts")) return "execute"
275
+ return null
129
276
  }
130
277
 
131
- /** Files whose types cross a boundary: contracts, DTOs, resolvers, controllers. */
132
- const BOUNDARY_FILE = /\.contracts\.[cm]?ts$|\/dto\/|\.(?:request|response|input|type|args)\.[cm]?ts$|\.(?:resolver|controller)\.[cm]?ts$/
278
+ /** The MethodDefinition a node sits in the signature of, or null. */
279
+ const methodOf = (node) => {
280
+ let current = node
281
+ while (current && !FUNCTION_TYPES.has(current.type)) current = current.parent
282
+ return current?.parent?.type === "MethodDefinition" ? current.parent : null
283
+ }
133
284
 
134
- /** An ORM entity type never appears in a contract, DTO, resolver or controller signature. */
285
+ /** An ORM entity type never appears in a contract, DTO, command or query, transport signature or handler `execute`. */
135
286
  export const noEntityInContract = {
136
287
  meta: {
137
288
  type: "problem",
138
- docs: { description: "No `*Entity` type in a contract, DTO, resolver or controller signature." },
289
+ docs: { description: "No type declared with `@Entity` is reachable from a transport signature, a handler `execute`, a `*.contracts.ts` or a message." },
139
290
  schema: [],
140
291
  messages: {
141
- entity: "`{{name}}` is an ORM entity and leaks into a boundary type. Return a projection (a plain contract type) and pass a principal id and role instead of an entity.",
292
+ entity: "`{{name}}` is an ORM entity and is reachable from a boundary type. Return a projection (a plain contract type) and pass a principal id and role instead of an entity.",
293
+ entityImport: "An entity file imports `{{source}}`. An entity is a schema declaration only: GraphQL types and validators belong to the transport's own DTOs, never to the entity.",
142
294
  },
143
295
  },
144
296
  create(context) {
145
- const filename = normalizePath(context.filename || context.getFilename())
146
- if (isDeclarationFile(filename) || !BOUNDARY_FILE.test(filename) || filename.includes("/persistence/")) return {}
147
- return {
148
- TSTypeReference(node) {
149
- if (node.typeName.type === "Identifier" && /Entity$/.test(node.typeName.name)) {
150
- context.report({ node, messageId: "entity", data: { name: node.typeName.name } })
297
+ const filename = context.filename || context.getFilename()
298
+ if (isDeclarationFile(filename)) return {}
299
+ const hfs = hfsOf(context)
300
+ const listeners = {}
301
+ if (baseName(filename).endsWith(".entity.ts")) {
302
+ listeners.ImportDeclaration = (node) => {
303
+ const source = String(node.source.value)
304
+ if (["@nestjs/graphql", "class-validator"].some((banned) => source === banned || source.startsWith(`${banned}/`))) {
305
+ context.report({ node, messageId: "entityImport", data: { source } })
151
306
  }
152
- },
307
+ }
308
+ }
309
+ const boundary = boundaryOf(hfs, filename)
310
+ if (!boundary) return listeners
311
+ const { checker, toTs } = typed(context)
312
+ const reported = new Set()
313
+ const judge = (typeNode) => {
314
+ if (reported.has(typeNode)) return
315
+ const tsNode = toTs(typeNode)
316
+ if (!tsNode) return
317
+ const name = entityReached(checker, checker.getTypeFromTypeNode(tsNode), new Set(), 0)
318
+ if (name) {
319
+ reported.add(typeNode)
320
+ context.report({ node: typeNode, messageId: "entity", data: { name } })
321
+ }
153
322
  }
323
+ /** Whether a signature is one this file is judged on. */
324
+ const inScope = (node) => boundary === "file" || methodOf(node)?.key?.name === "execute"
325
+ listeners.TSTypeAnnotation = (node) => {
326
+ const holder = node.parent
327
+ const isParameter = PARAMETER_HOLDERS.has(holder.type) && (FUNCTION_TYPES.has(holder.parent?.type) || holder.parent?.type === "TSParameterProperty")
328
+ const isReturn = FUNCTION_TYPES.has(holder.type) && holder.returnType === node
329
+ const isMember = holder.type === "PropertyDefinition" || holder.type === "TSPropertySignature"
330
+ if ((isParameter || isReturn || (isMember && boundary === "file")) && inScope(holder)) judge(node.typeAnnotation)
331
+ }
332
+ listeners.TSTypeAliasDeclaration = (node) => {
333
+ if (boundary === "file") judge(node.typeAnnotation)
334
+ }
335
+ // `extends Command<OrderEntity>` and `implements ICQRSHandler<...>`: the heritage names what the class carries across
336
+ listeners.TSTypeParameterInstantiation = (node) => {
337
+ const parent = node.parent
338
+ const heritage = parent.type === "TSClassImplements" || parent.type === "TSInterfaceHeritage" || parent.type === "ClassDeclaration" || parent.type === "ClassExpression"
339
+ if (heritage) for (const param of node.params) judge(param)
340
+ }
341
+ return listeners
154
342
  },
155
343
  }
156
344
 
157
345
  // -- R74 -------------------------------------------------------------------------------------------
158
346
 
159
- const isMigrationPath = (filename) => /\/migrations\/[^/]+\.[cm]?ts$/.test(normalizePath(filename))
160
-
161
347
  const methodNamed = (klass, name) =>
162
348
  klass.body.body.find((member) => member.type === "MethodDefinition" && !member.computed && keyName(member.key) === name)
163
349
 
@@ -177,8 +363,8 @@ export const migrationDownReversible = {
177
363
  },
178
364
  },
179
365
  create(context) {
180
- const filename = normalizePath(context.filename || context.getFilename())
181
- if (isDeclarationFile(filename) || !isMigrationPath(filename) || /\.(?:spec|test)\.ts$/.test(filename)) return {}
366
+ const filename = context.filename || context.getFilename()
367
+ if (isDeclarationFile(filename) || !isMigrationFile(hfsOf(context), filename)) return {}
182
368
  const check = (node) => {
183
369
  if (!node.body) return
184
370
  const up = methodNamed(node, "up")
@@ -195,8 +381,6 @@ export const migrationDownReversible = {
195
381
  /** The rules this law contributes to the plugin. */
196
382
  export const rules = {
197
383
  "no-runtime-schema": noRuntimeSchema,
198
- "sql-text-only": sqlTextOnly,
199
- "no-repository-file": noRepositoryFile,
200
384
  "no-entity-in-contract": noEntityInContract,
201
385
  "migration-down-reversible": migrationDownReversible,
202
386
  }
@@ -204,8 +388,6 @@ export const rules = {
204
388
  /** All three start at error: HFS keeps no baseline and the schema migration lanes clear the debt first. */
205
389
  export const recommended = {
206
390
  "starci-be/no-runtime-schema": "error",
207
- "starci-be/sql-text-only": "error",
208
- "starci-be/no-repository-file": "error",
209
391
  "starci-be/no-entity-in-contract": "error",
210
392
  "starci-be/migration-down-reversible": "error",
211
393
  }
package/size-budget.mjs CHANGED
@@ -2,91 +2,49 @@
2
2
  * The rules that hold the file size budget (catalog R20 `HFS_SIZE_GROWTH`, the file-level half).
3
3
  *
4
4
  * A source file has a hard-growth budget of `fileLines.hardGrowth` lines (`ruleParams.be.fileLines` of the
5
- * slot manifest, read through `lib/slots.mjs`). `file-size-growth` is the ratchet and fails only on a file that must not
5
+ * slot manifest, read through `settings.starci.hfs`, see `lib/hfs.mjs`). `file-size-growth` is the ratchet and fails only on a file that must not
6
6
  * be this long: a NEW file over the budget, or an existing file over the budget that is longer than its recorded size.
7
- * The recorded size is the file's line count at the parent commit (`git show HEAD:<file>`), or an entry of the
8
- * `recorded` option. A file may stay as large as it is; it may not grow, and it may not be born large.
7
+ * The recorded size is the file's line count at the parent commit (`git show HEAD:<file>`). A file may stay as large
8
+ * as it is; it may not grow, and it may not be born large. The rule takes no option: the budget is the manifest's, the
9
+ * baseline is git's.
9
10
  *
10
11
  * There is no soft-limit lint rule: a warning under a zero-warning gate is an exception in disguise. Listing the
11
12
  * files over the soft budget (`fileLines.soft`) is a report item of the hfs check, never a block.
12
- * There is no baseline file and no allowlist: the record is the repository's own history. A migration is exempt
13
- * (it is append-only and generated), and so is every test-lane file.
13
+ * There is no baseline file and no allowlist: the record is the repository's own history. A spec, a fixture and an e2e
14
+ * file are source files like any other. A migration is exempt (a file of the `be.persistence` slot named
15
+ * `<timestamp>-<name>.ts`: append-only, never edited), and so is a declaration file.
14
16
  */
15
- import { execFileSync } from "node:child_process"
16
- import { basename, dirname } from "node:path"
17
- import { isDeclarationFile, isMigrationFile, isTestLane, normalizePath } from "./lib/path.mjs"
18
- import { hfsParams } from "./lib/slots.mjs"
17
+ import { basename } from "node:path"
18
+ import { hfsOf } from "./lib/hfs.mjs"
19
+ import { hardGrowthLines, lineCount, recordedLines } from "./runtime/scripts/lib/recorded-lines.mjs"
19
20
 
20
- const lineCount = (text) => {
21
- const lines = text.split(/\r?\n/)
22
- return lines.at(-1) === "" ? lines.length - 1 : lines.length
23
- }
21
+ /** A migration is a file of the persistence slot named `<timestamp>-<name>.ts`. */
22
+ const isMigration = (hfs, filename) => hfs.slotOf(filename) === "be.persistence" && /^\d{13,14}-[^/]+\.[cm]?ts$/.test(basename(filename))
24
23
 
25
24
  /** Whether a file is a source file this budget governs. */
26
- const governed = (filename) =>
27
- /\.[cm]?tsx?$/.test(filename) &&
28
- !isDeclarationFile(filename) &&
29
- !isMigrationFile(filename) &&
30
- !isTestLane(filename) &&
31
- !filename.includes("/__generated__/")
32
-
33
- /**
34
- * The line count of a file at a git revision, or null when the file did not exist there or git is absent.
35
- *
36
- * @param {string} filename - Absolute path of the file.
37
- * @param {string} ref - The revision, `HEAD` by default (the parent of the commit being made).
38
- * @returns {number | null} The recorded line count.
39
- */
40
- export const recordedLines = (filename, ref = "HEAD") => {
41
- try {
42
- const text = execFileSync("git", ["-C", dirname(filename), "show", `${ref}:./${basename(filename)}`], {
43
- encoding: "utf8",
44
- stdio: ["ignore", "pipe", "ignore"],
45
- maxBuffer: 64 * 1024 * 1024,
46
- })
47
- return lineCount(text)
48
- } catch {
49
- // no repository, no such revision, or a new file: all mean there is no recorded size to compare with
50
- return null
51
- }
52
- }
53
-
54
- const optionSchema = [
55
- {
56
- type: "object",
57
- properties: {
58
- max: { type: "integer", minimum: 1 },
59
- ref: { type: "string" },
60
- recorded: { type: "object", additionalProperties: { type: "integer", minimum: 0 } },
61
- },
62
- additionalProperties: false,
63
- },
64
- ]
25
+ const governed = (hfs, filename) => /\.[cm]?tsx?$/.test(filename) && !/\.d\.[cm]?ts$/.test(filename) && !isMigration(hfs, filename)
65
26
 
66
27
  /** The ratchet: a file over the budget must not be new and must not grow. */
67
28
  export const fileSizeGrowth = {
68
29
  meta: {
69
30
  type: "problem",
70
31
  docs: { description: "A source file over the budget is neither new nor longer than its recorded size." },
71
- schema: optionSchema,
32
+ schema: [],
72
33
  messages: {
73
34
  born: "This new file has {{lines}} lines, over the budget of {{max}}. A new file starts within the budget: split it by responsibility.",
74
35
  grew: "This file has {{lines}} lines, over the budget of {{max}}, and it was {{recorded}} at the parent commit. A file over the budget may not grow: move the new code into its own file.",
75
36
  },
76
37
  },
77
38
  create(context) {
78
- const filename = normalizePath(context.filename || context.getFilename())
79
- if (!governed(filename)) return {}
80
- const options = context.options[0] ?? {}
81
- const max = options.max ?? hfsParams.fileLines.hardGrowth
39
+ const filename = context.filename || context.getFilename()
40
+ const hfs = hfsOf(context)
41
+ if (!governed(hfs, filename)) return {}
42
+ const max = hardGrowthLines(hfs.ruleParams.fileLines)
82
43
  return {
83
44
  "Program:exit"(node) {
84
45
  const lines = lineCount((context.sourceCode || context.getSourceCode()).text)
85
46
  if (lines <= max) return
86
- const listed = options.recorded
87
- ? Object.entries(options.recorded).find(([path]) => filename.endsWith(`/${path}`) || filename === path)
88
- : undefined
89
- const recorded = listed ? listed[1] : recordedLines(context.filename || context.getFilename(), options.ref)
47
+ const recorded = recordedLines(filename)
90
48
  const loc = { line: 1, column: 0 }
91
49
  if (recorded === null) context.report({ node, loc, messageId: "born", data: { lines, max } })
92
50
  else if (lines > recorded) context.report({ node, loc, messageId: "grew", data: { lines, max, recorded } })