@starci/eslint-canon-be 1.2.2 → 1.7.1
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 +47 -0
- package/README.md +46 -7
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- 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/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -8
- 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 +83 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/observability.mjs +275 -275
- package/package.json +4 -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/transport.mjs +168 -168
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/cdc.mjs +0 -150
- package/event-delivery.mjs +0 -93
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
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
|
+
}
|
package/testing.mjs
CHANGED
|
@@ -43,21 +43,17 @@
|
|
|
43
43
|
import { statSync } from "node:fs"
|
|
44
44
|
import { normalizePath } from "./lib/path.mjs"
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
const isUnitSpec = (filename) =>
|
|
48
|
-
const file = normalizePath(filename)
|
|
49
|
-
if (!/\.spec\.ts$/.test(file)) return false
|
|
50
|
-
return !/\.(?:e2e|int|harness)-spec\.ts$/.test(file)
|
|
51
|
-
}
|
|
46
|
+
/** Test kind 1 of exactly two: a unit spec, `<name>.spec.ts` beside its subject. */
|
|
47
|
+
const isUnitSpec = (filename) => /\.spec\.ts$/.test(normalizePath(filename))
|
|
52
48
|
|
|
53
|
-
/**
|
|
49
|
+
/** Test kind 2 of exactly two: `*.e2e-spec.ts`. */
|
|
54
50
|
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(normalizePath(filename))
|
|
55
51
|
|
|
56
|
-
/** The
|
|
57
|
-
const
|
|
52
|
+
/** The opt-in live e2e filter (`test:e2e:live`): e2e specs under `src/tests/e2e/live/` that need live third-party accounts. */
|
|
53
|
+
const isLiveE2eSpec = (filename) => isE2eSpec(filename) && /\/src\/tests\/e2e\/live\//.test(normalizePath(filename))
|
|
58
54
|
|
|
59
|
-
/** Test-only helpers whose only authority is the
|
|
60
|
-
const
|
|
55
|
+
/** Test-only environment helpers whose only authority is the live e2e filter. */
|
|
56
|
+
const isLiveE2eHelper = (filename) => /\/src\/tests\/e2e\/live\//.test(normalizePath(filename)) && !isE2eSpec(filename)
|
|
61
57
|
|
|
62
58
|
/**
|
|
63
59
|
* Matchers that assert a CALL happened rather than what came out of it.
|
|
@@ -136,7 +132,7 @@ export const noCallOnlySpec = {
|
|
|
136
132
|
// -- TESTING-7 -------------------------------------------------------------------------------------
|
|
137
133
|
|
|
138
134
|
const UNIT_TEST_BUCKET = /\/(?:src\/tests|tests?\/unit)(?:\/|$)/
|
|
139
|
-
const HFS_TEST_CATEGORY = /\/src\/tests\/(?:
|
|
135
|
+
const HFS_TEST_CATEGORY = /\/src\/tests\/(?:fixtures|e2e)\//
|
|
140
136
|
const SUBJECT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"]
|
|
141
137
|
|
|
142
138
|
/** A structural test in an HFS test category may test a source file beside it. */
|
|
@@ -219,7 +215,7 @@ const PROVIDER_PACKAGES = /^(?:@anthropic-ai\/|openai$|openai\/|ollama$|@google\
|
|
|
219
215
|
*/
|
|
220
216
|
const HARNESS_MODEL_HELPERS = /helpers\/models(?:\.service)?$/
|
|
221
217
|
|
|
222
|
-
/** An e2e never calls a model;
|
|
218
|
+
/** An e2e never calls a model; only a live e2e (src/tests/e2e/live/) may reach a provider. */
|
|
223
219
|
export const noModelCallInE2e = {
|
|
224
220
|
meta: {
|
|
225
221
|
type: "problem",
|
|
@@ -227,11 +223,12 @@ export const noModelCallInE2e = {
|
|
|
227
223
|
schema: [],
|
|
228
224
|
messages: {
|
|
229
225
|
provider:
|
|
230
|
-
"`{{source}}` reaches a model provider from an e2e. A model call costs money, takes seconds and answers differently every time - so this makes the flow suite expensive, slow and flaky at once, and the assertion has to be loosened until it stops catching anything. Stub the model and assert what can actually break: the entitlement, the quota, the persisted answer. Judging the answer itself belongs in
|
|
226
|
+
"`{{source}}` reaches a model provider from an e2e. A model call costs money, takes seconds and answers differently every time - so this makes the flow suite expensive, slow and flaky at once, and the assertion has to be loosened until it stops catching anything. Stub the model and assert what can actually break: the entitlement, the quota, the persisted answer. Judging the answer itself belongs in a live e2e under src/tests/e2e/live/.",
|
|
231
227
|
},
|
|
232
228
|
},
|
|
233
229
|
create(context) {
|
|
234
|
-
|
|
230
|
+
const filename = context.filename || context.getFilename()
|
|
231
|
+
if (!isE2eSpec(filename) || isLiveE2eSpec(filename)) return {}
|
|
235
232
|
return {
|
|
236
233
|
ImportDeclaration(node) {
|
|
237
234
|
const source = node.source && node.source.value
|
|
@@ -279,6 +276,16 @@ const aiInvokePickToken = (sourceCode) => {
|
|
|
279
276
|
return null
|
|
280
277
|
}
|
|
281
278
|
|
|
279
|
+
/** A file declares itself a model harness with the comment `@harness-kind model`; any other kind is not one. */
|
|
280
|
+
const DECLARED_MODEL_HARNESS = /@harness-kind\s+model\b/
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A model harness is identified by what it does, never by where it lives: it imports an LLM provider
|
|
284
|
+
* SDK, reaches for a house model helper or gateway symbol, or declares `@harness-kind model`. A live
|
|
285
|
+
* e2e for an identity provider (Keycloak), a payment gateway or any other third party sits in the same
|
|
286
|
+
* `src/tests/e2e/live/` folder and is not a model harness, so this rule leaves it alone.
|
|
287
|
+
*/
|
|
288
|
+
|
|
282
289
|
/** A harness calls one explicit provider SDK and never disguises it as `AiInvokeService`. */
|
|
283
290
|
export const harnessCallsProviderDirectly = {
|
|
284
291
|
meta: {
|
|
@@ -287,7 +294,7 @@ export const harnessCallsProviderDirectly = {
|
|
|
287
294
|
schema: [],
|
|
288
295
|
messages: {
|
|
289
296
|
missingProvider:
|
|
290
|
-
"This harness imports no approved provider SDK. A model-quality harness calls the declared provider client directly; a house helper, tier or gateway override can make it green about a model production does not use.",
|
|
297
|
+
"This model harness (it reaches for a house model helper or gateway, or declares `@harness-kind model`) imports no approved provider SDK. A model-quality harness calls the declared provider client directly; a house helper, tier or gateway override can make it green about a model production does not use.",
|
|
291
298
|
gateway:
|
|
292
299
|
"`{{name}}` impersonates or replaces the production AI gateway from a harness. Reuse the production prompt builder and parser around a direct provider SDK call instead.",
|
|
293
300
|
helper:
|
|
@@ -298,17 +305,19 @@ export const harnessCallsProviderDirectly = {
|
|
|
298
305
|
},
|
|
299
306
|
create(context) {
|
|
300
307
|
const filename = context.filename || context.getFilename()
|
|
301
|
-
const harness =
|
|
302
|
-
const authScope = harness ||
|
|
308
|
+
const harness = isLiveE2eSpec(filename)
|
|
309
|
+
const authScope = harness || isLiveE2eHelper(filename)
|
|
303
310
|
if (!authScope) return {}
|
|
304
311
|
|
|
305
312
|
let hasProviderImport = false
|
|
313
|
+
let modelSignal = false
|
|
306
314
|
return {
|
|
307
315
|
ImportDeclaration(node) {
|
|
308
316
|
const source = node.source && node.source.value
|
|
309
317
|
if (typeof source !== "string") return
|
|
310
318
|
if (harness && DIRECT_PROVIDER_PACKAGES.test(source)) hasProviderImport = true
|
|
311
319
|
if (harness && FORBIDDEN_HARNESS_HELPERS.test(source.replace(/\.(?:ts|js)$/, ""))) {
|
|
320
|
+
modelSignal = true
|
|
312
321
|
context.report({ node, messageId: "helper", data: { source } })
|
|
313
322
|
}
|
|
314
323
|
if (!harness) return
|
|
@@ -317,6 +326,7 @@ export const harnessCallsProviderDirectly = {
|
|
|
317
326
|
const local = specifier.local && specifier.local.name
|
|
318
327
|
const name = imported || local
|
|
319
328
|
if (!FORBIDDEN_HARNESS_SYMBOLS.has(name)) continue
|
|
329
|
+
modelSignal = true
|
|
320
330
|
context.report({ node: specifier, messageId: "gateway", data: { name } })
|
|
321
331
|
}
|
|
322
332
|
},
|
|
@@ -341,9 +351,11 @@ export const harnessCallsProviderDirectly = {
|
|
|
341
351
|
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
342
352
|
const pickToken = aiInvokePickToken(sourceCode)
|
|
343
353
|
if (pickToken) {
|
|
354
|
+
modelSignal = true
|
|
344
355
|
context.report({ node: pickToken, messageId: "gateway", data: { name: 'Pick<AiInvokeService, "run">' } })
|
|
345
356
|
}
|
|
346
|
-
|
|
357
|
+
const declared = sourceCode.getAllComments().some((comment) => DECLARED_MODEL_HARNESS.test(comment.value))
|
|
358
|
+
if ((modelSignal || declared) && !hasProviderImport) context.report({ node, messageId: "missingProvider" })
|
|
347
359
|
},
|
|
348
360
|
}
|
|
349
361
|
},
|