@zgeoff/oxlint-config 0.2.7 → 0.2.9

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/README.md CHANGED
@@ -8,7 +8,8 @@ await arguments, awaits hidden inside a control-flow condition or a `&&`/`||`/`?
8
8
  `switch` statement, every JSDoc block, any run of more than three line comments, and a repo config
9
9
  that loosens either comment rule. `zgeoff/function-verb` enforces the function-naming taxonomy from
10
10
  the shared agent guidelines, `zgeoff/test-beside-module` keeps each test file beside the module it
11
- tests, `zgeoff/import-zones` fails an import that crosses a module boundary the repo declares, and
11
+ tests, `zgeoff/import-zones` fails an import that crosses a module boundary the repo declares,
12
+ `zgeoff/no-restricted-words` keeps the words in its `words` option out of a path's text, and
12
13
  `zgeoff/no-file-snapshot` bans file snapshots.
13
14
 
14
15
  ## Usage
@@ -86,6 +87,25 @@ first statement, so put the comment above it:
86
87
  A second test file for a module (`<name>.host.test.ts`) needs its own `<name>.host.<ext>`, so the
87
88
  rule reports it beside only `<name>.ts`.
88
89
 
90
+ ## `zgeoff/no-restricted-words`
91
+
92
+ Off unless a repo configures it. The `words` option lists the words to keep out of the text of a
93
+ file: string literals, template text and comments. A word matches whole and without case, so `imp`
94
+ reports `'IMP daemon'` and `'imp-e2e'` but not `'impl'` or `'imp_name'`. Identifiers are code, not
95
+ text, and the rule never reads them. Scope the rule to a path with `overrides`, and turn it off for
96
+ the paths that may name the words:
97
+
98
+ ```jsonc
99
+ {
100
+ "rules": {
101
+ "zgeoff/no-restricted-words": ["error", { "words": ["imp", "impd"] }],
102
+ },
103
+ "overrides": [
104
+ { "files": ["packages/execution/**"], "rules": { "zgeoff/no-restricted-words": "off" } },
105
+ ],
106
+ }
107
+ ```
108
+
89
109
  ## `zgeoff/no-file-snapshot`
90
110
 
91
111
  Snapshots are inline only, so the golden value sits in the test that pins it. The rule reports every
@@ -102,17 +122,21 @@ the boundaries between them. A file belongs to every zone its path matches, so z
102
122
  Each entry has a `name`, which starts its report, an optional `reason`, which ends it, and optional
103
123
  `from` zones and `ignore` globs that limit which importing files it judges.
104
124
 
105
- - `edges` judge an import that resolves to a file: relative, or the package's own name resolved
106
- through its `exports` under `conditions` plus `default`. A target in a `to` zone fails unless it
107
- is also in an `allow` zone; with `allow` alone, a target in none of the `allow` zones fails, a
108
- target in no zone included. `allowTypeOnly` exempts type-only imports (`true`, or globs the target
109
- must match), and `allowWithin: "src/*"` exempts an import inside the importer's own `src/<name>/`.
125
+ - `edges` judge an import that resolves to a file: relative, with any bundler query such as `?url`
126
+ dropped, or the package's own name resolved through its `exports` under `conditions` plus
127
+ `default`. A target in a `to` zone fails unless it is also in an `allow` zone; with `allow` alone,
128
+ a target in none of the `allow` zones fails, a target in no zone included. `allowTypeOnly` exempts
129
+ type-only imports (`true`, or globs the target must match), and `allowWithin: "src/*"` exempts an
130
+ import inside the importer's own `src/<name>/`.
110
131
  - `modules` restrict a package or builtin (`node:` prefix optional, subpaths included) to the
111
- `allow` zones; `allowTypeOnly: true` exempts type-only imports.
132
+ `allow` zones; `allowTypeOnly: true` exempts type-only imports. A name that ends in `*` restricts
133
+ every specifier that starts with the text before it, such as `@scope/*`, and `node:*` restricts
134
+ every builtin.
112
135
  - `globals` restrict a call to a global, bare or through `globalThis`, `self` or `window`.
113
136
  - `unzoned` reports a file in a `from` zone that belongs to none of `zones`.
114
137
  - `unresolved` reports a relative or self-referencing import that resolves to no file, and a dynamic
115
- `import()` or `require()` whose specifier is computed.
138
+ `import()`, `require()` or `import.meta.require()` whose specifier is computed. Bun's
139
+ `import.meta.require` used as a value counts as computed too.
116
140
 
117
141
  ```jsonc
118
142
  {
package/import-zones.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isBuiltin } from 'node:module';
1
2
  import { relative, sep } from 'node:path';
2
3
  import findRepoRoot from './find-repo-root.js';
3
4
  import importZonesSchema from './import-zones-schema.js';
@@ -114,12 +115,25 @@ function isRestrictedModule(entry, specifier) {
114
115
  const name = toModuleName(specifier);
115
116
 
116
117
  return entry.modules.some((module) => {
118
+ if (module.endsWith('*')) {
119
+ return isPrefixMatch(module.slice(0, -1), specifier);
120
+ }
121
+
117
122
  const restricted = toModuleName(module);
118
123
 
119
124
  return name === restricted || name.startsWith(`${restricted}/`);
120
125
  });
121
126
  }
122
127
 
128
+ // `node:*` names every builtin, which a specifier may write without the prefix
129
+ function isPrefixMatch(prefix, specifier) {
130
+ if (specifier.startsWith(prefix)) {
131
+ return true;
132
+ }
133
+
134
+ return prefix === 'node:' && isBuiltin(specifier);
135
+ }
136
+
123
137
  function toModuleName(specifier) {
124
138
  return specifier.replace(/^node:/u, '');
125
139
  }
@@ -235,6 +249,18 @@ function isTypeOnlyExport(node) {
235
249
  );
236
250
  }
237
251
 
252
+ function isImportMetaRequire(node) {
253
+ return (
254
+ node.type === 'MemberExpression' &&
255
+ !node.computed &&
256
+ node.object.type === 'MetaProperty' &&
257
+ node.object.meta.name === 'import' &&
258
+ node.object.property.name === 'meta' &&
259
+ node.property.type === 'Identifier' &&
260
+ node.property.name === 'require'
261
+ );
262
+ }
263
+
238
264
  const importZones = {
239
265
  meta: {
240
266
  type: 'problem',
@@ -284,10 +310,21 @@ const importZones = {
284
310
  CallExpression(node) {
285
311
  checkGlobalCall(file, node);
286
312
 
287
- if (node.callee.type === 'Identifier' && node.callee.name === 'require') {
313
+ if (
314
+ (node.callee.type === 'Identifier' && node.callee.name === 'require') ||
315
+ isImportMetaRequire(node.callee)
316
+ ) {
288
317
  checkImport(file, { node, source: node.arguments[0], typeOnly: false });
289
318
  }
290
319
  },
320
+
321
+ // Bun's import.meta.require used as a value, such as `const load =
322
+ // import.meta.require`, loads modules no specifier names
323
+ MemberExpression(node) {
324
+ if (isImportMetaRequire(node) && node.parent?.callee !== node) {
325
+ emitUnresolved(file, node, { messageId: 'computed', data: {} });
326
+ }
327
+ },
291
328
  };
292
329
  },
293
330
  };
@@ -0,0 +1,61 @@
1
+ const noRestrictedWords = {
2
+ meta: {
3
+ type: 'problem',
4
+ messages: {
5
+ restricted:
6
+ 'Remove `{{word}}` from this {{place}}: the repo config restricts the word in this path.',
7
+ },
8
+ schema: [
9
+ {
10
+ type: 'object',
11
+ properties: {
12
+ words: { type: 'array', items: { type: 'string', minLength: 1 }, uniqueItems: true },
13
+ },
14
+ additionalProperties: false,
15
+ },
16
+ ],
17
+ },
18
+ create(context) {
19
+ const words = context.options[0]?.words ?? [];
20
+
21
+ if (words.length === 0) {
22
+ return {};
23
+ }
24
+
25
+ const pattern = buildWordPattern(words);
26
+
27
+ const checkText = (node, text, place) => {
28
+ for (const match of text.matchAll(pattern)) {
29
+ context.report({ node, messageId: 'restricted', data: { word: match[0], place } });
30
+ }
31
+ };
32
+
33
+ return {
34
+ Literal(node) {
35
+ if (typeof node.value === 'string') {
36
+ checkText(node, node.value, 'string');
37
+ }
38
+ },
39
+ TemplateElement(node) {
40
+ checkText(node, node.value.cooked ?? node.value.raw, 'template');
41
+ },
42
+ Program() {
43
+ for (const comment of context.sourceCode.getAllComments()) {
44
+ checkText(comment, comment.value, 'comment');
45
+ }
46
+ },
47
+ };
48
+ },
49
+ };
50
+
51
+ // a letter, digit or underscore on either side makes the match part of a
52
+ // longer word (`impl`, `simple`), so only those characters bound it
53
+ function buildWordPattern(words) {
54
+ const alternatives = words.map((word) =>
55
+ word.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw`\$&`),
56
+ );
57
+
58
+ return new RegExp(`(?<![\\p{L}\\p{N}_])(?:${alternatives.join('|')})(?![\\p{L}\\p{N}_])`, 'giu');
59
+ }
60
+
61
+ export default noRestrictedWords;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/oxlint-config",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Shareable oxlint config: strict categories, house-style rules, and a custom JS plugin",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,6 +12,7 @@
12
12
  "oxlintrc.json",
13
13
  "plugin.js",
14
14
  "function-verb.js",
15
+ "no-restricted-words.js",
15
16
  "max-consecutive-line-comments.js",
16
17
  "no-jsdoc.js",
17
18
  "no-comment-rule-override.js",
package/plugin.js CHANGED
@@ -4,6 +4,7 @@ import maxConsecutiveLineComments from './max-consecutive-line-comments.js';
4
4
  import noCommentRuleOverride from './no-comment-rule-override.js';
5
5
  import noFileSnapshot from './no-file-snapshot.js';
6
6
  import noJSDoc from './no-jsdoc.js';
7
+ import noRestrictedWords from './no-restricted-words.js';
7
8
  import testBesideModule from './test-beside-module.js';
8
9
 
9
10
  // custom rules loaded through oxlint's jsPlugins (ESLint v9 rule API): each
@@ -105,6 +106,7 @@ const plugin = {
105
106
  'max-consecutive-line-comments': maxConsecutiveLineComments,
106
107
  'no-comment-rule-override': noCommentRuleOverride,
107
108
  'function-verb': functionVerb,
109
+ 'no-restricted-words': noRestrictedWords,
108
110
  'test-beside-module': testBesideModule,
109
111
  'no-file-snapshot': noFileSnapshot,
110
112
  'import-zones': importZones,
@@ -9,7 +9,7 @@ const sourceExtensions = { '.js': ['.ts', '.tsx'], '.mjs': ['.mts'], '.cjs': ['.
9
9
  // own name resolves through its `exports`, and `#` names through its `imports`.
10
10
  export default function resolveImportTarget(filename, specifier, settings) {
11
11
  if (isPathSpecifier(specifier)) {
12
- return toTarget(findImportedFile(filename, specifier, settings.extensions));
12
+ return toTarget(findImportedFile(filename, toFileSpecifier(specifier), settings.extensions));
13
13
  }
14
14
 
15
15
  const manifest = findPackageManifest(dirname(filename), settings.root);
@@ -40,6 +40,13 @@ function resolveManifestTarget(manifest, specifier, conditions) {
40
40
  return { kind: 'module', specifier };
41
41
  }
42
42
 
43
+ // a bundler query such as Vite's `./styles.css?url` names the file before it
44
+ function toFileSpecifier(specifier) {
45
+ const query = specifier.indexOf('?');
46
+
47
+ return query === -1 ? specifier : specifier.slice(0, query);
48
+ }
49
+
43
50
  function toTarget(path) {
44
51
  return path === null ? { kind: 'missing' } : { kind: 'file', path };
45
52
  }