@agjs/tsforge 0.1.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.1.6",
4
+ "version": "0.1.8",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -289,6 +289,31 @@
289
289
  "bad": "// Example that violates the rule",
290
290
  "good": "// Corrected version"
291
291
  },
292
+ "tsforge/no-import-build-output": {
293
+ "what": "Disallow importing from build/output directories within the project. Source must import source, not compiled artifacts, to avoid stale-code drift and broken module boundaries.",
294
+ "bad": "// Example that violates the rule",
295
+ "good": "// Corrected version"
296
+ },
297
+ "tsforge/no-import-test-from-source": {
298
+ "what": "Disallow production/source files from importing test files. Tests may depend on source, never the reverse — test code must not ship in the production graph.",
299
+ "bad": "// Example that violates the rule",
300
+ "good": "// Corrected version"
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
+ },
292
317
  "tsforge/pkce-required-for-oidc": {
293
318
  "what": "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
294
319
  "bad": "// Example that violates the rule",
@@ -8,6 +8,8 @@ import { elysiaPack } from "./elysia";
8
8
  import { envAccessPack } from "./env-access";
9
9
  import { i18nKeysPack } from "./i18n-keys";
10
10
  import { jwtCookiesPack } from "./jwt-cookies";
11
+ import { moduleBoundariesPack } from "./module-boundaries";
12
+ import { nextjsPack } from "./nextjs";
11
13
  import { oauthSecurityPack } from "./oauth-security";
12
14
  import { reactComponentArchitecturePack } from "./react-component-architecture";
13
15
  import { structuredLoggingPack } from "./structured-logging";
@@ -25,6 +27,8 @@ export const RULE_PACKS = {
25
27
  "env-access": envAccessPack,
26
28
  "i18n-keys": i18nKeysPack,
27
29
  "jwt-cookies": jwtCookiesPack,
30
+ "module-boundaries": moduleBoundariesPack,
31
+ nextjs: nextjsPack,
28
32
  "oauth-security": oauthSecurityPack,
29
33
  "react-component-architecture": reactComponentArchitecturePack,
30
34
  "structured-logging": structuredLoggingPack,
@@ -0,0 +1,23 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { noImportBuildOutputRule } from "./rules/no-import-build-output";
4
+ import { noImportTestFromSourceRule } from "./rules/no-import-test-from-source";
5
+ import type { IRulePack } from "../rule-packs.types";
6
+
7
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
8
+ "no-import-build-output": noImportBuildOutputRule,
9
+ "no-import-test-from-source": noImportTestFromSourceRule,
10
+ };
11
+
12
+ export const moduleBoundariesPack: IRulePack = {
13
+ id: "module-boundaries",
14
+ description:
15
+ "Module boundary hygiene: keep the test/production and source/build-output boundaries clean so the dependency graph stays sound.",
16
+ rules,
17
+ rulesConfig: {
18
+ "no-import-build-output": "error",
19
+ "no-import-test-from-source": "error",
20
+ },
21
+ };
22
+
23
+ export default moduleBoundariesPack;
@@ -0,0 +1,71 @@
1
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { hasDirSegment, isRelativeImport } from "../utils";
5
+
6
+ export const RULE_NAME = "no-import-build-output";
7
+
8
+ export interface NoImportBuildOutputOptions {
9
+ readonly outputDirs?: readonly string[];
10
+ }
11
+
12
+ type RuleOptions = [NoImportBuildOutputOptions];
13
+ type MessageIds = "buildOutputImported";
14
+
15
+ const DEFAULT_OUTPUT_DIRS: readonly string[] = [
16
+ "dist",
17
+ "build",
18
+ "out",
19
+ ".next",
20
+ "coverage",
21
+ ];
22
+
23
+ const optionSchema: JSONSchema4 = {
24
+ type: "object",
25
+ additionalProperties: false,
26
+ properties: {
27
+ outputDirs: {
28
+ type: "array",
29
+ uniqueItems: true,
30
+ items: { type: "string" },
31
+ },
32
+ },
33
+ };
34
+
35
+ export const noImportBuildOutputRule = createRule<RuleOptions, MessageIds>({
36
+ name: RULE_NAME,
37
+ meta: {
38
+ type: "problem",
39
+ docs: {
40
+ description:
41
+ "Disallow importing from build/output directories within the project. Source must import source, not compiled artifacts, to avoid stale-code drift and broken module boundaries.",
42
+ },
43
+ schema: [optionSchema],
44
+ messages: {
45
+ buildOutputImported:
46
+ "Do not import from build output ('{{source}}'). Import the source module directly so the build graph stays the single source of truth.",
47
+ },
48
+ },
49
+ defaultOptions: [{ outputDirs: [...DEFAULT_OUTPUT_DIRS] }],
50
+ create(context, [options]) {
51
+ const outputDirs = new Set(options.outputDirs ?? DEFAULT_OUTPUT_DIRS);
52
+
53
+ return {
54
+ ImportDeclaration(node) {
55
+ const source = node.source.value;
56
+
57
+ if (typeof source !== "string" || !isRelativeImport(source)) {
58
+ return;
59
+ }
60
+
61
+ if (hasDirSegment(source, outputDirs)) {
62
+ context.report({
63
+ node: node.source,
64
+ messageId: "buildOutputImported",
65
+ data: { source },
66
+ });
67
+ }
68
+ },
69
+ };
70
+ },
71
+ });
@@ -0,0 +1,74 @@
1
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { hasDirSegment, isRelativeImport, isTestFileName } from "../utils";
5
+
6
+ export const RULE_NAME = "no-import-test-from-source";
7
+
8
+ export interface NoImportTestFromSourceOptions {
9
+ readonly testDirNames?: readonly string[];
10
+ }
11
+
12
+ type RuleOptions = [NoImportTestFromSourceOptions];
13
+ type MessageIds = "testImportedFromSource";
14
+
15
+ const DEFAULT_TEST_DIR_NAMES: readonly string[] = ["__tests__", "__mocks__"];
16
+
17
+ const optionSchema: JSONSchema4 = {
18
+ type: "object",
19
+ additionalProperties: false,
20
+ properties: {
21
+ testDirNames: {
22
+ type: "array",
23
+ uniqueItems: true,
24
+ items: { type: "string" },
25
+ },
26
+ },
27
+ };
28
+
29
+ export const noImportTestFromSourceRule = createRule<RuleOptions, MessageIds>({
30
+ name: RULE_NAME,
31
+ meta: {
32
+ type: "problem",
33
+ docs: {
34
+ description:
35
+ "Disallow production/source files from importing test files. Tests may depend on source, never the reverse — test code must not ship in the production graph.",
36
+ },
37
+ schema: [optionSchema],
38
+ messages: {
39
+ testImportedFromSource:
40
+ "Source files must not import test files ('{{source}}'). Move shared helpers into a non-test module so production code never depends on tests.",
41
+ },
42
+ },
43
+ defaultOptions: [{ testDirNames: [...DEFAULT_TEST_DIR_NAMES] }],
44
+ create(context, [options]) {
45
+ const testDirs = new Set(options.testDirNames ?? DEFAULT_TEST_DIR_NAMES);
46
+
47
+ // A test file may freely import other test files; only enforce the
48
+ // boundary when the importing file is itself non-test.
49
+ if (
50
+ isTestFileName(context.filename) ||
51
+ hasDirSegment(context.filename, testDirs)
52
+ ) {
53
+ return {};
54
+ }
55
+
56
+ return {
57
+ ImportDeclaration(node) {
58
+ const source = node.source.value;
59
+
60
+ if (typeof source !== "string" || !isRelativeImport(source)) {
61
+ return;
62
+ }
63
+
64
+ if (isTestFileName(source) || hasDirSegment(source, testDirs)) {
65
+ context.report({
66
+ node: node.source,
67
+ messageId: "testImportedFromSource",
68
+ data: { source },
69
+ });
70
+ }
71
+ },
72
+ };
73
+ },
74
+ });
@@ -0,0 +1,29 @@
1
+ /** True for relative import specifiers (`./x`, `../x`) — i.e. paths inside the
2
+ * project, as opposed to bare package specifiers (`react`, `@scope/pkg`). */
3
+ export function isRelativeImport(source: string): boolean {
4
+ return source.startsWith("./") || source.startsWith("../");
5
+ }
6
+
7
+ /** Path segments of a "/"-separated specifier, dropping "", ".", "..". */
8
+ export function pathSegments(specifier: string): string[] {
9
+ return specifier
10
+ .split("/")
11
+ .filter((seg) => seg.length > 0 && seg !== "." && seg !== "..");
12
+ }
13
+
14
+ /** True if any directory segment of the specifier is in `dirs`. */
15
+ export function hasDirSegment(
16
+ specifier: string,
17
+ dirs: ReadonlySet<string>
18
+ ): boolean {
19
+ return pathSegments(specifier).some((seg) => dirs.has(seg));
20
+ }
21
+
22
+ /** True if the specifier's final segment names a test/spec module
23
+ * (`foo.test`, `foo.test.ts`, `foo.spec.tsx`, …). */
24
+ export function isTestFileName(specifier: string): boolean {
25
+ const segments = specifier.split("/");
26
+ const base = segments[segments.length - 1] ?? "";
27
+
28
+ return /\.(?:test|spec)(?:\.[^.]+)?$/.test(base);
29
+ }
@@ -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",