@vibe-agent-toolkit/utils 0.1.41 → 0.1.42
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 +146 -31
- package/dist/asset.d.ts +9 -0
- package/dist/asset.d.ts.map +1 -0
- package/dist/asset.js +9 -0
- package/dist/asset.js.map +1 -0
- package/dist/crawl.d.ts +19 -0
- package/dist/crawl.d.ts.map +1 -0
- package/dist/crawl.js +19 -0
- package/dist/crawl.js.map +1 -0
- package/dist/file-crawler.d.ts +19 -2
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +19 -1
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs-utils.d.ts +60 -3
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +88 -10
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +7 -6
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -6
- package/dist/fs.js.map +1 -1
- package/dist/git-utils.d.ts +2 -0
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +16 -0
- package/dist/git-utils.js.map +1 -1
- package/dist/git.d.ts +23 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +23 -0
- package/dist/git.js.map +1 -0
- package/dist/gitignore-checker.d.ts +0 -9
- package/dist/gitignore-checker.d.ts.map +1 -1
- package/dist/gitignore-checker.js +0 -12
- package/dist/gitignore-checker.js.map +1 -1
- package/dist/glob/glob-pattern.js +1 -1
- package/dist/glob/glob-pattern.js.map +1 -1
- package/dist/glob.d.ts +8 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +8 -0
- package/dist/glob.js.map +1 -0
- package/dist/path-core.d.ts +196 -0
- package/dist/path-core.d.ts.map +1 -0
- package/dist/path-core.js +251 -0
- package/dist/path-core.js.map +1 -0
- package/dist/path-utils.d.ts +12 -183
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/path-utils.js +13 -237
- package/dist/path-utils.js.map +1 -1
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +12 -0
- package/dist/path.js.map +1 -0
- package/dist/process.d.ts +7 -4
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +7 -4
- package/dist/process.js.map +1 -1
- package/dist/project.d.ts +31 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +31 -0
- package/dist/project.js.map +1 -0
- package/dist/safe-exec.d.ts.map +1 -1
- package/dist/safe-exec.js +8 -4
- package/dist/safe-exec.js.map +1 -1
- package/dist/spawn-hardened.d.ts.map +1 -1
- package/dist/spawn-hardened.js +4 -17
- package/dist/spawn-hardened.js.map +1 -1
- package/dist/template-entry.d.ts +10 -0
- package/dist/template-entry.d.ts.map +1 -0
- package/dist/template-entry.js +10 -0
- package/dist/template-entry.js.map +1 -0
- package/dist/testing.d.ts +8 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +8 -0
- package/dist/testing.js.map +1 -0
- package/dist/windows-shell.d.ts +140 -8
- package/dist/windows-shell.d.ts.map +1 -1
- package/dist/windows-shell.js +218 -11
- package/dist/windows-shell.js.map +1 -1
- package/dist/yaml.d.ts +9 -0
- package/dist/yaml.d.ts.map +1 -0
- package/dist/yaml.js +9 -0
- package/dist/yaml.js.map +1 -0
- package/dist/zod.d.ts +10 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +10 -0
- package/dist/zod.js.map +1 -0
- package/eslint/README.md +217 -0
- package/eslint/index.cjs +161 -0
- package/eslint/index.d.cts +60 -0
- package/eslint/rules/dead-import.cjs +201 -0
- package/eslint/rules/eslint-rule-factory.cjs +410 -0
- package/eslint/rules/exempt-path-matcher.cjs +265 -0
- package/eslint/rules/no-bare-dynamic-import-path.cjs +142 -0
- package/eslint/rules/no-child-process-execSync.cjs +23 -0
- package/eslint/rules/no-command-direct-factory.cjs +246 -0
- package/eslint/rules/no-file-url-string-concat.cjs +77 -0
- package/eslint/rules/no-fs-mkdirSync.cjs +23 -0
- package/eslint/rules/no-fs-promises-cp.cjs +36 -0
- package/eslint/rules/no-fs-realpathSync.cjs +23 -0
- package/eslint/rules/no-hardcoded-path-split.cjs +141 -0
- package/eslint/rules/no-manual-path-normalize.cjs +161 -0
- package/eslint/rules/no-os-tmpdir.cjs +24 -0
- package/eslint/rules/no-path-join.cjs +14 -0
- package/eslint/rules/no-path-operations-in-comparisons.cjs +148 -0
- package/eslint/rules/no-path-relative.cjs +14 -0
- package/eslint/rules/no-path-resolve.cjs +14 -0
- package/eslint/rules/no-path-sep-in-strings.cjs +130 -0
- package/eslint/rules/no-path-startswith.cjs +139 -0
- package/eslint/rules/no-test-scoped-functions.cjs +134 -0
- package/eslint/rules/no-unix-shell-commands.cjs +152 -0
- package/eslint/rules/no-unsafe-root-join.cjs +85 -0
- package/eslint/rules/no-url-pathname-for-fs.cjs +107 -0
- package/eslint/rules/path-function-rule-factory.cjs +417 -0
- package/eslint/rules/prefer-startswith-over-regex.cjs +257 -0
- package/eslint/rules/require-justified-skip.cjs +348 -0
- package/eslint/rules/safe-import.cjs +164 -0
- package/package.json +65 -7
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint Rule Factory - Shared implementation for unsafe operation rules
|
|
3
|
+
*
|
|
4
|
+
* Creates ESLint rules that detect unsafe operations and suggest safe alternatives.
|
|
5
|
+
* Supports auto-fixing with import management.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} config - Rule configuration
|
|
8
|
+
* @param {string} config.unsafeFn - Name of unsafe function (e.g., 'tmpdir', 'mkdirSync')
|
|
9
|
+
* @param {string} config.unsafeModule - Module containing unsafe function (e.g., 'node:os', 'node:fs')
|
|
10
|
+
* @param {string} config.safeFn - Name of safe replacement function (e.g., 'normalizedTmpdir')
|
|
11
|
+
* @param {string} config.safeModule - Module containing safe function. Pass the
|
|
12
|
+
* NARROW subpath that owns the symbol (`@vibe-agent-toolkit/utils/fs`), never
|
|
13
|
+
* the barrel — see `safe-import.cjs` for why.
|
|
14
|
+
* @param {string} config.message - Error message to display
|
|
15
|
+
* @param {readonly string[]} [config.exemptFiles] - FALLBACK repo-relative paths
|
|
16
|
+
* allowed to call the unsafe function, used only when the consuming config
|
|
17
|
+
* passes no `exemptFiles` option. Ship this empty: an exemption names one file
|
|
18
|
+
* in one repo, so a baked-in default is a hole in every OTHER repo. Consumers
|
|
19
|
+
* declare their own via the rule option (see `exempt-path-matcher.cjs`).
|
|
20
|
+
* @param {boolean} [config.checkMemberExpression] - Check for obj.method() calls (default: false)
|
|
21
|
+
* @returns {Object} ESLint rule definition
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // no-os-tmpdir.cjs
|
|
25
|
+
* const factory = require('./eslint-rule-factory.cjs');
|
|
26
|
+
* module.exports = factory({
|
|
27
|
+
* unsafeFn: 'tmpdir',
|
|
28
|
+
* unsafeModule: 'node:os',
|
|
29
|
+
* safeFn: 'normalizedTmpdir',
|
|
30
|
+
* safeModule: SAFE_FS_MODULE,
|
|
31
|
+
* message: 'Use normalizedTmpdir() for Windows compatibility',
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* // …and in the consumer's eslint.config.js, naming ITS implementation file:
|
|
35
|
+
* // '@vibe-agent-toolkit/no-os-tmpdir': ['error', { exemptFiles: ['src/paths.ts'] }]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
DEAD_UNSAFE_IMPORT,
|
|
40
|
+
DEAD_UNSAFE_IMPORT_MESSAGE,
|
|
41
|
+
reportDeadUnsafeImports,
|
|
42
|
+
} = require('./dead-import.cjs');
|
|
43
|
+
const {
|
|
44
|
+
UNANCHORED_EXEMPT_FILE,
|
|
45
|
+
UNANCHORED_EXEMPT_MESSAGE,
|
|
46
|
+
createConfigurableExemptPathMatcher,
|
|
47
|
+
reportUnanchoredExemptEntries,
|
|
48
|
+
} = require('./exempt-path-matcher.cjs');
|
|
49
|
+
const {
|
|
50
|
+
EXEMPT_AND_SAFE_MODULE_SCHEMA,
|
|
51
|
+
insertAboveWithComments,
|
|
52
|
+
isNameAlreadyBound,
|
|
53
|
+
resolveSafeModule,
|
|
54
|
+
} = require('./safe-import.cjs');
|
|
55
|
+
|
|
56
|
+
/** Does this declaration bring in `name` as a named specifier? */
|
|
57
|
+
function importsName(importNode, name) {
|
|
58
|
+
return importNode.specifiers.some(
|
|
59
|
+
(spec) => spec.type === 'ImportSpecifier' && spec.imported.name === name,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The local name of `import os from 'node:os'` / `import * as os from 'node:os'`.
|
|
65
|
+
*
|
|
66
|
+
* Without it the member-expression check has no receiver to compare against,
|
|
67
|
+
* and matching on the property name alone turns every `env.tmpdir()` into an
|
|
68
|
+
* `os.tmpdir()` finding.
|
|
69
|
+
*/
|
|
70
|
+
function namespaceLocalName(importNode) {
|
|
71
|
+
const spec = importNode.specifiers.find(
|
|
72
|
+
(candidate) =>
|
|
73
|
+
candidate.type === 'ImportDefaultSpecifier' || candidate.type === 'ImportNamespaceSpecifier',
|
|
74
|
+
);
|
|
75
|
+
return spec ? spec.local.name : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The module specifier of `require('x')`, `import('x')` or `await import('x')`.
|
|
80
|
+
*
|
|
81
|
+
* A static `import * as os` is not the only way to end up holding the `node:os`
|
|
82
|
+
* namespace, and the fix does not care which way it happened — the whole callee
|
|
83
|
+
* is replaced by a free function, so `os.tmpdir()` becomes `normalizedTmpdir()`
|
|
84
|
+
* whatever bound `os`.
|
|
85
|
+
*
|
|
86
|
+
* This is NOT a return to rc.1, which matched any receiver at all and so
|
|
87
|
+
* "detected" these shapes only as a side effect of the defect that also produced
|
|
88
|
+
* `os.normalizedTmpdir()`. The receiver check stays; this widens what counts as
|
|
89
|
+
* evidence that the receiver IS the module's namespace, and nothing else.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} [init] - The initialiser of a variable declarator.
|
|
92
|
+
* @returns {string|null} The literal module name, or null.
|
|
93
|
+
*/
|
|
94
|
+
function namespaceModuleOf(init) {
|
|
95
|
+
const expr = init?.type === 'AwaitExpression' ? init.argument : init;
|
|
96
|
+
if (!expr) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const isDynamicImport = expr.type === 'ImportExpression';
|
|
100
|
+
const isRequire =
|
|
101
|
+
expr.type === 'CallExpression' &&
|
|
102
|
+
expr.callee.type === 'Identifier' &&
|
|
103
|
+
expr.callee.name === 'require';
|
|
104
|
+
if (!isDynamicImport && !isRequire) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
// `ImportExpression.source` / the sole `require` argument. A computed
|
|
108
|
+
// specifier names no module we can check, so it binds nothing we may rewrite.
|
|
109
|
+
const source = isDynamicImport ? expr.source : expr.arguments[0];
|
|
110
|
+
return source?.type === 'Literal' && typeof source.value === 'string' ? source.value : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Helper function to filter unsafe import specifiers
|
|
115
|
+
* Extracted to reduce nesting depth for code quality
|
|
116
|
+
*/
|
|
117
|
+
function filterUnsafeSpecifiers(importNode, unsafeFn) {
|
|
118
|
+
return importNode.specifiers.filter((s) => s.imported && s.imported.name === unsafeFn);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Helper function to remove unsafe import specifiers
|
|
123
|
+
* Extracted to reduce nesting depth for code quality
|
|
124
|
+
*/
|
|
125
|
+
function removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs) {
|
|
126
|
+
const fixes = [];
|
|
127
|
+
for (const spec of unsafeSpecs) {
|
|
128
|
+
const comma = sourceCode.getTokenAfter(spec);
|
|
129
|
+
if (comma?.value === ',') {
|
|
130
|
+
fixes.push(fixer.removeRange([spec.range[0], comma.range[1]]));
|
|
131
|
+
} else {
|
|
132
|
+
const commaBefore = sourceCode.getTokenBefore(spec);
|
|
133
|
+
if (commaBefore?.value === ',') {
|
|
134
|
+
fixes.push(fixer.removeRange([commaBefore.range[0], spec.range[1]]));
|
|
135
|
+
} else {
|
|
136
|
+
fixes.push(fixer.remove(spec));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return fixes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = function createNoUnsafeRule(config) {
|
|
144
|
+
const {
|
|
145
|
+
unsafeFn,
|
|
146
|
+
unsafeModule,
|
|
147
|
+
safeFn,
|
|
148
|
+
safeModule,
|
|
149
|
+
message,
|
|
150
|
+
exemptFiles = [],
|
|
151
|
+
checkMemberExpression = false,
|
|
152
|
+
} = config;
|
|
153
|
+
|
|
154
|
+
const exemptMatcherFor = createConfigurableExemptPathMatcher(exemptFiles);
|
|
155
|
+
|
|
156
|
+
// Normalize module names (support both 'node:os' and 'os')
|
|
157
|
+
const moduleVariants = [unsafeModule];
|
|
158
|
+
if (unsafeModule.startsWith('node:')) {
|
|
159
|
+
moduleVariants.push(unsafeModule.replace('node:', ''));
|
|
160
|
+
} else {
|
|
161
|
+
moduleVariants.push(`node:${unsafeModule}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
meta: {
|
|
166
|
+
type: 'problem',
|
|
167
|
+
docs: {
|
|
168
|
+
description: `Enforce use of ${safeFn}() instead of ${unsafeFn}()`,
|
|
169
|
+
category: 'Best Practices',
|
|
170
|
+
recommended: true,
|
|
171
|
+
},
|
|
172
|
+
fixable: 'code',
|
|
173
|
+
schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
|
|
174
|
+
messages: {
|
|
175
|
+
noUnsafeOperation: message,
|
|
176
|
+
[DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
|
|
177
|
+
[UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
create(context) {
|
|
182
|
+
const sourceCode = context.getSourceCode();
|
|
183
|
+
// Resolved per invocation, not at factory-construction time: the option is
|
|
184
|
+
// the consuming repo's, and one repo can configure the same rule
|
|
185
|
+
// differently across config blocks.
|
|
186
|
+
const targetModule = resolveSafeModule(context, safeModule);
|
|
187
|
+
|
|
188
|
+
// Only the declared implementation file(s) may call the unsafe function.
|
|
189
|
+
if (exemptMatcherFor(context)(context.getFilename())) {
|
|
190
|
+
// Still surface a malformed exemption list: the file we are standing in
|
|
191
|
+
// may be exempt only BECAUSE the entry is unanchored.
|
|
192
|
+
return {
|
|
193
|
+
Program(node) {
|
|
194
|
+
reportUnanchoredExemptEntries(context, node);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let hasUnsafeImport = false;
|
|
200
|
+
// Seeded from SCOPE, not from "did I see an import from safeModule?".
|
|
201
|
+
// A file already importing `safeFn` from the barrel needs the call
|
|
202
|
+
// rewritten but must NOT gain a second binding of the same name — that is
|
|
203
|
+
// a SyntaxError, not a redundant import. See `safe-import.cjs`.
|
|
204
|
+
let hasSafeImport = isNameAlreadyBound(sourceCode, safeFn);
|
|
205
|
+
// The SAME question, answered once and never mutated. `hasSafeImport`
|
|
206
|
+
// flips the moment a fix inserts the import, and the dead-import leg must
|
|
207
|
+
// not be armed by a flag a suppressed report can spend — ESLint runs
|
|
208
|
+
// `fix()` before the `eslint-disable` filter discards the problem.
|
|
209
|
+
const safeBoundInSource = hasSafeImport;
|
|
210
|
+
let unsafeImportNode = null;
|
|
211
|
+
const unsafeImportNodes = [];
|
|
212
|
+
let safeImportNode = null;
|
|
213
|
+
// A SET, because a namespace can be bound by a static import, a
|
|
214
|
+
// `require()`, or a dynamic `import()` — see `namespaceModuleOf`.
|
|
215
|
+
const unsafeNamespaceNames = new Set();
|
|
216
|
+
// Latches the REMOVAL only — never the insert. See `fix()` for why the
|
|
217
|
+
// two shared edits must be treated differently.
|
|
218
|
+
let unsafeImportRemoved = false;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
Program(node) {
|
|
222
|
+
reportUnanchoredExemptEntries(context, node);
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
'Program:exit'() {
|
|
226
|
+
reportDeadUnsafeImports(context, sourceCode, unsafeImportNodes, safeBoundInSource);
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
ImportDeclaration(node) {
|
|
230
|
+
if (moduleVariants.includes(node.source.value)) {
|
|
231
|
+
unsafeImportNode = node;
|
|
232
|
+
unsafeImportNodes.push(node);
|
|
233
|
+
hasUnsafeImport = hasUnsafeImport || importsName(node, unsafeFn);
|
|
234
|
+
const local = namespaceLocalName(node);
|
|
235
|
+
if (local) {
|
|
236
|
+
unsafeNamespaceNames.add(local);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (node.source.value === targetModule) {
|
|
240
|
+
safeImportNode = node;
|
|
241
|
+
hasSafeImport = hasSafeImport || importsName(node, safeFn);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
// `const os = require('node:os')` / `const os = await import('node:os')`.
|
|
246
|
+
//
|
|
247
|
+
// Recorded by NAME, matching how the static-import receiver has always
|
|
248
|
+
// been tracked, so a declaration must precede its use — which is the
|
|
249
|
+
// normal shape and the only one either form appears in. Resolving the
|
|
250
|
+
// receiver through scope instead would also reject a shadowing rebind,
|
|
251
|
+
// but it would change detection parity on a population an adopter has
|
|
252
|
+
// already measured across 4,963 files, so it is not worth trading here.
|
|
253
|
+
//
|
|
254
|
+
// KNOWN RESIDUAL, measured: `dead-import.cjs` only removes an
|
|
255
|
+
// `ImportDeclaration`, so after the rewrite `const os = require('node:os')`
|
|
256
|
+
// and `const os = await import('node:os')` are both left behind as
|
|
257
|
+
// `'os' is assigned a value but never used` (the dynamic form also draws
|
|
258
|
+
// `sonarjs/no-dead-store`). Removing a VariableDeclaration is a wider edit
|
|
259
|
+
// than removing an import (multiple declarators, destructuring, an `await`
|
|
260
|
+
// inside control flow), so it is deliberately not done here. A static
|
|
261
|
+
// `import * as os` — the shape that actually appears at scale — is cleaned
|
|
262
|
+
// up. An adopter confirmed the residual 2-for-2 and measured **zero** files
|
|
263
|
+
// using either dynamic shape across 4,963 tracked sources, so the
|
|
264
|
+
// population this would serve is currently empty.
|
|
265
|
+
//
|
|
266
|
+
// If it is ever extended that far, note what makes the dynamic case
|
|
267
|
+
// different in kind: the leftover `await import('node:os')` STILL RUNS.
|
|
268
|
+
// The module is loaded and the promise awaited, and only the binding is
|
|
269
|
+
// dead — so deleting the statement removes an execution, not just a name.
|
|
270
|
+
// For these builtins that is unobservable, which is precisely why the
|
|
271
|
+
// module list is closed; the same edit against an arbitrary module would
|
|
272
|
+
// not be safe, and no `sideEffects` metadata could tell you so.
|
|
273
|
+
VariableDeclarator(node) {
|
|
274
|
+
if (node.id.type !== 'Identifier') {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const source = namespaceModuleOf(node.init);
|
|
278
|
+
if (source !== null && moduleVariants.includes(source)) {
|
|
279
|
+
unsafeNamespaceNames.add(node.id.name);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
CallExpression(node) {
|
|
284
|
+
let isUnsafeCall = false;
|
|
285
|
+
|
|
286
|
+
// Check for direct function call: unsafeFn()
|
|
287
|
+
if (node.callee.name === unsafeFn) {
|
|
288
|
+
isUnsafeCall = true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Check for member expression: os.tmpdir()
|
|
292
|
+
//
|
|
293
|
+
// The RECEIVER must be the unsafe module's own namespace binding.
|
|
294
|
+
// Matching on the property name alone made `env.tmpdir()` — any
|
|
295
|
+
// object at all with a same-named method — an `os.tmpdir()` finding.
|
|
296
|
+
// That was survivable while the fixer rewrote only the property
|
|
297
|
+
// (`env.normalizedTmpdir()` fails to compile, so the false positive
|
|
298
|
+
// announced itself); once the whole callee is replaced it becomes
|
|
299
|
+
// `normalizedTmpdir()`, which compiles, type-checks, passes
|
|
300
|
+
// `no-undef`, and silently calls a different function with the
|
|
301
|
+
// receiver discarded. A false positive that produces WORKING code is
|
|
302
|
+
// strictly the more dangerous kind.
|
|
303
|
+
if (
|
|
304
|
+
checkMemberExpression &&
|
|
305
|
+
node.callee.type === 'MemberExpression' &&
|
|
306
|
+
node.callee.object.type === 'Identifier' &&
|
|
307
|
+
unsafeNamespaceNames.has(node.callee.object.name) &&
|
|
308
|
+
node.callee.property.name === unsafeFn
|
|
309
|
+
) {
|
|
310
|
+
isUnsafeCall = true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!isUnsafeCall) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
context.report({
|
|
318
|
+
node,
|
|
319
|
+
messageId: 'noUnsafeOperation',
|
|
320
|
+
data: { safeModule: targetModule },
|
|
321
|
+
fix(fixer) {
|
|
322
|
+
const fixes = [];
|
|
323
|
+
|
|
324
|
+
// Replace the WHOLE callee, member expression or not.
|
|
325
|
+
//
|
|
326
|
+
// Rewriting only the property turned `os.tmpdir()` into
|
|
327
|
+
// `os.normalizedTmpdir()` — a method that does not exist on the
|
|
328
|
+
// `node:os` namespace. The replacement is a free function from
|
|
329
|
+
// OUR package, and the fixer imported it correctly; it just left
|
|
330
|
+
// the call reaching for it through the wrong object. Silent, like
|
|
331
|
+
// the overlap bug below: lint went green (the rule no longer sees
|
|
332
|
+
// `tmpdir`), and it is a dangling MEMBER rather than a dangling
|
|
333
|
+
// identifier, so `no-undef` cannot see it either. `tsc` can.
|
|
334
|
+
fixes.push(fixer.replaceText(node.callee, safeFn));
|
|
335
|
+
|
|
336
|
+
// Add import if needed — on EVERY report, deliberately.
|
|
337
|
+
//
|
|
338
|
+
// The sister factory emits this once per file, because there a
|
|
339
|
+
// report that edits both the import and its own call site spans
|
|
340
|
+
// everything between them, N reports leave N nested ranges, and
|
|
341
|
+
// ESLint keeps one: the defect measured at 146 broken files.
|
|
342
|
+
//
|
|
343
|
+
// That guard does not belong here, and briefly having it was a
|
|
344
|
+
// mistake worth recording. These rules do NOT key detection on
|
|
345
|
+
// the import — `node.callee.name === unsafeFn` is true whether or
|
|
346
|
+
// not the specifier survives — and `hasSafeImport` is reseeded
|
|
347
|
+
// from scope each pass, so pass 2 always finished the job anyway.
|
|
348
|
+
// An adversarial run confirmed the guard changed no output at 4,
|
|
349
|
+
// 40 or 75 call sites. What it DID change was the failure mode:
|
|
350
|
+
// ESLint runs `fix()` for a suppressed problem before the
|
|
351
|
+
// `eslint-disable` filter discards it, so one disable comment on
|
|
352
|
+
// the first call site spent the once-per-file edit and stranded
|
|
353
|
+
// the file with calls the import no longer backs.
|
|
354
|
+
//
|
|
355
|
+
// Every report carrying its own import edit costs a pass and buys
|
|
356
|
+
// a fix that is correct on its own — including when applied alone
|
|
357
|
+
// from an editor's "fix this problem".
|
|
358
|
+
if (!hasSafeImport) {
|
|
359
|
+
if (safeImportNode) {
|
|
360
|
+
// Add to existing safe module import
|
|
361
|
+
const lastSpecifier = safeImportNode.specifiers.at(-1);
|
|
362
|
+
fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${safeFn}`));
|
|
363
|
+
} else {
|
|
364
|
+
// Land next to the imports, never after arbitrary code —
|
|
365
|
+
// `insertTextAfter(body[0])` on a file whose first statement
|
|
366
|
+
// is a `const` welds the declaration onto the end of it.
|
|
367
|
+
const targetNode = unsafeImportNode || sourceCode.ast.body[0];
|
|
368
|
+
const declaration = `import { ${safeFn} } from '${targetModule}';`;
|
|
369
|
+
fixes.push(
|
|
370
|
+
targetNode.type === 'ImportDeclaration'
|
|
371
|
+
? fixer.insertTextAfter(targetNode, `\n${declaration}`)
|
|
372
|
+
: insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Remove the unsafe import — LATCHED, unlike the insert above.
|
|
378
|
+
//
|
|
379
|
+
// The asymmetry is the whole design. An insert is safe to repeat
|
|
380
|
+
// (identical text, identical anchor, ESLint drops the duplicate)
|
|
381
|
+
// and repeating it is what keeps each report's fix correct on its
|
|
382
|
+
// own. A REMOVAL is not: if every report removes the specifier,
|
|
383
|
+
// one of those removals lands even when the report that would
|
|
384
|
+
// have rewritten the matching call was suppressed — and the
|
|
385
|
+
// suppressed call is left calling an identifier the import no
|
|
386
|
+
// longer provides. Measured: `tmpdir` undefined, permanently.
|
|
387
|
+
//
|
|
388
|
+
// Latched, the discarded first report simply takes the removal
|
|
389
|
+
// with it, and the worst case is an unused import that
|
|
390
|
+
// `no-unused-vars` will point at. A lint finding, not a crash.
|
|
391
|
+
if (hasUnsafeImport && unsafeImportNode && !unsafeImportRemoved) {
|
|
392
|
+
const unsafeSpecs = filterUnsafeSpecifiers(unsafeImportNode, unsafeFn);
|
|
393
|
+
if (unsafeImportNode.specifiers.length === 1 && unsafeSpecs.length === 1) {
|
|
394
|
+
// Remove entire import
|
|
395
|
+
fixes.push(fixer.remove(unsafeImportNode));
|
|
396
|
+
} else if (unsafeSpecs.length > 0) {
|
|
397
|
+
// Remove just the unsafe specifier
|
|
398
|
+
fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs));
|
|
399
|
+
}
|
|
400
|
+
unsafeImportRemoved = true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return fixes;
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared exemption matcher for local ESLint rule factories.
|
|
3
|
+
*
|
|
4
|
+
* Rules that ban an unsafe primitive have to exempt the ONE file that implements
|
|
5
|
+
* the safe replacement. Doing that with `filename.includes('path-utils.ts')` is a
|
|
6
|
+
* silent hole: any file anywhere — in this repo or in a consumer repo running
|
|
7
|
+
* these rules — whose path merely CONTAINS that string opts itself out. A private
|
|
8
|
+
* `tools/hooks/path-utils.ts` full of raw `tmpdir()` / `realpathSync()` calls
|
|
9
|
+
* linted clean for exactly this reason.
|
|
10
|
+
*
|
|
11
|
+
* Exemptions are therefore repo-relative paths matched at a path-segment
|
|
12
|
+
* boundary: the linted filename must either BE the exempt path, or END WITH
|
|
13
|
+
* `/` + the exempt path. Matching happens on forward slashes — a rule pack whose
|
|
14
|
+
* whole purpose is enforcing cross-platform path handling must not itself be
|
|
15
|
+
* `\` vs `/` dependent.
|
|
16
|
+
*
|
|
17
|
+
* Three shapes of exemption live here, and every local rule must take one of
|
|
18
|
+
* them rather than reaching for `includes()` again:
|
|
19
|
+
*
|
|
20
|
+
* - `createExemptPathMatcher` — "is this THAT file?" (a named implementation file)
|
|
21
|
+
* - `createExemptDirectoryMatcher` — "is this file INSIDE that directory?" (a package
|
|
22
|
+
* that owns a centralized wrapper)
|
|
23
|
+
* - `isTestFile` — "is this a test file?" (a category, anchored on the basename's
|
|
24
|
+
* extension rather than on a path segment)
|
|
25
|
+
*
|
|
26
|
+
* `eslint-rule-factory.cjs`, `path-function-rule-factory.cjs`,
|
|
27
|
+
* `no-command-direct-factory.cjs` and `no-unix-shell-commands.cjs` all use these.
|
|
28
|
+
* Do not re-implement any of them: the sibling-factory copy is how the bug
|
|
29
|
+
* shipped four times in the first place.
|
|
30
|
+
*
|
|
31
|
+
* A fourth export, `createConfigurableExemptPathMatcher`, wires the file-shaped
|
|
32
|
+
* exemption to the rule's own ESLint option so each CONSUMER declares the paths
|
|
33
|
+
* for its own repo. It also lives here (rather than in each factory) for the same
|
|
34
|
+
* reason: two copies is how the bug spread last time.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Forward-slash a path and drop any leading `./` or `/` noise used for anchoring. */
|
|
38
|
+
function normalizeForMatch(value) {
|
|
39
|
+
return String(value).replaceAll('\\', '/').replace(/^(?:\.\/)+/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a predicate that reports whether a linted filename is one of `exemptPaths`.
|
|
44
|
+
*
|
|
45
|
+
* @param {readonly string[]} exemptPaths - Repo-relative paths, e.g.
|
|
46
|
+
* `['packages/utils/src/path-utils.ts']`. A bare basename is accepted by the
|
|
47
|
+
* matcher but matches that filename ANYWHERE in the tree (ESLint filenames are
|
|
48
|
+
* absolute, so the `endsWith('/' + target)` leg is what fires) — pass the full
|
|
49
|
+
* repo-relative path. Rules surface that mistake via
|
|
50
|
+
* {@link reportUnanchoredExemptEntries}.
|
|
51
|
+
* @returns {(filename: string) => boolean} Anchored, separator-agnostic predicate.
|
|
52
|
+
*/
|
|
53
|
+
function createExemptPathMatcher(exemptPaths) {
|
|
54
|
+
const targets = [...exemptPaths]
|
|
55
|
+
.map((exemptPath) => normalizeForMatch(exemptPath).replace(/^\/+/, ''))
|
|
56
|
+
.filter((exemptPath) => exemptPath.length > 0);
|
|
57
|
+
|
|
58
|
+
return function isExemptPath(filename) {
|
|
59
|
+
if (!filename) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const normalized = normalizeForMatch(filename);
|
|
63
|
+
return targets.some(
|
|
64
|
+
(target) => normalized === target || normalized.endsWith(`/${target}`),
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* JSON Schema for the `exemptFiles` rule option, for a rule's `meta.schema`.
|
|
71
|
+
*
|
|
72
|
+
* `additionalProperties: false` on purpose: a typo'd option key must be an ESLint
|
|
73
|
+
* config error, not a silently ignored exemption list (which would read as "the
|
|
74
|
+
* rule stopped firing for no reason").
|
|
75
|
+
*/
|
|
76
|
+
const EXEMPT_FILES_SCHEMA = Object.freeze({
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
exemptFiles: {
|
|
80
|
+
type: 'array',
|
|
81
|
+
items: { type: 'string' },
|
|
82
|
+
uniqueItems: true,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wire a file-shaped exemption to the rule's `exemptFiles` option.
|
|
90
|
+
*
|
|
91
|
+
* An exemption names the ONE file in a SPECIFIC repo that implements the safe
|
|
92
|
+
* replacement, so it cannot be shipped as a useful default: a package publishing
|
|
93
|
+
* `packages/utils/src/path-utils.ts` as a built-in exemption hands every consumer
|
|
94
|
+
* a hole at that path. Hence the empty default here, and hence REPLACE rather
|
|
95
|
+
* than merge semantics — a consumer's list is the whole list.
|
|
96
|
+
*
|
|
97
|
+
* @param {readonly string[]} [defaultPaths] - Fallback used only when the rule is
|
|
98
|
+
* configured with no `exemptFiles` option. Ship this empty unless the rule
|
|
99
|
+
* itself owns the file (no shipped rule in this package does).
|
|
100
|
+
* @returns {(context: object) => (filename: string) => boolean} Resolver taking an
|
|
101
|
+
* ESLint rule context and returning the anchored predicate for that invocation.
|
|
102
|
+
*/
|
|
103
|
+
function createConfigurableExemptPathMatcher(defaultPaths = []) {
|
|
104
|
+
const defaultMatcher = createExemptPathMatcher(defaultPaths);
|
|
105
|
+
const cache = new Map();
|
|
106
|
+
|
|
107
|
+
return function exemptMatcherFor(context) {
|
|
108
|
+
const configured = context.options?.[0]?.exemptFiles;
|
|
109
|
+
if (!Array.isArray(configured)) {
|
|
110
|
+
return defaultMatcher;
|
|
111
|
+
}
|
|
112
|
+
// JSON, not join(): a delimiter cheap enough to be collision-free is a raw
|
|
113
|
+
// NUL, which this repo bans in source (git and ripgrep treat the file as
|
|
114
|
+
// binary and skip its contents), and any printable delimiter can legally
|
|
115
|
+
// appear in a path.
|
|
116
|
+
const key = JSON.stringify(configured);
|
|
117
|
+
let matcher = cache.get(key);
|
|
118
|
+
if (!matcher) {
|
|
119
|
+
matcher = createExemptPathMatcher(configured);
|
|
120
|
+
cache.set(key, matcher);
|
|
121
|
+
}
|
|
122
|
+
return matcher;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `messageId` every rule that accepts `exemptFiles` must declare, so an
|
|
128
|
+
* unanchored entry is reported through the normal lint channel.
|
|
129
|
+
*
|
|
130
|
+
* Not a JSON Schema `pattern` on the option, which would be the obvious place:
|
|
131
|
+
* the schema sees the RAW string, and `./path-utils.ts` contains a `/` while
|
|
132
|
+
* normalizing to exactly the same repo-wide exemption as `path-utils.ts`. A
|
|
133
|
+
* check that the wrong spelling slips past is worse than none. Not a
|
|
134
|
+
* `process.emitWarning` either — a notice on stderr is not a reported finding
|
|
135
|
+
* and gets scrolled past.
|
|
136
|
+
*/
|
|
137
|
+
const UNANCHORED_EXEMPT_FILE = 'unanchoredExemptFile';
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The `meta.messages` entry for {@link UNANCHORED_EXEMPT_FILE}.
|
|
141
|
+
*
|
|
142
|
+
* ESLint filenames are ABSOLUTE, and an exemption matches when the filename ends
|
|
143
|
+
* with `/` + the entry. So a bare basename does not mean "the file at the repo
|
|
144
|
+
* root" (as this module's JSDoc used to claim) — it means EVERY file with that
|
|
145
|
+
* name, anywhere in the tree, including ones added later by someone who never
|
|
146
|
+
* saw the config. That is the same repo-wide hole the anchoring rewrite closed,
|
|
147
|
+
* reopened one config entry at a time.
|
|
148
|
+
*/
|
|
149
|
+
const UNANCHORED_EXEMPT_MESSAGE =
|
|
150
|
+
'exemptFiles entry "{{entry}}" is a bare filename, so it exempts EVERY file named ' +
|
|
151
|
+
'"{{entry}}" anywhere in the repo — including files added later. Give the ' +
|
|
152
|
+
'repo-relative path instead (e.g. "packages/utils/src/{{entry}}").';
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The configured `exemptFiles` entries that are not anchored to a directory.
|
|
156
|
+
*
|
|
157
|
+
* Runs on the NORMALIZED entry, so `./x.ts` and `x.ts` are both caught.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} context - ESLint rule context.
|
|
160
|
+
* @returns {string[]} Offending entries, as the consumer spelled them.
|
|
161
|
+
*/
|
|
162
|
+
function findUnanchoredExemptEntries(context) {
|
|
163
|
+
const configured = context.options?.[0]?.exemptFiles;
|
|
164
|
+
if (!Array.isArray(configured)) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
return configured.filter((entry) => {
|
|
168
|
+
const normalized = normalizeForMatch(entry).replace(/^\/+/, '');
|
|
169
|
+
return normalized.length > 0 && !normalized.includes('/');
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Report every unanchored `exemptFiles` entry against the `Program` node.
|
|
175
|
+
*
|
|
176
|
+
* Deliberately stateless — no "warn once per process" dedupe. ESLint caches
|
|
177
|
+
* results per file, so a rule that remembers having warned goes SILENT on the
|
|
178
|
+
* second run against a warm cache, which is precisely when a stale config is
|
|
179
|
+
* least likely to be noticed.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} context - ESLint rule context.
|
|
182
|
+
* @param {object} node - The `Program` node to anchor the report on.
|
|
183
|
+
*/
|
|
184
|
+
function reportUnanchoredExemptEntries(context, node) {
|
|
185
|
+
for (const entry of findUnanchoredExemptEntries(context)) {
|
|
186
|
+
context.report({ node, messageId: UNANCHORED_EXEMPT_FILE, data: { entry } });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a predicate that reports whether a linted filename lives UNDER one of
|
|
192
|
+
* `exemptDirs`.
|
|
193
|
+
*
|
|
194
|
+
* The directory flavor of the same bug: `filename.includes('packages/git/')`
|
|
195
|
+
* also exempted `vendor/copy-packages/git/` and `tools/my-packages/git/` — any
|
|
196
|
+
* directory whose name merely ENDS WITH the exempt one. Anchoring means the
|
|
197
|
+
* directory must start the repo-relative path or be preceded by a `/`.
|
|
198
|
+
*
|
|
199
|
+
* @param {readonly string[]} exemptDirs - Repo-relative directories, with or
|
|
200
|
+
* without a trailing slash, e.g. `['packages/git/']`.
|
|
201
|
+
* @returns {(filename: string) => boolean} Anchored, separator-agnostic predicate.
|
|
202
|
+
*/
|
|
203
|
+
function createExemptDirectoryMatcher(exemptDirs) {
|
|
204
|
+
// Normalize each directory to a `<dir>/` PREFIX rather than stripping trailing
|
|
205
|
+
// slashes: a trailing-slash strip needs either `/\/+$/` (flagged by
|
|
206
|
+
// sonarjs/slow-regex) or `.split('/')` (banned repo-wide by this very rule pack).
|
|
207
|
+
const prefixes = [...exemptDirs]
|
|
208
|
+
.map((dir) => normalizeForMatch(dir).replace(/^\/+/, ''))
|
|
209
|
+
.filter((dir) => dir.length > 0)
|
|
210
|
+
.map((dir) => (dir.endsWith('/') ? dir : `${dir}/`));
|
|
211
|
+
|
|
212
|
+
return function isUnderExemptDirectory(filename) {
|
|
213
|
+
if (!filename) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const normalized = normalizeForMatch(filename);
|
|
217
|
+
return prefixes.some(
|
|
218
|
+
(prefix) => normalized.startsWith(prefix) || normalized.includes(`/${prefix}`),
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Test-file naming convention, anchored to the END of the BASENAME.
|
|
225
|
+
*
|
|
226
|
+
* This is a CATEGORY check, not a path exemption, so `createExemptPathMatcher`
|
|
227
|
+
* is the wrong tool: there is no repo-relative path to compare against. What
|
|
228
|
+
* "anchored" means here is that the extension must terminate the last path
|
|
229
|
+
* segment. `filename.includes('.test.ts')` satisfied neither end — it exempted
|
|
230
|
+
* `example.test.ts.bak`, a directory named `.test.ts-helpers/`, and (a real
|
|
231
|
+
* tracked file in this repo) `tsconfig.test.json` via the `.test.js` spelling.
|
|
232
|
+
*
|
|
233
|
+
* The repo convention is `*.test.ts` (unit, `*.integration.test.ts`, and
|
|
234
|
+
* `*.system.test.ts` all land on it); the js/mjs/cjs/mts/tsx variants are
|
|
235
|
+
* covered because a category predicate that only knew one extension would be
|
|
236
|
+
* the next silent hole. `.spec.` is deliberately absent — this repo has zero
|
|
237
|
+
* such files and vitest's `include` globs would not run them.
|
|
238
|
+
*/
|
|
239
|
+
const TEST_FILE_EXTENSION = /\.test\.[cm]?[jt]sx?$/;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} filename - Path as ESLint reports it (absolute, any separator).
|
|
243
|
+
* @returns {boolean} True when the file itself is a test file.
|
|
244
|
+
*/
|
|
245
|
+
function isTestFile(filename) {
|
|
246
|
+
if (!filename) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
const normalized = normalizeForMatch(filename);
|
|
250
|
+
const basename = normalized.slice(normalized.lastIndexOf('/') + 1);
|
|
251
|
+
return TEST_FILE_EXTENSION.test(basename);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
EXEMPT_FILES_SCHEMA,
|
|
256
|
+
UNANCHORED_EXEMPT_FILE,
|
|
257
|
+
UNANCHORED_EXEMPT_MESSAGE,
|
|
258
|
+
createConfigurableExemptPathMatcher,
|
|
259
|
+
createExemptDirectoryMatcher,
|
|
260
|
+
createExemptPathMatcher,
|
|
261
|
+
findUnanchoredExemptEntries,
|
|
262
|
+
isTestFile,
|
|
263
|
+
normalizeForMatch,
|
|
264
|
+
reportUnanchoredExemptEntries,
|
|
265
|
+
};
|