@starci/eslint-canon-be 1.2.0 → 1.2.2
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 +7 -0
- package/README.md +70 -69
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +388 -389
- package/cqrs.mjs +302 -303
- package/data-access.mjs +306 -307
- package/e2e-flow.mjs +222 -222
- package/event-delivery.mjs +93 -93
- package/exception-identity.mjs +200 -200
- package/exceptions.mjs +262 -265
- package/index.mjs +107 -107
- package/lib/path.mjs +2 -0
- package/module-layering.mjs +426 -432
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/testing.mjs +500 -454
- package/transport.mjs +168 -168
- package/type-safety.mjs +510 -380
package/observability.mjs
CHANGED
|
@@ -1,275 +1,275 @@
|
|
|
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
|
-
// -- OBSERVABILITY-5 -------------------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
/** Whether `node` is a bare reference to the caught error, by name. */
|
|
108
|
-
const isIdentifierNamed = (node, name) => Boolean(node) && node.type === "Identifier" && node.name === name
|
|
109
|
-
|
|
110
|
-
/** How each wording shape reads back to the author, keyed by the `kind` pushed in `scanErrorUsage`. */
|
|
111
|
-
const WORDING_SHAPES = {
|
|
112
|
-
message: (name) => `\`${name}.message\``,
|
|
113
|
-
stringWrap: (name) => `\`String(${name})\``,
|
|
114
|
-
template: (name) => `a template literal that interpolates \`${name}\` directly`,
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Walk a log call's data arguments for two things beside the caught error: a reference that carries
|
|
119
|
-
* only its rendered WORDING (`.message`, `String(error)`, `${error}` inside a template), and a
|
|
120
|
-
* reference that carries its IDENTITY (`.code`). The walk is a shallow recursion over the expression
|
|
121
|
-
* shapes a data object is normally built from - object properties, array elements, template
|
|
122
|
-
* expressions, ternary branches, boolean joins. It does not need a type checker: these three wording
|
|
123
|
-
* shapes and the one identity shape are syntactic, not semantic, which is exactly the boundary
|
|
124
|
-
* OBSERVABILITY-5 draws between what a parser can hold and what needs a reader.
|
|
125
|
-
*/
|
|
126
|
-
const scanErrorUsage = (node, errorName, found) => {
|
|
127
|
-
if (!node || typeof node !== "object" || typeof node.type !== "string") return
|
|
128
|
-
switch (node.type) {
|
|
129
|
-
case "MemberExpression":
|
|
130
|
-
if (!node.computed && isIdentifierNamed(node.object, errorName) && node.property.type === "Identifier") {
|
|
131
|
-
if (node.property.name === "message") found.wording.push({ node, kind: "message" })
|
|
132
|
-
if (node.property.name === "code") found.identity.push(node)
|
|
133
|
-
}
|
|
134
|
-
scanErrorUsage(node.object, errorName, found)
|
|
135
|
-
break
|
|
136
|
-
case "CallExpression":
|
|
137
|
-
if (
|
|
138
|
-
node.callee.type === "Identifier" && node.callee.name === "String"
|
|
139
|
-
&& node.arguments.length === 1 && isIdentifierNamed(node.arguments[0], errorName)
|
|
140
|
-
) {
|
|
141
|
-
found.wording.push({ node, kind: "stringWrap" })
|
|
142
|
-
}
|
|
143
|
-
for (const arg of node.arguments) scanErrorUsage(arg, errorName, found)
|
|
144
|
-
break
|
|
145
|
-
case "TemplateLiteral":
|
|
146
|
-
for (const expr of node.expressions) {
|
|
147
|
-
if (isIdentifierNamed(expr, errorName)) found.wording.push({ node: expr, kind: "template" })
|
|
148
|
-
else scanErrorUsage(expr, errorName, found)
|
|
149
|
-
}
|
|
150
|
-
break
|
|
151
|
-
case "ObjectExpression":
|
|
152
|
-
for (const prop of node.properties) scanErrorUsage(prop, errorName, found)
|
|
153
|
-
break
|
|
154
|
-
case "Property":
|
|
155
|
-
scanErrorUsage(node.value, errorName, found)
|
|
156
|
-
break
|
|
157
|
-
case "ArrayExpression":
|
|
158
|
-
for (const el of node.elements) scanErrorUsage(el, errorName, found)
|
|
159
|
-
break
|
|
160
|
-
case "ConditionalExpression":
|
|
161
|
-
scanErrorUsage(node.consequent, errorName, found)
|
|
162
|
-
scanErrorUsage(node.alternate, errorName, found)
|
|
163
|
-
break
|
|
164
|
-
case "LogicalExpression":
|
|
165
|
-
case "BinaryExpression":
|
|
166
|
-
scanErrorUsage(node.left, errorName, found)
|
|
167
|
-
scanErrorUsage(node.right, errorName, found)
|
|
168
|
-
break
|
|
169
|
-
default:
|
|
170
|
-
break
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* A failure log's data carries the exception's `code`, not only its rendered message. Scoped to log
|
|
176
|
-
* calls sitting inside a `catch`, and to references to that `catch`'s OWN error identifier - a field
|
|
177
|
-
* that happens to be named `error` elsewhere is not this rule's business, and neither is a call
|
|
178
|
-
* outside a `catch`, since OBSERVABILITY-5 only binds where the line is what an alert groups by.
|
|
179
|
-
*/
|
|
180
|
-
export const noErrorWordingAsLogIdentity = {
|
|
181
|
-
meta: {
|
|
182
|
-
type: "problem",
|
|
183
|
-
docs: {
|
|
184
|
-
description:
|
|
185
|
-
"OBSERVABILITY-5 (Rule 5): a failure log's data carries the exception's `code`, so improving the wording never splits an alert in two.",
|
|
186
|
-
},
|
|
187
|
-
schema: [],
|
|
188
|
-
messages: {
|
|
189
|
-
wordingOnly:
|
|
190
|
-
"This failure log carries {{shape}} but never `{{errorName}}.code`. Wording is what a person reads; code is what a dashboard groups by. Fuse the two and the alert lives or dies on how the sentence happens to be phrased today - reword it tomorrow for clarity and the old alert goes silent while a new one opens under the reworded text. Add `{{errorName}}.code` (and any other identity metadata) to the data, and keep the message only as a secondary field beside it.",
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
create(context) {
|
|
194
|
-
const catchStack = []
|
|
195
|
-
return {
|
|
196
|
-
CatchClause(node) {
|
|
197
|
-
catchStack.push(node.param && node.param.type === "Identifier" ? node.param.name : null)
|
|
198
|
-
},
|
|
199
|
-
"CatchClause:exit"() {
|
|
200
|
-
catchStack.pop()
|
|
201
|
-
},
|
|
202
|
-
CallExpression(node) {
|
|
203
|
-
const errorName = catchStack[catchStack.length - 1]
|
|
204
|
-
if (!errorName) return
|
|
205
|
-
const callee = node.callee
|
|
206
|
-
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
207
|
-
const method = callee.property.name
|
|
208
|
-
if (!LOG_METHODS.has(method)) return
|
|
209
|
-
if (!isLoggerReceiver(callee.object)) return
|
|
210
|
-
|
|
211
|
-
// the event name (first argument) is OBSERVABILITY-2's business, not this rule's
|
|
212
|
-
const dataArgs = node.arguments.slice(1)
|
|
213
|
-
if (dataArgs.length === 0) return
|
|
214
|
-
|
|
215
|
-
const found = { wording: [], identity: [] }
|
|
216
|
-
for (const arg of dataArgs) scanErrorUsage(arg, errorName, found)
|
|
217
|
-
if (found.wording.length > 0 && found.identity.length === 0) {
|
|
218
|
-
const first = found.wording[0]
|
|
219
|
-
context.report({
|
|
220
|
-
node: first.node,
|
|
221
|
-
messageId: "wordingOnly",
|
|
222
|
-
data: { errorName, shape: WORDING_SHAPES[first.kind](errorName) },
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
},
|
|
226
|
-
}
|
|
227
|
-
},
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** The rules this law contributes to the plugin. */
|
|
231
|
-
export const rules = {
|
|
232
|
-
"no-framework-logger": noFrameworkLogger,
|
|
233
|
-
"no-interpolated-log-message": noInterpolatedLogMessage,
|
|
234
|
-
"no-error-wording-as-log-identity": noErrorWordingAsLogIdentity,
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
239
|
-
*
|
|
240
|
-
* `no-framework-logger` and `no-interpolated-log-message` are both measured at zero debt in the
|
|
241
|
-
* reference repository ONCE the sanctioned exit is scoped: `no-framework-logger` reports four sites
|
|
242
|
-
* under the standalone-agent folder, and those are the exit rather than the debt. An agent running
|
|
243
|
-
* outside the request lifecycle has no request to correlate and no transport configured, so the
|
|
244
|
-
* house service would give it a dependency and nothing else. Scope that folder off in the consuming
|
|
245
|
-
* config -- once, by path -- rather than letting per-line suppressions accumulate until nobody can
|
|
246
|
-
* see how wide the exception has grown.
|
|
247
|
-
*
|
|
248
|
-
* `no-error-wording-as-log-identity` measures at 64 offenders across 39 files in the reference
|
|
249
|
-
* repository (`src/**`, probed against source directly since the published package predates this
|
|
250
|
-
* rule) -- real debt, not zero, so it ships at `warn` with that count on record rather than `error`.
|
|
251
|
-
* Flip to `error` once the count reaches zero; shipping `error` over standing debt is what teaches an
|
|
252
|
-
* author to scroll past a rule instead of fixing it.
|
|
253
|
-
*
|
|
254
|
-
* `no-console` is the standard rule rather than a house one, listed here so a repository switches
|
|
255
|
-
* all three on together: it is the third way out of the pipeline, and leaving it off makes the
|
|
256
|
-
* other two decorative.
|
|
257
|
-
*/
|
|
258
|
-
export const recommended = {
|
|
259
|
-
"starci-be/no-framework-logger": "error",
|
|
260
|
-
"starci-be/no-interpolated-log-message": "error",
|
|
261
|
-
"starci-be/no-error-wording-as-log-identity": "warn",
|
|
262
|
-
"no-console": "error",
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Paths where a plain logger is sanctioned -- programs with no request to correlate.
|
|
267
|
-
*
|
|
268
|
-
* Exported so a consuming config and a measuring gate use the SAME list. Two copies of an exemption
|
|
269
|
-
* is how one of them silently grows.
|
|
270
|
-
*/
|
|
271
|
-
export const standaloneProgramGlobs = [
|
|
272
|
-
"src/modules/playground-agent-core/**",
|
|
273
|
-
"apps/playground-*-agent/**",
|
|
274
|
-
"apps/cli/**",
|
|
275
|
-
]
|
|
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
|
+
// -- OBSERVABILITY-5 -------------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Whether `node` is a bare reference to the caught error, by name. */
|
|
108
|
+
const isIdentifierNamed = (node, name) => Boolean(node) && node.type === "Identifier" && node.name === name
|
|
109
|
+
|
|
110
|
+
/** How each wording shape reads back to the author, keyed by the `kind` pushed in `scanErrorUsage`. */
|
|
111
|
+
const WORDING_SHAPES = {
|
|
112
|
+
message: (name) => `\`${name}.message\``,
|
|
113
|
+
stringWrap: (name) => `\`String(${name})\``,
|
|
114
|
+
template: (name) => `a template literal that interpolates \`${name}\` directly`,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walk a log call's data arguments for two things beside the caught error: a reference that carries
|
|
119
|
+
* only its rendered WORDING (`.message`, `String(error)`, `${error}` inside a template), and a
|
|
120
|
+
* reference that carries its IDENTITY (`.code`). The walk is a shallow recursion over the expression
|
|
121
|
+
* shapes a data object is normally built from - object properties, array elements, template
|
|
122
|
+
* expressions, ternary branches, boolean joins. It does not need a type checker: these three wording
|
|
123
|
+
* shapes and the one identity shape are syntactic, not semantic, which is exactly the boundary
|
|
124
|
+
* OBSERVABILITY-5 draws between what a parser can hold and what needs a reader.
|
|
125
|
+
*/
|
|
126
|
+
const scanErrorUsage = (node, errorName, found) => {
|
|
127
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return
|
|
128
|
+
switch (node.type) {
|
|
129
|
+
case "MemberExpression":
|
|
130
|
+
if (!node.computed && isIdentifierNamed(node.object, errorName) && node.property.type === "Identifier") {
|
|
131
|
+
if (node.property.name === "message") found.wording.push({ node, kind: "message" })
|
|
132
|
+
if (node.property.name === "code") found.identity.push(node)
|
|
133
|
+
}
|
|
134
|
+
scanErrorUsage(node.object, errorName, found)
|
|
135
|
+
break
|
|
136
|
+
case "CallExpression":
|
|
137
|
+
if (
|
|
138
|
+
node.callee.type === "Identifier" && node.callee.name === "String"
|
|
139
|
+
&& node.arguments.length === 1 && isIdentifierNamed(node.arguments[0], errorName)
|
|
140
|
+
) {
|
|
141
|
+
found.wording.push({ node, kind: "stringWrap" })
|
|
142
|
+
}
|
|
143
|
+
for (const arg of node.arguments) scanErrorUsage(arg, errorName, found)
|
|
144
|
+
break
|
|
145
|
+
case "TemplateLiteral":
|
|
146
|
+
for (const expr of node.expressions) {
|
|
147
|
+
if (isIdentifierNamed(expr, errorName)) found.wording.push({ node: expr, kind: "template" })
|
|
148
|
+
else scanErrorUsage(expr, errorName, found)
|
|
149
|
+
}
|
|
150
|
+
break
|
|
151
|
+
case "ObjectExpression":
|
|
152
|
+
for (const prop of node.properties) scanErrorUsage(prop, errorName, found)
|
|
153
|
+
break
|
|
154
|
+
case "Property":
|
|
155
|
+
scanErrorUsage(node.value, errorName, found)
|
|
156
|
+
break
|
|
157
|
+
case "ArrayExpression":
|
|
158
|
+
for (const el of node.elements) scanErrorUsage(el, errorName, found)
|
|
159
|
+
break
|
|
160
|
+
case "ConditionalExpression":
|
|
161
|
+
scanErrorUsage(node.consequent, errorName, found)
|
|
162
|
+
scanErrorUsage(node.alternate, errorName, found)
|
|
163
|
+
break
|
|
164
|
+
case "LogicalExpression":
|
|
165
|
+
case "BinaryExpression":
|
|
166
|
+
scanErrorUsage(node.left, errorName, found)
|
|
167
|
+
scanErrorUsage(node.right, errorName, found)
|
|
168
|
+
break
|
|
169
|
+
default:
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A failure log's data carries the exception's `code`, not only its rendered message. Scoped to log
|
|
176
|
+
* calls sitting inside a `catch`, and to references to that `catch`'s OWN error identifier - a field
|
|
177
|
+
* that happens to be named `error` elsewhere is not this rule's business, and neither is a call
|
|
178
|
+
* outside a `catch`, since OBSERVABILITY-5 only binds where the line is what an alert groups by.
|
|
179
|
+
*/
|
|
180
|
+
export const noErrorWordingAsLogIdentity = {
|
|
181
|
+
meta: {
|
|
182
|
+
type: "problem",
|
|
183
|
+
docs: {
|
|
184
|
+
description:
|
|
185
|
+
"OBSERVABILITY-5 (Rule 5): a failure log's data carries the exception's `code`, so improving the wording never splits an alert in two.",
|
|
186
|
+
},
|
|
187
|
+
schema: [],
|
|
188
|
+
messages: {
|
|
189
|
+
wordingOnly:
|
|
190
|
+
"This failure log carries {{shape}} but never `{{errorName}}.code`. Wording is what a person reads; code is what a dashboard groups by. Fuse the two and the alert lives or dies on how the sentence happens to be phrased today - reword it tomorrow for clarity and the old alert goes silent while a new one opens under the reworded text. Add `{{errorName}}.code` (and any other identity metadata) to the data, and keep the message only as a secondary field beside it.",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
create(context) {
|
|
194
|
+
const catchStack = []
|
|
195
|
+
return {
|
|
196
|
+
CatchClause(node) {
|
|
197
|
+
catchStack.push(node.param && node.param.type === "Identifier" ? node.param.name : null)
|
|
198
|
+
},
|
|
199
|
+
"CatchClause:exit"() {
|
|
200
|
+
catchStack.pop()
|
|
201
|
+
},
|
|
202
|
+
CallExpression(node) {
|
|
203
|
+
const errorName = catchStack[catchStack.length - 1]
|
|
204
|
+
if (!errorName) return
|
|
205
|
+
const callee = node.callee
|
|
206
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
207
|
+
const method = callee.property.name
|
|
208
|
+
if (!LOG_METHODS.has(method)) return
|
|
209
|
+
if (!isLoggerReceiver(callee.object)) return
|
|
210
|
+
|
|
211
|
+
// the event name (first argument) is OBSERVABILITY-2's business, not this rule's
|
|
212
|
+
const dataArgs = node.arguments.slice(1)
|
|
213
|
+
if (dataArgs.length === 0) return
|
|
214
|
+
|
|
215
|
+
const found = { wording: [], identity: [] }
|
|
216
|
+
for (const arg of dataArgs) scanErrorUsage(arg, errorName, found)
|
|
217
|
+
if (found.wording.length > 0 && found.identity.length === 0) {
|
|
218
|
+
const first = found.wording[0]
|
|
219
|
+
context.report({
|
|
220
|
+
node: first.node,
|
|
221
|
+
messageId: "wordingOnly",
|
|
222
|
+
data: { errorName, shape: WORDING_SHAPES[first.kind](errorName) },
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The rules this law contributes to the plugin. */
|
|
231
|
+
export const rules = {
|
|
232
|
+
"no-framework-logger": noFrameworkLogger,
|
|
233
|
+
"no-interpolated-log-message": noInterpolatedLogMessage,
|
|
234
|
+
"no-error-wording-as-log-identity": noErrorWordingAsLogIdentity,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
239
|
+
*
|
|
240
|
+
* `no-framework-logger` and `no-interpolated-log-message` are both measured at zero debt in the
|
|
241
|
+
* reference repository ONCE the sanctioned exit is scoped: `no-framework-logger` reports four sites
|
|
242
|
+
* under the standalone-agent folder, and those are the exit rather than the debt. An agent running
|
|
243
|
+
* outside the request lifecycle has no request to correlate and no transport configured, so the
|
|
244
|
+
* house service would give it a dependency and nothing else. Scope that folder off in the consuming
|
|
245
|
+
* config -- once, by path -- rather than letting per-line suppressions accumulate until nobody can
|
|
246
|
+
* see how wide the exception has grown.
|
|
247
|
+
*
|
|
248
|
+
* `no-error-wording-as-log-identity` measures at 64 offenders across 39 files in the reference
|
|
249
|
+
* repository (`src/**`, probed against source directly since the published package predates this
|
|
250
|
+
* rule) -- real debt, not zero, so it ships at `warn` with that count on record rather than `error`.
|
|
251
|
+
* Flip to `error` once the count reaches zero; shipping `error` over standing debt is what teaches an
|
|
252
|
+
* author to scroll past a rule instead of fixing it.
|
|
253
|
+
*
|
|
254
|
+
* `no-console` is the standard rule rather than a house one, listed here so a repository switches
|
|
255
|
+
* all three on together: it is the third way out of the pipeline, and leaving it off makes the
|
|
256
|
+
* other two decorative.
|
|
257
|
+
*/
|
|
258
|
+
export const recommended = {
|
|
259
|
+
"starci-be/no-framework-logger": "error",
|
|
260
|
+
"starci-be/no-interpolated-log-message": "error",
|
|
261
|
+
"starci-be/no-error-wording-as-log-identity": "warn",
|
|
262
|
+
"no-console": "error",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Paths where a plain logger is sanctioned -- programs with no request to correlate.
|
|
267
|
+
*
|
|
268
|
+
* Exported so a consuming config and a measuring gate use the SAME list. Two copies of an exemption
|
|
269
|
+
* is how one of them silently grows.
|
|
270
|
+
*/
|
|
271
|
+
export const standaloneProgramGlobs = [
|
|
272
|
+
"src/modules/playground-agent-core/**",
|
|
273
|
+
"apps/playground-*-agent/**",
|
|
274
|
+
"apps/cli/**",
|
|
275
|
+
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-canon-be",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
"main": "./index.mjs",
|
|
12
12
|
"files": [
|
|
13
13
|
"*.mjs",
|
|
14
|
+
"lib/*.mjs",
|
|
14
15
|
"!*.test.mjs",
|
|
15
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md"
|
|
16
18
|
],
|
|
17
19
|
"engines": {
|
|
18
20
|
"node": ">=20.9.0"
|