@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,348 @@
|
|
|
1
|
+
/* eslint-disable sonarjs/todo-tag -- This rule governs disabled tests, so its
|
|
2
|
+
documentation and its own test-runner member list necessarily quote the
|
|
3
|
+
`todo` token that sonarjs/todo-tag bans. The ban is why the annotation
|
|
4
|
+
grammar below is SKIP(#N) and not TODO(#N). */
|
|
5
|
+
|
|
6
|
+
/* STILL BLIND (syntax-only rule): a skip reached through an imported/re-exported binding or an alias of an
|
|
7
|
+
alias; `it.only`; `expect(A).toBe(A)` on named constants; `assert.ok(true)`; a non-empty body with no assertion. */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ESLint rule: require-justified-skip
|
|
11
|
+
*
|
|
12
|
+
* Flags test code that claims coverage it does not provide:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Unconditional skips** — `it.skip`, `test.skip`, `describe.skip`, `suite.skip`,
|
|
15
|
+
* `it.todo`, `test.todo`, and the `xit`/`xdescribe`/`xtest` aliases. The suite
|
|
16
|
+
* still lists the case, so a reader (and a coverage report) sees a test that
|
|
17
|
+
* never runs. Also the spellings that dodge a naive `object.property` check:
|
|
18
|
+
* deeper chains (`test.concurrent.skip`), computed access (`it['skip']`), and
|
|
19
|
+
* alias assignment (`const gate = describe.skip`).
|
|
20
|
+
* 2. **Tautological assertions** — `expect(<literal>)` (or `expect.soft(...)`)
|
|
21
|
+
* whose matcher argument is also a literal (or absent): `expect(true).toBe(true)`,
|
|
22
|
+
* `expect(1 === 1).toBe(true)`, `expect([]).toEqual([])`, `assert(true)`. These
|
|
23
|
+
* are *worse* than a skip, because they report as PASSING.
|
|
24
|
+
* 3. **Empty test bodies** — `it('x', () => {})`. Same failure mode as (2): a
|
|
25
|
+
* listed, green case that asserts nothing. `describe` is exempt; an empty
|
|
26
|
+
* suite claims no case of its own.
|
|
27
|
+
*
|
|
28
|
+
* ## What this rule does NOT catch — read this before trusting it
|
|
29
|
+
*
|
|
30
|
+
* The coherence audit that motivated this rule found **9** instances of tests
|
|
31
|
+
* that asserted something other than what their name claimed. This rule catches
|
|
32
|
+
* **3 of the 9**. The other 6 were live, green, PASSING tests that asserted the
|
|
33
|
+
* wrong thing — a linter cannot see those, because nothing about their syntax is
|
|
34
|
+
* wrong. No amount of tightening this rule will reach them.
|
|
35
|
+
*
|
|
36
|
+
* The real control for that class is a convention, not a lint rule: **every
|
|
37
|
+
* assertion of absence sits beside a positive control on the same fixture.** If
|
|
38
|
+
* a test asserts "no issue was reported", a sibling assertion on the same
|
|
39
|
+
* fixture must prove the detector fires at all — otherwise a detector that never
|
|
40
|
+
* runs is indistinguishable from a detector that correctly found nothing.
|
|
41
|
+
*
|
|
42
|
+
* Worked shape: a suppression test asserts the issue list is empty, and its
|
|
43
|
+
* sibling asserts the *same* fixture produces the issue when suppression is
|
|
44
|
+
* turned off. The pair is what makes the empty result meaningful; either half
|
|
45
|
+
* alone proves nothing.
|
|
46
|
+
*
|
|
47
|
+
* Treat this rule as the cheap 3/9, not as coverage of the problem.
|
|
48
|
+
*
|
|
49
|
+
* ## Escape hatch — the annotation grammar
|
|
50
|
+
*
|
|
51
|
+
* A flagged call is exempt when a comment on the same line, or on the line
|
|
52
|
+
* immediately above, matches exactly one shape:
|
|
53
|
+
*
|
|
54
|
+
* /\bSKIP\(#\d+\):\s*\S/
|
|
55
|
+
*
|
|
56
|
+
* i.e. `SKIP(#163): references[] not populated by the extractor yet`. Three
|
|
57
|
+
* parts are all required: the uppercase keyword `SKIP`, a `#`-prefixed GitHub
|
|
58
|
+
* issue number, and a non-empty reason after the colon. A bare
|
|
59
|
+
* `// fix this later` does not qualify — an escape hatch that accepts any
|
|
60
|
+
* comment is not an escape hatch, it is an off switch.
|
|
61
|
+
*
|
|
62
|
+
* Why `SKIP(` and not the more natural `TODO(`: this repo runs
|
|
63
|
+
* `sonarjs/todo-tag` at error level, which bans the `TODO` token in comments
|
|
64
|
+
* outright. A grammar built on `TODO(#N)` would be unusable — every
|
|
65
|
+
* annotation would trip a second rule. One keyword, and it is `SKIP`.
|
|
66
|
+
*
|
|
67
|
+
* Grep the whole tree for outstanding debt with: `rg 'SKIP\(#'`
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // BAD — silently claims a case the suite never runs
|
|
71
|
+
* it.skip('validates broken links', () => { ... });
|
|
72
|
+
*
|
|
73
|
+
* // BAD — reports as PASSING while asserting nothing
|
|
74
|
+
* expect(true).toBe(true);
|
|
75
|
+
*
|
|
76
|
+
* // GOOD — a condition, not a claim of coverage
|
|
77
|
+
* it.skipIf(process.platform === 'win32')('uses POSIX permissions', () => { ... });
|
|
78
|
+
*
|
|
79
|
+
* // GOOD — annotated with a tracking issue and a reason
|
|
80
|
+
* // SKIP(#163): references[] not populated by the extractor yet
|
|
81
|
+
* it.skip('REFERENCE_TARGET_MISSING', () => { ... });
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/** Test-runner globals whose `.skip` / `.todo` members disable a case. */
|
|
85
|
+
const TEST_ROOTS = new Set(['it', 'test', 'describe', 'suite']);
|
|
86
|
+
|
|
87
|
+
/** Roots whose empty-bodied call is a case that asserts nothing. `describe` is not one. */
|
|
88
|
+
const EMPTY_BODY_ROOTS = new Set(['it', 'test']);
|
|
89
|
+
|
|
90
|
+
/** Members that disable a case unconditionally. `skipIf` / `runIf` are NOT here. */
|
|
91
|
+
const DISABLING_MEMBERS = new Set(['skip', 'todo']);
|
|
92
|
+
|
|
93
|
+
/** Bare-identifier aliases for the same thing. */
|
|
94
|
+
const DISABLING_ALIASES = new Set(['xit', 'xdescribe', 'xtest', 'xspecify']);
|
|
95
|
+
|
|
96
|
+
/** Identifiers that are literal values rather than references. */
|
|
97
|
+
const LITERAL_IDENTIFIERS = new Set(['undefined', 'NaN', 'Infinity']);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Annotation grammar. Deliberately narrow: keyword + `#`-issue + non-empty reason.
|
|
101
|
+
* See the module doc comment.
|
|
102
|
+
*/
|
|
103
|
+
const JUSTIFICATION_PATTERN = /\bSKIP\(#\d+\):\s*\S/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* True when `node` is a value that is fully known at parse time, so comparing it
|
|
107
|
+
* against another such value tells you nothing about the code under test.
|
|
108
|
+
*/
|
|
109
|
+
function isLiteralValue(node) {
|
|
110
|
+
if (!node) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (node.type === 'Literal') {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (node.type === 'TemplateLiteral') {
|
|
117
|
+
return node.expressions.length === 0;
|
|
118
|
+
}
|
|
119
|
+
if (node.type === 'Identifier') {
|
|
120
|
+
return LITERAL_IDENTIFIERS.has(node.name);
|
|
121
|
+
}
|
|
122
|
+
if (node.type === 'UnaryExpression') {
|
|
123
|
+
return isLiteralValue(node.argument);
|
|
124
|
+
}
|
|
125
|
+
// `1 === 1`, `2 > 1` — both operands known, so the comparison is decided at parse time.
|
|
126
|
+
if (node.type === 'BinaryExpression') {
|
|
127
|
+
return isLiteralValue(node.left) && isLiteralValue(node.right);
|
|
128
|
+
}
|
|
129
|
+
// `[]`, `[1, 2]`, `{}` — a collection built entirely from known values.
|
|
130
|
+
if (node.type === 'ArrayExpression') {
|
|
131
|
+
return node.elements.every((element) => element === null || isLiteralValue(element));
|
|
132
|
+
}
|
|
133
|
+
if (node.type === 'ObjectExpression') {
|
|
134
|
+
return node.properties.every(
|
|
135
|
+
(property) => property.type === 'Property' && !property.computed && isLiteralValue(property.value),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Property name of a member access, whether dotted (`it.skip`) or computed with a
|
|
143
|
+
* string key (`it['skip']`). Null when the key is not knowable statically.
|
|
144
|
+
*/
|
|
145
|
+
function memberName(node) {
|
|
146
|
+
if (!node.computed) {
|
|
147
|
+
return node.property.type === 'Identifier' ? node.property.name : null;
|
|
148
|
+
}
|
|
149
|
+
return node.property.type === 'Literal' && typeof node.property.value === 'string'
|
|
150
|
+
? node.property.value
|
|
151
|
+
: null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* True when a member chain bottoms out at a test-runner global, so
|
|
156
|
+
* `test.concurrent.skip` and `test.sequential.skip` count but `myLib.test.skip`
|
|
157
|
+
* does not.
|
|
158
|
+
*/
|
|
159
|
+
function isTestRootChain(node) {
|
|
160
|
+
let current = node;
|
|
161
|
+
while (current.type === 'MemberExpression') {
|
|
162
|
+
current = current.object;
|
|
163
|
+
}
|
|
164
|
+
return current.type === 'Identifier' && TEST_ROOTS.has(current.name);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `expect(...)` or `expect.soft(...)` — the soft variant is still an assertion. */
|
|
168
|
+
function isExpectCallee(callee) {
|
|
169
|
+
if (callee.type === 'Identifier') {
|
|
170
|
+
return callee.name === 'expect';
|
|
171
|
+
}
|
|
172
|
+
return (
|
|
173
|
+
callee.type === 'MemberExpression' &&
|
|
174
|
+
callee.object.type === 'Identifier' &&
|
|
175
|
+
callee.object.name === 'expect' &&
|
|
176
|
+
memberName(callee) === 'soft'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* True when a non-invoked `it.skip` is being stored under another name
|
|
182
|
+
* (`const gate = describe.skip`), which reaches every later `gate(...)` call.
|
|
183
|
+
* A ternary branch (`cond ? describe : describe.skip`) is excluded: its parent is
|
|
184
|
+
* the ConditionalExpression, not the declarator, so it stays a runtime gate.
|
|
185
|
+
*/
|
|
186
|
+
function isAliasAssignment(node) {
|
|
187
|
+
const { parent } = node;
|
|
188
|
+
if (!parent) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return (
|
|
192
|
+
(parent.type === 'VariableDeclarator' && parent.init === node) ||
|
|
193
|
+
(parent.type === 'AssignmentExpression' && parent.right === node)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True when a test callback is present but its block body is empty. */
|
|
198
|
+
function hasEmptyBody(callback) {
|
|
199
|
+
return (
|
|
200
|
+
Boolean(callback) &&
|
|
201
|
+
(callback.type === 'ArrowFunctionExpression' || callback.type === 'FunctionExpression') &&
|
|
202
|
+
callback.body.type === 'BlockStatement' &&
|
|
203
|
+
callback.body.body.length === 0
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Walk up from `node` through any member chain hanging off it and return the
|
|
209
|
+
* CallExpression that ultimately invokes it, or null when `node` is not being
|
|
210
|
+
* called at all.
|
|
211
|
+
*
|
|
212
|
+
* Used for both halves of this rule:
|
|
213
|
+
* - `it.skip(...)` and `it.skip.each([...])(...)` resolve to their call, so
|
|
214
|
+
* they are reported; `(NET ? describe : describe.skip)(...)` and
|
|
215
|
+
* `const d = cond ? describe : describe.skip` resolve to null, because the
|
|
216
|
+
* member expression is a ternary branch rather than a callee. That ternary
|
|
217
|
+
* is a runtime gate — the same thing skipIf() expresses — so it must not be
|
|
218
|
+
* flagged. (Both shapes exist in this repo; the first draft of this rule
|
|
219
|
+
* flagged them and was wrong.)
|
|
220
|
+
* - `expect(x)` resolves through `.not` / `.resolves` / `.toBe` to the
|
|
221
|
+
* matcher call.
|
|
222
|
+
*/
|
|
223
|
+
function resolveInvocation(node) {
|
|
224
|
+
let current = node;
|
|
225
|
+
let parent = current.parent;
|
|
226
|
+
while (parent?.type === 'MemberExpression' && parent.object === current) {
|
|
227
|
+
current = parent;
|
|
228
|
+
parent = current.parent;
|
|
229
|
+
}
|
|
230
|
+
if (parent?.type === 'CallExpression' && parent.callee === current) {
|
|
231
|
+
return parent;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Collect every source line covered by a justification comment. */
|
|
237
|
+
function collectJustifiedLines(sourceCode) {
|
|
238
|
+
const lines = new Set();
|
|
239
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
240
|
+
if (!JUSTIFICATION_PATTERN.test(comment.value)) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
for (let line = comment.loc.start.line; line <= comment.loc.end.line; line++) {
|
|
244
|
+
lines.add(line);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
meta: {
|
|
252
|
+
type: 'problem',
|
|
253
|
+
docs: {
|
|
254
|
+
description:
|
|
255
|
+
'Require a tracking-issue justification for skipped tests, and ban tautological assertions',
|
|
256
|
+
category: 'Best Practices',
|
|
257
|
+
recommended: true,
|
|
258
|
+
},
|
|
259
|
+
schema: [],
|
|
260
|
+
messages: {
|
|
261
|
+
unconditionalSkip:
|
|
262
|
+
'This test is disabled unconditionally, so the suite lists a case it never runs. ' +
|
|
263
|
+
"Fix it, delete it, or annotate it with a tracking issue: '// SKIP(#123): reason' " +
|
|
264
|
+
'on the line above. A platform or environment gate should use skipIf()/runIf() ' +
|
|
265
|
+
'instead — a condition is not a skip.',
|
|
266
|
+
tautologicalAssertion:
|
|
267
|
+
'This assertion compares literals, so it passes no matter what the code does — ' +
|
|
268
|
+
'worse than a skip, because it reports as PASSING. Assert on a value produced by ' +
|
|
269
|
+
'the code under test, or delete the assertion. If the literal really is the subject, ' +
|
|
270
|
+
"annotate it: '// SKIP(#123): reason'.",
|
|
271
|
+
emptyTestBody:
|
|
272
|
+
'This test has an empty body, so it asserts nothing while reporting as PASSING. ' +
|
|
273
|
+
'Give it an assertion, delete it, or — if the case is real but not written yet — ' +
|
|
274
|
+
"make the gap visible with it.todo() plus '// SKIP(#123): reason'.",
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
create(context) {
|
|
279
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
280
|
+
let justifiedLines = null;
|
|
281
|
+
|
|
282
|
+
/** A finding is exempt when annotated on its own line or the line above. */
|
|
283
|
+
function isJustified(node) {
|
|
284
|
+
justifiedLines ??= collectJustifiedLines(sourceCode);
|
|
285
|
+
const line = node.loc.start.line;
|
|
286
|
+
return justifiedLines.has(line) || justifiedLines.has(line - 1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function report(node, messageId) {
|
|
290
|
+
if (!isJustified(node)) {
|
|
291
|
+
context.report({ node, messageId });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
// `it.skip`, `describe.todo`, and the `it.skip.each(...)` chain (whose
|
|
297
|
+
// inner member expression is `it.skip`).
|
|
298
|
+
MemberExpression(node) {
|
|
299
|
+
if (!DISABLING_MEMBERS.has(memberName(node)) || !isTestRootChain(node.object)) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Only an invoked skip disables a case, or an alias that will be invoked
|
|
303
|
+
// later. A ternary branch is a gate.
|
|
304
|
+
if (!resolveInvocation(node) && !isAliasAssignment(node)) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
report(node, 'unconditionalSkip');
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
CallExpression(node) {
|
|
311
|
+
// `xit(...)` / `xdescribe(...)` aliases.
|
|
312
|
+
if (node.callee.type === 'Identifier' && DISABLING_ALIASES.has(node.callee.name)) {
|
|
313
|
+
report(node, 'unconditionalSkip');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// `it('x', () => {})` — a listed case whose body asserts nothing, yet PASSES.
|
|
318
|
+
if (node.callee.type === 'Identifier' && EMPTY_BODY_ROOTS.has(node.callee.name)) {
|
|
319
|
+
if (hasEmptyBody(node.arguments[1])) {
|
|
320
|
+
report(node, 'emptyTestBody');
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// `assert(true)` — never fails. `assert(false, 'unreachable')` is a real
|
|
326
|
+
// guard, so only the always-true spelling is reported.
|
|
327
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'assert') {
|
|
328
|
+
if (node.arguments[0]?.type === 'Literal' && node.arguments[0].value === true) {
|
|
329
|
+
report(node, 'tautologicalAssertion');
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// `expect(<literal>).<matcher>(<literal>?)`
|
|
335
|
+
if (!isExpectCallee(node.callee)) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (node.arguments.length !== 1 || !isLiteralValue(node.arguments[0])) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const matcherCall = resolveInvocation(node);
|
|
342
|
+
if (matcherCall?.arguments.every((argument) => isLiteralValue(argument))) {
|
|
343
|
+
report(matcherCall, 'tautologicalAssertion');
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
},
|
|
348
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each safe replacement lives, and whether it is already in scope.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the autofix targets a SUBPATH and not the barrel
|
|
5
|
+
*
|
|
6
|
+
* `@vibe-agent-toolkit/utils` publishes fifteen entry points. `safePath` and
|
|
7
|
+
* `toForwardSlash` are reachable from `./path`, which pulls in nothing but
|
|
8
|
+
* `node:path`; the barrel drags five third-party dependencies behind it. Every
|
|
9
|
+
* rule in this pack used to autofix to the barrel while the README table shipped
|
|
10
|
+
* beside it named the narrow subpath — so a release whose entire purpose was
|
|
11
|
+
* narrow subpaths shipped lint rules that mechanically rewrote code AWAY from
|
|
12
|
+
* them, one autofix at a time. An adopter running the pack over 4,670 files hit
|
|
13
|
+
* 4,719 such sites.
|
|
14
|
+
*
|
|
15
|
+
* Naming the modules once, here, is what keeps the fixer, the error message and
|
|
16
|
+
* the README from drifting apart again: a rule that hardcodes its own copy of
|
|
17
|
+
* the string is how the first divergence happened.
|
|
18
|
+
*
|
|
19
|
+
* ## Why `isNameAlreadyBound` exists
|
|
20
|
+
*
|
|
21
|
+
* A fixer that adds `import { safePath } from '<target>'` to a file that already
|
|
22
|
+
* imports `safePath` from somewhere else does not produce a redundant import —
|
|
23
|
+
* it produces `SyntaxError: Identifier 'safePath' has already been declared`.
|
|
24
|
+
*
|
|
25
|
+
* That was latent while the target WAS the barrel (the fixer's own "do I already
|
|
26
|
+
* import from the target?" check happened to cover the only module anyone
|
|
27
|
+
* imported from). Pointing the fixer at `./path` makes it live for exactly the
|
|
28
|
+
* population being migrated: every file that already reaches the helpers through
|
|
29
|
+
* the barrel. So the check has to be "is this NAME bound?", not "did I see an
|
|
30
|
+
* import from MY module?".
|
|
31
|
+
*
|
|
32
|
+
* Scope analysis rather than import scanning, because a top-level
|
|
33
|
+
* `const safePath = ...` collides just as fatally as an import does.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { EXEMPT_FILES_SCHEMA } = require('./exempt-path-matcher.cjs');
|
|
37
|
+
|
|
38
|
+
const PACKAGE_NAME = '@vibe-agent-toolkit/utils';
|
|
39
|
+
|
|
40
|
+
/** `safePath`, `toForwardSlash`. */
|
|
41
|
+
const SAFE_PATH_MODULE = `${PACKAGE_NAME}/path`;
|
|
42
|
+
/** `normalizePath`, `normalizedTmpdir`, `mkdirSyncReal`, `resolveFromImportMeta`, `dynamicImportPath`. */
|
|
43
|
+
const SAFE_FS_MODULE = `${PACKAGE_NAME}/fs`;
|
|
44
|
+
/** `safeExecSync`. */
|
|
45
|
+
const SAFE_PROCESS_MODULE = `${PACKAGE_NAME}/process`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is `name` already bound at the top level of the linted file?
|
|
49
|
+
*
|
|
50
|
+
* Checks the module scope (or the global scope for `sourceType: 'script'`), which
|
|
51
|
+
* is where an added `import` declaration would land. A binding of the same name
|
|
52
|
+
* inside a nested function is deliberately NOT a conflict — an import at the top
|
|
53
|
+
* of the file is still legal, it is merely shadowed there.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} sourceCode - ESLint `SourceCode` for the file being linted.
|
|
56
|
+
* @param {string} name - The identifier the fixer wants to import.
|
|
57
|
+
* @returns {boolean} True when adding an import of `name` would redeclare it.
|
|
58
|
+
*/
|
|
59
|
+
function isNameAlreadyBound(sourceCode, name) {
|
|
60
|
+
const globalScope = sourceCode.getScope(sourceCode.ast);
|
|
61
|
+
// `getScope(Program)` hands back the GLOBAL scope, not the module scope —
|
|
62
|
+
// and every `import` binding lives in the module scope, which is its child.
|
|
63
|
+
// Reading `globalScope.variables` alone finds nothing and the check silently
|
|
64
|
+
// answers "not bound" for every ES module, i.e. exactly the files it exists
|
|
65
|
+
// to protect. (RuleTester caught this: the fix emitted a duplicate binding.)
|
|
66
|
+
const moduleScope = globalScope.childScopes.find((child) => child.type === 'module');
|
|
67
|
+
const scope = moduleScope ?? globalScope;
|
|
68
|
+
return scope.variables.some((variable) => variable.name === name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `safeModule` rule option: point the fixer at YOUR re-export seam.
|
|
73
|
+
*
|
|
74
|
+
* The narrow-subpath defaults above are right for a consumer importing this
|
|
75
|
+
* package directly, and wrong for one that re-exports the helpers through its
|
|
76
|
+
* own module. An adopter measured the difference: of the 61 packages in their
|
|
77
|
+
* workspace that would receive a new import, **52 (620 files) do not declare
|
|
78
|
+
* `@vibe-agent-toolkit/utils` at all**. Under pnpm's isolated `node_modules` an
|
|
79
|
+
* undeclared import does not degrade — it fails to resolve. Across their top 25
|
|
80
|
+
* affected packages our default resolved in 0; their own seam resolved in 24.
|
|
81
|
+
* So the autofix is only as useful as its ability to name a specifier that
|
|
82
|
+
* resolves where the fix lands, and only the consuming repo knows what that is.
|
|
83
|
+
*
|
|
84
|
+
* PER-RULE rather than one shared `settings` key, because a seam does not have
|
|
85
|
+
* to split its symbols the way this package does. The same adopter's narrow
|
|
86
|
+
* entry carries `normalizedTmpdir`/`mkdirSyncReal` but NOT `safePath`, so their
|
|
87
|
+
* `no-os-tmpdir` and `no-path-join` need different targets — the exact "path
|
|
88
|
+
* rules here, fs rules there" case a single key cannot express.
|
|
89
|
+
*/
|
|
90
|
+
const SAFE_MODULE_PROPERTY = Object.freeze({
|
|
91
|
+
type: 'string',
|
|
92
|
+
minLength: 1,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Add the `safeModule` property to a rule's options schema.
|
|
97
|
+
*
|
|
98
|
+
* `additionalProperties: false` is preserved deliberately: a typo'd `safeModules`
|
|
99
|
+
* must be a loud config error, not a silently ignored override that leaves the
|
|
100
|
+
* fixer writing the default specifier into every file.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} [schema] - Existing options-object schema to extend.
|
|
103
|
+
* @returns {object} Frozen schema accepting `safeModule` alongside `schema`'s own keys.
|
|
104
|
+
*/
|
|
105
|
+
function withSafeModuleOption(schema) {
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: { ...schema?.properties, safeModule: SAFE_MODULE_PROPERTY },
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Options schema for rules that take BOTH `exemptFiles` and `safeModule`. */
|
|
114
|
+
const EXEMPT_AND_SAFE_MODULE_SCHEMA = withSafeModuleOption(EXEMPT_FILES_SCHEMA);
|
|
115
|
+
|
|
116
|
+
/** Options schema for rules that only name a module in their advice text. */
|
|
117
|
+
const SAFE_MODULE_ONLY_SCHEMA = withSafeModuleOption();
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The module this rule should name, for this rule invocation.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} context - ESLint rule context.
|
|
123
|
+
* @param {string} fallbackModule - The rule's narrow-subpath default.
|
|
124
|
+
* @returns {string} The configured `safeModule`, or the default.
|
|
125
|
+
*/
|
|
126
|
+
function resolveSafeModule(context, fallbackModule) {
|
|
127
|
+
const configured = context.options?.[0]?.safeModule;
|
|
128
|
+
return typeof configured === 'string' && configured.length > 0 ? configured : fallbackModule;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
EXEMPT_AND_SAFE_MODULE_SCHEMA,
|
|
133
|
+
PACKAGE_NAME,
|
|
134
|
+
SAFE_FS_MODULE,
|
|
135
|
+
SAFE_MODULE_ONLY_SCHEMA,
|
|
136
|
+
SAFE_PATH_MODULE,
|
|
137
|
+
SAFE_PROCESS_MODULE,
|
|
138
|
+
isNameAlreadyBound,
|
|
139
|
+
resolveSafeModule,
|
|
140
|
+
withSafeModuleOption,
|
|
141
|
+
};
|
package/package.json
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-agent-toolkit/utils",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42-rc.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core utility functions shared across the vibe-agent-toolkit packages",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"typescript",
|
|
9
9
|
"utilities",
|
|
10
|
-
"toolkit"
|
|
10
|
+
"toolkit",
|
|
11
|
+
"cross-platform",
|
|
12
|
+
"windows",
|
|
13
|
+
"eslint"
|
|
11
14
|
],
|
|
12
15
|
"author": "Jeff Dutton",
|
|
13
16
|
"license": "MIT",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.0.0"
|
|
19
|
+
},
|
|
14
20
|
"main": "./dist/index.js",
|
|
15
21
|
"types": "./dist/index.d.ts",
|
|
16
22
|
"exports": {
|
|
@@ -18,17 +24,63 @@
|
|
|
18
24
|
"types": "./dist/index.d.ts",
|
|
19
25
|
"import": "./dist/index.js"
|
|
20
26
|
},
|
|
21
|
-
"./
|
|
22
|
-
"types": "./dist/
|
|
23
|
-
"import": "./dist/
|
|
27
|
+
"./path": {
|
|
28
|
+
"types": "./dist/path.d.ts",
|
|
29
|
+
"import": "./dist/path.js"
|
|
24
30
|
},
|
|
25
31
|
"./fs": {
|
|
26
32
|
"types": "./dist/fs.d.ts",
|
|
27
33
|
"import": "./dist/fs.js"
|
|
28
|
-
}
|
|
34
|
+
},
|
|
35
|
+
"./process": {
|
|
36
|
+
"types": "./dist/process.d.ts",
|
|
37
|
+
"import": "./dist/process.js"
|
|
38
|
+
},
|
|
39
|
+
"./git": {
|
|
40
|
+
"types": "./dist/git.d.ts",
|
|
41
|
+
"import": "./dist/git.js"
|
|
42
|
+
},
|
|
43
|
+
"./glob": {
|
|
44
|
+
"types": "./dist/glob.d.ts",
|
|
45
|
+
"import": "./dist/glob.js"
|
|
46
|
+
},
|
|
47
|
+
"./zod": {
|
|
48
|
+
"types": "./dist/zod.d.ts",
|
|
49
|
+
"import": "./dist/zod.js"
|
|
50
|
+
},
|
|
51
|
+
"./template": {
|
|
52
|
+
"types": "./dist/template-entry.d.ts",
|
|
53
|
+
"import": "./dist/template-entry.js"
|
|
54
|
+
},
|
|
55
|
+
"./yaml": {
|
|
56
|
+
"types": "./dist/yaml.d.ts",
|
|
57
|
+
"import": "./dist/yaml.js"
|
|
58
|
+
},
|
|
59
|
+
"./testing": {
|
|
60
|
+
"types": "./dist/testing.d.ts",
|
|
61
|
+
"import": "./dist/testing.js"
|
|
62
|
+
},
|
|
63
|
+
"./asset": {
|
|
64
|
+
"types": "./dist/asset.d.ts",
|
|
65
|
+
"import": "./dist/asset.js"
|
|
66
|
+
},
|
|
67
|
+
"./crawl": {
|
|
68
|
+
"types": "./dist/crawl.d.ts",
|
|
69
|
+
"import": "./dist/crawl.js"
|
|
70
|
+
},
|
|
71
|
+
"./project": {
|
|
72
|
+
"types": "./dist/project.d.ts",
|
|
73
|
+
"import": "./dist/project.js"
|
|
74
|
+
},
|
|
75
|
+
"./eslint": {
|
|
76
|
+
"types": "./eslint/index.d.cts",
|
|
77
|
+
"default": "./eslint/index.cjs"
|
|
78
|
+
},
|
|
79
|
+
"./package.json": "./package.json"
|
|
29
80
|
},
|
|
30
81
|
"files": [
|
|
31
82
|
"dist",
|
|
83
|
+
"eslint",
|
|
32
84
|
"README.md"
|
|
33
85
|
],
|
|
34
86
|
"scripts": {
|
|
@@ -48,11 +100,17 @@
|
|
|
48
100
|
"yaml": "^2.6.1"
|
|
49
101
|
},
|
|
50
102
|
"peerDependencies": {
|
|
51
|
-
"
|
|
103
|
+
"eslint": ">=9.0.0"
|
|
104
|
+
},
|
|
105
|
+
"peerDependenciesMeta": {
|
|
106
|
+
"eslint": {
|
|
107
|
+
"optional": true
|
|
108
|
+
}
|
|
52
109
|
},
|
|
53
110
|
"devDependencies": {
|
|
54
111
|
"@types/picomatch": "^4.0.2",
|
|
55
112
|
"@types/which": "^3.0.4",
|
|
113
|
+
"eslint": "^9.39.5",
|
|
56
114
|
"rimraf": "^6.0.1",
|
|
57
115
|
"typescript": "^5.9.3",
|
|
58
116
|
"vitest": "^3.2.4",
|