@mettlecast/eslint-plugin-domain-module 0.2.22 → 0.2.23
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/dist/__tests__/api-needs-fixture.test.d.ts +1 -0
- package/dist/__tests__/api-needs-fixture.test.js +61 -0
- package/dist/__tests__/no-raw-fetch.test.d.ts +1 -0
- package/dist/__tests__/no-raw-fetch.test.js +37 -0
- package/dist/__tests__/prefer-result-over-throw.test.d.ts +1 -0
- package/dist/__tests__/prefer-result-over-throw.test.js +79 -0
- package/dist/__tests__/tanstack-query-options.test.d.ts +1 -0
- package/dist/__tests__/tanstack-query-options.test.js +29 -0
- package/dist/__tests__/use-tanstack-router.test.d.ts +1 -0
- package/dist/__tests__/use-tanstack-router.test.js +37 -0
- package/dist/__tests__/zod-defaults-required.test.d.ts +1 -0
- package/dist/__tests__/zod-defaults-required.test.js +29 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +19 -1
- package/dist/rules/api-needs-fixture.d.ts +4 -0
- package/dist/rules/api-needs-fixture.js +82 -0
- package/dist/rules/no-raw-fetch.d.ts +4 -0
- package/dist/rules/no-raw-fetch.js +41 -0
- package/dist/rules/prefer-result-over-throw.d.ts +4 -0
- package/dist/rules/prefer-result-over-throw.js +37 -0
- package/dist/rules/tanstack-query-options.d.ts +4 -0
- package/dist/rules/tanstack-query-options.js +30 -0
- package/dist/rules/use-tanstack-router.d.ts +4 -0
- package/dist/rules/use-tanstack-router.js +30 -0
- package/dist/rules/zod-defaults-required.d.ts +4 -0
- package/dist/rules/zod-defaults-required.js +91 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { afterAll, afterEach, beforeEach, describe, it, vi } from 'vitest';
|
|
4
|
+
import { apiNeedsFixture } from '../rules/api-needs-fixture.js';
|
|
5
|
+
vi.mock('node:fs', async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal();
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
existsSync: vi.fn(actual.existsSync),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
RuleTester.afterAll = afterAll;
|
|
13
|
+
RuleTester.describe = describe;
|
|
14
|
+
RuleTester.it = it;
|
|
15
|
+
const existsSyncMock = vi.mocked(existsSync);
|
|
16
|
+
const FIXTURE_DIRS_WITH_FIXTURE = new Set([
|
|
17
|
+
'/repo/domains/auth/api/__tests__/get-user.fixture.json',
|
|
18
|
+
]);
|
|
19
|
+
const FIXTURE_DIRS_WITHOUT_FIXTURE = new Set([
|
|
20
|
+
'/repo/domains/payments/api/__tests__/charge-card.fixture.json',
|
|
21
|
+
]);
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
existsSyncMock.mockReset();
|
|
24
|
+
existsSyncMock.mockImplementation((p) => {
|
|
25
|
+
const s = String(p);
|
|
26
|
+
if (FIXTURE_DIRS_WITH_FIXTURE.has(s))
|
|
27
|
+
return true;
|
|
28
|
+
if (FIXTURE_DIRS_WITHOUT_FIXTURE.has(s))
|
|
29
|
+
return false;
|
|
30
|
+
return false;
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
existsSyncMock.mockReset();
|
|
35
|
+
});
|
|
36
|
+
const tester = new RuleTester({
|
|
37
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
38
|
+
});
|
|
39
|
+
tester.run('api-needs-fixture', apiNeedsFixture, {
|
|
40
|
+
valid: [
|
|
41
|
+
{
|
|
42
|
+
filename: '/repo/domains/auth/api/get-user.ts',
|
|
43
|
+
code: "import { defineApi } from '@mettlecast/domain-runtime'; export const getUser = defineApi({ id: 'get-user', path: '/v1/users/{id}', method: 'GET', tenancy: 'required', versions: { v1: { status: 'stable', input: z.object({}), output: z.object({}), handler: async () => ({}) } } });",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
filename: '/repo/domains/auth/domain.config.ts',
|
|
47
|
+
code: "export const config = { id: 'auth', name: 'Auth' };",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
filename: '/repo/domains/auth/api/utils.ts',
|
|
51
|
+
code: "export function helper() { return 1; }",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
invalid: [
|
|
55
|
+
{
|
|
56
|
+
filename: '/repo/domains/payments/api/charge-card.ts',
|
|
57
|
+
code: "import { defineApi } from '@mettlecast/domain-runtime'; export const chargeCard = defineApi({ id: 'charge-card', path: '/v1/charge', method: 'POST', tenancy: 'required', versions: { v1: { status: 'stable', input: z.object({}), output: z.object({}), handler: async () => ({}) } } });",
|
|
58
|
+
errors: [{ messageId: 'apiNeedsFixture' }],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
import { noRawFetch } from '../rules/no-raw-fetch.js';
|
|
4
|
+
RuleTester.afterAll = afterAll;
|
|
5
|
+
RuleTester.describe = describe;
|
|
6
|
+
RuleTester.it = it;
|
|
7
|
+
const tester = new RuleTester({
|
|
8
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
tester.run('no-raw-fetch', noRawFetch, {
|
|
11
|
+
valid: [
|
|
12
|
+
{
|
|
13
|
+
filename: '/repo/domains/auth/api/get-user.ts',
|
|
14
|
+
code: "export const get = defineApi({ handler: async (input, ctx) => { return await ctx.fetch('https://example.com'); } });",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
filename: '/repo/domains/auth/api/get-user.ts',
|
|
18
|
+
code: "import ky from 'ky'; const r = await ky.get('https://example.com');",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
filename: '/repo/domains/auth/api/get-user.ts',
|
|
22
|
+
code: "const r = await globalThis.fetch('https://example.com');",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
invalid: [
|
|
26
|
+
{
|
|
27
|
+
filename: '/repo/domains/auth/api/get-user.ts',
|
|
28
|
+
code: "const r = await fetch('https://example.com');",
|
|
29
|
+
errors: [{ messageId: 'noRawFetch' }],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
filename: '/repo/domains/payments/api/charge.ts',
|
|
33
|
+
code: "fetch('https://api.stripe.com/v1/charges', { method: 'POST' });",
|
|
34
|
+
errors: [{ messageId: 'noRawFetch' }],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
import { preferResultOverThrow } from '../rules/prefer-result-over-throw.js';
|
|
4
|
+
RuleTester.afterAll = afterAll;
|
|
5
|
+
RuleTester.describe = describe;
|
|
6
|
+
RuleTester.it = it;
|
|
7
|
+
const ruleTester = new RuleTester({
|
|
8
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
ruleTester.run('prefer-result-over-throw', preferResultOverThrow, {
|
|
11
|
+
valid: [
|
|
12
|
+
{
|
|
13
|
+
filename: 'domains/auth/api/me.ts',
|
|
14
|
+
code: `
|
|
15
|
+
import { ok, err } from '@mettlecast/domain-runtime';
|
|
16
|
+
export const me = defineApi({
|
|
17
|
+
handler: async (_input, _ctx) => {
|
|
18
|
+
return ok({ id: 'usr_1' });
|
|
19
|
+
},
|
|
20
|
+
});`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
filename: 'domains/auth/api/me.ts',
|
|
24
|
+
code: `
|
|
25
|
+
import { redirect } from '@tanstack/react-router';
|
|
26
|
+
// TSR redirect is not a domain error — it's a navigation directive
|
|
27
|
+
throw redirect({ to: '/dashboard' });`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
filename: 'packages/domain-cli/src/commands/upgrade.ts',
|
|
31
|
+
code: `throw new Error('Upgrade failed');`,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
filename: 'domains/auth/api/me.ts',
|
|
35
|
+
code: `
|
|
36
|
+
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
37
|
+
export const me = defineApi({
|
|
38
|
+
handler: async (_input, _ctx) => {
|
|
39
|
+
const user = await ctx.db.query(...);
|
|
40
|
+
if (!user) return notFound('user', input.userId);
|
|
41
|
+
return ok(user);
|
|
42
|
+
},
|
|
43
|
+
});`,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
invalid: [
|
|
47
|
+
{
|
|
48
|
+
filename: 'domains/auth/api/me.ts',
|
|
49
|
+
code: `
|
|
50
|
+
export const me = defineApi({
|
|
51
|
+
handler: async () => {
|
|
52
|
+
throw new Error('User not found');
|
|
53
|
+
},
|
|
54
|
+
});`,
|
|
55
|
+
errors: [{ messageId: 'preferResult' }],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
filename: 'domains/orgs/api/list-orgs.ts',
|
|
59
|
+
code: `
|
|
60
|
+
export const listOrgs = defineApi({
|
|
61
|
+
handler: async (_input, _ctx) => {
|
|
62
|
+
if (!ctx.tenant) throw new Error('Missing tenant');
|
|
63
|
+
return { items: [] };
|
|
64
|
+
},
|
|
65
|
+
});`,
|
|
66
|
+
errors: [{ messageId: 'preferResult' }],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
filename: 'domains/tenants/api/create-tenant.ts',
|
|
70
|
+
code: `
|
|
71
|
+
export const createTenant = defineApi({
|
|
72
|
+
handler: async () => {
|
|
73
|
+
throw { kind: 'not_found' }; // raw object throw — no stack trace
|
|
74
|
+
},
|
|
75
|
+
});`,
|
|
76
|
+
errors: [{ messageId: 'preferResult' }],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
import { tanstackQueryOptions } from '../rules/tanstack-query-options.js';
|
|
4
|
+
RuleTester.afterAll = afterAll;
|
|
5
|
+
RuleTester.describe = describe;
|
|
6
|
+
RuleTester.it = it;
|
|
7
|
+
const tester = new RuleTester({
|
|
8
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
tester.run('tanstack-query-options', tanstackQueryOptions, {
|
|
11
|
+
valid: [
|
|
12
|
+
{
|
|
13
|
+
code: "import { useQuery, queryOptions } from '@tanstack/react-query'; const opts = queryOptions({ queryKey: ['agents'], queryFn: fetchAgents }); useQuery(opts);",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
code: "useQuery(agentListOptions);",
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
invalid: [
|
|
20
|
+
{
|
|
21
|
+
code: "useQuery({ queryKey: ['agents'], queryFn: fetchAgents });",
|
|
22
|
+
errors: [{ messageId: 'tanstackQueryOptions' }],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
code: "const x = useQuery({ queryKey: ['x'], queryFn: () => fetchX() });",
|
|
26
|
+
errors: [{ messageId: 'tanstackQueryOptions' }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
import { useTanstackRouter } from '../rules/use-tanstack-router.js';
|
|
4
|
+
RuleTester.afterAll = afterAll;
|
|
5
|
+
RuleTester.describe = describe;
|
|
6
|
+
RuleTester.it = it;
|
|
7
|
+
const tester = new RuleTester({
|
|
8
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
tester.run('use-tanstack-router', useTanstackRouter, {
|
|
11
|
+
valid: [
|
|
12
|
+
{
|
|
13
|
+
filename: '/repo/frontend/src/main.tsx',
|
|
14
|
+
code: "import { createRouter, RouterProvider } from '@tanstack/react-router';",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
filename: '/repo/frontend/src/pages/Agents.tsx',
|
|
18
|
+
code: "import { useNavigate } from '@tanstack/react-router';",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
filename: '/repo/scaffold-src/modules/shared/lib/legacy-helper.ts',
|
|
22
|
+
code: "import { HashRouter } from 'react-router-dom';",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
invalid: [
|
|
26
|
+
{
|
|
27
|
+
filename: '/repo/frontend/src/main.tsx',
|
|
28
|
+
code: "import { createBrowserRouter, RouterProvider } from 'react-router-dom';",
|
|
29
|
+
errors: [{ messageId: 'useTanstackRouter' }],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
filename: '/repo/frontend/src/components/Shell.tsx',
|
|
33
|
+
code: "import { useNavigate, useParams } from 'react-router-dom';",
|
|
34
|
+
errors: [{ messageId: 'useTanstackRouter' }],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
import { zodDefaultsRequired } from '../rules/zod-defaults-required.js';
|
|
4
|
+
RuleTester.afterAll = afterAll;
|
|
5
|
+
RuleTester.describe = describe;
|
|
6
|
+
RuleTester.it = it;
|
|
7
|
+
const tester = new RuleTester({
|
|
8
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
tester.run('zod-defaults-required', zodDefaultsRequired, {
|
|
11
|
+
valid: [
|
|
12
|
+
{
|
|
13
|
+
code: "const input = z.object({ name: z.string() }).default({ name: 'Alice' });",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
code: "const input = z.object({ pageSize: z.number() }).default({ pageSize: 20 });",
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
invalid: [
|
|
20
|
+
{
|
|
21
|
+
code: "const input = z.object({ name: z.string() }).optional();",
|
|
22
|
+
errors: [{ messageId: 'zodOptionalTopLevel' }],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
code: "const output = z.object({ ok: z.boolean() }).optional();",
|
|
26
|
+
errors: [{ messageId: 'zodOptionalTopLevel' }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,24 @@ declare const _default: {
|
|
|
20
20
|
readonly 'flow-domain-ownership': import("@typescript-eslint/utils/ts-eslint").RuleModule<"owningDomainMismatch" | "missingOwningDomain", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
21
21
|
name: string;
|
|
22
22
|
};
|
|
23
|
+
readonly 'no-raw-fetch': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noRawFetch", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
readonly 'use-tanstack-router': import("@typescript-eslint/utils/ts-eslint").RuleModule<"useTanstackRouter", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
27
|
+
name: string;
|
|
28
|
+
};
|
|
29
|
+
readonly 'tanstack-query-options': import("@typescript-eslint/utils/ts-eslint").RuleModule<"tanstackQueryOptions", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
readonly 'zod-defaults-required': import("@typescript-eslint/utils/ts-eslint").RuleModule<"zodOptionalTopLevel", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
33
|
+
name: string;
|
|
34
|
+
};
|
|
35
|
+
readonly 'api-needs-fixture': import("@typescript-eslint/utils/ts-eslint").RuleModule<"apiNeedsFixture", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
36
|
+
name: string;
|
|
37
|
+
};
|
|
38
|
+
readonly 'prefer-result-over-throw': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferResult", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
39
|
+
name: string;
|
|
40
|
+
};
|
|
23
41
|
};
|
|
24
42
|
configs: {
|
|
25
43
|
recommended: {
|
|
@@ -41,6 +59,24 @@ declare const _default: {
|
|
|
41
59
|
readonly 'flow-domain-ownership': import("@typescript-eslint/utils/ts-eslint").RuleModule<"owningDomainMismatch" | "missingOwningDomain", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
42
60
|
name: string;
|
|
43
61
|
};
|
|
62
|
+
readonly 'no-raw-fetch': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noRawFetch", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
63
|
+
name: string;
|
|
64
|
+
};
|
|
65
|
+
readonly 'use-tanstack-router': import("@typescript-eslint/utils/ts-eslint").RuleModule<"useTanstackRouter", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
66
|
+
name: string;
|
|
67
|
+
};
|
|
68
|
+
readonly 'tanstack-query-options': import("@typescript-eslint/utils/ts-eslint").RuleModule<"tanstackQueryOptions", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
69
|
+
name: string;
|
|
70
|
+
};
|
|
71
|
+
readonly 'zod-defaults-required': import("@typescript-eslint/utils/ts-eslint").RuleModule<"zodOptionalTopLevel", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
72
|
+
name: string;
|
|
73
|
+
};
|
|
74
|
+
readonly 'api-needs-fixture': import("@typescript-eslint/utils/ts-eslint").RuleModule<"apiNeedsFixture", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
75
|
+
name: string;
|
|
76
|
+
};
|
|
77
|
+
readonly 'prefer-result-over-throw': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferResult", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
78
|
+
name: string;
|
|
79
|
+
};
|
|
44
80
|
};
|
|
45
81
|
};
|
|
46
82
|
};
|
|
@@ -50,6 +86,12 @@ declare const _default: {
|
|
|
50
86
|
readonly '@mettlecast/domain-module/require-define-primitive': "warn";
|
|
51
87
|
readonly '@mettlecast/domain-module/no-cross-domain-internal-import': "error";
|
|
52
88
|
readonly '@mettlecast/domain-module/flow-domain-ownership': "error";
|
|
89
|
+
readonly '@mettlecast/domain-module/no-raw-fetch': "error";
|
|
90
|
+
readonly '@mettlecast/domain-module/use-tanstack-router': "error";
|
|
91
|
+
readonly '@mettlecast/domain-module/tanstack-query-options': "warn";
|
|
92
|
+
readonly '@mettlecast/domain-module/zod-defaults-required': "error";
|
|
93
|
+
readonly '@mettlecast/domain-module/api-needs-fixture': "error";
|
|
94
|
+
readonly '@mettlecast/domain-module/prefer-result-over-throw': "error";
|
|
53
95
|
};
|
|
54
96
|
};
|
|
55
97
|
};
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,12 @@ import { noRawHttpServer } from './rules/no-raw-http-server.js';
|
|
|
3
3
|
import { requireDefinePrimitive } from './rules/require-define-primitive.js';
|
|
4
4
|
import { noCrossDomainInternalImport } from './rules/no-cross-domain-internal-import.js';
|
|
5
5
|
import { flowDomainOwnership } from './rules/flow-domain-ownership.js';
|
|
6
|
+
import { noRawFetch } from './rules/no-raw-fetch.js';
|
|
7
|
+
import { useTanstackRouter } from './rules/use-tanstack-router.js';
|
|
8
|
+
import { tanstackQueryOptions } from './rules/tanstack-query-options.js';
|
|
9
|
+
import { zodDefaultsRequired } from './rules/zod-defaults-required.js';
|
|
10
|
+
import { apiNeedsFixture } from './rules/api-needs-fixture.js';
|
|
11
|
+
import { preferResultOverThrow } from './rules/prefer-result-over-throw.js';
|
|
6
12
|
/**
|
|
7
13
|
* All rules exported by @mettlecast/eslint-plugin-domain-module.
|
|
8
14
|
*/
|
|
@@ -12,10 +18,16 @@ const rules = {
|
|
|
12
18
|
'require-define-primitive': requireDefinePrimitive,
|
|
13
19
|
'no-cross-domain-internal-import': noCrossDomainInternalImport,
|
|
14
20
|
'flow-domain-ownership': flowDomainOwnership,
|
|
21
|
+
'no-raw-fetch': noRawFetch,
|
|
22
|
+
'use-tanstack-router': useTanstackRouter,
|
|
23
|
+
'tanstack-query-options': tanstackQueryOptions,
|
|
24
|
+
'zod-defaults-required': zodDefaultsRequired,
|
|
25
|
+
'api-needs-fixture': apiNeedsFixture,
|
|
26
|
+
'prefer-result-over-throw': preferResultOverThrow,
|
|
15
27
|
};
|
|
16
28
|
/**
|
|
17
29
|
* Recommended flat config preset.
|
|
18
|
-
* Enables all
|
|
30
|
+
* Enables all rules at their default severity levels.
|
|
19
31
|
*/
|
|
20
32
|
const recommended = {
|
|
21
33
|
plugins: {
|
|
@@ -27,6 +39,12 @@ const recommended = {
|
|
|
27
39
|
'@mettlecast/domain-module/require-define-primitive': 'warn',
|
|
28
40
|
'@mettlecast/domain-module/no-cross-domain-internal-import': 'error',
|
|
29
41
|
'@mettlecast/domain-module/flow-domain-ownership': 'error',
|
|
42
|
+
'@mettlecast/domain-module/no-raw-fetch': 'error',
|
|
43
|
+
'@mettlecast/domain-module/use-tanstack-router': 'error',
|
|
44
|
+
'@mettlecast/domain-module/tanstack-query-options': 'warn',
|
|
45
|
+
'@mettlecast/domain-module/zod-defaults-required': 'error',
|
|
46
|
+
'@mettlecast/domain-module/api-needs-fixture': 'error',
|
|
47
|
+
'@mettlecast/domain-module/prefer-result-over-throw': 'error',
|
|
30
48
|
},
|
|
31
49
|
};
|
|
32
50
|
/**
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
5
|
+
const DOMAIN_API_FILE_PATTERN = /[/\\]domains[/\\][^/\\]+[/\\]api[/\\][^/\\]+\.ts$/;
|
|
6
|
+
function extractFixtureIdFromDefineApi(node) {
|
|
7
|
+
const arg = node.arguments[0];
|
|
8
|
+
if (!arg || arg.type !== 'ObjectExpression')
|
|
9
|
+
return undefined;
|
|
10
|
+
const idProp = arg.properties.find((p) => p.type === 'Property' &&
|
|
11
|
+
'name' in p.key &&
|
|
12
|
+
!p.computed &&
|
|
13
|
+
p.key.name === 'id');
|
|
14
|
+
if (!idProp)
|
|
15
|
+
return undefined;
|
|
16
|
+
const val = idProp.value;
|
|
17
|
+
if (val.type === 'Literal' && typeof val.value === 'string')
|
|
18
|
+
return val.value;
|
|
19
|
+
if (val.type === 'TemplateLiteral' && val.expressions.length === 0 && val.quasis.length === 1) {
|
|
20
|
+
return val.quasis[0]?.value.cooked ?? undefined;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function isDefineApiCall(node) {
|
|
25
|
+
if (!node)
|
|
26
|
+
return false;
|
|
27
|
+
return node.callee.type === 'Identifier' && node.callee.name === 'defineApi';
|
|
28
|
+
}
|
|
29
|
+
export const apiNeedsFixture = createRule({
|
|
30
|
+
name: 'api-needs-fixture',
|
|
31
|
+
meta: {
|
|
32
|
+
type: 'problem',
|
|
33
|
+
docs: {
|
|
34
|
+
description: 'Every exported defineApi({...}) in domains/{x}/api/*.ts must have a matching __tests__/{id}.fixture.json sibling file.',
|
|
35
|
+
},
|
|
36
|
+
messages: {
|
|
37
|
+
apiNeedsFixture: 'API handler "{{name}}" is missing a __tests__/{{name}}.fixture.json sibling file.\n fixHint: Create `domains/<domain>/api/__tests__/{{name}}.fixture.json` with realistic input/output examples. This file is consumed by `mc-domain-module test` to drive the handler in a local fixture run.\n kNodeRef: K:runbook:fixture-driven-tests',
|
|
38
|
+
},
|
|
39
|
+
schema: [],
|
|
40
|
+
},
|
|
41
|
+
defaultOptions: [],
|
|
42
|
+
create(context) {
|
|
43
|
+
const filename = context.filename ?? context.getFilename?.() ?? '';
|
|
44
|
+
if (!filename || !DOMAIN_API_FILE_PATTERN.test(filename))
|
|
45
|
+
return {};
|
|
46
|
+
const apiDir = dirname(filename);
|
|
47
|
+
return {
|
|
48
|
+
ExportNamedDeclaration(node) {
|
|
49
|
+
const decl = node.declaration;
|
|
50
|
+
if (!decl)
|
|
51
|
+
return;
|
|
52
|
+
let callNode;
|
|
53
|
+
if (decl.type === 'VariableDeclaration') {
|
|
54
|
+
for (const d of decl.declarations) {
|
|
55
|
+
if (d.init && d.init.type === 'CallExpression' && isDefineApiCall(d.init)) {
|
|
56
|
+
callNode = d.init;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// The declaration union is narrow — use a runtime type-check
|
|
63
|
+
// with a double-cast through unknown to avoid TS's
|
|
64
|
+
// "may be a mistake" error on discriminated unions.
|
|
65
|
+
const d = decl;
|
|
66
|
+
if (d.type === 'CallExpression' && isDefineApiCall(decl)) {
|
|
67
|
+
callNode = decl;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!callNode)
|
|
71
|
+
return;
|
|
72
|
+
const id = extractFixtureIdFromDefineApi(callNode);
|
|
73
|
+
if (!id)
|
|
74
|
+
return;
|
|
75
|
+
const fixturePath = join(apiDir, '__tests__', `${id}.fixture.json`);
|
|
76
|
+
if (!existsSync(fixturePath)) {
|
|
77
|
+
context.report({ node, messageId: 'apiNeedsFixture', data: { name: id } });
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
const FETCH_ALLOWED_OBJECT_NAMES = new Set(['ctx', 'globalThis']);
|
|
4
|
+
export const noRawFetch = createRule({
|
|
5
|
+
name: 'no-raw-fetch',
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow raw fetch() calls in domain handler files. Use ctx.fetch or a shared HTTP client (ky) instead.',
|
|
10
|
+
},
|
|
11
|
+
messages: {
|
|
12
|
+
noRawFetch: 'Raw fetch() is not allowed in domain handlers. Use ctx.fetch or a shared HTTP client (e.g. ky).\n fixHint: Replace `fetch(url, opts)` with `await ctx.fetch(url, opts)` (or `ky(...)` for non-domain code). ctx.fetch is provided by DomainContext and is mockable in local dev.\n kNodeRef: K:convention:tier-1-foundations',
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
},
|
|
16
|
+
defaultOptions: [],
|
|
17
|
+
create(context) {
|
|
18
|
+
function isAllowedMemberAccess(callee) {
|
|
19
|
+
const obj = callee.object;
|
|
20
|
+
if (obj.type !== 'Identifier')
|
|
21
|
+
return false;
|
|
22
|
+
return obj.name === 'ctx' || FETCH_ALLOWED_OBJECT_NAMES.has(obj.name);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
CallExpression(node) {
|
|
26
|
+
const callee = node.callee;
|
|
27
|
+
if (callee.type === 'Identifier' && callee.name === 'fetch') {
|
|
28
|
+
context.report({ node, messageId: 'noRawFetch' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (callee.type === 'MemberExpression' &&
|
|
32
|
+
callee.property.type === 'Identifier' &&
|
|
33
|
+
callee.property.name === 'fetch') {
|
|
34
|
+
if (isAllowedMemberAccess(callee))
|
|
35
|
+
return;
|
|
36
|
+
context.report({ node, messageId: 'noRawFetch' });
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
const DOMAIN_API_FILE_PATTERN = /[/\\]domains[/\\][^/\\]+[/\\]api[/\\][^/\\]+\.ts$/;
|
|
4
|
+
/** Returns true when a throw statement is a TSR redirect (allowed). */
|
|
5
|
+
function isTsrRedirect(node) {
|
|
6
|
+
const arg = node.argument;
|
|
7
|
+
if (!arg || arg.type !== 'CallExpression')
|
|
8
|
+
return false;
|
|
9
|
+
const callee = arg.callee;
|
|
10
|
+
return callee.type === 'Identifier' && callee.name === 'redirect';
|
|
11
|
+
}
|
|
12
|
+
export const preferResultOverThrow = createRule({
|
|
13
|
+
name: 'prefer-result-over-throw',
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'problem',
|
|
16
|
+
docs: {
|
|
17
|
+
description: 'Domain API handlers must return Result<T, AppError> instead of throwing raw errors. Thrown errors escape the handler and produce untyped 500 responses.',
|
|
18
|
+
},
|
|
19
|
+
messages: {
|
|
20
|
+
preferResult: 'Domain API handler should return `err({ ... })` instead of throwing. Thrown errors produce untraceable 500s with no structured error information.\n fixHint: Wrap the throw in a `try/catch` or replace with `return err({ kind: "internal", traceId, message: err.message })`.\n kNodeRef: K:convention:typed-errors',
|
|
21
|
+
},
|
|
22
|
+
schema: [],
|
|
23
|
+
},
|
|
24
|
+
defaultOptions: [],
|
|
25
|
+
create(context) {
|
|
26
|
+
const filename = context.filename ?? context.getFilename?.() ?? '';
|
|
27
|
+
if (!filename || !DOMAIN_API_FILE_PATTERN.test(filename))
|
|
28
|
+
return {};
|
|
29
|
+
return {
|
|
30
|
+
ThrowStatement(node) {
|
|
31
|
+
if (isTsrRedirect(node))
|
|
32
|
+
return;
|
|
33
|
+
context.report({ node, messageId: 'preferResult' });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
export const tanstackQueryOptions = createRule({
|
|
4
|
+
name: 'tanstack-query-options',
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'suggestion',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'Inline useQuery({...}) call sites should wrap their options in queryOptions() for typed query keys and shared query definitions.',
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
tanstackQueryOptions: 'useQuery() is being called with an inline object literal. Wrap the options in queryOptions() for typed query keys and shared definitions.\n fixHint: Replace `useQuery({ queryKey, queryFn, ... })` with `useQuery(queryOptions({ queryKey, queryFn, ... }))` and export the queryOptions() from a queries module so multiple components can share it.\n kNodeRef: K:convention:react-query-options',
|
|
12
|
+
},
|
|
13
|
+
schema: [],
|
|
14
|
+
},
|
|
15
|
+
defaultOptions: [],
|
|
16
|
+
create(context) {
|
|
17
|
+
return {
|
|
18
|
+
CallExpression(node) {
|
|
19
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== 'useQuery')
|
|
20
|
+
return;
|
|
21
|
+
const first = node.arguments[0];
|
|
22
|
+
if (!first)
|
|
23
|
+
return;
|
|
24
|
+
if (first.type === 'ObjectExpression') {
|
|
25
|
+
context.report({ node, messageId: 'tanstackQueryOptions' });
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
const FRONTEND_PATH_PATTERN = /[/\\](?:frontend|dashboard[\\/]src|src)[\\/]/;
|
|
4
|
+
export const useTanstackRouter = createRule({
|
|
5
|
+
name: 'use-tanstack-router',
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow react-router-dom imports in frontend source. Use @tanstack/react-router instead.',
|
|
10
|
+
},
|
|
11
|
+
messages: {
|
|
12
|
+
useTanstackRouter: 'Import from "react-router-dom" is not allowed. Use @tanstack/react-router in frontend code.\n fixHint: Replace `from \'react-router-dom\'` with `from \'@tanstack/react-router\'` and update API usage (e.g. createRootRoute/createRoute/Outlet). See K:convention:router-stack.\n kNodeRef: K:convention:router-stack',
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
},
|
|
16
|
+
defaultOptions: [],
|
|
17
|
+
create(context) {
|
|
18
|
+
const filename = context.filename ?? context.getFilename?.() ?? '';
|
|
19
|
+
if (!FRONTEND_PATH_PATTERN.test(filename))
|
|
20
|
+
return {};
|
|
21
|
+
return {
|
|
22
|
+
ImportDeclaration(node) {
|
|
23
|
+
const source = node.source.value;
|
|
24
|
+
if (typeof source === 'string' && source === 'react-router-dom') {
|
|
25
|
+
context.report({ node, messageId: 'useTanstackRouter' });
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/LawrenceGeeHouse/Mettlecast/blob/develop/packages/eslint-plugin-domain-module/docs/${name}.md`);
|
|
3
|
+
const ZOD_ROOT = 'z';
|
|
4
|
+
function isZodRootIdentifier(node) {
|
|
5
|
+
return !!node && node.type === 'Identifier' && node.name === ZOD_ROOT;
|
|
6
|
+
}
|
|
7
|
+
function isZodObjectCall(node) {
|
|
8
|
+
const callee = node.callee;
|
|
9
|
+
return (callee.type === 'MemberExpression' &&
|
|
10
|
+
isZodRootIdentifier(callee.object) &&
|
|
11
|
+
callee.property.type === 'Identifier' &&
|
|
12
|
+
callee.property.name === 'object');
|
|
13
|
+
}
|
|
14
|
+
function isOptionalCall(node) {
|
|
15
|
+
const callee = node.callee;
|
|
16
|
+
return (callee.type === 'MemberExpression' &&
|
|
17
|
+
callee.property.type === 'Identifier' &&
|
|
18
|
+
callee.property.name === 'optional');
|
|
19
|
+
}
|
|
20
|
+
function chainHasDefault(node) {
|
|
21
|
+
let cur = node.callee;
|
|
22
|
+
while (cur) {
|
|
23
|
+
if (cur.type === 'MemberExpression') {
|
|
24
|
+
if (cur.property.type === 'Identifier' && cur.property.name === 'default') {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
cur = cur.object;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (cur.type === 'CallExpression') {
|
|
31
|
+
cur = cur.callee;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function chainStartsAtZodRoot(node) {
|
|
39
|
+
let cur = node.callee;
|
|
40
|
+
while (cur) {
|
|
41
|
+
if (cur.type === 'CallExpression') {
|
|
42
|
+
cur = cur.callee;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (cur.type === 'MemberExpression') {
|
|
46
|
+
cur = cur.object;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
return isZodRootIdentifier(cur);
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
export const zodDefaultsRequired = createRule({
|
|
54
|
+
name: 'zod-defaults-required',
|
|
55
|
+
meta: {
|
|
56
|
+
type: 'problem',
|
|
57
|
+
docs: {
|
|
58
|
+
description: 'Top-level Zod input/output schemas must end in .default(...). Field-level .optional() inside z.object({...}) is allowed.',
|
|
59
|
+
},
|
|
60
|
+
messages: {
|
|
61
|
+
zodOptionalTopLevel: 'Top-level Zod input/output schema must end in .default(...). Field-level .optional() inside z.object({...}) is allowed; top-level schemas are not.\n fixHint: Append `.default({...})` with a realistic example payload to the chain (e.g. `z.object({...}).default({...})`). This is required by validate-domain.yml CI gate.\n kNodeRef: K:convention:zod-defaults-required',
|
|
62
|
+
},
|
|
63
|
+
schema: [],
|
|
64
|
+
},
|
|
65
|
+
defaultOptions: [],
|
|
66
|
+
create(context) {
|
|
67
|
+
let zodObjectDepth = 0;
|
|
68
|
+
return {
|
|
69
|
+
CallExpression(node) {
|
|
70
|
+
if (isZodObjectCall(node)) {
|
|
71
|
+
zodObjectDepth++;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (zodObjectDepth > 0)
|
|
75
|
+
return;
|
|
76
|
+
if (!isOptionalCall(node))
|
|
77
|
+
return;
|
|
78
|
+
if (chainHasDefault(node))
|
|
79
|
+
return;
|
|
80
|
+
if (!chainStartsAtZodRoot(node))
|
|
81
|
+
return;
|
|
82
|
+
context.report({ node, messageId: 'zodOptionalTopLevel' });
|
|
83
|
+
},
|
|
84
|
+
'CallExpression:exit'(node) {
|
|
85
|
+
if (isZodObjectCall(node)) {
|
|
86
|
+
zodObjectDepth--;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
});
|