@starci/eslint-canon-be 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The rules that hold `observability.md`.
3
+ *
4
+ * Two rules and one config line, covering the three ways a log escapes the pipeline: the framework's
5
+ * own logger, `console`, and a name fused with its data. The third is the one worth a rule of its
6
+ * own, because it is the only one that still LOOKS like structured logging - the call goes through
7
+ * the right service and produces an unqueryable line anyway.
8
+ *
9
+ * `no-console` is not reimplemented here: the standard rule already does it exactly, and shipping a
10
+ * second implementation of a rule everybody has would be a maintenance cost with no gain. It is
11
+ * named in `recommended` so a consuming repository switches it on with the others.
12
+ *
13
+ * OBSERVABILITY-4 and -5 are judgements a rule cannot make. Whether a log records a decision or
14
+ * merely an arrival needs to know what the code is FOR, and no parser knows that.
15
+ */
16
+
17
+ /** Log methods the house service exposes. */
18
+ const LOG_METHODS = new Set(["log", "error", "warn", "info", "debug", "verbose"])
19
+
20
+ /** The house logging service, by the name it is injected under. */
21
+ const LOGGER_RECEIVER = "winstonService"
22
+
23
+ /** The framework logger this replaces. */
24
+ const FRAMEWORK_LOGGER = "Logger"
25
+
26
+ /** The package the framework logger comes from. */
27
+ const FRAMEWORK_PACKAGE = "@nestjs/common"
28
+
29
+ /** Whether a call's receiver is the house logging service, injected or via `this`. */
30
+ const isLoggerReceiver = (node) => {
31
+ if (node.type === "Identifier") return node.name === LOGGER_RECEIVER
32
+ if (node.type === "MemberExpression" && !node.computed) return node.property.name === LOGGER_RECEIVER
33
+ return false
34
+ }
35
+
36
+ // -- OBSERVABILITY-1 -------------------------------------------------------------------------------
37
+
38
+ /** Logs leave through the house service, never the framework's own logger. */
39
+ export const noFrameworkLogger = {
40
+ meta: {
41
+ type: "problem",
42
+ docs: { description: "Only the house logging service logs; the framework's `Logger` is refused." },
43
+ schema: [],
44
+ messages: {
45
+ imported:
46
+ "`Logger` from `{{pkg}}` bypasses the correlation id and the transport configuration the house service wires up - the line is written in the right SHAPE and still arrives without the context every other line carries, or does not arrive at all. Inject the house logging service.",
47
+ constructed:
48
+ "`new Logger(...)` - same bypass, reached through a local construction rather than the import. Inject the house logging service.",
49
+ },
50
+ },
51
+ create(context) {
52
+ return {
53
+ ImportDeclaration(node) {
54
+ if (node.source.value !== FRAMEWORK_PACKAGE) return
55
+ for (const specifier of node.specifiers || []) {
56
+ if (specifier.type !== "ImportSpecifier") continue
57
+ if (specifier.imported && specifier.imported.name === FRAMEWORK_LOGGER) {
58
+ context.report({ node: specifier, messageId: "imported", data: { pkg: FRAMEWORK_PACKAGE } })
59
+ }
60
+ }
61
+ },
62
+ // caught separately so an aliased import cannot walk past the check above
63
+ NewExpression(node) {
64
+ if (node.callee.type === "Identifier" && node.callee.name === FRAMEWORK_LOGGER) {
65
+ context.report({ node, messageId: "constructed" })
66
+ }
67
+ },
68
+ }
69
+ },
70
+ }
71
+
72
+ // -- OBSERVABILITY-2 -------------------------------------------------------------------------------
73
+
74
+ /** The event name is a member of a closed set, never a string built at the call site. */
75
+ export const noInterpolatedLogMessage = {
76
+ meta: {
77
+ type: "problem",
78
+ docs: { description: "A log call's first argument is an enum member, not a built string." },
79
+ schema: [],
80
+ messages: {
81
+ built:
82
+ "The first argument to `{{method}}(...)` names WHAT happened and must come from the log-name enum. A string built here fuses the name with the data, so in one move the name stops being groupable and the data stops being queryable - and the day somebody rewords it, every dashboard built on it goes quiet. Pass the enum member, and put the variable part in the data object beside it.",
83
+ },
84
+ },
85
+ create(context) {
86
+ return {
87
+ CallExpression(node) {
88
+ const callee = node.callee
89
+ if (callee.type !== "MemberExpression" || callee.computed) return
90
+ const method = callee.property.name
91
+ if (!LOG_METHODS.has(method)) return
92
+ if (!isLoggerReceiver(callee.object)) return
93
+
94
+ const first = node.arguments[0]
95
+ if (!first) return
96
+ const built = first.type === "TemplateLiteral"
97
+ || (first.type === "BinaryExpression" && first.operator === "+")
98
+ || (first.type === "Literal" && typeof first.value === "string")
99
+ if (built) context.report({ node: first, messageId: "built", data: { method } })
100
+ },
101
+ }
102
+ },
103
+ }
104
+
105
+ /** The rules this law contributes to the plugin. */
106
+ export const rules = {
107
+ "no-framework-logger": noFrameworkLogger,
108
+ "no-interpolated-log-message": noInterpolatedLogMessage,
109
+ }
110
+
111
+ /**
112
+ * The level this law asks for, as the plugin's own opinion.
113
+ *
114
+ * Both measured at zero debt in the reference repository ONCE the sanctioned exit is scoped:
115
+ * `no-framework-logger` reports four sites under the standalone-agent folder, and those are the
116
+ * exit rather than the debt. An agent running outside the request lifecycle has no request to
117
+ * correlate and no transport configured, so the house service would give it a dependency and
118
+ * nothing else. Scope that folder off in the consuming config -- once, by path -- rather than
119
+ * letting per-line suppressions accumulate until nobody can see how wide the exception has grown.
120
+ *
121
+ * `no-console` is the standard rule rather than a house one, listed here so a repository switches
122
+ * all three on together: it is the third way out of the pipeline, and leaving it off makes the
123
+ * other two decorative.
124
+ */
125
+ export const recommended = {
126
+ "starci-be/no-framework-logger": "error",
127
+ "starci-be/no-interpolated-log-message": "error",
128
+ "no-console": "error",
129
+ }
130
+
131
+ /**
132
+ * Paths where a plain logger is sanctioned -- programs with no request to correlate.
133
+ *
134
+ * Exported so a consuming config and a measuring gate use the SAME list. Two copies of an exemption
135
+ * is how one of them silently grows.
136
+ */
137
+ export const standaloneProgramGlobs = [
138
+ "src/modules/playground-agent-core/**",
139
+ "apps/playground-*-agent/**",
140
+ "apps/cli/**",
141
+ ]
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@starci/eslint-canon-be",
3
+ "version": "1.0.0",
4
+ "description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "private": false,
8
+ "exports": {
9
+ ".": "./index.mjs"
10
+ },
11
+ "main": "./index.mjs",
12
+ "files": [
13
+ "*.mjs",
14
+ "!*.test.mjs",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20.9.0"
19
+ },
20
+ "peerDependencies": {
21
+ "eslint": ">=9"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test \"*.test.mjs\""
28
+ },
29
+ "keywords": [
30
+ "eslint",
31
+ "eslintplugin",
32
+ "starci",
33
+ "canon"
34
+ ]
35
+ }
package/testing.mjs ADDED
@@ -0,0 +1,353 @@
1
+ /**
2
+ * The rules that hold `testing.md`.
3
+ *
4
+ * MOST OF THAT LAW IS NOT MACHINE-CHECKABLE, and pretending otherwise would be worse than
5
+ * checking nothing. No rule can tell whether a file represents a business flow, whether the
6
+ * unhappy path it covers drags a critical flow behind it, or whether the decision branches are
7
+ * covered - those are read by a person.
8
+ *
9
+ * What a rule CAN see is the two shapes that are wrong on their face regardless of intent: a spec
10
+ * whose every assertion is about a call rather than a result, and an end-to-end spec that never
11
+ * reads any state back. Both were measured before being written: eight specs and one e2e file in
12
+ * the source repository, which is why the second lands with a carve-out rather than at zero.
13
+ */
14
+
15
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
16
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
17
+
18
+ /** The fast lane: a plain unit spec, excluding every other suffix that also ends in `spec.ts`. */
19
+ const isUnitSpec = (filename) => {
20
+ const file = normalizePath(filename)
21
+ if (!/\.spec\.ts$/.test(file)) return false
22
+ return !/\.(?:e2e|int|harness)-spec\.ts$/.test(file)
23
+ }
24
+
25
+ /** The flow lane. */
26
+ const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(normalizePath(filename))
27
+
28
+ /** The paid model-quality lane. */
29
+ const isHarnessSpec = (filename) => /\.harness-spec\.ts$/.test(normalizePath(filename))
30
+
31
+ /** Test-only helpers whose only authority is the harness lane. */
32
+ const isHarnessHelper = (filename) => /\/src\/tests\/helpers\//.test(normalizePath(filename))
33
+
34
+ /**
35
+ * Matchers that assert a CALL happened rather than what came out of it.
36
+ *
37
+ * Each is legitimate as a second assertion - "the mail went out" is a real observable effect. The
38
+ * rule fires only when a whole file has nothing else.
39
+ */
40
+ const CALL_MATCHERS = new Set([
41
+ "toHaveBeenCalled",
42
+ "toHaveBeenCalledWith",
43
+ "toHaveBeenCalledTimes",
44
+ "toHaveBeenLastCalledWith",
45
+ "toHaveBeenNthCalledWith",
46
+ "toBeCalled",
47
+ "toBeCalledWith",
48
+ "toBeCalledTimes",
49
+ "toHaveReturned",
50
+ ])
51
+
52
+ /** Names that mean the test read state back out of the database. */
53
+ const STATE_READERS = /^(?:entityManager|dataSource|EntityManager|DataSource|getRepository|queryRunner)$/
54
+
55
+ /**
56
+ * The matcher a given `expect(...)` call ends in.
57
+ *
58
+ * Climbs the member chain so modifiers pass through: `expect(x).not.toHaveBeenCalled()` answers
59
+ * `toHaveBeenCalled`, and `expect(p).resolves.toBe(1)` answers `toBe`.
60
+ */
61
+ const matcherOf = (expectCall) => {
62
+ let cursor = expectCall
63
+ let last = null
64
+ while (cursor.parent && cursor.parent.type === "MemberExpression" && cursor.parent.object === cursor) {
65
+ last = cursor.parent.property && cursor.parent.property.name
66
+ cursor = cursor.parent
67
+ }
68
+ return last
69
+ }
70
+
71
+ // -- TESTING-6 -------------------------------------------------------------------------------------
72
+
73
+ /** A spec whose every assertion is about a call restates the source instead of testing it. */
74
+ export const noCallOnlySpec = {
75
+ meta: {
76
+ type: "problem",
77
+ docs: { description: "A unit spec asserts a result or a state change, not only that a call happened." },
78
+ schema: [],
79
+ messages: {
80
+ callOnly:
81
+ "Every assertion in this spec is `{{matchers}}` - it restates the handler's own source. Rename a collaborator's method and this file goes red; change the business rule to a wrong value and it stays green. Assert what came back, or what changed. A call assertion is legitimate as a SECOND assertion, where the call itself is the observable effect.",
82
+ },
83
+ },
84
+ create(context) {
85
+ if (!isUnitSpec(context.filename || context.getFilename())) return {}
86
+ let assertions = 0
87
+ let callAssertions = 0
88
+ const seen = new Set()
89
+ return {
90
+ CallExpression(node) {
91
+ if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "expect") return
92
+ const matcher = matcherOf(node)
93
+ if (matcher === null) return
94
+ assertions += 1
95
+ if (!CALL_MATCHERS.has(matcher)) return
96
+ callAssertions += 1
97
+ seen.add(matcher)
98
+ },
99
+ "Program:exit"(node) {
100
+ // the file asserts nothing at all, or asserts something other than a call somewhere
101
+ if (assertions === 0 || callAssertions !== assertions) return
102
+ context.report({ node, messageId: "callOnly", data: { matchers: [...seen].join(", ") } })
103
+ },
104
+ }
105
+ },
106
+ }
107
+
108
+ // -- TESTING-2 -------------------------------------------------------------------------------------
109
+
110
+ /** An end-to-end spec that never reads state back proves only that the server replied. */
111
+ export const e2eAssertsPersistedState = {
112
+ meta: {
113
+ type: "problem",
114
+ docs: { description: "An e2e spec reads state back rather than asserting only on the response." },
115
+ schema: [],
116
+ messages: {
117
+ noState:
118
+ "This e2e never reads any state back - it asserts on responses alone, so the flow can stop persisting and this file stays green. Read the row, the balance or the entitlement out of the database and assert THAT. A flow that genuinely has no persisted consequence needs an eslint-disable naming what it observes instead.",
119
+ },
120
+ },
121
+ create(context) {
122
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
123
+ let readsState = false
124
+ return {
125
+ Identifier(node) {
126
+ if (STATE_READERS.test(node.name)) readsState = true
127
+ },
128
+ "Program:exit"(node) {
129
+ if (readsState) return
130
+ context.report({ node, messageId: "noState" })
131
+ },
132
+ }
133
+ },
134
+ }
135
+
136
+ // -- TESTING-9 -------------------------------------------------------------------------------------
137
+
138
+ /** Provider SDKs. Importing one into a flow test is a real model call by any other name. */
139
+ const PROVIDER_PACKAGES = /^(?:@anthropic-ai\/|openai$|openai\/|ollama$|@google\/generative-ai|@mistralai\/|cohere-ai)/
140
+
141
+ /**
142
+ * The harness's own model helpers, which exist to reach a provider.
143
+ *
144
+ * Matched WITHOUT the `tests/` prefix: an e2e sits beside the helper folder, so the import it
145
+ * actually writes is the relative `../helpers/models.service`, and a pattern anchored on the
146
+ * absolute path never sees it.
147
+ */
148
+ const HARNESS_MODEL_HELPERS = /helpers\/models(?:\.service)?$/
149
+
150
+ /** An e2e never calls a model; the harness is the only lane that does. */
151
+ export const noModelCallInE2e = {
152
+ meta: {
153
+ type: "problem",
154
+ docs: { description: "An e2e spec never reaches a model provider." },
155
+ schema: [],
156
+ messages: {
157
+ provider:
158
+ "`{{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.",
159
+ },
160
+ },
161
+ create(context) {
162
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
163
+ return {
164
+ ImportDeclaration(node) {
165
+ const source = node.source && node.source.value
166
+ if (typeof source !== "string") return
167
+ if (!PROVIDER_PACKAGES.test(source) && !HARNESS_MODEL_HELPERS.test(source)) return
168
+ context.report({ node, messageId: "provider", data: { source } })
169
+ },
170
+ }
171
+ },
172
+ }
173
+
174
+ // -- TESTING-3 -------------------------------------------------------------------------------------
175
+
176
+ /** CQRS application buses are internal dispatchers, never production entry points. */
177
+ const APPLICATION_BUSES = new Set(["CommandBus", "QueryBus", "EventBus"])
178
+
179
+ /** Methods whose direct invocation skips the transport owned by production. */
180
+ const INTERNAL_ENTRY_METHODS = new Set(["execute", "process"])
181
+
182
+ /** An e2e enters through a production transport rather than an application object. */
183
+ export const e2eUsesProductionTransport = {
184
+ meta: {
185
+ type: "problem",
186
+ docs: { description: "An e2e enters through GraphQL, HTTP, socket, broker or scheduler transport." },
187
+ schema: [],
188
+ messages: {
189
+ busImport:
190
+ "`{{name}}` is an application dispatcher, not a production transport. An e2e must enter through GraphQL, HTTP, a real socket, a broker message or the scheduler boundary so routing, guards, validation and serialization are part of the proof.",
191
+ direct:
192
+ "Direct `.{{method}}()` invocation skips the production transport and starts inside the application. Move this decision/wiring test to the integration lane, or drive the flow through GraphQL, HTTP, a real socket, broker or scheduler boundary.",
193
+ },
194
+ },
195
+ create(context) {
196
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
197
+ return {
198
+ ImportDeclaration(node) {
199
+ if (!node.source || node.source.value !== "@nestjs/cqrs") return
200
+ for (const specifier of node.specifiers) {
201
+ if (specifier.type !== "ImportSpecifier") continue
202
+ const imported = specifier.imported && (specifier.imported.name || specifier.imported.value)
203
+ if (!APPLICATION_BUSES.has(imported)) continue
204
+ context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
205
+ }
206
+ },
207
+ CallExpression(node) {
208
+ const callee = node.callee
209
+ if (!callee || callee.type !== "MemberExpression" || callee.computed) return
210
+ const method = callee.property && callee.property.name
211
+ if (!INTERNAL_ENTRY_METHODS.has(method)) return
212
+ context.report({ node: callee.property, messageId: "direct", data: { method } })
213
+ },
214
+ }
215
+ },
216
+ }
217
+
218
+ // -- TESTING-10 ------------------------------------------------------------------------------------
219
+
220
+ /** Official provider clients that a harness may call without a house routing layer. */
221
+ const DIRECT_PROVIDER_PACKAGES = /^(?:@anthropic-ai\/sdk(?:\/|$)|openai(?:\/|$)|@google\/genai(?:\/|$)|@google\/generative-ai(?:\/|$)|@mistralai\/(?:mistralai|mistralai-ts)(?:\/|$)|cohere-ai(?:\/|$)|ollama(?:\/|$))/
222
+
223
+ /** Helpers that hide a model call or impersonate the production gateway. */
224
+ const FORBIDDEN_HARNESS_HELPERS = /(?:^|\/)(?:models(?:\.service)?|harness-invoke(?:\.service)?)$/
225
+
226
+ /** Consumer/CLI credential authorities that are never provider API credentials. */
227
+ const FORBIDDEN_HARNESS_AUTH = /(?:CLAUDE_CODE_OAUTH_TOKEN|claude-code-token|sk-ant-oat|OAUTH_BETA|CHATGPT_(?:SESSION|AUTH)_TOKEN|CODEX_(?:SESSION|AUTH)_TOKEN|auth-profile)/i
228
+
229
+ /** Imported symbols that reveal a fake production gateway in the harness lane. */
230
+ const FORBIDDEN_HARNESS_SYMBOLS = new Set([
231
+ "AiInvokeService",
232
+ "HarnessInvokeService",
233
+ "createHarnessInvoke",
234
+ ])
235
+
236
+ /** Return the static property name of an object/member property when one exists. */
237
+ const propertyName = (property) => {
238
+ if (!property) return null
239
+ if (property.type === "Identifier") return property.name
240
+ if (property.type === "Literal") return property.value
241
+ return null
242
+ }
243
+
244
+ /** Find the `AiInvokeService` token in a `Pick<AiInvokeService, ...>` type. */
245
+ const aiInvokePickToken = (sourceCode) => {
246
+ const tokens = sourceCode.getTokens(sourceCode.ast)
247
+ for (let index = 0; index < tokens.length - 2; index += 1) {
248
+ if (tokens[index].value !== "Pick" || tokens[index + 1].value !== "<") continue
249
+ if (tokens[index + 2].value === "AiInvokeService") return tokens[index + 2]
250
+ }
251
+ return null
252
+ }
253
+
254
+ /** A harness calls one explicit provider SDK and never disguises it as `AiInvokeService`. */
255
+ export const harnessCallsProviderDirectly = {
256
+ meta: {
257
+ type: "problem",
258
+ docs: { description: "A model-quality harness calls an approved provider SDK directly with provider API credentials." },
259
+ schema: [],
260
+ messages: {
261
+ missingProvider:
262
+ "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.",
263
+ gateway:
264
+ "`{{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.",
265
+ helper:
266
+ "`{{source}}` hides the provider call behind a house harness helper. Import and call the approved provider SDK in this harness; only credential loading may be shared.",
267
+ consumerAuth:
268
+ "`{{authority}}` is a consumer or CLI credential authority, not a provider-issued server API key. Read an explicit harness API-key environment variable instead.",
269
+ },
270
+ },
271
+ create(context) {
272
+ const filename = context.filename || context.getFilename()
273
+ const harness = isHarnessSpec(filename)
274
+ const authScope = harness || isHarnessHelper(filename)
275
+ if (!authScope) return {}
276
+
277
+ let hasProviderImport = false
278
+ return {
279
+ ImportDeclaration(node) {
280
+ const source = node.source && node.source.value
281
+ if (typeof source !== "string") return
282
+ if (harness && DIRECT_PROVIDER_PACKAGES.test(source)) hasProviderImport = true
283
+ if (harness && FORBIDDEN_HARNESS_HELPERS.test(source.replace(/\.(?:ts|js)$/, ""))) {
284
+ context.report({ node, messageId: "helper", data: { source } })
285
+ }
286
+ if (!harness) return
287
+ for (const specifier of node.specifiers) {
288
+ const imported = specifier.imported && (specifier.imported.name || specifier.imported.value)
289
+ const local = specifier.local && specifier.local.name
290
+ const name = imported || local
291
+ if (!FORBIDDEN_HARNESS_SYMBOLS.has(name)) continue
292
+ context.report({ node: specifier, messageId: "gateway", data: { name } })
293
+ }
294
+ },
295
+ Literal(node) {
296
+ if (typeof node.value !== "string" || !FORBIDDEN_HARNESS_AUTH.test(node.value)) return
297
+ context.report({ node, messageId: "consumerAuth", data: { authority: node.value } })
298
+ },
299
+ Property(node) {
300
+ if (!harness || propertyName(node.key) !== "provide") return
301
+ if (!node.value || node.value.type !== "Identifier" || node.value.name !== "AiInvokeService") return
302
+ context.report({ node, messageId: "gateway", data: { name: "provide: AiInvokeService" } })
303
+ },
304
+ CallExpression(node) {
305
+ if (!harness || !node.callee || node.callee.type !== "MemberExpression") return
306
+ if (propertyName(node.callee.property) !== "overrideProvider") return
307
+ const argument = node.arguments && node.arguments[0]
308
+ if (!argument || argument.type !== "Identifier" || argument.name !== "AiInvokeService") return
309
+ context.report({ node, messageId: "gateway", data: { name: "overrideProvider(AiInvokeService)" } })
310
+ },
311
+ "Program:exit"(node) {
312
+ if (!harness) return
313
+ const sourceCode = context.sourceCode || context.getSourceCode()
314
+ const pickToken = aiInvokePickToken(sourceCode)
315
+ if (pickToken) {
316
+ context.report({ node: pickToken, messageId: "gateway", data: { name: 'Pick<AiInvokeService, "run">' } })
317
+ }
318
+ if (!hasProviderImport) context.report({ node, messageId: "missingProvider" })
319
+ },
320
+ }
321
+ },
322
+ }
323
+
324
+ /** The rules this law contributes to the plugin. */
325
+ export const rules = {
326
+ "no-call-only-spec": noCallOnlySpec,
327
+ "e2e-asserts-persisted-state": e2eAssertsPersistedState,
328
+ "no-model-call-in-e2e": noModelCallInE2e,
329
+ "e2e-uses-production-transport": e2eUsesProductionTransport,
330
+ "harness-calls-provider-directly": harnessCallsProviderDirectly,
331
+ }
332
+
333
+ /**
334
+ * The level this law asks for, as the plugin's own opinion.
335
+ *
336
+ * MEASURED WITH THE RULES THEMSELVES against the reference repository, which is the only count
337
+ * worth writing down: 1 call-only spec of 181, and 1 e2e of 48 that never reads state back.
338
+ *
339
+ * That number was wrong twice before it was right, and both ways are worth knowing. A grep-based
340
+ * estimate said EIGHT - it counted files whose assertions merely INCLUDED a call matcher, which is
341
+ * the shape this rule deliberately permits. A first run of the rule itself said THREE - it counted
342
+ * eslint's own complaints about inline `eslint-disable` comments referring to rules the measuring
343
+ * config never loaded. Count only the reports carrying this rule's own id.
344
+ *
345
+ * Both land at `warn` with the count, get burned down, and flip to `error` at zero.
346
+ */
347
+ export const recommended = {
348
+ "starci-be/no-call-only-spec": "error", // no=0 of 182 - burned down from 1
349
+ "starci-be/e2e-asserts-persisted-state": "error", // no=0 of 47 - burned down from 1
350
+ "starci-be/no-model-call-in-e2e": "error", // no=0 of 47
351
+ "starci-be/e2e-uses-production-transport": "error",
352
+ "starci-be/harness-calls-provider-directly": "error",
353
+ }
package/transport.mjs ADDED
@@ -0,0 +1,103 @@
1
+ const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
2
+
3
+ /** The decorator's own name, whether it was written `@Controller` or `@Controller("x")`. */
4
+ const decoratorName = (node) => {
5
+ const expression = node.expression
6
+ if (!expression) return null
7
+ if (expression.type === "Identifier") return expression.name
8
+ if (expression.type === "CallExpression" && expression.callee.type === "Identifier") {
9
+ return expression.callee.name
10
+ }
11
+ return null
12
+ }
13
+
14
+ /** The route string, or "" for `@Controller()` and for a path given as an object. */
15
+ const routeOf = (node) => {
16
+ const expression = node.expression
17
+ if (!expression || expression.type !== "CallExpression") return ""
18
+ const first = expression.arguments[0]
19
+ if (first && first.type === "Literal" && typeof first.value === "string") return first.value
20
+ return ""
21
+ }
22
+
23
+ /**
24
+ * Which of the four sanctioned reasons this file shows, if any.
25
+ *
26
+ * READ OFF THE FILE, NOT OFF A LIST. A registry of blessed routes rots the moment somebody adds a
27
+ * route and forgets it; the evidence here is the same evidence a reader would use.
28
+ */
29
+ const reasonFor = (route, filename, text) => {
30
+ const path = normalize(filename)
31
+ // a probe answers when the app is degraded, before the feature layer is necessarily up
32
+ if (/^healthz?$/.test(route) || /\/health(z)?[./]/.test(path)) return "probe"
33
+ // something outside posts to a URL we gave it, and it will never send a GraphQL document
34
+ if (/webhook/i.test(route) || /webhook/i.test(path)) return "external"
35
+ // bytes, not fields: multipart in, stream out
36
+ if (/FileInterceptor|FilesInterceptor|AnyFilesInterceptor|StreamableFile|@Res\s*\(|createReadStream/.test(text)) {
37
+ return "bytes"
38
+ }
39
+ // a machine registering itself, with no user session to carry
40
+ if (/^(pods|internal|agents)\//.test(route)) return "machine"
41
+ // an identity that is not a user session - a platform operator or a service token
42
+ if (/^api\/ops(\/|$)/.test(route) || /Operator[A-Za-z]*Guard|ServiceToken|OPS_TOKEN/.test(text)) {
43
+ return "operator"
44
+ }
45
+ return null
46
+ }
47
+
48
+ /** A REST door exists only where GraphQL cannot go, and the file has to show which case it is. */
49
+ export const restDoorNeedsAReason = {
50
+ meta: {
51
+ type: "problem",
52
+ docs: { description: "A @Controller is permitted only where GraphQL cannot serve." },
53
+ schema: [],
54
+ messages: {
55
+ unjustified:
56
+ "This REST door shows none of the four reasons a door may not be GraphQL: an external system posting to a fixed URL, a payload of bytes rather than fields, a machine with no user session, or an identity that is not one. A plain JSON read belongs in the GraphQL schema, where the client already is.",
57
+ },
58
+ },
59
+ create(context) {
60
+ const filename = context.filename || context.getFilename()
61
+ const text = (context.sourceCode || context.getSourceCode()).getText()
62
+ return {
63
+ Decorator(node) {
64
+ if (decoratorName(node) !== "Controller") return
65
+ if (reasonFor(routeOf(node), filename, text)) return
66
+ context.report({ node, messageId: "unjustified" })
67
+ },
68
+ }
69
+ },
70
+ }
71
+
72
+ /** A door is a door whatever its transport, and every door in this repository lives in features/. */
73
+ export const doorLivesInFeatures = {
74
+ meta: {
75
+ type: "problem",
76
+ docs: { description: "HTTP doors sit under features/, beside the GraphQL ones." },
77
+ schema: [],
78
+ messages: {
79
+ wrongTree:
80
+ "A @Controller under src/modules/ is a door parked among the capabilities it calls, where it reads as one and gets imported like one. Every GraphQL door in this repository already lives under features/; transport was never what decided the address.",
81
+ },
82
+ },
83
+ create(context) {
84
+ const path = normalize(context.filename || context.getFilename())
85
+ if (!/\/src\/modules\//.test(path)) return {}
86
+ return {
87
+ Decorator(node) {
88
+ if (decoratorName(node) !== "Controller") return
89
+ context.report({ node, messageId: "wrongTree" })
90
+ },
91
+ }
92
+ },
93
+ }
94
+
95
+ export const rules = {
96
+ "rest-door-needs-a-reason": restDoorNeedsAReason,
97
+ "door-lives-in-features": doorLivesInFeatures,
98
+ }
99
+
100
+ export const recommended = {
101
+ "starci-be/rest-door-needs-a-reason": "error",
102
+ "starci-be/door-lives-in-features": "error",
103
+ }