@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }