@veda-hq/eslint-plugin-shuddhi 0.2.0 → 0.6.4

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 CHANGED
@@ -50,7 +50,7 @@ of globs, with a `namespace` parameter:
50
50
  | input parsing | `no-hand-rolled-argv`, `env-through-schema`, `strict-scalar-parse`, `schema-parse-untrusted`, `schema-satisfies-contract`, `prisma-json-through-schema`, `no-regex-structured-parse` | `inputParsingConfigs` |
51
51
  | server boundary | `nest-body-dto`, `dto-field-constraints`, `no-local-api-contract`, `dto-implements-contract`, `no-capability-literal`, `compare-and-set` | `boundaryBlocks` |
52
52
  | NestJS and Prisma | `no-raw-sql-interpolation`, `nest-http-exception`, `prisma-transaction-multiwrite`, `nest-param-pipe`, `no-unbound-test-app`, `require-orderby-findfirst`, `no-self-mock-sut` | none |
53
- | test lint | 41 rules on test structure, vacuous assertions, process hygiene, fixtures and fakes | `testLintBlocks` |
53
+ | test lint | 43 rules on test structure, vacuous assertions (among them `jsonnull-in-matcher`: a Prisma null sentinel inside `toMatchObject` or `objectContaining`), process hygiene, fixtures and fakes; and for scripts, `no-import-meta-dirname-cjs` (`import.meta.dirname` in a file tsx runs as CommonJS) | `testLintBlocks` |
54
54
 
55
55
  Every name of a repository's own helper (the safe-logging module, the child
56
56
  process runner, the argument parser, the contracts package) is a rule option,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veda-hq/eslint-plugin-shuddhi",
3
- "version": "0.2.0",
3
+ "version": "0.6.4",
4
4
  "description": "ESLint rules for code coding agents write: error handling, input parsing, NestJS and Prisma boundaries, and test quality. Each rule closes a defect class reviewers kept finding after push.",
5
5
  "keywords": [
6
6
  "eslint",
@@ -59,6 +59,7 @@ export const TEST_FILE_RULES = {
59
59
  [id('no-partial-git-mask')]: 'error',
60
60
  [id('env-via-stub')]: 'error',
61
61
  [id('detached-teardown')]: 'error',
62
+ [id('jsonnull-in-matcher')]: 'error',
62
63
  };
63
64
 
64
65
  /** Rules for Nest server tests only. */
@@ -67,10 +68,14 @@ export const SERVER_TEST_RULES = {
67
68
  [id('e2e-uses-supertest')]: 'error',
68
69
  };
69
70
 
70
- /** Rules for tooling scripts (SHELL-09). */
71
+ /**
72
+ * Rules for tooling scripts (SHELL-09): the non-test sources `testLintBlocks`
73
+ * binds to its `scripts` globs.
74
+ */
71
75
  export const SCRIPT_RULES = {
72
76
  [id('no-process-exit')]: 'error',
73
77
  [id('cli-entry-helper')]: 'error',
78
+ [id('no-import-meta-dirname-cjs')]: 'error',
74
79
  };
75
80
 
76
81
  /** Rules for Detox end-to-end suites (TEST-WEAK-01). */
@@ -0,0 +1,195 @@
1
+ /**
2
+ * SHELL-09, the module format a script runs in: `import.meta.dirname` and
3
+ * `import.meta.filename` in a file that runs as CommonJS
4
+ * (`no-import-meta-dirname-cjs`). `import-meta-cjs.test.ts` owns it.
5
+ *
6
+ * WHY. tsx runs a `.ts`, `.tsx` or `.js` file as CommonJS when its nearest
7
+ * package.json has no `"type": "module"` (or there is none). Its CJS
8
+ * transform defines `import.meta.url` (except in `.cts` and `.cjs` files)
9
+ * and nothing else, so `import.meta.dirname` is undefined and
10
+ * `resolve(import.meta.dirname, ...)` throws when the script loads. Vitest
11
+ * runs the same file as ESM, where both exist, so the script's tests pass.
12
+ * Measured on tsx 4.21.
13
+ *
14
+ * SCOPE. `.mts` and `.mjs` files always run as ESM and are never reported;
15
+ * `.cts` and `.cjs` files always run as CommonJS and are reported whatever
16
+ * the package says. Test files (`*.test.*`, `*.spec.*`) are skipped: Vitest
17
+ * runs them as ESM. A package.json that cannot be read or parsed leaves the
18
+ * format unknown, and nothing is reported.
19
+ *
20
+ * ACCEPTED LIMIT. A `.js` file is reported as tsx runs it. Plain Node (not
21
+ * tsx) may run a `.js` file in a package with no `type` field as ESM, from
22
+ * its `import.meta` syntax (module syntax detection); that is not measured
23
+ * here, and such a file may be a false positive.
24
+ */
25
+ import { readFileSync } from 'node:fs';
26
+ import { basename, dirname, extname, join, resolve } from 'node:path';
27
+
28
+ import { propertyName, WRAPPERS } from './helpers.js';
29
+
30
+ /** Extensions whose format the nearest package.json decides. */
31
+ const BY_PACKAGE = new Set(['.ts', '.tsx', '.js']);
32
+
33
+ /** Extensions that always run as CommonJS. */
34
+ const ALWAYS_CJS = new Set(['.cts', '.cjs']);
35
+
36
+ /** Vitest runs these as ESM. */
37
+ const TEST_FILE = /\.(?:test|spec)\./;
38
+
39
+ /** The `import.meta` properties tsx leaves undefined under CommonJS. */
40
+ const REPLACEMENTS = {
41
+ cjsPackage: {
42
+ dirname: 'dirname(fileURLToPath(import.meta.url))',
43
+ filename: 'fileURLToPath(import.meta.url)',
44
+ },
45
+ cjsFile: { dirname: '__dirname', filename: '__filename' },
46
+ };
47
+
48
+ /**
49
+ * A package.json's text, null when there is none at `path`, or undefined
50
+ * when it exists but cannot be read.
51
+ *
52
+ * @param {string} path
53
+ */
54
+ function readPackageJson(path) {
55
+ try {
56
+ return readFileSync(path, 'utf8');
57
+ } catch (error) {
58
+ // fail-open: a missing file means no package.json here, any other read failure an unknown format; the rule then reports nothing.
59
+ return error.code === 'ENOENT' ? null : undefined;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * A lookup of the module format a directory's files run in: `'module'`,
65
+ * `'commonjs'` (no package.json up to the root, or a nearest one with no
66
+ * `"type": "module"`) or `'unknown'` (the nearest one cannot be read or
67
+ * parsed). Each directory is looked up once: every directory a walk passes
68
+ * is cached with its answer.
69
+ *
70
+ * @param {(path: string) => string | null | undefined} [read]
71
+ */
72
+ function packageTypeLookup(read = readPackageJson) {
73
+ const cache = new Map();
74
+ return function packageTypeOf(start) {
75
+ const passed = [];
76
+ let type = 'commonjs';
77
+ for (let dir = start; ; dir = dirname(dir)) {
78
+ if (cache.has(dir)) {
79
+ type = cache.get(dir);
80
+ break;
81
+ }
82
+ passed.push(dir);
83
+ const text = read(join(dir, 'package.json'));
84
+ if (text !== null) {
85
+ type = formatOf(text);
86
+ break;
87
+ }
88
+ if (dirname(dir) === dir) break;
89
+ }
90
+ for (const dir of passed) cache.set(dir, type);
91
+ return type;
92
+ };
93
+ }
94
+
95
+ /** The format a package.json's text sets, or `'unknown'` when unreadable. */
96
+ function formatOf(text) {
97
+ if (text === undefined) return 'unknown';
98
+ try {
99
+ return JSON.parse(text)?.type === 'module' ? 'module' : 'commonjs';
100
+ } catch {
101
+ // fail-open: a package.json that does not parse leaves the format unknown, and the rule reports nothing.
102
+ return 'unknown';
103
+ }
104
+ }
105
+
106
+ /** The lookup the rule uses: one per lint process. */
107
+ const packageTypeOf = packageTypeLookup();
108
+
109
+ const noImportMetaDirnameCjs = {
110
+ meta: {
111
+ type: 'problem',
112
+ docs: {
113
+ description:
114
+ 'import.meta.dirname and import.meta.filename in a file that tsx runs as CommonJS, where they are undefined (SHELL-09)',
115
+ },
116
+ schema: [],
117
+ messages: {
118
+ cjsPackage:
119
+ 'import.meta.{{property}} is undefined when this file runs as CommonJS (its nearest package.json, if any, has no "type": "module"; tsx runs it as CJS and defines only import.meta.url there). Use {{replacement}}.',
120
+ cjsFile:
121
+ 'import.meta.{{property}} is undefined in a {{extension}} file, which always runs as CommonJS (tsx defines not even import.meta.url there). Use {{replacement}}.',
122
+ },
123
+ },
124
+ create(context) {
125
+ const given = context.physicalFilename ?? context.filename;
126
+ // `<input>` and `<text>`: ESLint's names for code with no file.
127
+ if (/^<.*>$/.test(given)) return {};
128
+ const filename = resolve(context.cwd, given);
129
+ const name = basename(filename);
130
+ if (TEST_FILE.test(name)) return {};
131
+ const extension = extname(name);
132
+ if (!ALWAYS_CJS.has(extension) && !BY_PACKAGE.has(extension)) return {};
133
+ /** The message for this file, decided on the first hit. */
134
+ let messageId;
135
+ const messageFor = () => {
136
+ if (messageId === undefined) {
137
+ messageId = ALWAYS_CJS.has(extension)
138
+ ? 'cjsFile'
139
+ : packageTypeOf(dirname(filename)) === 'commonjs'
140
+ ? 'cjsPackage'
141
+ : null;
142
+ }
143
+ return messageId;
144
+ };
145
+ const report = (node, property) => {
146
+ if (property !== 'dirname' && property !== 'filename') return;
147
+ const id = messageFor();
148
+ if (id === null) return;
149
+ context.report({
150
+ node,
151
+ messageId: id,
152
+ data: {
153
+ property,
154
+ extension,
155
+ replacement: REPLACEMENTS[id][property],
156
+ },
157
+ });
158
+ };
159
+ return {
160
+ MetaProperty(node) {
161
+ if (node.meta.name !== 'import' || node.property.name !== 'meta') {
162
+ return;
163
+ }
164
+ // Through type-only wrappers: `(import.meta as Meta).dirname`.
165
+ let inner = node;
166
+ while (
167
+ WRAPPERS.has(inner.parent.type) &&
168
+ inner.parent.expression === inner
169
+ ) {
170
+ inner = inner.parent;
171
+ }
172
+ const parent = inner.parent;
173
+ if (parent.type === 'MemberExpression' && parent.object === inner) {
174
+ report(parent, propertyName(parent));
175
+ return;
176
+ }
177
+ // `const { dirname } = import.meta`
178
+ if (
179
+ parent.type === 'VariableDeclarator' &&
180
+ parent.init === inner &&
181
+ parent.id.type === 'ObjectPattern'
182
+ ) {
183
+ for (const property of parent.id.properties) {
184
+ if (property.type === 'Property' && !property.computed) {
185
+ const { key } = property;
186
+ report(property, key.type === 'Identifier' ? key.name : key.value);
187
+ }
188
+ }
189
+ }
190
+ },
191
+ };
192
+ },
193
+ };
194
+
195
+ export { noImportMetaDirnameCjs, packageTypeLookup };
@@ -13,8 +13,11 @@
13
13
  * a module here, owned by the colocated suite of the same name:
14
14
  * `vacuous-assertion` (TEST-WEAK-03), `test-structure` (TEST-STRUCT-02),
15
15
  * `process-hygiene` (SHELL-09 and TEST-STRUCT-11), `sql-fake` (TEST-WEAK-10)
16
- * and `detox` (TEST-WEAK-01). `helpers.js` holds only what two or more
17
- * families read; a family imports nothing from another.
16
+ * and `detox` (TEST-WEAK-01), with two single-rule modules:
17
+ * `jsonnull-in-matcher` (a TEST-WEAK-03 shape: a Prisma null sentinel inside
18
+ * a partial matcher) and `import-meta-cjs` (a SHELL-09 script rule:
19
+ * `import.meta.dirname` in a file tsx runs as CommonJS). `helpers.js` holds
20
+ * only what two or more families read; a family imports nothing from another.
18
21
  *
19
22
  * `c05.js` adds three to the same test-file block: a test that returns before
20
23
  * its assertions with none on the return path, a recursive removal of a path
@@ -63,6 +66,8 @@ import {
63
66
  variableOf,
64
67
  WRAPPERS,
65
68
  } from './helpers.js';
69
+ import { noImportMetaDirnameCjs } from './import-meta-cjs.js';
70
+ import { jsonnullInMatcher } from './jsonnull-in-matcher.js';
66
71
  import {
67
72
  cliEntryHelper,
68
73
  detachedTeardown,
@@ -199,6 +204,8 @@ export const rules = {
199
204
  'spawn-timeout': spawnTimeout,
200
205
  'no-raw-group-kill': noRawGroupKill,
201
206
  'detached-teardown': detachedTeardown,
207
+ 'jsonnull-in-matcher': jsonnullInMatcher,
208
+ 'no-import-meta-dirname-cjs': noImportMetaDirnameCjs,
202
209
  };
203
210
 
204
211
  /** The c05 candidate extensions (`c05-candidates.js`), in catalogue order. */
@@ -0,0 +1,150 @@
1
+ /**
2
+ * TEST-WEAK-03, one more vacuous shape: a Prisma null sentinel inside a
3
+ * partial matcher (`jsonnull-in-matcher`). `jsonnull-in-matcher.test.ts`
4
+ * owns it.
5
+ *
6
+ * WHY. `Prisma.JsonNull`, `Prisma.DbNull` and `Prisma.AnyNull` are class
7
+ * instances with no own enumerable keys. A partial matcher compares an
8
+ * expected object by its keys, so the sentinel has nothing to compare:
9
+ * inside `toMatchObject` it matches any stored value (5, null, an object,
10
+ * the other sentinel), and inside `expect.objectContaining` any value that
11
+ * is an object with no keys (`Prisma.DbNull`, `Prisma.JsonNull`, `{}`), so it
12
+ * cannot tell JSON null from database NULL. Measured on Vitest 4.1 with the
13
+ * Prisma runtime's own sentinel instances. `toBe` and `toStrictEqual`
14
+ * compare the instance itself.
15
+ *
16
+ * SCOPE. The sentinel is reported where it is the matcher's argument or is
17
+ * reached from it through object property values, array elements, spreads,
18
+ * type-only wrappers and `const` bindings (three deep). A nested call (an
19
+ * inner `expect.objectContaining`) is a matcher of its own and is checked
20
+ * when that call is visited.
21
+ */
22
+ import {
23
+ calledMethod,
24
+ constInit,
25
+ propertyName,
26
+ unwrap,
27
+ variableOf,
28
+ } from './helpers.js';
29
+
30
+ /** The sentinels with no enumerable keys. */
31
+ const PRISMA_NULLS = new Set(['JsonNull', 'DbNull', 'AnyNull']);
32
+
33
+ /** How deep a `const` binding is followed from a matcher argument. */
34
+ const BINDING_DEPTH = 3;
35
+
36
+ /**
37
+ * The sentinel's name when `node` is one: `Prisma.JsonNull`, a named import
38
+ * of it from `@prisma/client` (or a subpath), or a `const { JsonNull } =
39
+ * Prisma` binding; otherwise null.
40
+ */
41
+ function prismaNullName(node, context) {
42
+ if (node.type === 'MemberExpression') {
43
+ const name = propertyName(node);
44
+ const object = unwrap(node.object);
45
+ return object.type === 'Identifier' &&
46
+ object.name === 'Prisma' &&
47
+ PRISMA_NULLS.has(name)
48
+ ? name
49
+ : null;
50
+ }
51
+ if (node.type !== 'Identifier') return null;
52
+ const variable = variableOf(node, context);
53
+ if (!variable || variable.defs.length !== 1) return null;
54
+ const [def] = variable.defs;
55
+ if (def.type === 'ImportBinding') {
56
+ const imported =
57
+ def.node.type === 'ImportSpecifier' ? def.node.imported : null;
58
+ const name = imported?.name ?? imported?.value;
59
+ return /^@prisma\/client(\/|$)/.test(def.parent.source.value) &&
60
+ PRISMA_NULLS.has(name)
61
+ ? name
62
+ : null;
63
+ }
64
+ // `const { JsonNull } = Prisma`, or renamed: `const { DbNull: dbNull } = Prisma`.
65
+ const init = def.type === 'Variable' ? unwrap(def.node.init) : null;
66
+ if (
67
+ init?.type !== 'Identifier' ||
68
+ init.name !== 'Prisma' ||
69
+ def.node.id.type !== 'ObjectPattern'
70
+ ) {
71
+ return null;
72
+ }
73
+ const property = def.node.id.properties.find(
74
+ entry => entry.type === 'Property' && unwrap(entry.value) === def.name,
75
+ );
76
+ const key = property && !property.computed ? property.key.name : null;
77
+ return PRISMA_NULLS.has(key) ? key : null;
78
+ }
79
+
80
+ /** The partial matcher a call is, or null: `toMatchObject` or `objectContaining`. */
81
+ function partialMatcher(call) {
82
+ const method = calledMethod(call);
83
+ if (method === 'toMatchObject' || method === 'objectContaining') return method;
84
+ return null;
85
+ }
86
+
87
+ const jsonnullInMatcher = {
88
+ meta: {
89
+ type: 'problem',
90
+ docs: {
91
+ description:
92
+ 'A Prisma null sentinel inside toMatchObject or objectContaining, where it has no keys to compare (TEST-WEAK-03)',
93
+ },
94
+ schema: [],
95
+ messages: {
96
+ toMatchObject:
97
+ 'Prisma.{{name}} matches any value inside toMatchObject (it has no enumerable keys). Assert the field on its own: expect(data.col).toBe(Prisma.{{name}}) for a value given to Prisma, expect(row.col).toBeNull() for a row read back.',
98
+ objectContaining:
99
+ 'Prisma.{{name}} matches any object without keys inside objectContaining (it has no enumerable keys), so Prisma.DbNull, Prisma.JsonNull and {} all pass. Assert the field on its own: expect(data.col).toBe(Prisma.{{name}}) for a value given to Prisma, expect(row.col).toBeNull() for a row read back.',
100
+ },
101
+ },
102
+ create(context) {
103
+ const reported = new Set();
104
+
105
+ /** Report every sentinel reached from `node`, the argument of `matcher`. */
106
+ function visit(node, matcher, depth) {
107
+ const value = unwrap(node);
108
+ if (!value) return;
109
+ switch (value.type) {
110
+ case 'ObjectExpression':
111
+ for (const property of value.properties) {
112
+ visit(
113
+ property.type === 'Property' ? property.value : property.argument,
114
+ matcher,
115
+ depth,
116
+ );
117
+ }
118
+ return;
119
+ case 'ArrayExpression':
120
+ for (const element of value.elements) visit(element, matcher, depth);
121
+ return;
122
+ case 'SpreadElement':
123
+ visit(value.argument, matcher, depth);
124
+ return;
125
+ default:
126
+ break;
127
+ }
128
+ const name = prismaNullName(value, context);
129
+ if (name !== null) {
130
+ if (reported.has(value)) return;
131
+ reported.add(value);
132
+ context.report({ node: value, messageId: matcher, data: { name } });
133
+ return;
134
+ }
135
+ if (value.type === 'Identifier' && depth < BINDING_DEPTH) {
136
+ visit(constInit(value, context), matcher, depth + 1);
137
+ }
138
+ }
139
+
140
+ return {
141
+ CallExpression(node) {
142
+ const matcher = partialMatcher(node);
143
+ if (matcher === null) return;
144
+ for (const argument of node.arguments) visit(argument, matcher, 0);
145
+ },
146
+ };
147
+ },
148
+ };
149
+
150
+ export { jsonnullInMatcher };