@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.
- package/CHANGELOG.md +95 -5
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +273 -91
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- package/lib/slots.mjs +0 -83
package/schema-authority.mjs
CHANGED
|
@@ -1,24 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that hold HFS schema authority and the persistence boundary (catalog R34,
|
|
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
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
19
|
-
import {
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 =
|
|
158
|
+
const filename = context.filename || context.getFilename()
|
|
51
159
|
if (isDeclarationFile(filename)) return {}
|
|
52
|
-
const
|
|
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" &&
|
|
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"
|
|
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
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
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
|
-
|
|
81
|
-
const SQL_FILE = /\.sql\.[cm]?ts$/
|
|
82
|
-
/** A repository file, banned. */
|
|
83
|
-
const REPOSITORY_FILE = /\.repository\.[cm]?ts$/
|
|
219
|
+
// -- R37 -------------------------------------------------------------------------------------------
|
|
84
220
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
/**
|
|
132
|
-
const
|
|
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,
|
|
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
|
|
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
|
|
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 =
|
|
146
|
-
if (isDeclarationFile(filename)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 =
|
|
181
|
-
if (isDeclarationFile(filename) || !
|
|
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/
|
|
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>`)
|
|
8
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
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:
|
|
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 =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const max =
|
|
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
|
|
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 } })
|