@agjs/tsforge 0.1.7 → 0.1.8
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/package.json +1 -1
- package/src/loop/rule-docs.generated.json +15 -0
- package/src/rule-packs/index.ts +2 -0
- package/src/rule-packs/nextjs/index.ts +26 -0
- package/src/rule-packs/nextjs/rules/client-hooks-require-use-client.ts +74 -0
- package/src/rule-packs/nextjs/rules/no-next-head-in-app.ts +36 -0
- package/src/rule-packs/nextjs/rules/no-pages-router-data-fetching-in-app.ts +71 -0
- package/src/rule-packs/nextjs/utils.ts +55 -0
- package/src/stack-detection/packs.ts +11 -0
package/package.json
CHANGED
|
@@ -299,6 +299,21 @@
|
|
|
299
299
|
"bad": "// Example that violates the rule",
|
|
300
300
|
"good": "// Corrected version"
|
|
301
301
|
},
|
|
302
|
+
"tsforge/client-hooks-require-use-client": {
|
|
303
|
+
"what": "Require the 'use client' directive in app-router page/layout/template files that call client-only hooks. Server Components cannot use state/effect/navigation hooks — doing so crashes at runtime.",
|
|
304
|
+
"bad": "// Example that violates the rule",
|
|
305
|
+
"good": "// Corrected version"
|
|
306
|
+
},
|
|
307
|
+
"tsforge/no-next-head-in-app": {
|
|
308
|
+
"what": "Disallow importing 'next/head' in app-router files. The <Head> component is a no-op under app/ — use the Metadata API (export const metadata / generateMetadata) instead.",
|
|
309
|
+
"bad": "// Example that violates the rule",
|
|
310
|
+
"good": "// Corrected version"
|
|
311
|
+
},
|
|
312
|
+
"tsforge/no-pages-router-data-fetching-in-app": {
|
|
313
|
+
"what": "Disallow pages-router data-fetching exports (getServerSideProps, getStaticProps, getStaticPaths, getInitialProps) in app-router files. Next.js ignores them under app/, so they are silent dead code — use async Server Components or route handlers instead.",
|
|
314
|
+
"bad": "// Example that violates the rule",
|
|
315
|
+
"good": "// Corrected version"
|
|
316
|
+
},
|
|
302
317
|
"tsforge/pkce-required-for-oidc": {
|
|
303
318
|
"what": "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
|
|
304
319
|
"bad": "// Example that violates the rule",
|
package/src/rule-packs/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { envAccessPack } from "./env-access";
|
|
|
9
9
|
import { i18nKeysPack } from "./i18n-keys";
|
|
10
10
|
import { jwtCookiesPack } from "./jwt-cookies";
|
|
11
11
|
import { moduleBoundariesPack } from "./module-boundaries";
|
|
12
|
+
import { nextjsPack } from "./nextjs";
|
|
12
13
|
import { oauthSecurityPack } from "./oauth-security";
|
|
13
14
|
import { reactComponentArchitecturePack } from "./react-component-architecture";
|
|
14
15
|
import { structuredLoggingPack } from "./structured-logging";
|
|
@@ -27,6 +28,7 @@ export const RULE_PACKS = {
|
|
|
27
28
|
"i18n-keys": i18nKeysPack,
|
|
28
29
|
"jwt-cookies": jwtCookiesPack,
|
|
29
30
|
"module-boundaries": moduleBoundariesPack,
|
|
31
|
+
nextjs: nextjsPack,
|
|
30
32
|
"oauth-security": oauthSecurityPack,
|
|
31
33
|
"react-component-architecture": reactComponentArchitecturePack,
|
|
32
34
|
"structured-logging": structuredLoggingPack,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { clientHooksRequireUseClientRule } from "./rules/client-hooks-require-use-client";
|
|
4
|
+
import { noNextHeadInAppRule } from "./rules/no-next-head-in-app";
|
|
5
|
+
import { noPagesRouterDataFetchingInAppRule } from "./rules/no-pages-router-data-fetching-in-app";
|
|
6
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
7
|
+
|
|
8
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
9
|
+
"client-hooks-require-use-client": clientHooksRequireUseClientRule,
|
|
10
|
+
"no-next-head-in-app": noNextHeadInAppRule,
|
|
11
|
+
"no-pages-router-data-fetching-in-app": noPagesRouterDataFetchingInAppRule,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const nextjsPack: IRulePack = {
|
|
15
|
+
id: "nextjs",
|
|
16
|
+
description:
|
|
17
|
+
"Next.js app-router correctness: server/client component boundaries and dead pages-router APIs.",
|
|
18
|
+
rules,
|
|
19
|
+
rulesConfig: {
|
|
20
|
+
"client-hooks-require-use-client": "error",
|
|
21
|
+
"no-next-head-in-app": "error",
|
|
22
|
+
"no-pages-router-data-fetching-in-app": "error",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default nextjsPack;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createRule } from "../../create-rule";
|
|
2
|
+
import {
|
|
3
|
+
calleeName,
|
|
4
|
+
hasDirective,
|
|
5
|
+
isAppRouterFile,
|
|
6
|
+
isRouteEntryFile,
|
|
7
|
+
} from "../utils";
|
|
8
|
+
|
|
9
|
+
export const RULE_NAME = "client-hooks-require-use-client";
|
|
10
|
+
|
|
11
|
+
type MessageIds = "missingUseClient";
|
|
12
|
+
|
|
13
|
+
/** Hooks that only work in Client Components — calling them in a Server
|
|
14
|
+
* Component throws at runtime. */
|
|
15
|
+
const CLIENT_HOOKS = new Set<string>([
|
|
16
|
+
"useState",
|
|
17
|
+
"useEffect",
|
|
18
|
+
"useLayoutEffect",
|
|
19
|
+
"useReducer",
|
|
20
|
+
"useImperativeHandle",
|
|
21
|
+
"useSyncExternalStore",
|
|
22
|
+
"useRouter",
|
|
23
|
+
"usePathname",
|
|
24
|
+
"useSearchParams",
|
|
25
|
+
"useParams",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const clientHooksRequireUseClientRule = createRule<[], MessageIds>({
|
|
29
|
+
name: RULE_NAME,
|
|
30
|
+
meta: {
|
|
31
|
+
type: "problem",
|
|
32
|
+
docs: {
|
|
33
|
+
description:
|
|
34
|
+
"Require the 'use client' directive in app-router page/layout/template files that call client-only hooks. Server Components cannot use state/effect/navigation hooks — doing so crashes at runtime.",
|
|
35
|
+
},
|
|
36
|
+
schema: [],
|
|
37
|
+
messages: {
|
|
38
|
+
missingUseClient:
|
|
39
|
+
"'{{hook}}' is a client-only hook but this Server Component has no 'use client' directive. Add 'use client' at the top of the file or move the interactivity into a Client Component.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
defaultOptions: [],
|
|
43
|
+
create(context) {
|
|
44
|
+
if (
|
|
45
|
+
!isAppRouterFile(context.filename) ||
|
|
46
|
+
!isRouteEntryFile(context.filename)
|
|
47
|
+
) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let clientComponent = false;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
Program(node) {
|
|
55
|
+
clientComponent = hasDirective(node, "use client");
|
|
56
|
+
},
|
|
57
|
+
CallExpression(node) {
|
|
58
|
+
if (clientComponent) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const name = calleeName(node.callee);
|
|
63
|
+
|
|
64
|
+
if (name !== null && CLIENT_HOOKS.has(name)) {
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: "missingUseClient",
|
|
68
|
+
data: { hook: name },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createRule } from "../../create-rule";
|
|
2
|
+
import { isAppRouterFile } from "../utils";
|
|
3
|
+
|
|
4
|
+
export const RULE_NAME = "no-next-head-in-app";
|
|
5
|
+
|
|
6
|
+
type MessageIds = "nextHeadInApp";
|
|
7
|
+
|
|
8
|
+
export const noNextHeadInAppRule = createRule<[], MessageIds>({
|
|
9
|
+
name: RULE_NAME,
|
|
10
|
+
meta: {
|
|
11
|
+
type: "problem",
|
|
12
|
+
docs: {
|
|
13
|
+
description:
|
|
14
|
+
"Disallow importing 'next/head' in app-router files. The <Head> component is a no-op under app/ — use the Metadata API (export const metadata / generateMetadata) instead.",
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
messages: {
|
|
18
|
+
nextHeadInApp:
|
|
19
|
+
"'next/head' does nothing in the app router. Use the Metadata API (export const metadata or generateMetadata) instead.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultOptions: [],
|
|
23
|
+
create(context) {
|
|
24
|
+
if (!isAppRouterFile(context.filename)) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
ImportDeclaration(node) {
|
|
30
|
+
if (node.source.value === "next/head") {
|
|
31
|
+
context.report({ node: node.source, messageId: "nextHeadInApp" });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { isAppRouterFile } from "../utils";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "no-pages-router-data-fetching-in-app";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "pagesDataFnInApp";
|
|
9
|
+
|
|
10
|
+
/** Pages-router data-fetching exports — inert (dead code) under the app router. */
|
|
11
|
+
const PAGES_DATA_FNS = new Set<string>([
|
|
12
|
+
"getServerSideProps",
|
|
13
|
+
"getStaticProps",
|
|
14
|
+
"getStaticPaths",
|
|
15
|
+
"getInitialProps",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const noPagesRouterDataFetchingInAppRule = createRule<[], MessageIds>({
|
|
19
|
+
name: RULE_NAME,
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem",
|
|
22
|
+
docs: {
|
|
23
|
+
description:
|
|
24
|
+
"Disallow pages-router data-fetching exports (getServerSideProps, getStaticProps, getStaticPaths, getInitialProps) in app-router files. Next.js ignores them under app/, so they are silent dead code — use async Server Components or route handlers instead.",
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: {
|
|
28
|
+
pagesDataFnInApp:
|
|
29
|
+
"'{{name}}' is a pages-router API and is ignored under app/. Fetch data inside an async Server Component or a route handler instead.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultOptions: [],
|
|
33
|
+
create(context) {
|
|
34
|
+
if (!isAppRouterFile(context.filename)) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function reportName(node: TSESTree.Node, name: string): void {
|
|
39
|
+
context.report({ node, messageId: "pagesDataFnInApp", data: { name } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
ExportNamedDeclaration(node) {
|
|
44
|
+
const decl = node.declaration;
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
decl?.type === "FunctionDeclaration" &&
|
|
48
|
+
decl.id !== null &&
|
|
49
|
+
PAGES_DATA_FNS.has(decl.id.name)
|
|
50
|
+
) {
|
|
51
|
+
reportName(decl.id, decl.id.name);
|
|
52
|
+
} else if (decl?.type === "VariableDeclaration") {
|
|
53
|
+
for (const d of decl.declarations) {
|
|
54
|
+
if (d.id.type === "Identifier" && PAGES_DATA_FNS.has(d.id.name)) {
|
|
55
|
+
reportName(d.id, d.id.name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const spec of node.specifiers) {
|
|
61
|
+
if (
|
|
62
|
+
spec.exported.type === "Identifier" &&
|
|
63
|
+
PAGES_DATA_FNS.has(spec.exported.name)
|
|
64
|
+
) {
|
|
65
|
+
reportName(spec, spec.exported.name);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
/** True if the file lives under a Next.js app-router directory (an `app` segment). */
|
|
4
|
+
export function isAppRouterFile(filename: string): boolean {
|
|
5
|
+
return filename.split(/[\\/]/).includes("app");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** True if the file is an app-router route-entry file (page/layout/template),
|
|
9
|
+
* which default to React Server Components. */
|
|
10
|
+
export function isRouteEntryFile(filename: string): boolean {
|
|
11
|
+
const base = filename.split(/[\\/]/).pop() ?? "";
|
|
12
|
+
|
|
13
|
+
return /^(?:page|layout|template)\.(?:tsx|ts|jsx|js)$/.test(base);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True if the program's directive prologue contains `directive`
|
|
17
|
+
* (e.g. "use client" / "use server"). */
|
|
18
|
+
export function hasDirective(
|
|
19
|
+
program: TSESTree.Program,
|
|
20
|
+
directive: string
|
|
21
|
+
): boolean {
|
|
22
|
+
for (const stmt of program.body) {
|
|
23
|
+
if (
|
|
24
|
+
stmt.type !== "ExpressionStatement" ||
|
|
25
|
+
stmt.expression.type !== "Literal" ||
|
|
26
|
+
typeof stmt.expression.value !== "string"
|
|
27
|
+
) {
|
|
28
|
+
return false; // directive prologue has ended
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (stmt.expression.value === directive) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve a call's callee to a simple name: `useState` or `React.useState`
|
|
40
|
+
* → "useState". Returns null for computed or complex callees. */
|
|
41
|
+
export function calleeName(callee: TSESTree.Node): string | null {
|
|
42
|
+
if (callee.type === "Identifier") {
|
|
43
|
+
return callee.name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
callee.type === "MemberExpression" &&
|
|
48
|
+
!callee.computed &&
|
|
49
|
+
callee.property.type === "Identifier"
|
|
50
|
+
) {
|
|
51
|
+
return callee.property.name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
@@ -61,6 +61,17 @@ export const PACK_REGISTRY = {
|
|
|
61
61
|
guidance: "Follow Elysia patterns for HTTP routing and middleware.",
|
|
62
62
|
} as const satisfies IRulePackDescriptor,
|
|
63
63
|
|
|
64
|
+
nextjs: {
|
|
65
|
+
id: "nextjs",
|
|
66
|
+
label: "Next.js",
|
|
67
|
+
description:
|
|
68
|
+
"App-router correctness: server/client boundaries and dead pages-router APIs",
|
|
69
|
+
category: "framework",
|
|
70
|
+
appliesWhen: { anyDeps: ["next"] },
|
|
71
|
+
guidance:
|
|
72
|
+
"Follow Next.js app-router conventions: mark interactive files 'use client' and use the Metadata API and Server Components instead of pages-router APIs.",
|
|
73
|
+
} as const satisfies IRulePackDescriptor,
|
|
74
|
+
|
|
64
75
|
bullmq: {
|
|
65
76
|
id: "bullmq",
|
|
66
77
|
label: "BullMQ",
|