@cmssy/eslint-plugin 12.4.0 → 12.6.0

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.
Files changed (3) hide show
  1. package/dist/index.cjs +153 -17
  2. package/dist/index.js +155 -19
  3. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -9,9 +9,81 @@ var path = require('path');
9
9
  var SERVER_MODULES = [/^@cmssy\/next\/server$/];
10
10
  var SERVER_SYMBOLS = /* @__PURE__ */ new Set(["defineCmssyConfig"]);
11
11
  var EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs"];
12
- function resolveLocal(fromFile, specifier) {
13
- if (!specifier.startsWith(".")) return null;
14
- const base = path.resolve(path.dirname(fromFile), specifier);
12
+ var aliasCache = /* @__PURE__ */ new Map();
13
+ function stripJsonComments(text) {
14
+ let out = "";
15
+ let inString = false;
16
+ let escaped = false;
17
+ for (let i = 0; i < text.length; i += 1) {
18
+ const char = text[i];
19
+ if (inString) {
20
+ out += char;
21
+ if (escaped) escaped = false;
22
+ else if (char === "\\") escaped = true;
23
+ else if (char === '"') inString = false;
24
+ continue;
25
+ }
26
+ if (char === '"') {
27
+ inString = true;
28
+ out += char;
29
+ continue;
30
+ }
31
+ if (char === "/" && text[i + 1] === "/") {
32
+ while (i < text.length && text[i] !== "\n") i += 1;
33
+ out += "\n";
34
+ continue;
35
+ }
36
+ if (char === "/" && text[i + 1] === "*") {
37
+ i += 2;
38
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
39
+ i += 1;
40
+ }
41
+ i += 1;
42
+ continue;
43
+ }
44
+ out += char;
45
+ }
46
+ return out.replace(/,(\s*[}\]])/g, "$1");
47
+ }
48
+ function readTsconfig(file) {
49
+ try {
50
+ return JSON.parse(
51
+ stripJsonComments(fs.readFileSync(file, "utf8"))
52
+ );
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ function aliasConfigFor(fromFile) {
58
+ let dir = path.dirname(fromFile);
59
+ const visited = [];
60
+ for (let up = 0; up < 12; up += 1) {
61
+ const cached = aliasCache.get(dir);
62
+ if (cached !== void 0) {
63
+ for (const seen of visited) aliasCache.set(seen, cached);
64
+ return cached;
65
+ }
66
+ visited.push(dir);
67
+ const candidate = path.resolve(dir, "tsconfig.json");
68
+ if (fs.existsSync(candidate)) {
69
+ const config = readTsconfig(candidate);
70
+ const options = config?.compilerOptions ?? {};
71
+ const paths = options.paths;
72
+ if (paths && Object.keys(paths).length > 0) {
73
+ const baseUrl = typeof options.baseUrl === "string" ? path.resolve(dir, options.baseUrl) : dir;
74
+ const found = { baseUrl, paths };
75
+ for (const seen of visited) aliasCache.set(seen, found);
76
+ return found;
77
+ }
78
+ }
79
+ const parent = path.dirname(dir);
80
+ if (parent === dir) break;
81
+ dir = parent;
82
+ }
83
+ for (const seen of visited) aliasCache.set(seen, null);
84
+ return null;
85
+ }
86
+ function withExtension(base) {
15
87
  for (const ext of EXTENSIONS) {
16
88
  const candidate = `${base}${ext}`;
17
89
  if (fs.existsSync(candidate) && !candidate.endsWith("/")) return candidate;
@@ -22,13 +94,45 @@ function resolveLocal(fromFile, specifier) {
22
94
  }
23
95
  return null;
24
96
  }
97
+ function resolveAlias(fromFile, specifier) {
98
+ const config = aliasConfigFor(fromFile);
99
+ if (!config) return null;
100
+ for (const [pattern, targets] of Object.entries(config.paths)) {
101
+ const star = pattern.indexOf("*");
102
+ if (star === -1) {
103
+ if (pattern !== specifier) continue;
104
+ for (const target of targets) {
105
+ const hit = withExtension(path.resolve(config.baseUrl, target));
106
+ if (hit) return hit;
107
+ }
108
+ continue;
109
+ }
110
+ const prefix = pattern.slice(0, star);
111
+ const suffix = pattern.slice(star + 1);
112
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
113
+ const middle = specifier.slice(
114
+ prefix.length,
115
+ specifier.length - suffix.length
116
+ );
117
+ for (const target of targets) {
118
+ const hit = withExtension(
119
+ path.resolve(config.baseUrl, target.replace("*", middle))
120
+ );
121
+ if (hit) return hit;
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ function resolveLocal(fromFile, specifier) {
127
+ if (specifier.startsWith(".")) {
128
+ return withExtension(path.resolve(path.dirname(fromFile), specifier));
129
+ }
130
+ return resolveAlias(fromFile, specifier);
131
+ }
25
132
  function valueImports(code) {
26
- const withoutTypes = code.replace(
27
- /^\s*(?:import|export)\s+type\s[^;]*;/gm,
28
- ""
29
- );
133
+ const withoutTypes = code.replace(/^\s*(?:import|export)\s+type\s[^;]*;/gm, "").replace(/\bimport\s*\([^)]*\)/g, "");
30
134
  return [
31
- ...withoutTypes.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)
135
+ ...withoutTypes.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)
32
136
  ].map(([, specifier]) => specifier ?? "");
33
137
  }
34
138
  function importsServerConfig(code) {
@@ -43,6 +147,11 @@ function importsServerConfig(code) {
43
147
  )
44
148
  );
45
149
  }
150
+ function hasDirective(source, directive) {
151
+ return new RegExp(
152
+ `^\\s*(?:\\/\\/[^\\n]*\\n|\\/\\*[\\s\\S]*?\\*\\/\\s*)*["']${directive}["']`
153
+ ).test(source);
154
+ }
46
155
  function chainToServerConfig(file, cache2, seen = /* @__PURE__ */ new Set()) {
47
156
  if (seen.has(file)) return null;
48
157
  seen.add(file);
@@ -55,6 +164,10 @@ function chainToServerConfig(file, cache2, seen = /* @__PURE__ */ new Set()) {
55
164
  cache2.set(file, null);
56
165
  return null;
57
166
  }
167
+ if (hasDirective(code, "use server")) {
168
+ cache2.set(file, null);
169
+ return null;
170
+ }
58
171
  if (importsServerConfig(code)) {
59
172
  const chain = [file];
60
173
  cache2.set(file, chain);
@@ -73,10 +186,16 @@ function chainToServerConfig(file, cache2, seen = /* @__PURE__ */ new Set()) {
73
186
  cache2.set(file, null);
74
187
  return null;
75
188
  }
76
- function isClientFile(source) {
77
- return /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*["']use client["']/.test(
78
- source
79
- );
189
+ function normalize(path$1) {
190
+ return path$1.split(path.sep).join("/");
191
+ }
192
+ function isClientEntry(filename, entries) {
193
+ const file = normalize(filename);
194
+ return entries.some((entry) => {
195
+ const wanted = normalize(entry);
196
+ if (path.isAbsolute(wanted)) return file === wanted;
197
+ return file === wanted || file.endsWith(`/${wanted}`);
198
+ });
80
199
  }
81
200
  var cache = /* @__PURE__ */ new Map();
82
201
  var noServerConfigInClient = {
@@ -85,14 +204,31 @@ var noServerConfigInClient = {
85
204
  docs: {
86
205
  description: "Disallow a client component from importing values that read the cmssy server config"
87
206
  },
88
- schema: [],
207
+ schema: [
208
+ {
209
+ type: "object",
210
+ properties: {
211
+ clientEntries: {
212
+ type: "array",
213
+ items: { type: "string" }
214
+ }
215
+ },
216
+ additionalProperties: false
217
+ }
218
+ ],
89
219
  messages: {
90
- reachesConfig: 'This client component pulls the cmssy config into the browser bundle, where server env does not exist - the page will fail at runtime with "missing required configuration".\n {{chain}}\nImport the type instead (types are erased), or move the value into a module that does not touch the config.'
220
+ reachesConfig: 'This client component pulls the cmssy config into the browser bundle, where server env does not exist - the page will fail at runtime with "missing required configuration".\n {{chain}}\nImport the type instead (types are erased), or move the value into a module that does not touch the config.',
221
+ reachesConfigFromEntry: "This module is loaded in the browser, so it pulls the cmssy config there, where server env does not exist.\n {{chain}}\nImport the type instead (types are erased), or load the value with a dynamic import inside the function that needs it."
91
222
  }
92
223
  },
93
224
  create(context) {
94
225
  const filename = context.filename ?? context.getFilename();
95
- if (!isClientFile(context.sourceCode.getText())) return {};
226
+ const options = context.options[0] ?? {};
227
+ const entries = options.clientEntries ?? [];
228
+ const client = hasDirective(context.sourceCode.getText(), "use client");
229
+ const entry = isClientEntry(filename, entries);
230
+ if (!client && !entry) return {};
231
+ const messageId = client ? "reachesConfig" : "reachesConfigFromEntry";
96
232
  return {
97
233
  ImportDeclaration(node) {
98
234
  const typed = node;
@@ -104,7 +240,7 @@ var noServerConfigInClient = {
104
240
  if (SERVER_MODULES.some((pattern) => pattern.test(specifier))) {
105
241
  context.report({
106
242
  node,
107
- messageId: "reachesConfig",
243
+ messageId,
108
244
  data: { chain: `${specifier} is server-only` }
109
245
  });
110
246
  return;
@@ -115,7 +251,7 @@ var noServerConfigInClient = {
115
251
  if (!chain) return;
116
252
  context.report({
117
253
  node,
118
- messageId: "reachesConfig",
254
+ messageId,
119
255
  data: {
120
256
  chain: [filename, ...chain].map((f) => f.split("/").slice(-2).join("/")).join(" -> ")
121
257
  }
package/dist/index.js CHANGED
@@ -1,13 +1,85 @@
1
- import { existsSync, readFileSync } from 'fs';
2
- import { resolve, dirname } from 'path';
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { isAbsolute, resolve, dirname, sep } from 'path';
3
3
 
4
4
  // src/no-server-config-in-client.ts
5
5
  var SERVER_MODULES = [/^@cmssy\/next\/server$/];
6
6
  var SERVER_SYMBOLS = /* @__PURE__ */ new Set(["defineCmssyConfig"]);
7
7
  var EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs"];
8
- function resolveLocal(fromFile, specifier) {
9
- if (!specifier.startsWith(".")) return null;
10
- const base = resolve(dirname(fromFile), specifier);
8
+ var aliasCache = /* @__PURE__ */ new Map();
9
+ function stripJsonComments(text) {
10
+ let out = "";
11
+ let inString = false;
12
+ let escaped = false;
13
+ for (let i = 0; i < text.length; i += 1) {
14
+ const char = text[i];
15
+ if (inString) {
16
+ out += char;
17
+ if (escaped) escaped = false;
18
+ else if (char === "\\") escaped = true;
19
+ else if (char === '"') inString = false;
20
+ continue;
21
+ }
22
+ if (char === '"') {
23
+ inString = true;
24
+ out += char;
25
+ continue;
26
+ }
27
+ if (char === "/" && text[i + 1] === "/") {
28
+ while (i < text.length && text[i] !== "\n") i += 1;
29
+ out += "\n";
30
+ continue;
31
+ }
32
+ if (char === "/" && text[i + 1] === "*") {
33
+ i += 2;
34
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
35
+ i += 1;
36
+ }
37
+ i += 1;
38
+ continue;
39
+ }
40
+ out += char;
41
+ }
42
+ return out.replace(/,(\s*[}\]])/g, "$1");
43
+ }
44
+ function readTsconfig(file) {
45
+ try {
46
+ return JSON.parse(
47
+ stripJsonComments(readFileSync(file, "utf8"))
48
+ );
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ function aliasConfigFor(fromFile) {
54
+ let dir = dirname(fromFile);
55
+ const visited = [];
56
+ for (let up = 0; up < 12; up += 1) {
57
+ const cached = aliasCache.get(dir);
58
+ if (cached !== void 0) {
59
+ for (const seen of visited) aliasCache.set(seen, cached);
60
+ return cached;
61
+ }
62
+ visited.push(dir);
63
+ const candidate = resolve(dir, "tsconfig.json");
64
+ if (existsSync(candidate)) {
65
+ const config = readTsconfig(candidate);
66
+ const options = config?.compilerOptions ?? {};
67
+ const paths = options.paths;
68
+ if (paths && Object.keys(paths).length > 0) {
69
+ const baseUrl = typeof options.baseUrl === "string" ? resolve(dir, options.baseUrl) : dir;
70
+ const found = { baseUrl, paths };
71
+ for (const seen of visited) aliasCache.set(seen, found);
72
+ return found;
73
+ }
74
+ }
75
+ const parent = dirname(dir);
76
+ if (parent === dir) break;
77
+ dir = parent;
78
+ }
79
+ for (const seen of visited) aliasCache.set(seen, null);
80
+ return null;
81
+ }
82
+ function withExtension(base) {
11
83
  for (const ext of EXTENSIONS) {
12
84
  const candidate = `${base}${ext}`;
13
85
  if (existsSync(candidate) && !candidate.endsWith("/")) return candidate;
@@ -18,13 +90,45 @@ function resolveLocal(fromFile, specifier) {
18
90
  }
19
91
  return null;
20
92
  }
93
+ function resolveAlias(fromFile, specifier) {
94
+ const config = aliasConfigFor(fromFile);
95
+ if (!config) return null;
96
+ for (const [pattern, targets] of Object.entries(config.paths)) {
97
+ const star = pattern.indexOf("*");
98
+ if (star === -1) {
99
+ if (pattern !== specifier) continue;
100
+ for (const target of targets) {
101
+ const hit = withExtension(resolve(config.baseUrl, target));
102
+ if (hit) return hit;
103
+ }
104
+ continue;
105
+ }
106
+ const prefix = pattern.slice(0, star);
107
+ const suffix = pattern.slice(star + 1);
108
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
109
+ const middle = specifier.slice(
110
+ prefix.length,
111
+ specifier.length - suffix.length
112
+ );
113
+ for (const target of targets) {
114
+ const hit = withExtension(
115
+ resolve(config.baseUrl, target.replace("*", middle))
116
+ );
117
+ if (hit) return hit;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ function resolveLocal(fromFile, specifier) {
123
+ if (specifier.startsWith(".")) {
124
+ return withExtension(resolve(dirname(fromFile), specifier));
125
+ }
126
+ return resolveAlias(fromFile, specifier);
127
+ }
21
128
  function valueImports(code) {
22
- const withoutTypes = code.replace(
23
- /^\s*(?:import|export)\s+type\s[^;]*;/gm,
24
- ""
25
- );
129
+ const withoutTypes = code.replace(/^\s*(?:import|export)\s+type\s[^;]*;/gm, "").replace(/\bimport\s*\([^)]*\)/g, "");
26
130
  return [
27
- ...withoutTypes.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)
131
+ ...withoutTypes.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)
28
132
  ].map(([, specifier]) => specifier ?? "");
29
133
  }
30
134
  function importsServerConfig(code) {
@@ -39,6 +143,11 @@ function importsServerConfig(code) {
39
143
  )
40
144
  );
41
145
  }
146
+ function hasDirective(source, directive) {
147
+ return new RegExp(
148
+ `^\\s*(?:\\/\\/[^\\n]*\\n|\\/\\*[\\s\\S]*?\\*\\/\\s*)*["']${directive}["']`
149
+ ).test(source);
150
+ }
42
151
  function chainToServerConfig(file, cache2, seen = /* @__PURE__ */ new Set()) {
43
152
  if (seen.has(file)) return null;
44
153
  seen.add(file);
@@ -51,6 +160,10 @@ function chainToServerConfig(file, cache2, seen = /* @__PURE__ */ new Set()) {
51
160
  cache2.set(file, null);
52
161
  return null;
53
162
  }
163
+ if (hasDirective(code, "use server")) {
164
+ cache2.set(file, null);
165
+ return null;
166
+ }
54
167
  if (importsServerConfig(code)) {
55
168
  const chain = [file];
56
169
  cache2.set(file, chain);
@@ -69,10 +182,16 @@ function chainToServerConfig(file, cache2, seen = /* @__PURE__ */ new Set()) {
69
182
  cache2.set(file, null);
70
183
  return null;
71
184
  }
72
- function isClientFile(source) {
73
- return /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*["']use client["']/.test(
74
- source
75
- );
185
+ function normalize(path) {
186
+ return path.split(sep).join("/");
187
+ }
188
+ function isClientEntry(filename, entries) {
189
+ const file = normalize(filename);
190
+ return entries.some((entry) => {
191
+ const wanted = normalize(entry);
192
+ if (isAbsolute(wanted)) return file === wanted;
193
+ return file === wanted || file.endsWith(`/${wanted}`);
194
+ });
76
195
  }
77
196
  var cache = /* @__PURE__ */ new Map();
78
197
  var noServerConfigInClient = {
@@ -81,14 +200,31 @@ var noServerConfigInClient = {
81
200
  docs: {
82
201
  description: "Disallow a client component from importing values that read the cmssy server config"
83
202
  },
84
- schema: [],
203
+ schema: [
204
+ {
205
+ type: "object",
206
+ properties: {
207
+ clientEntries: {
208
+ type: "array",
209
+ items: { type: "string" }
210
+ }
211
+ },
212
+ additionalProperties: false
213
+ }
214
+ ],
85
215
  messages: {
86
- reachesConfig: 'This client component pulls the cmssy config into the browser bundle, where server env does not exist - the page will fail at runtime with "missing required configuration".\n {{chain}}\nImport the type instead (types are erased), or move the value into a module that does not touch the config.'
216
+ reachesConfig: 'This client component pulls the cmssy config into the browser bundle, where server env does not exist - the page will fail at runtime with "missing required configuration".\n {{chain}}\nImport the type instead (types are erased), or move the value into a module that does not touch the config.',
217
+ reachesConfigFromEntry: "This module is loaded in the browser, so it pulls the cmssy config there, where server env does not exist.\n {{chain}}\nImport the type instead (types are erased), or load the value with a dynamic import inside the function that needs it."
87
218
  }
88
219
  },
89
220
  create(context) {
90
221
  const filename = context.filename ?? context.getFilename();
91
- if (!isClientFile(context.sourceCode.getText())) return {};
222
+ const options = context.options[0] ?? {};
223
+ const entries = options.clientEntries ?? [];
224
+ const client = hasDirective(context.sourceCode.getText(), "use client");
225
+ const entry = isClientEntry(filename, entries);
226
+ if (!client && !entry) return {};
227
+ const messageId = client ? "reachesConfig" : "reachesConfigFromEntry";
92
228
  return {
93
229
  ImportDeclaration(node) {
94
230
  const typed = node;
@@ -100,7 +236,7 @@ var noServerConfigInClient = {
100
236
  if (SERVER_MODULES.some((pattern) => pattern.test(specifier))) {
101
237
  context.report({
102
238
  node,
103
- messageId: "reachesConfig",
239
+ messageId,
104
240
  data: { chain: `${specifier} is server-only` }
105
241
  });
106
242
  return;
@@ -111,7 +247,7 @@ var noServerConfigInClient = {
111
247
  if (!chain) return;
112
248
  context.report({
113
249
  node,
114
- messageId: "reachesConfig",
250
+ messageId,
115
251
  data: {
116
252
  chain: [filename, ...chain].map((f) => f.split("/").slice(-2).join("/")).join(" -> ")
117
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmssy/eslint-plugin",
3
- "version": "12.4.0",
3
+ "version": "12.6.0",
4
4
  "description": "Catches the cmssy wiring mistakes that a build cannot: server config dragged into a client bundle.",
5
5
  "keywords": [
6
6
  "cmssy",