@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.
@@ -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
+ }
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
- /** The fast lane: a plain unit spec, excluding every other suffix that also ends in `spec.ts`. */
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
- /** The flow lane. */
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 paid model-quality lane. */
57
- const isHarnessSpec = (filename) => /\.harness-spec\.ts$/.test(normalizePath(filename))
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 harness lane. */
60
- const isHarnessHelper = (filename) => /\/src\/tests\/helpers\//.test(normalizePath(filename))
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\/(?:integration|fixtures|harness|e2e)\//
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; the harness is the only lane that does. */
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 the harness.",
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
- if (!isE2eSpec(context.filename || context.getFilename())) return {}
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 = isHarnessSpec(filename)
302
- const authScope = harness || isHarnessHelper(filename)
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
- if (!hasProviderImport) context.report({ node, messageId: "missingProvider" })
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
  },