Skip to content

Commit ef9a92f

Browse files
committed
lint(app): ban new lib imports
1 parent 8ac0e3d commit ef9a92f

5 files changed

Lines changed: 248 additions & 1 deletion

File tree

packages/app/eslint.config.mts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import simpleImportSort from "eslint-plugin-simple-import-sort";
1515
import sortDestructureKeys from "eslint-plugin-sort-destructure-keys";
1616
import globals from "globals";
1717
import eslintCommentsConfigs from "@eslint-community/eslint-plugin-eslint-comments/configs";
18+
import { appLegacyLibImportAllowlist, noLibImportsRule } from "./eslint/no-lib-imports.mjs";
1819

1920
const codegenPlugin = fixupPluginRules(
2021
codegen as unknown as Parameters<typeof fixupPluginRules>[0],
@@ -53,6 +54,7 @@ export default defineConfig(
5354
sonarjs,
5455
unicorn,
5556
import: fixupPluginRules(importPlugin),
57+
local: { rules: { "no-lib-imports": noLibImportsRule } },
5658
"sort-destructure-keys": sortDestructureKeys,
5759
"simple-import-sort": simpleImportSort,
5860
codegen: codegenPlugin,
@@ -71,6 +73,9 @@ export default defineConfig(
7173
rules: {
7274
...sonarjs.configs.recommended.rules,
7375
...unicorn.configs.recommended.rules,
76+
"local/no-lib-imports": ["error", {
77+
allowInFiles: appLegacyLibImportAllowlist,
78+
}],
7479
"no-restricted-imports": ["error", {
7580
paths: [
7681
{

packages/app/eslint.effect-ts-check.config.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments"
1111
import globals from "globals"
1212
import tseslint from "typescript-eslint"
13+
import { appLegacyLibImportAllowlist, noLibImportsRule } from "./eslint/no-lib-imports.mjs"
1314

1415
const restrictedImports = [
1516
{
@@ -147,9 +148,13 @@ export default tseslint.config(
147148
},
148149
plugins: {
149150
"@typescript-eslint": tseslint.plugin,
150-
"eslint-comments": eslintComments
151+
"eslint-comments": eslintComments,
152+
local: { rules: { "no-lib-imports": noLibImportsRule } }
151153
},
152154
rules: {
155+
"local/no-lib-imports": ["error", {
156+
allowInFiles: appLegacyLibImportAllowlist
157+
}],
153158
"no-console": "error",
154159
"no-restricted-imports": ["error", {
155160
paths: restrictedImports,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// @ts-check
2+
3+
const bannedPackageName = "@effect-template/lib"
4+
5+
/** @type {ReadonlyArray<string>} */
6+
export const appLegacyLibImportAllowlist = [
7+
"src/app/program.ts",
8+
"src/docker-git/cli/input.ts",
9+
"src/docker-git/cli/parser-apply.ts",
10+
"src/docker-git/cli/parser-attach.ts",
11+
"src/docker-git/cli/parser-auth.ts",
12+
"src/docker-git/cli/parser-clone.ts",
13+
"src/docker-git/cli/parser-create.ts",
14+
"src/docker-git/cli/parser-mcp-playwright.ts",
15+
"src/docker-git/cli/parser-options.ts",
16+
"src/docker-git/cli/parser-panes.ts",
17+
"src/docker-git/cli/parser-scrap.ts",
18+
"src/docker-git/cli/parser-session-gists.ts",
19+
"src/docker-git/cli/parser-sessions.ts",
20+
"src/docker-git/cli/parser-shared.ts",
21+
"src/docker-git/cli/parser-state.ts",
22+
"src/docker-git/cli/parser.ts",
23+
"src/docker-git/cli/read-command.ts",
24+
"src/docker-git/cli/usage.ts",
25+
"src/docker-git/menu-actions.ts",
26+
"src/docker-git/menu-auth-data.ts",
27+
"src/docker-git/menu-auth-effects.ts",
28+
"src/docker-git/menu-auth-helpers.ts",
29+
"src/docker-git/menu-auth-snapshot-builder.ts",
30+
"src/docker-git/menu-auth.ts",
31+
"src/docker-git/menu-create.ts",
32+
"src/docker-git/menu-labeled-env.ts",
33+
"src/docker-git/menu-menu.ts",
34+
"src/docker-git/menu-project-auth-data.ts",
35+
"src/docker-git/menu-project-auth-flows.ts",
36+
"src/docker-git/menu-project-auth.ts",
37+
"src/docker-git/menu-render-select.ts",
38+
"src/docker-git/menu-render.ts",
39+
"src/docker-git/menu-select-actions.ts",
40+
"src/docker-git/menu-select-connect.ts",
41+
"src/docker-git/menu-select-load.ts",
42+
"src/docker-git/menu-select-order.ts",
43+
"src/docker-git/menu-select-runtime.ts",
44+
"src/docker-git/menu-select-view.ts",
45+
"src/docker-git/menu-startup.ts",
46+
"src/docker-git/menu-types.ts",
47+
"src/docker-git/menu.ts",
48+
"src/docker-git/program.ts",
49+
"src/docker-git/tmux.ts",
50+
"tests/docker-git/entrypoint-auth.test.ts",
51+
"tests/docker-git/fixtures/project-item.ts",
52+
"tests/docker-git/menu-select-connect.test.ts",
53+
"tests/docker-git/parser-helpers.ts",
54+
"tests/docker-git/parser.test.ts"
55+
]
56+
57+
/** @param {string} value */
58+
const normalizePath = (value) => value.replaceAll("\\", "/")
59+
60+
/** @param {string} value */
61+
const isDirectLibImport = (value) =>
62+
value === bannedPackageName || value.startsWith(`${bannedPackageName}/`)
63+
64+
/**
65+
* @param {string} filename
66+
* @param {ReadonlyArray<string>} allowInFiles
67+
*/
68+
const isAllowlistedFile = (filename, allowInFiles) => {
69+
const normalized = normalizePath(filename)
70+
return allowInFiles.some((entry) => normalized === entry || normalized.endsWith(`/${entry}`))
71+
}
72+
73+
/** @param {(import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined} source */
74+
const readSourceText = (source) =>
75+
source && source.type === "Literal" && typeof source.value === "string"
76+
? source.value
77+
: null
78+
79+
/**
80+
* @param {import("eslint").Rule.RuleContext} context
81+
* @returns {import("eslint").Rule.RuleListener}
82+
*/
83+
const createRuleListener = (context) => {
84+
const [options = {}] = context.options
85+
const allowInFiles = Array.isArray(options.allowInFiles)
86+
? options.allowInFiles.map(
87+
/** @param {unknown} value */ (value) => normalizePath(String(value))
88+
)
89+
: []
90+
const filename = typeof context.filename === "string" ? context.filename : ""
91+
92+
if (isAllowlistedFile(filename, allowInFiles)) {
93+
return {}
94+
}
95+
96+
/** @param {(import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined} source */
97+
const checkSource = (source) => {
98+
if (source == null) {
99+
return
100+
}
101+
102+
const sourceText = readSourceText(source)
103+
if (sourceText === null || !isDirectLibImport(sourceText)) {
104+
return
105+
}
106+
107+
context.report({
108+
node: source,
109+
messageId: "noLibImport",
110+
data: { source: sourceText }
111+
})
112+
}
113+
114+
return {
115+
/** @param {{ readonly source?: (import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined }} node */
116+
ExportAllDeclaration(node) {
117+
checkSource(node.source)
118+
},
119+
/** @param {{ readonly source?: (import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined }} node */
120+
ExportNamedDeclaration(node) {
121+
checkSource(node.source)
122+
},
123+
/** @param {{ readonly source?: (import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined }} node */
124+
ImportDeclaration(node) {
125+
checkSource(node.source)
126+
},
127+
/** @param {{ readonly source?: (import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined }} node */
128+
ImportExpression(node) {
129+
checkSource(node.source)
130+
},
131+
/** @param {{ readonly source?: (import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined, readonly argument?: (import("eslint").JSSyntaxElement & { readonly value?: unknown }) | null | undefined }} node */
132+
TSImportType(node) {
133+
checkSource("source" in node ? node.source : node.argument)
134+
}
135+
}
136+
}
137+
138+
/** @type {import("eslint").Rule.RuleModule} */
139+
export const noLibImportsRule = {
140+
meta: {
141+
type: "problem",
142+
docs: {
143+
description: "forbid direct imports from @effect-template/lib inside package/app"
144+
},
145+
schema: [
146+
{
147+
type: "object",
148+
properties: {
149+
allowInFiles: {
150+
type: "array",
151+
items: { type: "string" }
152+
}
153+
},
154+
additionalProperties: false
155+
}
156+
],
157+
messages: {
158+
noLibImport:
159+
"Direct import '{{source}}' from @effect-template/lib is forbidden in package/app. Use the API client or a local app adapter instead."
160+
}
161+
},
162+
create: createRuleListener
163+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
import { Linter } from "eslint"
3+
import tseslint from "typescript-eslint"
4+
5+
import { noLibImportsRule } from "../../eslint/no-lib-imports.mjs"
6+
7+
const verify = (source: string, filePath: string, allowInFiles: ReadonlyArray<string> = []) => {
8+
const linter = new Linter({ configType: "flat" })
9+
10+
return linter.verify(
11+
source,
12+
[
13+
{
14+
files: ["**/*.ts"],
15+
languageOptions: {
16+
ecmaVersion: "latest",
17+
sourceType: "module",
18+
parser: tseslint.parser
19+
},
20+
plugins: {
21+
local: { rules: { "no-lib-imports": noLibImportsRule } }
22+
},
23+
rules: {
24+
"local/no-lib-imports": ["error", { allowInFiles }]
25+
}
26+
}
27+
],
28+
filePath
29+
)
30+
}
31+
32+
describe("noLibImportsRule", () => {
33+
it("rejects import declarations from lib", () => {
34+
const messages = verify(
35+
"import { listProjects } from \"@effect-template/lib\"\n",
36+
"src/new-client.ts"
37+
)
38+
39+
expect(messages).toHaveLength(1)
40+
expect(messages[0]?.message).toContain("Direct import")
41+
expect(messages[0]?.message).toContain("@effect-template/lib")
42+
})
43+
44+
it("rejects type import expressions from lib", () => {
45+
const messages = verify(
46+
"type Template = import(\"@effect-template/lib/core/domain\").TemplateConfig\n",
47+
"src/new-client.ts"
48+
)
49+
50+
expect(messages).toHaveLength(1)
51+
expect(messages[0]?.message).toContain("@effect-template/lib/core/domain")
52+
})
53+
54+
it("allows non-lib imports", () => {
55+
const messages = verify(
56+
"import { request } from \"./api-client.js\"\n",
57+
"src/new-client.ts"
58+
)
59+
60+
expect(messages).toHaveLength(0)
61+
})
62+
63+
it("allows explicit legacy allowlist entries", () => {
64+
const messages = verify(
65+
"import { listProjects } from \"@effect-template/lib\"\n",
66+
"src/docker-git/program.ts",
67+
["src/docker-git/program.ts"]
68+
)
69+
70+
expect(messages).toHaveLength(0)
71+
})
72+
})

packages/app/tsconfig.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
4+
"allowJs": true,
45
"rootDir": ".",
56
"outDir": "dist",
67
"types": ["vitest"],
@@ -11,6 +12,7 @@
1112
}
1213
},
1314
"include": [
15+
"eslint/**/*",
1416
"src/**/*",
1517
"tests/**/*",
1618
"vite.config.ts",

0 commit comments

Comments
 (0)