@starci/eslint-canon-be 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cdc.mjs +150 -44
- package/comments.mjs +147 -1
- package/cqrs.mjs +103 -7
- package/data-access.mjs +144 -10
- package/e2e-flow.mjs +59 -9
- package/event-delivery.mjs +93 -40
- package/module-layering.mjs +268 -7
- package/naming.mjs +80 -3
- package/observability.mjs +140 -6
- package/package.json +1 -1
- package/testing.mjs +147 -0
- package/transport.mjs +168 -103
- package/type-safety.mjs +242 -8
package/testing.mjs
CHANGED
|
@@ -10,6 +10,34 @@
|
|
|
10
10
|
* whose every assertion is about a call rather than a result, and an end-to-end spec that never
|
|
11
11
|
* reads any state back. Both were measured before being written: eight specs and one e2e file in
|
|
12
12
|
* the source repository, which is why the second lands with a carve-out rather than at zero.
|
|
13
|
+
*
|
|
14
|
+
* Two more codes turned out to have a genuinely mechanical HALF, added later and kept narrow on
|
|
15
|
+
* purpose:
|
|
16
|
+
*
|
|
17
|
+
* - TESTING-1. A filename cannot be checked against a business sentence - but the anti-pattern
|
|
18
|
+
* the law names by example (`rewards-queries.e2e-spec.ts`) IS a closed, denylisted shape: a
|
|
19
|
+
* filename whose last segment is an API noun rather than a business one. The rule refuses only
|
|
20
|
+
* that shape; it does not claim to certify a good name, and it is the same requirement E2E-1 in
|
|
21
|
+
* `e2e-flow.md` states on the same file, so one rule holds both codes rather than reporting the
|
|
22
|
+
* same file twice under two names.
|
|
23
|
+
* - TESTING-7. "A stub of a model returns a payload the production parser can actually parse" -
|
|
24
|
+
* the rule cannot verify the payload parses, but it CAN refuse the one shape the law names by
|
|
25
|
+
* example: a bare marker string (`"stubbed"`, `"ok"`, `"test"`) standing in for the whole
|
|
26
|
+
* answer. An object literal, or a `JSON.stringify(...)` call, is out of the rule's reach on
|
|
27
|
+
* purpose - that is where a false positive would start.
|
|
28
|
+
*
|
|
29
|
+
* TESTING-5 / TESTING-8 ("a configured lane either holds tests or does not exist") was drafted as a
|
|
30
|
+
* filesystem-reading rule and then WITHDRAWN. It reads correctly as JS to `@typescript-eslint/parser`
|
|
31
|
+
* (`{"rootDir": "..."}` parses as an `ExpressionStatement` over an `ObjectExpression` under this
|
|
32
|
+
* parser, no wrapping needed) and its own file-scoped RuleTester cases passed. It is left out anyway,
|
|
33
|
+
* because the question it would answer is not "does this repository's config have this shape" but
|
|
34
|
+
* "does the CONSUMING repository's `eslint.config.mjs` even point a linter at this file at all" - and
|
|
35
|
+
* in the measured reference repository it does not: `files` there is five TypeScript globs under
|
|
36
|
+
* `src`, `apps`, `libs`, `test` and `tests` - none of them `.json` - so `jest-e2e.json` and
|
|
37
|
+
* `jest-harness.json` sit outside every one of them. A rule that cannot be reached by the config
|
|
38
|
+
* that would run it is not a `warn`-with-a-count away from correct; it is dead code reporting a "0"
|
|
39
|
+
* that means "never asked", not "already true" - the one measurement this family refuses to publish
|
|
40
|
+
* as if it were the other.
|
|
13
41
|
*/
|
|
14
42
|
|
|
15
43
|
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
@@ -277,12 +305,118 @@ export const harnessCallsProviderDirectly = {
|
|
|
277
305
|
},
|
|
278
306
|
}
|
|
279
307
|
|
|
308
|
+
// -- TESTING-1 (and E2E-1 in e2e-flow.md, the same requirement on the same file) -------------------
|
|
309
|
+
|
|
310
|
+
/** Nouns that name an API shape rather than a business promise. `rewards-queries` is the anchor. */
|
|
311
|
+
const API_SHAPED_FILENAME_NOUNS = new Set([
|
|
312
|
+
"queries", "query", "mutations", "mutation",
|
|
313
|
+
"resolvers", "resolver", "endpoints", "endpoint",
|
|
314
|
+
"controllers", "controller", "handlers", "handler",
|
|
315
|
+
"modules", "module", "apis", "api",
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
/** The hyphen-separated words of an e2e filename, with the lane suffix stripped. */
|
|
319
|
+
const e2eFilenameSegments = (filename) => {
|
|
320
|
+
const base = normalizePath(filename).split("/").pop() || ""
|
|
321
|
+
return base.replace(/\.e2e-spec\.ts$/, "").split("-").filter(Boolean)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** An e2e file is one business flow; the filename may not be an API-shape noun wearing its clothes. */
|
|
325
|
+
export const noApiShapedE2eFilename = {
|
|
326
|
+
meta: {
|
|
327
|
+
type: "problem",
|
|
328
|
+
docs: {
|
|
329
|
+
description:
|
|
330
|
+
"An e2e filename reads as a business sentence (TESTING-1; E2E-1 in e2e-flow.md), not a resolver/endpoint/module group.",
|
|
331
|
+
},
|
|
332
|
+
schema: [],
|
|
333
|
+
messages: {
|
|
334
|
+
apiShaped:
|
|
335
|
+
"`{{segment}}` names an API shape - a resolver group, an endpoint, a module - not a business promise. TESTING-1 / E2E-1: one e2e file is one business flow and the filename IS that flow; `rewards-queries.e2e-spec.ts` is exactly the shape this refuses. Name the file for the sentence it proves (`course-purchase.e2e-spec.ts`), not the API surface it happens to hit.",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
create(context) {
|
|
339
|
+
const filename = context.filename || context.getFilename()
|
|
340
|
+
if (!isE2eSpec(filename)) return {}
|
|
341
|
+
const segments = e2eFilenameSegments(filename)
|
|
342
|
+
if (segments.length === 0) return {}
|
|
343
|
+
const last = segments[segments.length - 1].toLowerCase()
|
|
344
|
+
if (!API_SHAPED_FILENAME_NOUNS.has(last)) return {}
|
|
345
|
+
return {
|
|
346
|
+
Program(node) {
|
|
347
|
+
context.report({ node, messageId: "apiShaped", data: { segment: last } })
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// -- TESTING-7 -------------------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/** Test-infra helpers, where the shared model stub this code governs actually lives. */
|
|
356
|
+
const isTestHelperFile = (filename) => /\/tests\/helpers\//.test(normalizePath(filename))
|
|
357
|
+
|
|
358
|
+
/** Bare markers a stub returns when nobody gave it a real answer to stand in for. */
|
|
359
|
+
const MARKER_STRINGS = new Set(["stubbed", "stub", "ok", "test", "mock", "fake", "todo", "tbd", "n/a", "pending", ""])
|
|
360
|
+
|
|
361
|
+
/** A string literal that is nothing but a marker - not an object, not a `JSON.stringify(...)` call. */
|
|
362
|
+
const isMarkerLiteral = (node) => Boolean(
|
|
363
|
+
node && node.type === "Literal" && typeof node.value === "string" && MARKER_STRINGS.has(node.value.trim().toLowerCase()),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
/** The world's default model stub returns a payload the production parser can actually parse. */
|
|
367
|
+
export const noMarkerModelStub = {
|
|
368
|
+
meta: {
|
|
369
|
+
type: "problem",
|
|
370
|
+
docs: {
|
|
371
|
+
description: "A model stub in test infra returns a parseable payload (TESTING-7), never a bare marker string.",
|
|
372
|
+
},
|
|
373
|
+
schema: [],
|
|
374
|
+
messages: {
|
|
375
|
+
marker:
|
|
376
|
+
"This stub resolves to `{{value}}`, a marker rather than an answer. TESTING-7: a stub of a model returns a payload the production parser can actually parse - a marker string means the strict-JSON parser the real flow depends on never runs, so the most fragile seam in the flow is never exercised. Return the shape the production parser expects (an object, or a `JSON.stringify(...)` of one), the way `DEFAULT_MODEL_ANSWER` does.",
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
create(context) {
|
|
380
|
+
if (!isTestHelperFile(context.filename || context.getFilename())) return {}
|
|
381
|
+
return {
|
|
382
|
+
CallExpression(node) {
|
|
383
|
+
const callee = node.callee
|
|
384
|
+
if (!callee || callee.type !== "MemberExpression" || callee.computed) return
|
|
385
|
+
const method = callee.property.name
|
|
386
|
+
if (method === "mockResolvedValue" || method === "mockReturnValue") {
|
|
387
|
+
const argument = node.arguments[0]
|
|
388
|
+
if (isMarkerLiteral(argument)) {
|
|
389
|
+
context.report({ node: argument, messageId: "marker", data: { value: JSON.stringify(argument.value) } })
|
|
390
|
+
}
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
if (method !== "mockImplementation") return
|
|
394
|
+
const fn = node.arguments[0]
|
|
395
|
+
if (!fn || (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression")) return
|
|
396
|
+
if (fn.body.type !== "BlockStatement") {
|
|
397
|
+
if (isMarkerLiteral(fn.body)) {
|
|
398
|
+
context.report({ node: fn.body, messageId: "marker", data: { value: JSON.stringify(fn.body.value) } })
|
|
399
|
+
}
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
for (const statement of fn.body.body) {
|
|
403
|
+
if (statement.type === "ReturnStatement" && isMarkerLiteral(statement.argument)) {
|
|
404
|
+
context.report({ node: statement.argument, messageId: "marker", data: { value: JSON.stringify(statement.argument.value) } })
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
280
412
|
/** The rules this law contributes to the plugin. */
|
|
281
413
|
export const rules = {
|
|
282
414
|
"no-call-only-spec": noCallOnlySpec,
|
|
283
415
|
"e2e-asserts-persisted-state": e2eAssertsPersistedState,
|
|
284
416
|
"no-model-call-in-e2e": noModelCallInE2e,
|
|
285
417
|
"harness-calls-provider-directly": harnessCallsProviderDirectly,
|
|
418
|
+
"no-api-shaped-e2e-filename": noApiShapedE2eFilename,
|
|
419
|
+
"no-marker-model-stub": noMarkerModelStub,
|
|
286
420
|
}
|
|
287
421
|
|
|
288
422
|
/**
|
|
@@ -298,10 +432,23 @@ export const rules = {
|
|
|
298
432
|
* config never loaded. Count only the reports carrying this rule's own id.
|
|
299
433
|
*
|
|
300
434
|
* Both land at `warn` with the count, get burned down, and flip to `error` at zero.
|
|
435
|
+
*
|
|
436
|
+
* The two later rules were measured the same way, against the same repository:
|
|
437
|
+
*
|
|
438
|
+
* - `no-api-shaped-e2e-filename` found TWO of 77 - `installment-plan-queries.e2e-spec.ts` and
|
|
439
|
+
* `rewards-queries.e2e-spec.ts`, the exact anchor example the law names. Real, pre-existing debt
|
|
440
|
+
* this task does not fix, so it lands at `warn` rather than claiming a burn-down that has not
|
|
441
|
+
* happened.
|
|
442
|
+
* - `no-marker-model-stub` found ZERO of the world's own stub in `tests/helpers/flow-world.ts`:
|
|
443
|
+
* the default answer is `JSON.stringify({ answer: "..." })`, which is a `CallExpression`, not a
|
|
444
|
+
* `Literal`, so the rule does not even see it as a candidate. It ships at `error` as a
|
|
445
|
+
* regression guard on a shape that is already correct.
|
|
301
446
|
*/
|
|
302
447
|
export const recommended = {
|
|
303
448
|
"starci-be/no-call-only-spec": "error", // no=0 of 182 - burned down from 1
|
|
304
449
|
"starci-be/e2e-asserts-persisted-state": "error", // no=0 of 47 - burned down from 1
|
|
305
450
|
"starci-be/no-model-call-in-e2e": "error", // no=0 of 47
|
|
306
451
|
"starci-be/harness-calls-provider-directly": "error",
|
|
452
|
+
"starci-be/no-api-shaped-e2e-filename": "warn", // no=2 of 77 - pre-existing, not burned down here
|
|
453
|
+
"starci-be/no-marker-model-stub": "error", // no=0 - the world's stub already returns JSON.stringify(...)
|
|
307
454
|
}
|
package/transport.mjs
CHANGED
|
@@ -1,103 +1,168 @@
|
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
/** An import specifier's alias prefix for a door. */
|
|
96
|
+
const FEATURES_ALIAS = "@features/"
|
|
97
|
+
|
|
98
|
+
/** A relative or deep specifier climbing into `features/`, once the alias form is ruled out. */
|
|
99
|
+
const FEATURES_SEGMENT = /(^|\/)features\//
|
|
100
|
+
|
|
101
|
+
/** Whether an import specifier reaches into `features/`, by alias or by path. */
|
|
102
|
+
const reachesFeatures = (specifier) => {
|
|
103
|
+
if (typeof specifier !== "string") return false
|
|
104
|
+
if (specifier.startsWith(FEATURES_ALIAS)) return true
|
|
105
|
+
return FEATURES_SEGMENT.test(specifier)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rule 6: "A capability stays under `modules/` and is called by a door, never reached directly."
|
|
110
|
+
* A door under `features/` importing a capability under `modules/` is the entire shape TRANSPORT-3
|
|
111
|
+
* sets up, and this rule is not that edge - it fires only the other way: a file living under
|
|
112
|
+
* `modules/` reaching back into `features/`. That reversal makes the capability the caller and the
|
|
113
|
+
* door the thing being reached, which is backwards from what a capability is - it is CALLED, and it
|
|
114
|
+
* stays ignorant of who calls it. One capability importing another capability under `modules/` is
|
|
115
|
+
* untouched here; this rule polices one edge, not the whole graph.
|
|
116
|
+
*/
|
|
117
|
+
export const noCapabilityImportsFeatures = {
|
|
118
|
+
meta: {
|
|
119
|
+
type: "problem",
|
|
120
|
+
docs: {
|
|
121
|
+
description:
|
|
122
|
+
"TRANSPORT Rule 6: a capability under modules/ never imports from features/ - a door calls into a capability, never the other way round.",
|
|
123
|
+
},
|
|
124
|
+
schema: [],
|
|
125
|
+
messages: {
|
|
126
|
+
reversed:
|
|
127
|
+
"`{{specifier}}` reaches from a capability under modules/ into features/, where doors live. A capability is CALLED by a door and stays ignorant of who calls it; importing back into features/ makes the capability the caller, which is the exact edge this law forbids. Move the shared code under modules/, or invert the call so the door reaches in.",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
create(context) {
|
|
131
|
+
const path = normalize(context.filename || context.getFilename())
|
|
132
|
+
if (!/\/src\/modules\//.test(path)) return {}
|
|
133
|
+
const check = (node, specifier) => {
|
|
134
|
+
if (reachesFeatures(specifier)) context.report({ node, messageId: "reversed", data: { specifier } })
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
ImportDeclaration(node) {
|
|
138
|
+
check(node.source, node.source.value)
|
|
139
|
+
},
|
|
140
|
+
ExportNamedDeclaration(node) {
|
|
141
|
+
if (node.source) check(node.source, node.source.value)
|
|
142
|
+
},
|
|
143
|
+
ExportAllDeclaration(node) {
|
|
144
|
+
if (node.source) check(node.source, node.source.value)
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const rules = {
|
|
151
|
+
"rest-door-needs-a-reason": restDoorNeedsAReason,
|
|
152
|
+
"door-lives-in-features": doorLivesInFeatures,
|
|
153
|
+
"no-capability-imports-features": noCapabilityImportsFeatures,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `no-capability-imports-features` measures at 9 offenders across 6 files in the reference
|
|
158
|
+
* repository (`src/**`, probed against source directly since the published package predates this
|
|
159
|
+
* rule) -- every one a capability under `modules/bussiness/**` importing a single shared helper
|
|
160
|
+
* published under `@features/api/processors/ai/shared/xp/write-coin-history`. That is standing debt,
|
|
161
|
+
* not zero, so it ships at `warn` with the count on record, matching this module's own Exceptions
|
|
162
|
+
* section: measured debt arrives at `warn` and flips to `error` once it reaches zero.
|
|
163
|
+
*/
|
|
164
|
+
export const recommended = {
|
|
165
|
+
"starci-be/rest-door-needs-a-reason": "error",
|
|
166
|
+
"starci-be/door-lives-in-features": "error",
|
|
167
|
+
"starci-be/no-capability-imports-features": "warn",
|
|
168
|
+
}
|