@vibe-agent-toolkit/utils 0.1.41 → 0.1.42-rc.1
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 +191 -0
- package/eslint/index.cjs +161 -0
- package/eslint/index.d.cts +60 -0
- package/eslint/rules/eslint-rule-factory.cjs +241 -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 +130 -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 +216 -0
- package/eslint/rules/prefer-startswith-over-regex.cjs +111 -0
- package/eslint/rules/require-justified-skip.cjs +348 -0
- package/eslint/rules/safe-import.cjs +141 -0
- package/package.json +65 -7
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-url-pathname-for-fs
|
|
3
|
+
*
|
|
4
|
+
* Prevents `.pathname` access on a `URL` constructed from `import.meta.url`.
|
|
5
|
+
* On Windows, `new URL('../x', import.meta.url).pathname` returns `/D:/...`
|
|
6
|
+
* which breaks `fs` operations (ENOENT or `D:\D:\...`).
|
|
7
|
+
*
|
|
8
|
+
* Fix: use `fileURLToPath(new URL(...))` from `node:url`, or
|
|
9
|
+
* `resolveFromImportMeta()` from `@vibe-agent-toolkit/utils/fs`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // ❌ BAD — Windows-broken
|
|
13
|
+
* const p = new URL('../fixtures/x.yaml', import.meta.url).pathname;
|
|
14
|
+
*
|
|
15
|
+
* // ✅ GOOD
|
|
16
|
+
* import { fileURLToPath } from 'node:url';
|
|
17
|
+
* const p = fileURLToPath(new URL('../fixtures/x.yaml', import.meta.url));
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
SAFE_FS_MODULE,
|
|
24
|
+
SAFE_MODULE_ONLY_SCHEMA,
|
|
25
|
+
resolveSafeModule,
|
|
26
|
+
} = require('./safe-import.cjs');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns true if `node` is the `import.meta.url` `MemberExpression`.
|
|
30
|
+
*/
|
|
31
|
+
function isImportMetaUrl(node) {
|
|
32
|
+
return (
|
|
33
|
+
node?.type === 'MemberExpression' &&
|
|
34
|
+
node.object?.type === 'MetaProperty' &&
|
|
35
|
+
node.object.meta?.name === 'import' &&
|
|
36
|
+
node.object.property?.name === 'meta' &&
|
|
37
|
+
node.property?.name === 'url'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SKIP_KEYS = new Set(['parent', 'loc', 'range']);
|
|
42
|
+
|
|
43
|
+
function childIsAstNode(value) {
|
|
44
|
+
return value && typeof value === 'object' && typeof value.type === 'string';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Walks an AST subtree looking for `import.meta.url`.
|
|
49
|
+
* Returns true if found, false otherwise.
|
|
50
|
+
*/
|
|
51
|
+
function containsImportMetaUrl(node) {
|
|
52
|
+
if (!node || typeof node !== 'object') return false;
|
|
53
|
+
if (isImportMetaUrl(node)) return true;
|
|
54
|
+
for (const key of Object.keys(node)) {
|
|
55
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
56
|
+
|
|
57
|
+
const value = node[key];
|
|
58
|
+
if (Array.isArray(value) && value.some((item) => containsImportMetaUrl(item))) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (childIsAstNode(value) && containsImportMetaUrl(value)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isUrlConstructorWithImportMeta(node) {
|
|
69
|
+
if (node?.type !== 'NewExpression') return false;
|
|
70
|
+
if (node.callee?.name !== 'URL') return false;
|
|
71
|
+
return node.arguments.some((arg) => containsImportMetaUrl(arg));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
meta: {
|
|
76
|
+
type: 'problem',
|
|
77
|
+
docs: {
|
|
78
|
+
description:
|
|
79
|
+
'Disallow `.pathname` on a URL built from `import.meta.url`; use `fileURLToPath()` instead.',
|
|
80
|
+
category: 'Cross-platform compatibility',
|
|
81
|
+
recommended: true,
|
|
82
|
+
},
|
|
83
|
+
messages: {
|
|
84
|
+
useFileURLToPath:
|
|
85
|
+
'Do not use `.pathname` on `new URL(..., import.meta.url)` — on Windows it returns `/D:/...` and breaks `fs`. ' +
|
|
86
|
+
'Use `fileURLToPath(new URL(...))` from `node:url` or `resolveFromImportMeta()` from `{{safeModule}}`.',
|
|
87
|
+
},
|
|
88
|
+
schema: [SAFE_MODULE_ONLY_SCHEMA],
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
create(context) {
|
|
92
|
+
return {
|
|
93
|
+
MemberExpression(node) {
|
|
94
|
+
if (node.property.type !== 'Identifier' || node.property.name !== 'pathname') {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (isUrlConstructorWithImportMeta(node.object)) {
|
|
98
|
+
context.report({
|
|
99
|
+
node,
|
|
100
|
+
messageId: 'useFileURLToPath',
|
|
101
|
+
data: { safeModule: resolveSafeModule(context, SAFE_FS_MODULE) },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint Rule Factory for banning specific path functions from node:path
|
|
3
|
+
*
|
|
4
|
+
* Handles both import styles:
|
|
5
|
+
* - Named: import { join } from 'node:path' → join(...)
|
|
6
|
+
* - Default/namespace: import path from 'node:path' → path.join(...)
|
|
7
|
+
*
|
|
8
|
+
* Auto-fixes to safePath.fn() from `@vibe-agent-toolkit/utils/path` — the narrow
|
|
9
|
+
* subpath that owns `safePath`, NOT the barrel. See `safe-import.cjs`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
UNANCHORED_EXEMPT_FILE,
|
|
14
|
+
UNANCHORED_EXEMPT_MESSAGE,
|
|
15
|
+
createConfigurableExemptPathMatcher,
|
|
16
|
+
reportUnanchoredExemptEntries,
|
|
17
|
+
} = require('./exempt-path-matcher.cjs');
|
|
18
|
+
const {
|
|
19
|
+
EXEMPT_AND_SAFE_MODULE_SCHEMA,
|
|
20
|
+
SAFE_PATH_MODULE,
|
|
21
|
+
isNameAlreadyBound,
|
|
22
|
+
resolveSafeModule,
|
|
23
|
+
} = require('./safe-import.cjs');
|
|
24
|
+
|
|
25
|
+
const PATH_MODULES = new Set(['node:path', 'path']);
|
|
26
|
+
const SAFE_OBJECT = 'safePath';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The files allowed to call raw `node:path` functions are whichever ones the
|
|
30
|
+
* CONSUMING repo says implement (or assert) its `safePath` wrappers — declared
|
|
31
|
+
* per-rule as `{ exemptFiles: [...] }`, matched at a path-segment boundary (see
|
|
32
|
+
* `exempt-path-matcher.cjs` for why these are not substrings).
|
|
33
|
+
*
|
|
34
|
+
* This list used to be hardcoded to VAT's own `packages/utils/src/path-core.ts`
|
|
35
|
+
* et al. Those paths are meaningless in an adopter's tree and actively harmful
|
|
36
|
+
* as a default — a same-named file at the same repo-relative path would inherit
|
|
37
|
+
* an exemption it never declared. Default: nothing is exempt.
|
|
38
|
+
*/
|
|
39
|
+
const exemptMatcherFor = createConfigurableExemptPathMatcher();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Remove a named import specifier, handling comma cleanup.
|
|
43
|
+
*/
|
|
44
|
+
function removeSpecifier(fixer, sourceCode, importNode, spec) {
|
|
45
|
+
if (importNode.specifiers.length === 1) {
|
|
46
|
+
return [fixer.remove(importNode)];
|
|
47
|
+
}
|
|
48
|
+
const comma = sourceCode.getTokenAfter(spec);
|
|
49
|
+
if (comma?.value === ',') {
|
|
50
|
+
return [fixer.removeRange([spec.range[0], comma.range[1]])];
|
|
51
|
+
}
|
|
52
|
+
const commaBefore = sourceCode.getTokenBefore(spec);
|
|
53
|
+
if (commaBefore?.value === ',') {
|
|
54
|
+
return [fixer.removeRange([commaBefore.range[0], spec.range[1]])];
|
|
55
|
+
}
|
|
56
|
+
return [fixer.remove(spec)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Track path module specifiers from an import declaration.
|
|
61
|
+
*/
|
|
62
|
+
function trackPathImport(node, unsafeFn, state) {
|
|
63
|
+
for (const spec of node.specifiers) {
|
|
64
|
+
if (spec.type === 'ImportSpecifier' && spec.imported.name === unsafeFn) {
|
|
65
|
+
state.namedImportSpec = spec;
|
|
66
|
+
state.namedImportNode = node;
|
|
67
|
+
}
|
|
68
|
+
if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
|
|
69
|
+
state.defaultImportName = spec.local.name;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Track safe module import from an import declaration.
|
|
76
|
+
*/
|
|
77
|
+
function trackSafeImport(node, state) {
|
|
78
|
+
state.safeImportNode = node;
|
|
79
|
+
for (const spec of node.specifiers) {
|
|
80
|
+
if (spec.type === 'ImportSpecifier' && spec.imported.name === SAFE_OBJECT) {
|
|
81
|
+
state.hasSafePathImport = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if a call expression is an unsafe path function call.
|
|
88
|
+
* Returns { isUnsafe, isNamed } or null if not a match.
|
|
89
|
+
*/
|
|
90
|
+
function classifyCall(node, unsafeFn, state) {
|
|
91
|
+
// Direct call from named import: join(...)
|
|
92
|
+
if (
|
|
93
|
+
node.callee.type === 'Identifier' &&
|
|
94
|
+
node.callee.name === unsafeFn &&
|
|
95
|
+
state.namedImportSpec
|
|
96
|
+
) {
|
|
97
|
+
return { isNamed: true };
|
|
98
|
+
}
|
|
99
|
+
// Member expression: path.join(...)
|
|
100
|
+
if (
|
|
101
|
+
node.callee.type === 'MemberExpression' &&
|
|
102
|
+
node.callee.object.type === 'Identifier' &&
|
|
103
|
+
node.callee.object.name === state.defaultImportName &&
|
|
104
|
+
node.callee.property.type === 'Identifier' &&
|
|
105
|
+
node.callee.property.name === unsafeFn
|
|
106
|
+
) {
|
|
107
|
+
return { isNamed: false };
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build auto-fix for an unsafe path function call.
|
|
114
|
+
*/
|
|
115
|
+
function buildFix(fixer, node, unsafeFn, isNamed, sourceCode, state) {
|
|
116
|
+
const fixes = [fixer.replaceText(node.callee, `${SAFE_OBJECT}.${unsafeFn}`)];
|
|
117
|
+
|
|
118
|
+
if (!state.hasSafePathImport) {
|
|
119
|
+
if (state.safeImportNode) {
|
|
120
|
+
const lastSpec = state.safeImportNode.specifiers.at(-1);
|
|
121
|
+
fixes.push(fixer.insertTextAfter(lastSpec, `, ${SAFE_OBJECT}`));
|
|
122
|
+
} else {
|
|
123
|
+
const targetNode = state.namedImportNode || sourceCode.ast.body[0];
|
|
124
|
+
fixes.push(fixer.insertTextAfter(targetNode, `\nimport { ${SAFE_OBJECT} } from '${state.safeModule}';`));
|
|
125
|
+
}
|
|
126
|
+
state.hasSafePathImport = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isNamed && state.namedImportNode) {
|
|
130
|
+
fixes.push(...removeSpecifier(fixer, sourceCode, state.namedImportNode, state.namedImportSpec));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return fixes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = function createPathFunctionRule(config) {
|
|
137
|
+
const { unsafeFn, message } = config;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
meta: {
|
|
141
|
+
type: 'problem',
|
|
142
|
+
docs: {
|
|
143
|
+
description: `Enforce safePath.${unsafeFn}() instead of path.${unsafeFn}()`,
|
|
144
|
+
category: 'Cross-platform compatibility',
|
|
145
|
+
recommended: true,
|
|
146
|
+
},
|
|
147
|
+
fixable: 'code',
|
|
148
|
+
schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
|
|
149
|
+
messages: {
|
|
150
|
+
noUnsafePathFn: message,
|
|
151
|
+
[UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
create(context) {
|
|
156
|
+
if (exemptMatcherFor(context)(context.getFilename())) {
|
|
157
|
+
// Still surface a malformed exemption list: the file we are standing in
|
|
158
|
+
// may be exempt only BECAUSE the entry is unanchored.
|
|
159
|
+
return {
|
|
160
|
+
Program(node) {
|
|
161
|
+
reportUnanchoredExemptEntries(context, node);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sourceCode = context.getSourceCode();
|
|
167
|
+
const state = {
|
|
168
|
+
// Resolved per invocation — the option belongs to the consuming repo,
|
|
169
|
+
// which may point different rules at different re-export entries.
|
|
170
|
+
safeModule: resolveSafeModule(context, SAFE_PATH_MODULE),
|
|
171
|
+
namedImportSpec: null,
|
|
172
|
+
namedImportNode: null,
|
|
173
|
+
defaultImportName: null,
|
|
174
|
+
// Seeded from SCOPE, not from "did I see an import from SAFE_MODULE?".
|
|
175
|
+
// A file already importing `safePath` from the barrel needs the call
|
|
176
|
+
// rewritten but must NOT gain a second binding of the same name.
|
|
177
|
+
hasSafePathImport: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
|
|
178
|
+
safeImportNode: null,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
Program(node) {
|
|
183
|
+
reportUnanchoredExemptEntries(context, node);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
ImportDeclaration(node) {
|
|
187
|
+
if (PATH_MODULES.has(node.source.value)) {
|
|
188
|
+
trackPathImport(node, unsafeFn, state);
|
|
189
|
+
}
|
|
190
|
+
if (node.source.value === state.safeModule) {
|
|
191
|
+
trackSafeImport(node, state);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
CallExpression(node) {
|
|
196
|
+
const classification = classifyCall(node, unsafeFn, state);
|
|
197
|
+
if (!classification) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
context.report({
|
|
202
|
+
node,
|
|
203
|
+
messageId: 'noUnsafePathFn',
|
|
204
|
+
// The module name reaches the message through `{{safeModule}}` rather
|
|
205
|
+
// than being spelled out in each rule's string, so the advice cannot
|
|
206
|
+
// drift from where the fixer actually writes the import.
|
|
207
|
+
data: { safeModule: state.safeModule },
|
|
208
|
+
fix(fixer) {
|
|
209
|
+
return buildFix(fixer, node, unsafeFn, classification.isNamed, sourceCode, state);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: prefer-startswith-over-regex
|
|
3
|
+
*
|
|
4
|
+
* Catches `/^literal/.test(s)` and `/literal$/.test(s)` patterns where the
|
|
5
|
+
* literal portion contains only plain characters and `\/` escape sequences,
|
|
6
|
+
* and recommends `s.startsWith('literal')` / `s.endsWith('literal')`.
|
|
7
|
+
*
|
|
8
|
+
* Why a local rule?
|
|
9
|
+
* `unicorn/prefer-string-starts-ends-with` already handles the simple case
|
|
10
|
+
* but conservatively rejects any pattern containing `\` — including the
|
|
11
|
+
* common `\/` (escaped slash) sequence. SonarCloud's S6557 catches these,
|
|
12
|
+
* but only post-merge. This rule shifts that detection left into ESLint.
|
|
13
|
+
*
|
|
14
|
+
* Examples:
|
|
15
|
+
* /^file:\/\//.test(s) → s.startsWith('file://')
|
|
16
|
+
* /^https?:\/\//.test(s) → NOT flagged (contains `?` quantifier)
|
|
17
|
+
* /^[a-z]+/.test(s) → NOT flagged (contains `[` character class)
|
|
18
|
+
* /\.txt$/.test(s) → NOT flagged (contains `.` metachar)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const METACHARS = new Set(['^', '$', '+', '[', '{', '(', '.', '?', '*', '|']);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Treat `\/` as a single literal `/` and check the remainder for any
|
|
27
|
+
* regex metacharacter or other backslash-escape we don't understand.
|
|
28
|
+
* Returns the literal string if safely convertible, otherwise null.
|
|
29
|
+
*/
|
|
30
|
+
function literalEquivalent(patternBody) {
|
|
31
|
+
// Step 1: collapse `\/` (the only escape we accept) into a literal `/`.
|
|
32
|
+
const flattened = patternBody.replaceAll(String.raw`\/`, '/');
|
|
33
|
+
// Step 2: any remaining `\` is an escape we don't understand (\d, \w, \\, etc.).
|
|
34
|
+
if (flattened.includes('\\')) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Step 3: reject any regex metacharacter we'd be silently flattening.
|
|
38
|
+
for (const ch of flattened) {
|
|
39
|
+
if (METACHARS.has(ch)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return flattened;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
meta: {
|
|
48
|
+
type: 'problem',
|
|
49
|
+
docs: {
|
|
50
|
+
description:
|
|
51
|
+
String.raw`Prefer String#startsWith / String#endsWith over /^literal/.test() — even when the literal includes \/ escape sequences`,
|
|
52
|
+
recommended: true,
|
|
53
|
+
},
|
|
54
|
+
messages: {
|
|
55
|
+
preferStartsWith:
|
|
56
|
+
"Prefer `<string>.startsWith('{{literal}}')` over `/{{pattern}}/.test(<string>)`. " +
|
|
57
|
+
String.raw`Treat \/ as the literal / character.`,
|
|
58
|
+
preferEndsWith:
|
|
59
|
+
"Prefer `<string>.endsWith('{{literal}}')` over `/{{pattern}}/.test(<string>)`. " +
|
|
60
|
+
String.raw`Treat \/ as the literal / character.`,
|
|
61
|
+
},
|
|
62
|
+
schema: [],
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
create(context) {
|
|
66
|
+
return {
|
|
67
|
+
CallExpression(node) {
|
|
68
|
+
if (
|
|
69
|
+
node.callee.type !== 'MemberExpression' ||
|
|
70
|
+
node.callee.property.type !== 'Identifier' ||
|
|
71
|
+
node.callee.property.name !== 'test'
|
|
72
|
+
) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const obj = node.callee.object;
|
|
76
|
+
if (obj.type !== 'Literal' || !obj.regex) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const { pattern, flags } = obj.regex;
|
|
80
|
+
if (flags.includes('i') || flags.includes('m')) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (pattern.startsWith('^')) {
|
|
85
|
+
const body = pattern.slice(1);
|
|
86
|
+
const literal = literalEquivalent(body);
|
|
87
|
+
if (literal !== null && literal !== '') {
|
|
88
|
+
context.report({
|
|
89
|
+
node,
|
|
90
|
+
messageId: 'preferStartsWith',
|
|
91
|
+
data: { literal, pattern },
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (pattern.endsWith('$') && !pattern.endsWith(String.raw`\$`)) {
|
|
98
|
+
const body = pattern.slice(0, -1);
|
|
99
|
+
const literal = literalEquivalent(body);
|
|
100
|
+
if (literal !== null && literal !== '') {
|
|
101
|
+
context.report({
|
|
102
|
+
node,
|
|
103
|
+
messageId: 'preferEndsWith',
|
|
104
|
+
data: { literal, pattern },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|