@noctcore/eslint-plugin-observability 0.1.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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @noctcore/eslint-plugin-observability
2
+
3
+ Structured-logging discipline rules — context objects over interpolated messages, no sensitive fields
4
+ in logs, no error-detail loss. Flat-config only, ESLint 9+.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ bun add -D @noctcore/eslint-plugin-observability # or npm i -D / pnpm add -D
10
+ ```
11
+
12
+ ## Use
13
+
14
+ ```js
15
+ // eslint.config.js
16
+ import observability from '@noctcore/eslint-plugin-observability';
17
+
18
+ export default [
19
+ observability.configs.recommended,
20
+ ];
21
+ ```
22
+
23
+ Or wire rules individually:
24
+
25
+ ```js
26
+ import observability from '@noctcore/eslint-plugin-observability';
27
+
28
+ export default [
29
+ {
30
+ plugins: { 'noctcore-observability': observability },
31
+ rules: {
32
+ 'noctcore-observability/structured-log-arguments': ['error', { loggers: ['log', 'audit'] }],
33
+ },
34
+ },
35
+ ];
36
+ ```
37
+
38
+ ## Rules
39
+
40
+ | Rule | Description | Recommended |
41
+ | --- | --- | --- |
42
+ | [`structured-log-arguments`](./docs/rules/structured-log-arguments.md) | Pass dynamic values in a structured context object, not interpolated into the message string. | `error` |
43
+ | [`no-sensitive-fields-in-logs`](./docs/rules/no-sensitive-fields-in-logs.md) | Name-heuristic guard against writing credentials/secrets into log sinks. | `warn` |
44
+ | [`no-error-detail-loss`](./docs/rules/no-error-detail-loss.md) | A catch block that reports failure must log the error itself, not only `e.message`. | `error` |
package/dist/index.cjs ADDED
@@ -0,0 +1,354 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ configs: () => configs,
24
+ default: () => index_default,
25
+ rules: () => rules
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/configs/recommended.ts
30
+ var recommended = {
31
+ "noctcore-observability/structured-log-arguments": "error",
32
+ "noctcore-observability/no-sensitive-fields-in-logs": "warn",
33
+ "noctcore-observability/no-error-detail-loss": "error"
34
+ };
35
+
36
+ // src/rules/no-error-detail-loss.ts
37
+ var import_utils2 = require("@typescript-eslint/utils");
38
+
39
+ // src/createRule.ts
40
+ var import_eslint_utils = require("@noctcore/eslint-utils");
41
+ var createRule = (0, import_eslint_utils.makeCreateRule)("observability");
42
+
43
+ // src/utils.ts
44
+ var import_utils = require("@typescript-eslint/utils");
45
+ var DEFAULT_LOGGERS = ["console", "logger", "log"];
46
+ var LOG_METHODS = /* @__PURE__ */ new Set([
47
+ "info",
48
+ "warn",
49
+ "error",
50
+ "debug"
51
+ ]);
52
+ function loggerObjectName(object) {
53
+ if (object.type === import_utils.AST_NODE_TYPES.Identifier) {
54
+ return object.name;
55
+ }
56
+ if (object.type === import_utils.AST_NODE_TYPES.MemberExpression && !object.computed && object.property.type === import_utils.AST_NODE_TYPES.Identifier) {
57
+ return object.property.name;
58
+ }
59
+ return void 0;
60
+ }
61
+ function loggerCallMethod(node, loggers) {
62
+ const callee = node.callee;
63
+ if (callee.type !== import_utils.AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== import_utils.AST_NODE_TYPES.Identifier) {
64
+ return null;
65
+ }
66
+ const method = callee.property.name;
67
+ if (!LOG_METHODS.has(method)) {
68
+ return null;
69
+ }
70
+ const name = loggerObjectName(callee.object);
71
+ if (name === void 0 || !loggers.has(name)) {
72
+ return null;
73
+ }
74
+ return method;
75
+ }
76
+
77
+ // src/rules/no-error-detail-loss.ts
78
+ var RULE_NAME = "no-error-detail-loss";
79
+ function containsLoggerCall(block, loggers) {
80
+ let found = false;
81
+ function walk(node) {
82
+ if (found) {
83
+ return;
84
+ }
85
+ if (node.type === import_utils2.AST_NODE_TYPES.CallExpression && loggerCallMethod(node, loggers) !== null) {
86
+ found = true;
87
+ return;
88
+ }
89
+ for (const key of Object.keys(node)) {
90
+ if (key === "parent") {
91
+ continue;
92
+ }
93
+ const value = node[key];
94
+ if (Array.isArray(value)) {
95
+ for (const child of value) {
96
+ if (child && typeof child.type === "string") {
97
+ walk(child);
98
+ }
99
+ }
100
+ } else if (value && typeof value.type === "string") {
101
+ walk(value);
102
+ }
103
+ }
104
+ }
105
+ walk(block);
106
+ return found;
107
+ }
108
+ function isLossyReference(id) {
109
+ const parent = id.parent;
110
+ if (parent.type === import_utils2.AST_NODE_TYPES.MemberExpression && parent.object === id && !parent.computed && parent.property.type === import_utils2.AST_NODE_TYPES.Identifier && parent.property.name === "message") {
111
+ return true;
112
+ }
113
+ if (parent.type === import_utils2.AST_NODE_TYPES.CallExpression && parent.callee.type === import_utils2.AST_NODE_TYPES.Identifier && parent.callee.name === "String" && parent.arguments.includes(id)) {
114
+ return true;
115
+ }
116
+ if (parent.type === import_utils2.AST_NODE_TYPES.TemplateLiteral) {
117
+ return true;
118
+ }
119
+ return false;
120
+ }
121
+ var noErrorDetailLossRule = createRule({
122
+ name: RULE_NAME,
123
+ meta: {
124
+ type: "suggestion",
125
+ docs: {
126
+ description: "A catch block that reports a failure must not reduce the error to only `e.message` / `String(e)` / `${e}` \u2014 log the error itself so its stack and cause survive."
127
+ },
128
+ schema: [],
129
+ messages: {
130
+ detailLoss: "This catch logs only the error message \u2014 its stack and `cause` are lost. Log the error object itself (e.g. `log.error('...', { err: {{name}} })`) so the failure is diagnosable."
131
+ }
132
+ },
133
+ defaultOptions: [],
134
+ create(context) {
135
+ const loggers = new Set(DEFAULT_LOGGERS);
136
+ return {
137
+ CatchClause(node) {
138
+ if (node.param === null || node.param.type !== import_utils2.AST_NODE_TYPES.Identifier) {
139
+ return;
140
+ }
141
+ if (!containsLoggerCall(node.body, loggers)) {
142
+ return;
143
+ }
144
+ const [variable] = context.sourceCode.getDeclaredVariables(node);
145
+ if (variable === void 0) {
146
+ return;
147
+ }
148
+ const reads = variable.references.filter((ref) => ref.isRead());
149
+ if (reads.length === 0) {
150
+ return;
151
+ }
152
+ const allLossy = reads.every(
153
+ (ref) => isLossyReference(ref.identifier)
154
+ );
155
+ if (!allLossy) {
156
+ return;
157
+ }
158
+ context.report({
159
+ node: node.param,
160
+ messageId: "detailLoss",
161
+ data: { name: node.param.name }
162
+ });
163
+ }
164
+ };
165
+ }
166
+ });
167
+
168
+ // src/rules/no-sensitive-fields-in-logs.ts
169
+ var import_utils4 = require("@typescript-eslint/utils");
170
+ var RULE_NAME2 = "no-sensitive-fields-in-logs";
171
+ var DEFAULT_DENY_NAMES = [
172
+ "password",
173
+ "token",
174
+ "secret",
175
+ "authorization",
176
+ "cookie",
177
+ "apiKey",
178
+ "ssn"
179
+ ];
180
+ var optionSchema = {
181
+ type: "object",
182
+ additionalProperties: false,
183
+ properties: {
184
+ denyNames: {
185
+ type: "array",
186
+ items: { type: "string" }
187
+ }
188
+ }
189
+ };
190
+ function nameSegments(name) {
191
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^a-zA-Z0-9]+/).join(" ").toLowerCase().split(/\s+/).filter(Boolean);
192
+ }
193
+ function compact(name) {
194
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
195
+ }
196
+ function makeMatcher(denyNames) {
197
+ const single = [];
198
+ const multi = [];
199
+ for (const deny of denyNames) {
200
+ const segs = nameSegments(deny);
201
+ if (segs.length <= 1) {
202
+ single.push(compact(deny));
203
+ } else {
204
+ multi.push(compact(deny));
205
+ }
206
+ }
207
+ return (name) => {
208
+ const segs = new Set(nameSegments(name));
209
+ if (single.some((deny) => segs.has(deny))) {
210
+ return true;
211
+ }
212
+ const flat = compact(name);
213
+ return multi.some((deny) => flat.includes(deny));
214
+ };
215
+ }
216
+ var noSensitiveFieldsInLogsRule = createRule({
217
+ name: RULE_NAME2,
218
+ meta: {
219
+ type: "suggestion",
220
+ docs: {
221
+ description: "A logger call must not reference an identifier, property, or object key whose name matches a sensitive-field denylist (password, token, secret, ...). Redact it before logging."
222
+ },
223
+ schema: [optionSchema],
224
+ messages: {
225
+ sensitiveField: "`{{name}}` looks like a sensitive field being written to a log sink. Redact it before logging (e.g. `redact({{name}})`) or omit it \u2014 logs are long-lived and widely readable."
226
+ }
227
+ },
228
+ defaultOptions: [{ denyNames: DEFAULT_DENY_NAMES }],
229
+ create(context, [options]) {
230
+ const matches = makeMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
231
+ function walk(node, visit) {
232
+ visit(node);
233
+ for (const key of Object.keys(node)) {
234
+ if (key === "parent") {
235
+ continue;
236
+ }
237
+ const value = node[key];
238
+ if (Array.isArray(value)) {
239
+ for (const child of value) {
240
+ if (child && typeof child.type === "string") {
241
+ walk(child, visit);
242
+ }
243
+ }
244
+ } else if (value && typeof value.type === "string") {
245
+ walk(value, visit);
246
+ }
247
+ }
248
+ }
249
+ return {
250
+ CallExpression(node) {
251
+ if (loggerCallMethod(node, new Set(DEFAULT_LOGGERS)) === null) {
252
+ return;
253
+ }
254
+ const reported = /* @__PURE__ */ new Set();
255
+ const report = (name, target) => {
256
+ const at = target.range[0];
257
+ if (reported.has(at)) {
258
+ return;
259
+ }
260
+ reported.add(at);
261
+ context.report({
262
+ node: target,
263
+ messageId: "sensitiveField",
264
+ data: { name }
265
+ });
266
+ };
267
+ for (const arg of node.arguments) {
268
+ walk(arg, (n) => {
269
+ if (n.type === import_utils4.AST_NODE_TYPES.Identifier && matches(n.name)) {
270
+ report(n.name, n);
271
+ } else if (n.type === import_utils4.AST_NODE_TYPES.Property && n.key.type === import_utils4.AST_NODE_TYPES.Literal && typeof n.key.value === "string" && matches(n.key.value)) {
272
+ report(n.key.value, n.key);
273
+ }
274
+ });
275
+ }
276
+ }
277
+ };
278
+ }
279
+ });
280
+
281
+ // src/rules/structured-log-arguments.ts
282
+ var import_utils6 = require("@typescript-eslint/utils");
283
+ var RULE_NAME3 = "structured-log-arguments";
284
+ var optionSchema2 = {
285
+ type: "object",
286
+ additionalProperties: false,
287
+ properties: {
288
+ loggers: {
289
+ type: "array",
290
+ items: { type: "string" }
291
+ }
292
+ }
293
+ };
294
+ var structuredLogArgumentsRule = createRule({
295
+ name: RULE_NAME3,
296
+ meta: {
297
+ type: "suggestion",
298
+ docs: {
299
+ description: "A logger call must not interpolate dynamic values into the message string (a template literal with expressions). Pass a static message and a structured context object instead."
300
+ },
301
+ schema: [optionSchema2],
302
+ messages: {
303
+ interpolatedMessage: "This `.{{method}}(...)` call interpolates dynamic values into the message string \u2014 they become unqueryable free text. Pass a static message plus a structured context object (e.g. `log.{{method}}('processing task', { taskId })`) so each field stays indexable."
304
+ }
305
+ },
306
+ defaultOptions: [{ loggers: DEFAULT_LOGGERS }],
307
+ create(context, [options]) {
308
+ const loggers = new Set(options.loggers ?? DEFAULT_LOGGERS);
309
+ return {
310
+ CallExpression(node) {
311
+ const method = loggerCallMethod(node, loggers);
312
+ if (method === null) {
313
+ return;
314
+ }
315
+ for (const arg of node.arguments) {
316
+ if (arg.type === import_utils6.AST_NODE_TYPES.TemplateLiteral && arg.expressions.length > 0) {
317
+ context.report({
318
+ node: arg,
319
+ messageId: "interpolatedMessage",
320
+ data: { method }
321
+ });
322
+ }
323
+ }
324
+ }
325
+ };
326
+ }
327
+ });
328
+
329
+ // src/rules/index.ts
330
+ var rules = {
331
+ "structured-log-arguments": structuredLogArgumentsRule,
332
+ "no-sensitive-fields-in-logs": noSensitiveFieldsInLogsRule,
333
+ "no-error-detail-loss": noErrorDetailLossRule
334
+ };
335
+
336
+ // src/index.ts
337
+ var NAMESPACE = "noctcore-observability";
338
+ var VERSION = "0.1.0";
339
+ var plugin = {
340
+ meta: { name: "@noctcore/eslint-plugin-observability", version: VERSION },
341
+ rules,
342
+ configs: {}
343
+ };
344
+ plugin.configs.recommended = {
345
+ plugins: { [NAMESPACE]: plugin },
346
+ rules: recommended
347
+ };
348
+ var configs = plugin.configs;
349
+ var index_default = plugin;
350
+ // Annotate the CommonJS export names for ESM import in node:
351
+ 0 && (module.exports = {
352
+ configs,
353
+ rules
354
+ });
@@ -0,0 +1,45 @@
1
+ import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
+
3
+ interface NoSensitiveFieldsInLogsOptions {
4
+ readonly denyNames?: readonly string[];
5
+ }
6
+
7
+ interface StructuredLogArgumentsOptions {
8
+ readonly loggers?: readonly string[];
9
+ }
10
+
11
+ /** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
12
+ declare const rules: {
13
+ 'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
14
+ name: string;
15
+ };
16
+ 'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
17
+ name: string;
18
+ };
19
+ 'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
20
+ name: string;
21
+ };
22
+ };
23
+
24
+ declare const plugin: {
25
+ meta: {
26
+ name: string;
27
+ version: string;
28
+ };
29
+ rules: {
30
+ 'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
31
+ name: string;
32
+ };
33
+ 'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
34
+ name: string;
35
+ };
36
+ 'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
37
+ name: string;
38
+ };
39
+ };
40
+ configs: Record<string, unknown>;
41
+ };
42
+
43
+ declare const configs: Record<string, unknown>;
44
+
45
+ export { configs, plugin as default, rules };
@@ -0,0 +1,45 @@
1
+ import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
+
3
+ interface NoSensitiveFieldsInLogsOptions {
4
+ readonly denyNames?: readonly string[];
5
+ }
6
+
7
+ interface StructuredLogArgumentsOptions {
8
+ readonly loggers?: readonly string[];
9
+ }
10
+
11
+ /** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
12
+ declare const rules: {
13
+ 'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
14
+ name: string;
15
+ };
16
+ 'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
17
+ name: string;
18
+ };
19
+ 'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
20
+ name: string;
21
+ };
22
+ };
23
+
24
+ declare const plugin: {
25
+ meta: {
26
+ name: string;
27
+ version: string;
28
+ };
29
+ rules: {
30
+ 'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
31
+ name: string;
32
+ };
33
+ 'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
34
+ name: string;
35
+ };
36
+ 'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
37
+ name: string;
38
+ };
39
+ };
40
+ configs: Record<string, unknown>;
41
+ };
42
+
43
+ declare const configs: Record<string, unknown>;
44
+
45
+ export { configs, plugin as default, rules };
package/dist/index.js ADDED
@@ -0,0 +1,326 @@
1
+ // src/configs/recommended.ts
2
+ var recommended = {
3
+ "noctcore-observability/structured-log-arguments": "error",
4
+ "noctcore-observability/no-sensitive-fields-in-logs": "warn",
5
+ "noctcore-observability/no-error-detail-loss": "error"
6
+ };
7
+
8
+ // src/rules/no-error-detail-loss.ts
9
+ import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
10
+
11
+ // src/createRule.ts
12
+ import { makeCreateRule } from "@noctcore/eslint-utils";
13
+ var createRule = makeCreateRule("observability");
14
+
15
+ // src/utils.ts
16
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
17
+ var DEFAULT_LOGGERS = ["console", "logger", "log"];
18
+ var LOG_METHODS = /* @__PURE__ */ new Set([
19
+ "info",
20
+ "warn",
21
+ "error",
22
+ "debug"
23
+ ]);
24
+ function loggerObjectName(object) {
25
+ if (object.type === AST_NODE_TYPES.Identifier) {
26
+ return object.name;
27
+ }
28
+ if (object.type === AST_NODE_TYPES.MemberExpression && !object.computed && object.property.type === AST_NODE_TYPES.Identifier) {
29
+ return object.property.name;
30
+ }
31
+ return void 0;
32
+ }
33
+ function loggerCallMethod(node, loggers) {
34
+ const callee = node.callee;
35
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== AST_NODE_TYPES.Identifier) {
36
+ return null;
37
+ }
38
+ const method = callee.property.name;
39
+ if (!LOG_METHODS.has(method)) {
40
+ return null;
41
+ }
42
+ const name = loggerObjectName(callee.object);
43
+ if (name === void 0 || !loggers.has(name)) {
44
+ return null;
45
+ }
46
+ return method;
47
+ }
48
+
49
+ // src/rules/no-error-detail-loss.ts
50
+ var RULE_NAME = "no-error-detail-loss";
51
+ function containsLoggerCall(block, loggers) {
52
+ let found = false;
53
+ function walk(node) {
54
+ if (found) {
55
+ return;
56
+ }
57
+ if (node.type === AST_NODE_TYPES2.CallExpression && loggerCallMethod(node, loggers) !== null) {
58
+ found = true;
59
+ return;
60
+ }
61
+ for (const key of Object.keys(node)) {
62
+ if (key === "parent") {
63
+ continue;
64
+ }
65
+ const value = node[key];
66
+ if (Array.isArray(value)) {
67
+ for (const child of value) {
68
+ if (child && typeof child.type === "string") {
69
+ walk(child);
70
+ }
71
+ }
72
+ } else if (value && typeof value.type === "string") {
73
+ walk(value);
74
+ }
75
+ }
76
+ }
77
+ walk(block);
78
+ return found;
79
+ }
80
+ function isLossyReference(id) {
81
+ const parent = id.parent;
82
+ if (parent.type === AST_NODE_TYPES2.MemberExpression && parent.object === id && !parent.computed && parent.property.type === AST_NODE_TYPES2.Identifier && parent.property.name === "message") {
83
+ return true;
84
+ }
85
+ if (parent.type === AST_NODE_TYPES2.CallExpression && parent.callee.type === AST_NODE_TYPES2.Identifier && parent.callee.name === "String" && parent.arguments.includes(id)) {
86
+ return true;
87
+ }
88
+ if (parent.type === AST_NODE_TYPES2.TemplateLiteral) {
89
+ return true;
90
+ }
91
+ return false;
92
+ }
93
+ var noErrorDetailLossRule = createRule({
94
+ name: RULE_NAME,
95
+ meta: {
96
+ type: "suggestion",
97
+ docs: {
98
+ description: "A catch block that reports a failure must not reduce the error to only `e.message` / `String(e)` / `${e}` \u2014 log the error itself so its stack and cause survive."
99
+ },
100
+ schema: [],
101
+ messages: {
102
+ detailLoss: "This catch logs only the error message \u2014 its stack and `cause` are lost. Log the error object itself (e.g. `log.error('...', { err: {{name}} })`) so the failure is diagnosable."
103
+ }
104
+ },
105
+ defaultOptions: [],
106
+ create(context) {
107
+ const loggers = new Set(DEFAULT_LOGGERS);
108
+ return {
109
+ CatchClause(node) {
110
+ if (node.param === null || node.param.type !== AST_NODE_TYPES2.Identifier) {
111
+ return;
112
+ }
113
+ if (!containsLoggerCall(node.body, loggers)) {
114
+ return;
115
+ }
116
+ const [variable] = context.sourceCode.getDeclaredVariables(node);
117
+ if (variable === void 0) {
118
+ return;
119
+ }
120
+ const reads = variable.references.filter((ref) => ref.isRead());
121
+ if (reads.length === 0) {
122
+ return;
123
+ }
124
+ const allLossy = reads.every(
125
+ (ref) => isLossyReference(ref.identifier)
126
+ );
127
+ if (!allLossy) {
128
+ return;
129
+ }
130
+ context.report({
131
+ node: node.param,
132
+ messageId: "detailLoss",
133
+ data: { name: node.param.name }
134
+ });
135
+ }
136
+ };
137
+ }
138
+ });
139
+
140
+ // src/rules/no-sensitive-fields-in-logs.ts
141
+ import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
142
+ var RULE_NAME2 = "no-sensitive-fields-in-logs";
143
+ var DEFAULT_DENY_NAMES = [
144
+ "password",
145
+ "token",
146
+ "secret",
147
+ "authorization",
148
+ "cookie",
149
+ "apiKey",
150
+ "ssn"
151
+ ];
152
+ var optionSchema = {
153
+ type: "object",
154
+ additionalProperties: false,
155
+ properties: {
156
+ denyNames: {
157
+ type: "array",
158
+ items: { type: "string" }
159
+ }
160
+ }
161
+ };
162
+ function nameSegments(name) {
163
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^a-zA-Z0-9]+/).join(" ").toLowerCase().split(/\s+/).filter(Boolean);
164
+ }
165
+ function compact(name) {
166
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
167
+ }
168
+ function makeMatcher(denyNames) {
169
+ const single = [];
170
+ const multi = [];
171
+ for (const deny of denyNames) {
172
+ const segs = nameSegments(deny);
173
+ if (segs.length <= 1) {
174
+ single.push(compact(deny));
175
+ } else {
176
+ multi.push(compact(deny));
177
+ }
178
+ }
179
+ return (name) => {
180
+ const segs = new Set(nameSegments(name));
181
+ if (single.some((deny) => segs.has(deny))) {
182
+ return true;
183
+ }
184
+ const flat = compact(name);
185
+ return multi.some((deny) => flat.includes(deny));
186
+ };
187
+ }
188
+ var noSensitiveFieldsInLogsRule = createRule({
189
+ name: RULE_NAME2,
190
+ meta: {
191
+ type: "suggestion",
192
+ docs: {
193
+ description: "A logger call must not reference an identifier, property, or object key whose name matches a sensitive-field denylist (password, token, secret, ...). Redact it before logging."
194
+ },
195
+ schema: [optionSchema],
196
+ messages: {
197
+ sensitiveField: "`{{name}}` looks like a sensitive field being written to a log sink. Redact it before logging (e.g. `redact({{name}})`) or omit it \u2014 logs are long-lived and widely readable."
198
+ }
199
+ },
200
+ defaultOptions: [{ denyNames: DEFAULT_DENY_NAMES }],
201
+ create(context, [options]) {
202
+ const matches = makeMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
203
+ function walk(node, visit) {
204
+ visit(node);
205
+ for (const key of Object.keys(node)) {
206
+ if (key === "parent") {
207
+ continue;
208
+ }
209
+ const value = node[key];
210
+ if (Array.isArray(value)) {
211
+ for (const child of value) {
212
+ if (child && typeof child.type === "string") {
213
+ walk(child, visit);
214
+ }
215
+ }
216
+ } else if (value && typeof value.type === "string") {
217
+ walk(value, visit);
218
+ }
219
+ }
220
+ }
221
+ return {
222
+ CallExpression(node) {
223
+ if (loggerCallMethod(node, new Set(DEFAULT_LOGGERS)) === null) {
224
+ return;
225
+ }
226
+ const reported = /* @__PURE__ */ new Set();
227
+ const report = (name, target) => {
228
+ const at = target.range[0];
229
+ if (reported.has(at)) {
230
+ return;
231
+ }
232
+ reported.add(at);
233
+ context.report({
234
+ node: target,
235
+ messageId: "sensitiveField",
236
+ data: { name }
237
+ });
238
+ };
239
+ for (const arg of node.arguments) {
240
+ walk(arg, (n) => {
241
+ if (n.type === AST_NODE_TYPES3.Identifier && matches(n.name)) {
242
+ report(n.name, n);
243
+ } else if (n.type === AST_NODE_TYPES3.Property && n.key.type === AST_NODE_TYPES3.Literal && typeof n.key.value === "string" && matches(n.key.value)) {
244
+ report(n.key.value, n.key);
245
+ }
246
+ });
247
+ }
248
+ }
249
+ };
250
+ }
251
+ });
252
+
253
+ // src/rules/structured-log-arguments.ts
254
+ import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
255
+ var RULE_NAME3 = "structured-log-arguments";
256
+ var optionSchema2 = {
257
+ type: "object",
258
+ additionalProperties: false,
259
+ properties: {
260
+ loggers: {
261
+ type: "array",
262
+ items: { type: "string" }
263
+ }
264
+ }
265
+ };
266
+ var structuredLogArgumentsRule = createRule({
267
+ name: RULE_NAME3,
268
+ meta: {
269
+ type: "suggestion",
270
+ docs: {
271
+ description: "A logger call must not interpolate dynamic values into the message string (a template literal with expressions). Pass a static message and a structured context object instead."
272
+ },
273
+ schema: [optionSchema2],
274
+ messages: {
275
+ interpolatedMessage: "This `.{{method}}(...)` call interpolates dynamic values into the message string \u2014 they become unqueryable free text. Pass a static message plus a structured context object (e.g. `log.{{method}}('processing task', { taskId })`) so each field stays indexable."
276
+ }
277
+ },
278
+ defaultOptions: [{ loggers: DEFAULT_LOGGERS }],
279
+ create(context, [options]) {
280
+ const loggers = new Set(options.loggers ?? DEFAULT_LOGGERS);
281
+ return {
282
+ CallExpression(node) {
283
+ const method = loggerCallMethod(node, loggers);
284
+ if (method === null) {
285
+ return;
286
+ }
287
+ for (const arg of node.arguments) {
288
+ if (arg.type === AST_NODE_TYPES4.TemplateLiteral && arg.expressions.length > 0) {
289
+ context.report({
290
+ node: arg,
291
+ messageId: "interpolatedMessage",
292
+ data: { method }
293
+ });
294
+ }
295
+ }
296
+ }
297
+ };
298
+ }
299
+ });
300
+
301
+ // src/rules/index.ts
302
+ var rules = {
303
+ "structured-log-arguments": structuredLogArgumentsRule,
304
+ "no-sensitive-fields-in-logs": noSensitiveFieldsInLogsRule,
305
+ "no-error-detail-loss": noErrorDetailLossRule
306
+ };
307
+
308
+ // src/index.ts
309
+ var NAMESPACE = "noctcore-observability";
310
+ var VERSION = "0.1.0";
311
+ var plugin = {
312
+ meta: { name: "@noctcore/eslint-plugin-observability", version: VERSION },
313
+ rules,
314
+ configs: {}
315
+ };
316
+ plugin.configs.recommended = {
317
+ plugins: { [NAMESPACE]: plugin },
318
+ rules: recommended
319
+ };
320
+ var configs = plugin.configs;
321
+ var index_default = plugin;
322
+ export {
323
+ configs,
324
+ index_default as default,
325
+ rules
326
+ };
@@ -0,0 +1,54 @@
1
+ # `noctcore-observability/no-error-detail-loss`
2
+
3
+ > When a catch block reports a failure, log the error itself — not just its message.
4
+
5
+ ## Why
6
+
7
+ A caught error carries a **stack trace** and often a **`cause`** — the parts you actually need to
8
+ debug a production failure. A catch block that logs only `e.message`, `String(e)`, or `` `${e}` ``
9
+ throws that away: the log records _that_ something failed but not _where_ or _why_.
10
+
11
+ ```ts
12
+ // ✗ stack and cause are gone
13
+ try { await run(); } catch (e) {
14
+ logger.error(`run failed: ${e.message}`);
15
+ }
16
+
17
+ // ✓ the whole error survives
18
+ try { await run(); } catch (e) {
19
+ logger.error('run failed', { err: e });
20
+ }
21
+ ```
22
+
23
+ ## What it flags
24
+
25
+ A `catch (e)` block where **all** of the following hold:
26
+
27
+ - the catch binding is a plain identifier (`catch (e)`);
28
+ - the block contains a logger call (so a failure is actually being reported);
29
+ - the binding **is** referenced (an unused binding is a different concern); and
30
+ - **every** reference to the binding is a lossy form — `e.message`, `String(e)`, or bare `` `${e}` ``.
31
+
32
+ If the error is passed whole anywhere (`logger.error('x', e)`, `{ err: e }`), or `.stack` / `.cause` /
33
+ any other property is read, or it is re-thrown, its diagnostics survive and the rule stays silent:
34
+
35
+ ```ts
36
+ // ✓ .stack is read — not a lossy form
37
+ catch (e) { logger.error(`failed: ${e.stack}`); }
38
+
39
+ // ✓ mixed use — the full capture wins
40
+ catch (e) { logger.error(`${e.message}`, { err: e }); }
41
+ ```
42
+
43
+ Distinct from a fully-**unused** catch binding (`catch (e) { cleanup(); }`), which this rule
44
+ deliberately does not touch.
45
+
46
+ ## Options
47
+
48
+ This rule has no options.
49
+
50
+ ## When not to use it
51
+
52
+ If you deliberately log only messages (e.g. to a user-facing channel that must not include stack
53
+ traces) **and** capture the full error elsewhere, this rule will be noisy — scope it off for those
54
+ files.
@@ -0,0 +1,58 @@
1
+ # `noctcore-observability/no-sensitive-fields-in-logs`
2
+
3
+ > A name-heuristic guard against writing credentials and secrets into log sinks. Ships at `warn`.
4
+
5
+ ## Why
6
+
7
+ Logs are long-lived, widely readable, and shipped to third-party aggregators. A `password`, `token`,
8
+ `secret`, or `authorization` header written into a log line is a credential leak that outlives the
9
+ request by months. This rule catches the most common shape — a variable, property, or object key
10
+ whose **name** matches a sensitive-field denylist appearing inside a logger call.
11
+
12
+ It reads **names, never values**, so it is a heuristic and ships at `warn`, not `error` — treat a hit
13
+ as "look here", not "definitely a bug".
14
+
15
+ ## What it flags
16
+
17
+ Inside a logger call (`<logger>.<method>(...)`), any identifier, member-access property, or object key
18
+ whose name matches the denylist:
19
+
20
+ ```ts
21
+ // ✗ all three flag
22
+ logger.info('login', { password });
23
+ logger.error('auth failed', { userPassword: pw });
24
+ logger.info('session', user.token);
25
+
26
+ // ✓ redact first
27
+ logger.info('login', { password: redact(password) });
28
+ ```
29
+
30
+ Matching is **name-segment aware**. A single-word denyName (`token`) matches a camelCase or
31
+ snake_case segment (`accessToken`, `access_token`) but **not** a longer word that merely contains it
32
+ (`tokenize`, `tokenizer`). A multi-word denyName (`apiKey`) matches the compacted name
33
+ (`myApiKey` → contains `apikey`).
34
+
35
+ String **literals** are never inspected — only names — so a message that mentions a sensitive word is
36
+ fine:
37
+
38
+ ```ts
39
+ // ✓ a literal, not a value
40
+ logger.info('password reset email sent');
41
+ ```
42
+
43
+ ## Options
44
+
45
+ ```ts
46
+ type Options = {
47
+ /**
48
+ * Field names to treat as sensitive (case-insensitive, segment-aware).
49
+ * Default: ['password', 'token', 'secret', 'authorization', 'cookie', 'apiKey', 'ssn'].
50
+ */
51
+ denyNames?: string[];
52
+ };
53
+ ```
54
+
55
+ ## When not to use it
56
+
57
+ If your logging pipeline already redacts sensitive fields centrally (a serializer denylist), this
58
+ rule is redundant. Otherwise keep it on at `warn` as a second line of defence.
@@ -0,0 +1,64 @@
1
+ # `noctcore-observability/structured-log-arguments`
2
+
3
+ > Pass dynamic values in a structured context object, not interpolated into the log message string.
4
+
5
+ ## Why
6
+
7
+ Dynamic values baked into a log **message** string are unqueryable. When you write
8
+
9
+ ```ts
10
+ logger.info(`processing task ${taskId} for ${userId}`);
11
+ ```
12
+
13
+ a log aggregator stores one opaque line of free text — it cannot index, filter, group, or alert on
14
+ `taskId` or `userId`, because they are fused into the message. The value belongs in a structured
15
+ context object, where each field stays a first-class, queryable attribute:
16
+
17
+ ```ts
18
+ logger.info('processing task', { taskId, userId });
19
+ ```
20
+
21
+ ## What it flags
22
+
23
+ A logger call — `<logger>.<method>(...)` where `<method>` is `info` / `warn` / `error` / `debug` and
24
+ `<logger>` is a configured logger name — that receives a **template literal with expressions** as a
25
+ **direct** positional argument.
26
+
27
+ ```ts
28
+ // ✗ dynamic values interpolated into the message
29
+ logger.error(`failed: ${err.code}`);
30
+
31
+ // ✓ static message + structured context
32
+ logger.error('request failed', { code: err.code });
33
+ ```
34
+
35
+ Matched purely structurally (no type information). Both `logger.info(...)` and a logger held on a
36
+ namespace or `this` (`this.logger.info(...)`, `app.log.warn(...)`) are recognised.
37
+
38
+ Only **direct** arguments are inspected. A template literal nested inside a context object is building
39
+ a value, not the message, and is never flagged:
40
+
41
+ ```ts
42
+ // ✓ the template builds a URL field, not the message
43
+ logger.info('fetching', { url: `${base}/tasks` });
44
+ ```
45
+
46
+ A template with **no** expressions carries no dynamic value and is ignored
47
+ (`logger.info(\`ready\`)`).
48
+
49
+ > Note: `console.log(...)` uses the `log` method, which is not one of the tracked log levels
50
+ > (`info` / `warn` / `error` / `debug`), so it is out of scope by design.
51
+
52
+ ## Options
53
+
54
+ ```ts
55
+ type Options = {
56
+ /** Logger object names to scan. Default: ['console', 'logger', 'log']. */
57
+ loggers?: string[];
58
+ };
59
+ ```
60
+
61
+ ## When not to use it
62
+
63
+ If your logging layer only accepts a single formatted string (no structured-context argument), this
64
+ rule cannot be satisfied — turn it off for that codebase.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@noctcore/eslint-plugin-observability",
3
+ "version": "0.1.0",
4
+ "description": "Structured-logging discipline ESLint rules — context objects over interpolated messages, no sensitive fields in logs, no error-detail loss.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "docs",
21
+ "README.md"
22
+ ],
23
+ "sideEffects": false,
24
+ "keywords": [
25
+ "eslint",
26
+ "eslintplugin",
27
+ "eslint-plugin",
28
+ "observability",
29
+ "logging",
30
+ "noctcore"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/noctcore/eslint-plugins.git",
39
+ "directory": "packages/eslint-plugin-observability"
40
+ },
41
+ "homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/eslint-plugin-observability",
42
+ "bugs": "https://github.com/noctcore/eslint-plugins/issues",
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run"
47
+ },
48
+ "dependencies": {
49
+ "@noctcore/eslint-utils": "^0.1.0",
50
+ "@typescript-eslint/utils": "^8.61.1"
51
+ },
52
+ "peerDependencies": {
53
+ "eslint": ">=9.0.0",
54
+ "typescript": ">=5.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@noctcore/eslint-test-utils": "workspace:*",
58
+ "@types/node": "^22.0.0",
59
+ "@typescript-eslint/parser": "^8.61.1",
60
+ "@typescript-eslint/rule-tester": "^8.61.1",
61
+ "tsup": "^8.5.1",
62
+ "typescript": "^5.6.0",
63
+ "vitest": "^3"
64
+ }
65
+ }