@vibe-agent-toolkit/utils 0.1.42-rc.1 → 0.2.0-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 +19 -3
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +88 -2
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs-utils.d.ts +389 -30
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +425 -56
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +2 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/git-root-cache.d.ts +44 -0
- package/dist/git-root-cache.d.ts.map +1 -0
- package/dist/git-root-cache.js +68 -0
- package/dist/git-root-cache.js.map +1 -0
- package/dist/git-utils.d.ts +11 -0
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +28 -8
- package/dist/git-utils.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -2
- package/dist/index.js.map +1 -1
- package/dist/numeric-args.d.ts +24 -0
- package/dist/numeric-args.d.ts.map +1 -0
- package/dist/numeric-args.js +37 -0
- package/dist/numeric-args.js.map +1 -0
- package/dist/path-core.d.ts +30 -0
- package/dist/path-core.d.ts.map +1 -1
- package/dist/path-core.js +32 -0
- package/dist/path-core.js.map +1 -1
- package/dist/path.d.ts +1 -1
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -1
- package/dist/path.js.map +1 -1
- package/dist/project-utils.d.ts +7 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +9 -1
- package/dist/project-utils.js.map +1 -1
- package/dist/test-helpers.d.ts +16 -0
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +28 -1
- package/dist/test-helpers.js.map +1 -1
- package/eslint/README.md +27 -1
- package/eslint/rules/dead-import.cjs +251 -0
- package/eslint/rules/eslint-rule-factory.cjs +213 -29
- package/eslint/rules/no-manual-path-normalize.cjs +59 -7
- package/eslint/rules/path-function-rule-factory.cjs +314 -34
- package/eslint/rules/prefer-startswith-over-regex.cjs +209 -40
- package/eslint/rules/safe-import.cjs +23 -0
- package/package.json +2 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ESLint rule: prefer-startswith-over-regex
|
|
3
3
|
*
|
|
4
|
-
* Catches `/^literal/.test(s)` and `/literal$/.test(s)` patterns
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Catches `/^literal/.test(s)` and `/literal$/.test(s)` patterns whose body
|
|
5
|
+
* flattens to a plain string, and recommends `s.startsWith('literal')` /
|
|
6
|
+
* `s.endsWith('literal')`.
|
|
7
7
|
*
|
|
8
8
|
* Why a local rule?
|
|
9
9
|
* `unicorn/prefer-string-starts-ends-with` already handles the simple case
|
|
@@ -11,11 +11,32 @@
|
|
|
11
11
|
* common `\/` (escaped slash) sequence. SonarCloud's S6557 catches these,
|
|
12
12
|
* but only post-merge. This rule shifts that detection left into ESLint.
|
|
13
13
|
*
|
|
14
|
+
* ## Two shapes this rule deliberately does NOT limit itself to
|
|
15
|
+
*
|
|
16
|
+
* Both were narrowings in the first draft, and an adopter found each of them
|
|
17
|
+
* the same way: SonarCloud raised a MAJOR S6557 on code this rule had reported
|
|
18
|
+
* green.
|
|
19
|
+
*
|
|
20
|
+
* 1. **The regex need not be inline.** `const RE = /^x/; RE.test(s)` is the
|
|
21
|
+
* same violation as `/^x/.test(s)` — see {@link resolveRegex}.
|
|
22
|
+
* 2. **An escaped non-special character is a literal character.** `\*` is an
|
|
23
|
+
* unambiguous `*`; refusing every escape but `\/` skipped it — see
|
|
24
|
+
* {@link literalEquivalent}.
|
|
25
|
+
*
|
|
26
|
+
* Neither could have been caught by scanning an adopter's tree. The rule runs
|
|
27
|
+
* at `error` there, so its finding count is zero BY CONSTRUCTION — lint cannot
|
|
28
|
+
* go green while a violation exists. A 0-vs-0 tie against another
|
|
29
|
+
* implementation is not agreement, it is two rules both failing to fire. For
|
|
30
|
+
* any rule an adopter reports zero findings for, that rule is *unmeasured*.
|
|
31
|
+
*
|
|
14
32
|
* Examples:
|
|
15
33
|
* /^file:\/\//.test(s) → s.startsWith('file://')
|
|
34
|
+
* /^\*glob/.test(s) → s.startsWith('*glob')
|
|
35
|
+
* const R = /^a/; R.test(s) → s.startsWith('a')
|
|
16
36
|
* /^https?:\/\//.test(s) → NOT flagged (contains `?` quantifier)
|
|
17
37
|
* /^[a-z]+/.test(s) → NOT flagged (contains `[` character class)
|
|
18
|
-
* /\.txt$/.test(s) → NOT flagged (
|
|
38
|
+
* /\.txt$/.test(s) → NOT flagged (`.` is a metachar; `\.` would flag)
|
|
39
|
+
* /^\d+/.test(s) → NOT flagged (`\d` is a character class)
|
|
19
40
|
*/
|
|
20
41
|
|
|
21
42
|
'use strict';
|
|
@@ -23,24 +44,149 @@
|
|
|
23
44
|
const METACHARS = new Set(['^', '$', '+', '[', '{', '(', '.', '?', '*', '|']);
|
|
24
45
|
|
|
25
46
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
47
|
+
* Escape sequences whose meaning is NOT "the character that follows the
|
|
48
|
+
* backslash": character classes (`\d`, `\w`, `\s`, `\p`), assertions (`\b`),
|
|
49
|
+
* numeric escapes and backreferences (`\0`–`\9`, `\k`), and the code-point
|
|
50
|
+
* forms (`\x`, `\u`, `\c`). `\n`, `\r`, `\t`, `\v`, `\f` ARE single literal
|
|
51
|
+
* characters, but flattening them would put a raw control character into the
|
|
52
|
+
* suggested `startsWith('…')` string, so they are rejected too.
|
|
53
|
+
*
|
|
54
|
+
* Every other escape — `\/`, `\.`, `\*`, `\+`, `\(`, `\\`, `\-` … — is an
|
|
55
|
+
* identity escape, and the character it protects is exactly what a
|
|
56
|
+
* `startsWith` comparison would look for.
|
|
57
|
+
*/
|
|
58
|
+
const MEANINGFUL_ESCAPE = /[0-9BDPSWbcdfknprstuvwx]/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Flatten a regex body to the plain string it is equivalent to.
|
|
62
|
+
*
|
|
63
|
+
* Returns the literal string if safely convertible, otherwise null. Scans
|
|
64
|
+
* character by character rather than doing a `replaceAll` of the one escape we
|
|
65
|
+
* happen to like: `\/` was accepted and `\*` was not, though both denote a
|
|
66
|
+
* single literal character and neither is a metacharacter once escaped.
|
|
29
67
|
*/
|
|
30
68
|
function literalEquivalent(patternBody) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
let literal = '';
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < patternBody.length; index += 1) {
|
|
72
|
+
const char = patternBody[index];
|
|
73
|
+
|
|
74
|
+
if (char === '\\') {
|
|
75
|
+
const escaped = patternBody[index + 1];
|
|
76
|
+
// A trailing lone backslash is not a valid pattern; refuse to guess.
|
|
77
|
+
if (escaped === undefined || MEANINGFUL_ESCAPE.test(escaped)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
literal += escaped;
|
|
81
|
+
index += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Unescaped metacharacter: flattening it would change what matches.
|
|
86
|
+
if (METACHARS.has(char)) {
|
|
40
87
|
return null;
|
|
41
88
|
}
|
|
89
|
+
literal += char;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return literal;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Count the run of consecutive `\\` characters immediately preceding `index`
|
|
97
|
+
* in `text`.
|
|
98
|
+
*
|
|
99
|
+
* Used to decide whether a trailing `$` is an anchor or an escaped literal
|
|
100
|
+
* dollar sign: an EVEN run (including zero) means the `$` itself is
|
|
101
|
+
* unescaped — a genuine end-of-string anchor. An ODD run means the last of
|
|
102
|
+
* those backslashes escapes the `$`, making it a literal character. Looking
|
|
103
|
+
* only at the last two characters of the pattern (`\\$`) gets this wrong for
|
|
104
|
+
* two or more consecutive backslashes: `/\\\\$/` (an escaped backslash `\\\\`
|
|
105
|
+
* followed by an unescaped `$` anchor) also ends in the two characters `\\$`,
|
|
106
|
+
* but the `$` there IS an anchor.
|
|
107
|
+
*/
|
|
108
|
+
function countTrailingBackslashes(text, index) {
|
|
109
|
+
let count = 0;
|
|
110
|
+
let position = index - 1;
|
|
111
|
+
while (position >= 0 && text[position] === '\\') {
|
|
112
|
+
count += 1;
|
|
113
|
+
position -= 1;
|
|
114
|
+
}
|
|
115
|
+
return count;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Find the variable `identifier` resolves to, searching outward from its scope.
|
|
120
|
+
*/
|
|
121
|
+
function findVariable(sourceCode, identifier) {
|
|
122
|
+
for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
123
|
+
const found = scope.variables.find((variable) => variable.name === identifier.name);
|
|
124
|
+
if (found) {
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The regex `node` denotes: itself when it is a regex literal, or the literal
|
|
133
|
+
* a single-assignment variable was initialised with.
|
|
134
|
+
*
|
|
135
|
+
* The indirection matters because hoisting a regex to a module-level `const` is
|
|
136
|
+
* the normal way to write one — and examining only inline literals meant the
|
|
137
|
+
* rule went quiet on exactly the code most likely to run hot.
|
|
138
|
+
*
|
|
139
|
+
* Conservative on purpose: one definition, one write, and that write is a regex
|
|
140
|
+
* literal. A binding assigned more than once could hold anything by the time
|
|
141
|
+
* `.test()` runs, and nothing here proves which value that is.
|
|
142
|
+
*
|
|
143
|
+
* `indirect` is what lets the caller treat a hoisted regex differently from an
|
|
144
|
+
* inline one — see the `g`/`y` guard, which only a shared object can trip.
|
|
145
|
+
*
|
|
146
|
+
* @returns {{pattern: string, flags: string, indirect: boolean} | null}
|
|
147
|
+
*/
|
|
148
|
+
function resolveRegex(sourceCode, node) {
|
|
149
|
+
if (node.type === 'Literal' && node.regex) {
|
|
150
|
+
return { ...node.regex, indirect: false };
|
|
151
|
+
}
|
|
152
|
+
if (node.type !== 'Identifier') {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const variable = findVariable(sourceCode, node);
|
|
157
|
+
if (variable?.defs.length !== 1) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const [definition] = variable.defs;
|
|
161
|
+
if (definition.type !== 'Variable' || !definition.node.init) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (variable.references.filter((reference) => reference.isWrite()).length !== 1) {
|
|
165
|
+
return null;
|
|
42
166
|
}
|
|
43
|
-
|
|
167
|
+
|
|
168
|
+
const { init } = definition.node;
|
|
169
|
+
return init.type === 'Literal' && init.regex ? { ...init.regex, indirect: true } : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render the flattened literal as JS SOURCE, not as a bare character run.
|
|
174
|
+
*
|
|
175
|
+
* The literal is a string of characters; the message drops it into
|
|
176
|
+
* `startsWith(…)`, which a human reads as source. Those are different
|
|
177
|
+
* languages, and interpolating one into the other loses exactly the characters
|
|
178
|
+
* that matter. `/^C:\\Users/` flattens to `C:\Users` — one backslash — and
|
|
179
|
+
* emitting it raw produced the advice `startsWith('C:\Users')`, which JavaScript
|
|
180
|
+
* reads back as `"C:Users"`. Worse in the realistic case: a `/^\\\\/` UNC check
|
|
181
|
+
* became `startsWith('\\')`, i.e. ONE backslash, silently true for any
|
|
182
|
+
* single-backslash path. A literal containing `'` produced advice that is a
|
|
183
|
+
* `SyntaxError` outright.
|
|
184
|
+
*
|
|
185
|
+
* This rule has no fixer, so the message IS the deliverable — there is no
|
|
186
|
+
* autofixer downstream that would have escaped it correctly.
|
|
187
|
+
*/
|
|
188
|
+
function asSourceLiteral(literal) {
|
|
189
|
+
return JSON.stringify(literal);
|
|
44
190
|
}
|
|
45
191
|
|
|
46
192
|
module.exports = {
|
|
@@ -48,21 +194,26 @@ module.exports = {
|
|
|
48
194
|
type: 'problem',
|
|
49
195
|
docs: {
|
|
50
196
|
description:
|
|
51
|
-
String.raw`Prefer String#startsWith / String#endsWith over /^literal/.test() —
|
|
197
|
+
String.raw`Prefer String#startsWith / String#endsWith over /^literal/.test() — including escaped literals such as \/ and \*, and regexes held in a const`,
|
|
52
198
|
recommended: true,
|
|
53
199
|
},
|
|
54
200
|
messages: {
|
|
201
|
+
// `{{pattern}}` carries its FLAGS. Rendering `/^abc/` for a source
|
|
202
|
+
// `/^abc/g` hid the one character that decides whether the advice is
|
|
203
|
+
// right, from the one person positioned to notice.
|
|
55
204
|
preferStartsWith:
|
|
56
|
-
|
|
57
|
-
String.raw`
|
|
205
|
+
'Prefer `<string>.startsWith({{literal}})` over `/{{pattern}}/{{flags}}.test(<string>)`. ' +
|
|
206
|
+
String.raw`An escaped character such as \/ or \* is the literal character itself.`,
|
|
58
207
|
preferEndsWith:
|
|
59
|
-
|
|
60
|
-
String.raw`
|
|
208
|
+
'Prefer `<string>.endsWith({{literal}})` over `/{{pattern}}/{{flags}}.test(<string>)`. ' +
|
|
209
|
+
String.raw`An escaped character such as \/ or \* is the literal character itself.`,
|
|
61
210
|
},
|
|
62
211
|
schema: [],
|
|
63
212
|
},
|
|
64
213
|
|
|
65
214
|
create(context) {
|
|
215
|
+
const sourceCode = context.getSourceCode();
|
|
216
|
+
|
|
66
217
|
return {
|
|
67
218
|
CallExpression(node) {
|
|
68
219
|
if (
|
|
@@ -72,37 +223,55 @@ module.exports = {
|
|
|
72
223
|
) {
|
|
73
224
|
return;
|
|
74
225
|
}
|
|
75
|
-
|
|
76
|
-
|
|
226
|
+
// `startsWith` needs a string receiver where `.test()` would have
|
|
227
|
+
// coerced one. Arity is the only part of that this rule can check
|
|
228
|
+
// without types — a zero-argument `.test()` coerces `undefined` to
|
|
229
|
+
// "undefined" and is nobody's prefix check. A non-string ARGUMENT
|
|
230
|
+
// (`/^\[object/.test(v)`) remains a known limitation: `.test` coerces,
|
|
231
|
+
// `startsWith` throws, and only a type checker can tell them apart.
|
|
232
|
+
if (node.arguments.length !== 1) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const regex = resolveRegex(sourceCode, node.callee.object);
|
|
237
|
+
if (!regex) {
|
|
77
238
|
return;
|
|
78
239
|
}
|
|
79
|
-
const { pattern, flags } =
|
|
240
|
+
const { pattern, flags, indirect } = regex;
|
|
80
241
|
if (flags.includes('i') || flags.includes('m')) {
|
|
81
242
|
return;
|
|
82
243
|
}
|
|
244
|
+
// `g` and `y` make `.test()` STATEFUL through `lastIndex`. A regex
|
|
245
|
+
// LITERAL is reconstructed on every evaluation, so its cursor is always
|
|
246
|
+
// 0 and the flags are inert; a hoisted `const` is one object that
|
|
247
|
+
// remembers. `const RE = /^abc/g` answers [true, false, true, false] to
|
|
248
|
+
// four calls on the same string where `startsWith` answers true four
|
|
249
|
+
// times — so resolving through a binding is precisely what makes this
|
|
250
|
+
// advice wrong, and precisely where it must not be given.
|
|
251
|
+
if (indirect && (flags.includes('g') || flags.includes('y'))) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const report = (messageId, literal) => {
|
|
256
|
+
context.report({
|
|
257
|
+
node,
|
|
258
|
+
messageId,
|
|
259
|
+
data: { literal: asSourceLiteral(literal), pattern, flags },
|
|
260
|
+
});
|
|
261
|
+
};
|
|
83
262
|
|
|
84
263
|
if (pattern.startsWith('^')) {
|
|
85
|
-
const
|
|
86
|
-
const literal = literalEquivalent(body);
|
|
264
|
+
const literal = literalEquivalent(pattern.slice(1));
|
|
87
265
|
if (literal !== null && literal !== '') {
|
|
88
|
-
|
|
89
|
-
node,
|
|
90
|
-
messageId: 'preferStartsWith',
|
|
91
|
-
data: { literal, pattern },
|
|
92
|
-
});
|
|
266
|
+
report('preferStartsWith', literal);
|
|
93
267
|
return;
|
|
94
268
|
}
|
|
95
269
|
}
|
|
96
270
|
|
|
97
|
-
if (pattern.endsWith('$') &&
|
|
98
|
-
const
|
|
99
|
-
const literal = literalEquivalent(body);
|
|
271
|
+
if (pattern.endsWith('$') && countTrailingBackslashes(pattern, pattern.length - 1) % 2 === 0) {
|
|
272
|
+
const literal = literalEquivalent(pattern.slice(0, -1));
|
|
100
273
|
if (literal !== null && literal !== '') {
|
|
101
|
-
|
|
102
|
-
node,
|
|
103
|
-
messageId: 'preferEndsWith',
|
|
104
|
-
data: { literal, pattern },
|
|
105
|
-
});
|
|
274
|
+
report('preferEndsWith', literal);
|
|
106
275
|
}
|
|
107
276
|
}
|
|
108
277
|
},
|
|
@@ -68,6 +68,28 @@ function isNameAlreadyBound(sourceCode, name) {
|
|
|
68
68
|
return scope.variables.some((variable) => variable.name === name);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Insert `text` above `node`, ABOVE its leading comments.
|
|
73
|
+
*
|
|
74
|
+
* `fixer.insertTextBefore(node)` uses the node's own start offset, which is
|
|
75
|
+
* after any comment attached to it — so inserting an import before the first
|
|
76
|
+
* statement dropped it BETWEEN an `eslint-disable-next-line` and the line that
|
|
77
|
+
* directive protects. The directive then applies to the inserted import, and
|
|
78
|
+
* the statement the developer had deliberately suppressed silently becomes
|
|
79
|
+
* fixable. A fixer that can revoke a suppression is a fixer that edits code
|
|
80
|
+
* nobody asked it to touch.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} fixer - ESLint rule fixer.
|
|
83
|
+
* @param {object} sourceCode - ESLint `SourceCode` for the file being fixed.
|
|
84
|
+
* @param {object} node - The node to insert above.
|
|
85
|
+
* @param {string} text - Text to insert, including its own trailing newline.
|
|
86
|
+
*/
|
|
87
|
+
function insertAboveWithComments(fixer, sourceCode, node, text) {
|
|
88
|
+
const comments = sourceCode.getCommentsBefore(node);
|
|
89
|
+
const start = (comments[0] ?? node).range[0];
|
|
90
|
+
return fixer.insertTextBeforeRange([start, start], text);
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
/**
|
|
72
94
|
* The `safeModule` rule option: point the fixer at YOUR re-export seam.
|
|
73
95
|
*
|
|
@@ -135,6 +157,7 @@ module.exports = {
|
|
|
135
157
|
SAFE_MODULE_ONLY_SCHEMA,
|
|
136
158
|
SAFE_PATH_MODULE,
|
|
137
159
|
SAFE_PROCESS_MODULE,
|
|
160
|
+
insertAboveWithComments,
|
|
138
161
|
isNameAlreadyBound,
|
|
139
162
|
resolveSafeModule,
|
|
140
163
|
withSafeModuleOption,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-agent-toolkit/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-rc.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core utility functions shared across the vibe-agent-toolkit packages",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"README.md"
|
|
85
85
|
],
|
|
86
86
|
"scripts": {
|
|
87
|
-
"build": "tsc && tsx ../dev-tools/src/copy-yaml-assets.ts",
|
|
87
|
+
"build": "rimraf --glob dist \"*.tsbuildinfo\" && tsc && tsx ../dev-tools/src/copy-yaml-assets.ts",
|
|
88
88
|
"test:unit": "vitest run",
|
|
89
89
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
90
90
|
"test:system": "vitest run --config vitest.system.config.ts",
|