@starci/eslint-canon-be 1.2.2 → 1.7.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 +43 -0
- package/README.md +82 -7
- package/async-discipline.mjs +83 -0
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -4
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/package.json +1 -1
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +31 -19
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold HFS schema authority and the persistence boundary (catalog R34, R36, R37).
|
|
3
|
+
*
|
|
4
|
+
* The schema changes by migration and by nothing else, and the SQL that touches it lives in one place:
|
|
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-only-in-repository` (R36 `BE_SQL_OUTSIDE_REPOSITORY`) keeps raw SQL and query builders inside a
|
|
11
|
+
* `*.repository.ts` of a `persistence/` folder, so a use case or a service never contains SQL.
|
|
12
|
+
* - `no-entity-in-contract` (R37 `BE_ENTITY_IN_CONTRACT`) keeps ORM entities out of the types that cross a
|
|
13
|
+
* boundary: `*.contracts.ts`, DTOs, resolver and controller signatures. A contract returns a projection.
|
|
14
|
+
*/
|
|
15
|
+
import { keyName, staticText, walk } from "./lib/ast.mjs"
|
|
16
|
+
import { isDeclarationFile, isMigrationFile, normalizePath } from "./lib/path.mjs"
|
|
17
|
+
|
|
18
|
+
const DDL = /\b(?:CREATE|ALTER|DROP)\s+TABLE\b/i
|
|
19
|
+
|
|
20
|
+
const isFalse = (node) => node?.type === "Literal" && node.value === false
|
|
21
|
+
|
|
22
|
+
/** The text of every string literal and template chunk under `node`. */
|
|
23
|
+
const stringsUnder = (node) => {
|
|
24
|
+
const found = []
|
|
25
|
+
walk(node, (child) => {
|
|
26
|
+
if (child.type === "Literal" && typeof child.value === "string") found.push(child.value)
|
|
27
|
+
else if (child.type === "TemplateElement") found.push(child.value.cooked ?? "")
|
|
28
|
+
})
|
|
29
|
+
return found
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The schema is decided by migrations run by `apps/migrate`, never by the running process. */
|
|
33
|
+
export const noRuntimeSchema = {
|
|
34
|
+
meta: {
|
|
35
|
+
type: "problem",
|
|
36
|
+
docs: { description: "No synchronize, no migrationsRun in a service, no runtime DDL, no entity or migration glob." },
|
|
37
|
+
schema: [],
|
|
38
|
+
messages: {
|
|
39
|
+
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.",
|
|
40
|
+
synchronizeCall: "`.synchronize()` builds the schema at runtime. Write a migration and let `apps/migrate` run it.",
|
|
41
|
+
migrationsRun: "`migrationsRun` is not the literal `false`. Only `apps/migrate` runs migrations, once per connection, before api and worker start.",
|
|
42
|
+
ddl: "A `CREATE|ALTER|DROP TABLE` string outside `persistence/migrations/`. Schema-changing SQL belongs in a migration.",
|
|
43
|
+
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.",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
create(context) {
|
|
47
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
48
|
+
if (isDeclarationFile(filename)) return {}
|
|
49
|
+
const migration = isMigrationFile(filename)
|
|
50
|
+
return {
|
|
51
|
+
Property(node) {
|
|
52
|
+
const key = keyName(node.key)
|
|
53
|
+
if (key === "synchronize" && !isFalse(node.value)) {
|
|
54
|
+
context.report({ node, messageId: "synchronize" })
|
|
55
|
+
} else if (key === "migrationsRun" && !isFalse(node.value)) {
|
|
56
|
+
context.report({ node, messageId: "migrationsRun" })
|
|
57
|
+
} else if ((key === "entities" || key === "migrations") && stringsUnder(node.value).some((text) => text.includes("*"))) {
|
|
58
|
+
context.report({ node, messageId: "glob", data: { key } })
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
CallExpression(node) {
|
|
62
|
+
if (node.callee.type !== "MemberExpression" || node.callee.computed) return
|
|
63
|
+
if (node.callee.property.type === "Identifier" && node.callee.property.name === "synchronize") {
|
|
64
|
+
context.report({ node, messageId: "synchronizeCall" })
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
Literal(node) {
|
|
68
|
+
if (!migration && typeof node.value === "string" && DDL.test(node.value)) context.report({ node, messageId: "ddl" })
|
|
69
|
+
},
|
|
70
|
+
TemplateElement(node) {
|
|
71
|
+
if (!migration && DDL.test(node.value.cooked ?? "")) context.report({ node, messageId: "ddl" })
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const REPOSITORY_FILE = /\/persistence\/(?:[^/]+\/)*[^/]+\.repository\.[cm]?ts$/
|
|
78
|
+
|
|
79
|
+
/** Raw SQL and query builders live in a `*.repository.ts` under `persistence/`, nowhere else. */
|
|
80
|
+
export const sqlOnlyInRepository = {
|
|
81
|
+
meta: {
|
|
82
|
+
type: "problem",
|
|
83
|
+
docs: { description: "`.query(<sql>)` and `createQueryBuilder` only inside a persistence `*.repository.ts`." },
|
|
84
|
+
schema: [],
|
|
85
|
+
messages: {
|
|
86
|
+
sql: "Raw SQL or a query builder outside a persistence repository. Move it into the `*.repository.ts` of the owning capability's `persistence/` and call that from the use case.",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
create(context) {
|
|
90
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
91
|
+
if (isDeclarationFile(filename) || isMigrationFile(filename) || REPOSITORY_FILE.test(filename)) return {}
|
|
92
|
+
return {
|
|
93
|
+
CallExpression(node) {
|
|
94
|
+
const callee = node.callee
|
|
95
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
|
|
96
|
+
const name = callee.property.name
|
|
97
|
+
if (name === "createQueryBuilder") {
|
|
98
|
+
context.report({ node, messageId: "sql" })
|
|
99
|
+
} else if (name === "query" && staticText(node.arguments[0]) !== null) {
|
|
100
|
+
context.report({ node, messageId: "sql" })
|
|
101
|
+
} else if (name === "query" && node.arguments[0]?.type === "TemplateLiteral") {
|
|
102
|
+
context.report({ node, messageId: "sql" })
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Files whose types cross a boundary: contracts, DTOs, resolvers, controllers. */
|
|
110
|
+
const BOUNDARY_FILE = /\.contracts\.[cm]?ts$|\/dto\/|\.(?:request|response|input|type|args)\.[cm]?ts$|\.(?:resolver|controller)\.[cm]?ts$/
|
|
111
|
+
|
|
112
|
+
/** An ORM entity type never appears in a contract, DTO, resolver or controller signature. */
|
|
113
|
+
export const noEntityInContract = {
|
|
114
|
+
meta: {
|
|
115
|
+
type: "problem",
|
|
116
|
+
docs: { description: "No `*Entity` type in a contract, DTO, resolver or controller signature." },
|
|
117
|
+
schema: [],
|
|
118
|
+
messages: {
|
|
119
|
+
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.",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
create(context) {
|
|
123
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
124
|
+
if (isDeclarationFile(filename) || !BOUNDARY_FILE.test(filename) || filename.includes("/persistence/")) return {}
|
|
125
|
+
return {
|
|
126
|
+
TSTypeReference(node) {
|
|
127
|
+
if (node.typeName.type === "Identifier" && /Entity$/.test(node.typeName.name)) {
|
|
128
|
+
context.report({ node, messageId: "entity", data: { name: node.typeName.name } })
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -- R74 -------------------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const isMigrationPath = (filename) => /\/migrations\/[^/]+\.[cm]?ts$/.test(normalizePath(filename))
|
|
138
|
+
|
|
139
|
+
const methodNamed = (klass, name) =>
|
|
140
|
+
klass.body.body.find((member) => member.type === "MethodDefinition" && !member.computed && keyName(member.key) === name)
|
|
141
|
+
|
|
142
|
+
/** Whether a `down` body does nothing but refuse. */
|
|
143
|
+
const onlyThrows = (body) => body.body.length > 0 && body.body.every((statement) => statement.type === "ThrowStatement")
|
|
144
|
+
|
|
145
|
+
/** A migration can be undone. */
|
|
146
|
+
export const migrationDownReversible = {
|
|
147
|
+
meta: {
|
|
148
|
+
type: "problem",
|
|
149
|
+
docs: { description: "A migration declares a `down()` that reverses its `up()`." },
|
|
150
|
+
schema: [],
|
|
151
|
+
messages: {
|
|
152
|
+
missing: "This migration has an `up()` and no `down()`. A release that must roll back has no way to. Write `down()` as the exact inverse of `up()`.",
|
|
153
|
+
empty: "`down()` is empty, so a rollback leaves the schema changed while the migration table says it was undone. Write the inverse of `up()`.",
|
|
154
|
+
throws: "`down()` only throws. A rollback then fails halfway through a deploy. Write the inverse of `up()`; when `up()` drops data, `down()` recreates the structure and the data loss is stated in the migration's comment.",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
create(context) {
|
|
158
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
159
|
+
if (isDeclarationFile(filename) || !isMigrationPath(filename) || /\.(?:spec|test)\.ts$/.test(filename)) return {}
|
|
160
|
+
const check = (node) => {
|
|
161
|
+
if (!node.body) return
|
|
162
|
+
const up = methodNamed(node, "up")
|
|
163
|
+
if (!up) return
|
|
164
|
+
const down = methodNamed(node, "down")
|
|
165
|
+
if (!down) context.report({ node: node.id ?? node, messageId: "missing" })
|
|
166
|
+
else if (down.value.body.body.length === 0) context.report({ node: down.key, messageId: "empty" })
|
|
167
|
+
else if (onlyThrows(down.value.body)) context.report({ node: down.key, messageId: "throws" })
|
|
168
|
+
}
|
|
169
|
+
return { ClassDeclaration: check }
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The rules this law contributes to the plugin. */
|
|
174
|
+
export const rules = {
|
|
175
|
+
"no-runtime-schema": noRuntimeSchema,
|
|
176
|
+
"sql-only-in-repository": sqlOnlyInRepository,
|
|
177
|
+
"no-entity-in-contract": noEntityInContract,
|
|
178
|
+
"migration-down-reversible": migrationDownReversible,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** All three start at error: HFS keeps no baseline and the schema migration lanes clear the debt first. */
|
|
182
|
+
export const recommended = {
|
|
183
|
+
"starci-be/no-runtime-schema": "error",
|
|
184
|
+
"starci-be/sql-only-in-repository": "error",
|
|
185
|
+
"starci-be/no-entity-in-contract": "error",
|
|
186
|
+
"starci-be/migration-down-reversible": "error",
|
|
187
|
+
}
|
package/size-budget.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the file size budget (catalog R20 `HFS_SIZE_GROWTH`, the file-level half).
|
|
3
|
+
*
|
|
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
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* There is no soft-limit lint rule: a warning under a zero-warning gate is an exception in disguise. Listing the
|
|
11
|
+
* 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.
|
|
14
|
+
*/
|
|
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"
|
|
19
|
+
|
|
20
|
+
const lineCount = (text) => {
|
|
21
|
+
const lines = text.split(/\r?\n/)
|
|
22
|
+
return lines.at(-1) === "" ? lines.length - 1 : lines.length
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 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
|
+
]
|
|
65
|
+
|
|
66
|
+
/** The ratchet: a file over the budget must not be new and must not grow. */
|
|
67
|
+
export const fileSizeGrowth = {
|
|
68
|
+
meta: {
|
|
69
|
+
type: "problem",
|
|
70
|
+
docs: { description: "A source file over the budget is neither new nor longer than its recorded size." },
|
|
71
|
+
schema: optionSchema,
|
|
72
|
+
messages: {
|
|
73
|
+
born: "This new file has {{lines}} lines, over the budget of {{max}}. A new file starts within the budget: split it by responsibility.",
|
|
74
|
+
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
|
+
},
|
|
76
|
+
},
|
|
77
|
+
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
|
|
82
|
+
return {
|
|
83
|
+
"Program:exit"(node) {
|
|
84
|
+
const lines = lineCount((context.sourceCode || context.getSourceCode()).text)
|
|
85
|
+
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)
|
|
90
|
+
const loc = { line: 1, column: 0 }
|
|
91
|
+
if (recorded === null) context.report({ node, loc, messageId: "born", data: { lines, max } })
|
|
92
|
+
else if (lines > recorded) context.report({ node, loc, messageId: "grew", data: { lines, max, recorded } })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The rules this law contributes to the plugin. */
|
|
99
|
+
export const rules = {
|
|
100
|
+
"file-size-growth": fileSizeGrowth,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The ratchet is an error. */
|
|
104
|
+
export const recommended = {
|
|
105
|
+
"starci-be/file-size-growth": "error",
|
|
106
|
+
}
|
package/spec-quality.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold spec quality (catalog R48 `BE_SPEC_QUALITY`).
|
|
3
|
+
*
|
|
4
|
+
* - `spec-no-source-read` refuses a spec that reads source files. An architecture rule is a lint rule or a
|
|
5
|
+
* check, not a unit test that opens `src/` with `fs` and greps it; such a spec passes and fails on text, not on
|
|
6
|
+
* behavior, and it goes stale the day a file moves.
|
|
7
|
+
* - `spec-typed-doubles` refuses `as never` and `as unknown as X` in a spec. A double of a dependency is
|
|
8
|
+
* `mock<T>()` (the typed helper from `@starci/jest-preset`), which fails to compile when the dependency's
|
|
9
|
+
* shape changes; a cast keeps compiling and lies.
|
|
10
|
+
*
|
|
11
|
+
* The e2e canon rules apply to `src/tests/e2e/**` through the repository's `lint:e2e` script (run by hand, and
|
|
12
|
+
* green before an e2e conclusion is drawn); that is a matter of which globs the config names, not of a rule.
|
|
13
|
+
*/
|
|
14
|
+
import { walk } from "./lib/ast.mjs"
|
|
15
|
+
import { isDeclarationFile, isSpecFile, normalizePath } from "./lib/path.mjs"
|
|
16
|
+
|
|
17
|
+
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"])
|
|
18
|
+
const FS_READERS = new Set(["readFile", "readFileSync", "readdir", "readdirSync", "glob", "globSync", "opendir", "opendirSync", "createReadStream"])
|
|
19
|
+
|
|
20
|
+
/** A path argument that leads to the repository's own source: a location of this file, the working directory, or a source folder. */
|
|
21
|
+
const SOURCE_TEXT = /(?:^|[\\/])(?:src|apps|libs)(?:[\\/]|$)|\.[cm]?tsx?$/
|
|
22
|
+
const SOURCE_ANCHORS = new Set(["__dirname", "__filename"])
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a path expression points at source: it names `__dirname`, `import.meta`, `process.cwd()`, or a
|
|
26
|
+
* `src`, `apps` or `libs` folder or a `.ts` file. An identifier is followed once to its `const` initializer, so
|
|
27
|
+
* `const dir = join(__dirname, "x"); readdirSync(dir)` is recognised. A spec that reads the files ITS SUBJECT
|
|
28
|
+
* wrote to a temporary directory is behavior, and stays outside this rule.
|
|
29
|
+
*/
|
|
30
|
+
const pointsAtSource = (expression, scope, depth = 0) => {
|
|
31
|
+
if (!expression) return false
|
|
32
|
+
let found = false
|
|
33
|
+
walk(expression, (node) => {
|
|
34
|
+
if (found) return
|
|
35
|
+
if (node.type === "Identifier" && SOURCE_ANCHORS.has(node.name)) found = true
|
|
36
|
+
else if (node.type === "MetaProperty") found = true
|
|
37
|
+
else if (node.type === "MemberExpression" && node.object.type === "MetaProperty") found = true
|
|
38
|
+
else if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "process" && node.callee.property.name === "cwd") found = true
|
|
39
|
+
else if (node.type === "Literal" && typeof node.value === "string" && SOURCE_TEXT.test(node.value)) found = true
|
|
40
|
+
else if (node.type === "TemplateElement" && SOURCE_TEXT.test(node.value.cooked ?? "")) found = true
|
|
41
|
+
else if (node.type === "Identifier" && depth < 2) {
|
|
42
|
+
let reference = scope
|
|
43
|
+
while (reference && !reference.set.has(node.name)) reference = reference.upper
|
|
44
|
+
const definition = reference?.set.get(node.name)?.defs[0]
|
|
45
|
+
if (definition?.node.type === "VariableDeclarator" && pointsAtSource(definition.node.init, scope, depth + 1)) found = true
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
return found
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A spec never opens source files to assert on their text. */
|
|
52
|
+
export const specNoSourceRead = {
|
|
53
|
+
meta: {
|
|
54
|
+
type: "problem",
|
|
55
|
+
docs: { description: "A spec does not read files with fs." },
|
|
56
|
+
schema: [],
|
|
57
|
+
messages: {
|
|
58
|
+
read: "This spec reads files with `fs`. A rule about how source is written belongs in the lint canon or an architecture check; a spec asserts behavior.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
create(context) {
|
|
62
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
63
|
+
if (isDeclarationFile(filename) || !isSpecFile(filename)) return {}
|
|
64
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
65
|
+
const namespaces = new Set()
|
|
66
|
+
const named = new Set()
|
|
67
|
+
return {
|
|
68
|
+
ImportDeclaration(node) {
|
|
69
|
+
if (typeof node.source.value !== "string" || !FS_MODULES.has(node.source.value)) return
|
|
70
|
+
for (const specifier of node.specifiers) {
|
|
71
|
+
if (specifier.type === "ImportSpecifier") {
|
|
72
|
+
const imported = specifier.imported.name ?? specifier.imported.value
|
|
73
|
+
if (FS_READERS.has(imported)) named.add(specifier.local.name)
|
|
74
|
+
else if (imported === "promises") namespaces.add(specifier.local.name)
|
|
75
|
+
} else {
|
|
76
|
+
namespaces.add(specifier.local.name)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
CallExpression(node) {
|
|
81
|
+
const callee = node.callee
|
|
82
|
+
let reads = false
|
|
83
|
+
if (callee.type === "Identifier" && named.has(callee.name)) {
|
|
84
|
+
reads = true
|
|
85
|
+
} else if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && FS_READERS.has(callee.property.name)) {
|
|
86
|
+
const object = callee.object
|
|
87
|
+
const base = object.type === "MemberExpression" ? object.object : object
|
|
88
|
+
reads = base.type === "Identifier" && namespaces.has(base.name)
|
|
89
|
+
}
|
|
90
|
+
if (reads && pointsAtSource(node.arguments[0], sourceCode.getScope(node))) context.report({ node, messageId: "read" })
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A double is a typed `mock<T>()`, never a cast. */
|
|
97
|
+
export const specTypedDoubles = {
|
|
98
|
+
meta: {
|
|
99
|
+
type: "problem",
|
|
100
|
+
docs: { description: "A spec uses `mock<T>()` for a double, not `as never` or `as unknown as X`." },
|
|
101
|
+
schema: [],
|
|
102
|
+
messages: {
|
|
103
|
+
never: "`as never` forces a value through the type system. Build the double with `mock<T>()` so it stays checked against the real dependency.",
|
|
104
|
+
doubleCast: "`as unknown as X` forces a value through the type system. Build the double with `mock<T>()` so it stays checked against the real dependency.",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
create(context) {
|
|
108
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
109
|
+
if (isDeclarationFile(filename) || !(isSpecFile(filename) || filename.includes("/src/tests/"))) return {}
|
|
110
|
+
return {
|
|
111
|
+
TSAsExpression(node) {
|
|
112
|
+
if (node.typeAnnotation.type === "TSNeverKeyword") {
|
|
113
|
+
context.report({ node, messageId: "never" })
|
|
114
|
+
} else if (node.expression.type === "TSAsExpression" && node.expression.typeAnnotation.type === "TSUnknownKeyword") {
|
|
115
|
+
context.report({ node, messageId: "doubleCast" })
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The rules this law contributes to the plugin. */
|
|
123
|
+
export const rules = {
|
|
124
|
+
"spec-no-source-read": specNoSourceRead,
|
|
125
|
+
"spec-typed-doubles": specTypedDoubles,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Both start at error: the migration lanes replace the casts and the source-reading specs before adoption. */
|
|
129
|
+
export const recommended = {
|
|
130
|
+
"starci-be/spec-no-source-read": "error",
|
|
131
|
+
"starci-be/spec-typed-doubles": "error",
|
|
132
|
+
}
|
package/suppression.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds "no suppression anywhere" (catalog R18 `HFS_INLINE_SUPPRESSION`).
|
|
3
|
+
*
|
|
4
|
+
* `no-inline-suppression` reports every comment that switches a check off at the place it is written:
|
|
5
|
+
* `eslint-disable` in all its forms (block, line, next-line, and the matching `eslint-enable`), an
|
|
6
|
+
* `eslint <rule>: off` configuration comment, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, and the
|
|
7
|
+
* `vn-ok` language marker. The factory also sets `noInlineConfig: true`, so ESLint itself ignores such a
|
|
8
|
+
* comment; this rule is what turns the ignored comment into a failure a person reads, instead of a silent
|
|
9
|
+
* no-op that looks like an exemption.
|
|
10
|
+
*
|
|
11
|
+
* It replaces `no-line-suppression`, which named only the type-safety rules and so let every other rule be
|
|
12
|
+
* silenced. There is no exit here: fix the code, or propose a change to the canon.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DISABLE = /^\s*eslint-(?:disable|enable)(?:-next-line|-line)?(?:\s|$)/
|
|
16
|
+
const CONFIG = /^\s*eslint\s+[@\w/-]+\s*:\s*(?:0|1|2|off|warn|error|\[)/
|
|
17
|
+
const TS_DIRECTIVE = /^\s*(?:\/\s*)?@ts-(?:ignore|expect-error|nocheck)\b/
|
|
18
|
+
const LANGUAGE_MARKER = /\bvn-ok\b/
|
|
19
|
+
|
|
20
|
+
const kindOf = (text) => {
|
|
21
|
+
if (DISABLE.test(text) || CONFIG.test(text)) return "eslint"
|
|
22
|
+
if (TS_DIRECTIVE.test(text)) return "typescript"
|
|
23
|
+
if (LANGUAGE_MARKER.test(text)) return "vn-ok"
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** No comment switches a check off at the place it is written. */
|
|
28
|
+
export const noInlineSuppression = {
|
|
29
|
+
meta: {
|
|
30
|
+
type: "problem",
|
|
31
|
+
docs: { description: "No eslint-disable, ts-ignore, ts-expect-error, ts-nocheck or vn-ok comment." },
|
|
32
|
+
schema: [],
|
|
33
|
+
messages: {
|
|
34
|
+
eslint: "An `eslint-disable`/`eslint-enable` or rule-configuration comment. HFS does not switch a rule off in place: fix the code, or propose a change to the rule in the canon.",
|
|
35
|
+
typescript: "A `@ts-` directive silences the compiler at one place. Fix the type, or narrow it with a guard.",
|
|
36
|
+
"vn-ok": "A `vn-ok` marker exempts a line from the language rule. HFS keeps no exemption marker: write the text in English, or move the data to a locale or data file.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
create(context) {
|
|
40
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
41
|
+
return {
|
|
42
|
+
"Program:exit"() {
|
|
43
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
44
|
+
const kind = kindOf(comment.value)
|
|
45
|
+
if (kind) context.report({ loc: comment.loc, messageId: kind })
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The rules this law contributes to the plugin. */
|
|
53
|
+
export const rules = {
|
|
54
|
+
"no-inline-suppression": noInlineSuppression,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Error from the start: there is no baseline and no allowlist for a suppression. */
|
|
58
|
+
export const recommended = {
|
|
59
|
+
"starci-be/no-inline-suppression": "error",
|
|
60
|
+
}
|
package/temporal.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `platform/clock` (catalog R79 `BE_AMBIENT_CLOCK`).
|
|
3
|
+
*
|
|
4
|
+
* - `no-ambient-clock` refuses `Date.now()`, a bare `new Date()` and `performance.now()` outside
|
|
5
|
+
* `platform/clock`. Business code asks the injected `Clock` port for the time (`clock.now()`),
|
|
6
|
+
* because a call to the ambient clock cannot be replaced in a spec - a test then either sleeps
|
|
7
|
+
* for real or races the wall clock, and a scheduled job cannot be driven to a chosen instant.
|
|
8
|
+
*
|
|
9
|
+
* `new Date(value)` - built from a value the caller already holds (a stored timestamp, a parsed
|
|
10
|
+
* header) - is not an ambient read and is left alone; only the zero-argument form reads "now".
|
|
11
|
+
*/
|
|
12
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
13
|
+
|
|
14
|
+
const PLATFORM_CLOCK = /\/src\/modules\/platform\/clock\//
|
|
15
|
+
|
|
16
|
+
/** Whether a callee is `Date.now`. */
|
|
17
|
+
const isDateNow = (node) =>
|
|
18
|
+
node.type === "MemberExpression" &&
|
|
19
|
+
!node.computed &&
|
|
20
|
+
node.object.type === "Identifier" &&
|
|
21
|
+
node.object.name === "Date" &&
|
|
22
|
+
node.property.type === "Identifier" &&
|
|
23
|
+
node.property.name === "now"
|
|
24
|
+
|
|
25
|
+
/** Whether a callee is `performance.now`. */
|
|
26
|
+
const isPerformanceNow = (node) =>
|
|
27
|
+
node.type === "MemberExpression" &&
|
|
28
|
+
!node.computed &&
|
|
29
|
+
node.object.type === "Identifier" &&
|
|
30
|
+
node.object.name === "performance" &&
|
|
31
|
+
node.property.type === "Identifier" &&
|
|
32
|
+
node.property.name === "now"
|
|
33
|
+
|
|
34
|
+
/** Only `platform/clock` reads the ambient clock. */
|
|
35
|
+
export const noAmbientClock = {
|
|
36
|
+
meta: {
|
|
37
|
+
type: "problem",
|
|
38
|
+
docs: { description: "`Date.now()`, a bare `new Date()` and `performance.now()` are read only inside `platform/clock`." },
|
|
39
|
+
schema: [],
|
|
40
|
+
messages: {
|
|
41
|
+
now: "`{{call}}` reads the ambient clock directly. Inject the `Clock` port (`clock.now()`) instead: a spec then drives time with a `FakeClock` and a scheduled job can be tested at a chosen instant, neither of which a direct read allows.",
|
|
42
|
+
date: "`new Date()` with no argument reads the ambient clock. Inject the `Clock` port and build the date from `clock.now()`, or - if this really is parsing a value the caller already holds - pass that value to `new Date(value)`.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
47
|
+
if (isDeclarationFile(filename) || isTestLane(filename) || PLATFORM_CLOCK.test(filename)) return {}
|
|
48
|
+
return {
|
|
49
|
+
CallExpression(node) {
|
|
50
|
+
const { callee } = node
|
|
51
|
+
if (isDateNow(callee)) context.report({ node, messageId: "now", data: { call: "Date.now()" } })
|
|
52
|
+
else if (isPerformanceNow(callee)) context.report({ node, messageId: "now", data: { call: "performance.now()" } })
|
|
53
|
+
},
|
|
54
|
+
NewExpression(node) {
|
|
55
|
+
if (node.callee.type === "Identifier" && node.callee.name === "Date" && node.arguments.length === 0) {
|
|
56
|
+
context.report({ node, messageId: "date" })
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The rule this law contributes to the plugin. */
|
|
64
|
+
export const rules = {
|
|
65
|
+
"no-ambient-clock": noAmbientClock,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Starts at error: no baseline exists, and the repositories' fix lanes clear the debt with a Clock codemod. */
|
|
69
|
+
export const recommended = {
|
|
70
|
+
"starci-be/no-ambient-clock": "error",
|
|
71
|
+
}
|