@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.
Files changed (116) hide show
  1. package/README.md +146 -31
  2. package/dist/asset.d.ts +9 -0
  3. package/dist/asset.d.ts.map +1 -0
  4. package/dist/asset.js +9 -0
  5. package/dist/asset.js.map +1 -0
  6. package/dist/crawl.d.ts +19 -0
  7. package/dist/crawl.d.ts.map +1 -0
  8. package/dist/crawl.js +19 -0
  9. package/dist/crawl.js.map +1 -0
  10. package/dist/file-crawler.d.ts +19 -2
  11. package/dist/file-crawler.d.ts.map +1 -1
  12. package/dist/file-crawler.js +19 -1
  13. package/dist/file-crawler.js.map +1 -1
  14. package/dist/fs-utils.d.ts +60 -3
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +88 -10
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/fs.d.ts +7 -6
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/fs.js +7 -6
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git-utils.d.ts +2 -0
  23. package/dist/git-utils.d.ts.map +1 -1
  24. package/dist/git-utils.js +16 -0
  25. package/dist/git-utils.js.map +1 -1
  26. package/dist/git.d.ts +23 -0
  27. package/dist/git.d.ts.map +1 -0
  28. package/dist/git.js +23 -0
  29. package/dist/git.js.map +1 -0
  30. package/dist/gitignore-checker.d.ts +0 -9
  31. package/dist/gitignore-checker.d.ts.map +1 -1
  32. package/dist/gitignore-checker.js +0 -12
  33. package/dist/gitignore-checker.js.map +1 -1
  34. package/dist/glob/glob-pattern.js +1 -1
  35. package/dist/glob/glob-pattern.js.map +1 -1
  36. package/dist/glob.d.ts +8 -0
  37. package/dist/glob.d.ts.map +1 -0
  38. package/dist/glob.js +8 -0
  39. package/dist/glob.js.map +1 -0
  40. package/dist/path-core.d.ts +196 -0
  41. package/dist/path-core.d.ts.map +1 -0
  42. package/dist/path-core.js +251 -0
  43. package/dist/path-core.js.map +1 -0
  44. package/dist/path-utils.d.ts +12 -183
  45. package/dist/path-utils.d.ts.map +1 -1
  46. package/dist/path-utils.js +13 -237
  47. package/dist/path-utils.js.map +1 -1
  48. package/dist/path.d.ts +12 -0
  49. package/dist/path.d.ts.map +1 -0
  50. package/dist/path.js +12 -0
  51. package/dist/path.js.map +1 -0
  52. package/dist/process.d.ts +7 -4
  53. package/dist/process.d.ts.map +1 -1
  54. package/dist/process.js +7 -4
  55. package/dist/process.js.map +1 -1
  56. package/dist/project.d.ts +31 -0
  57. package/dist/project.d.ts.map +1 -0
  58. package/dist/project.js +31 -0
  59. package/dist/project.js.map +1 -0
  60. package/dist/safe-exec.d.ts.map +1 -1
  61. package/dist/safe-exec.js +8 -4
  62. package/dist/safe-exec.js.map +1 -1
  63. package/dist/spawn-hardened.d.ts.map +1 -1
  64. package/dist/spawn-hardened.js +4 -17
  65. package/dist/spawn-hardened.js.map +1 -1
  66. package/dist/template-entry.d.ts +10 -0
  67. package/dist/template-entry.d.ts.map +1 -0
  68. package/dist/template-entry.js +10 -0
  69. package/dist/template-entry.js.map +1 -0
  70. package/dist/testing.d.ts +8 -0
  71. package/dist/testing.d.ts.map +1 -0
  72. package/dist/testing.js +8 -0
  73. package/dist/testing.js.map +1 -0
  74. package/dist/windows-shell.d.ts +140 -8
  75. package/dist/windows-shell.d.ts.map +1 -1
  76. package/dist/windows-shell.js +218 -11
  77. package/dist/windows-shell.js.map +1 -1
  78. package/dist/yaml.d.ts +9 -0
  79. package/dist/yaml.d.ts.map +1 -0
  80. package/dist/yaml.js +9 -0
  81. package/dist/yaml.js.map +1 -0
  82. package/dist/zod.d.ts +10 -0
  83. package/dist/zod.d.ts.map +1 -0
  84. package/dist/zod.js +10 -0
  85. package/dist/zod.js.map +1 -0
  86. package/eslint/README.md +217 -0
  87. package/eslint/index.cjs +161 -0
  88. package/eslint/index.d.cts +60 -0
  89. package/eslint/rules/dead-import.cjs +201 -0
  90. package/eslint/rules/eslint-rule-factory.cjs +410 -0
  91. package/eslint/rules/exempt-path-matcher.cjs +265 -0
  92. package/eslint/rules/no-bare-dynamic-import-path.cjs +142 -0
  93. package/eslint/rules/no-child-process-execSync.cjs +23 -0
  94. package/eslint/rules/no-command-direct-factory.cjs +246 -0
  95. package/eslint/rules/no-file-url-string-concat.cjs +77 -0
  96. package/eslint/rules/no-fs-mkdirSync.cjs +23 -0
  97. package/eslint/rules/no-fs-promises-cp.cjs +36 -0
  98. package/eslint/rules/no-fs-realpathSync.cjs +23 -0
  99. package/eslint/rules/no-hardcoded-path-split.cjs +141 -0
  100. package/eslint/rules/no-manual-path-normalize.cjs +161 -0
  101. package/eslint/rules/no-os-tmpdir.cjs +24 -0
  102. package/eslint/rules/no-path-join.cjs +14 -0
  103. package/eslint/rules/no-path-operations-in-comparisons.cjs +148 -0
  104. package/eslint/rules/no-path-relative.cjs +14 -0
  105. package/eslint/rules/no-path-resolve.cjs +14 -0
  106. package/eslint/rules/no-path-sep-in-strings.cjs +130 -0
  107. package/eslint/rules/no-path-startswith.cjs +139 -0
  108. package/eslint/rules/no-test-scoped-functions.cjs +134 -0
  109. package/eslint/rules/no-unix-shell-commands.cjs +152 -0
  110. package/eslint/rules/no-unsafe-root-join.cjs +85 -0
  111. package/eslint/rules/no-url-pathname-for-fs.cjs +107 -0
  112. package/eslint/rules/path-function-rule-factory.cjs +417 -0
  113. package/eslint/rules/prefer-startswith-over-regex.cjs +257 -0
  114. package/eslint/rules/require-justified-skip.cjs +348 -0
  115. package/eslint/rules/safe-import.cjs +164 -0
  116. 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,164 @@
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
+ * 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
+
93
+ /**
94
+ * The `safeModule` rule option: point the fixer at YOUR re-export seam.
95
+ *
96
+ * The narrow-subpath defaults above are right for a consumer importing this
97
+ * package directly, and wrong for one that re-exports the helpers through its
98
+ * own module. An adopter measured the difference: of the 61 packages in their
99
+ * workspace that would receive a new import, **52 (620 files) do not declare
100
+ * `@vibe-agent-toolkit/utils` at all**. Under pnpm's isolated `node_modules` an
101
+ * undeclared import does not degrade — it fails to resolve. Across their top 25
102
+ * affected packages our default resolved in 0; their own seam resolved in 24.
103
+ * So the autofix is only as useful as its ability to name a specifier that
104
+ * resolves where the fix lands, and only the consuming repo knows what that is.
105
+ *
106
+ * PER-RULE rather than one shared `settings` key, because a seam does not have
107
+ * to split its symbols the way this package does. The same adopter's narrow
108
+ * entry carries `normalizedTmpdir`/`mkdirSyncReal` but NOT `safePath`, so their
109
+ * `no-os-tmpdir` and `no-path-join` need different targets — the exact "path
110
+ * rules here, fs rules there" case a single key cannot express.
111
+ */
112
+ const SAFE_MODULE_PROPERTY = Object.freeze({
113
+ type: 'string',
114
+ minLength: 1,
115
+ });
116
+
117
+ /**
118
+ * Add the `safeModule` property to a rule's options schema.
119
+ *
120
+ * `additionalProperties: false` is preserved deliberately: a typo'd `safeModules`
121
+ * must be a loud config error, not a silently ignored override that leaves the
122
+ * fixer writing the default specifier into every file.
123
+ *
124
+ * @param {object} [schema] - Existing options-object schema to extend.
125
+ * @returns {object} Frozen schema accepting `safeModule` alongside `schema`'s own keys.
126
+ */
127
+ function withSafeModuleOption(schema) {
128
+ return Object.freeze({
129
+ type: 'object',
130
+ properties: { ...schema?.properties, safeModule: SAFE_MODULE_PROPERTY },
131
+ additionalProperties: false,
132
+ });
133
+ }
134
+
135
+ /** Options schema for rules that take BOTH `exemptFiles` and `safeModule`. */
136
+ const EXEMPT_AND_SAFE_MODULE_SCHEMA = withSafeModuleOption(EXEMPT_FILES_SCHEMA);
137
+
138
+ /** Options schema for rules that only name a module in their advice text. */
139
+ const SAFE_MODULE_ONLY_SCHEMA = withSafeModuleOption();
140
+
141
+ /**
142
+ * The module this rule should name, for this rule invocation.
143
+ *
144
+ * @param {object} context - ESLint rule context.
145
+ * @param {string} fallbackModule - The rule's narrow-subpath default.
146
+ * @returns {string} The configured `safeModule`, or the default.
147
+ */
148
+ function resolveSafeModule(context, fallbackModule) {
149
+ const configured = context.options?.[0]?.safeModule;
150
+ return typeof configured === 'string' && configured.length > 0 ? configured : fallbackModule;
151
+ }
152
+
153
+ module.exports = {
154
+ EXEMPT_AND_SAFE_MODULE_SCHEMA,
155
+ PACKAGE_NAME,
156
+ SAFE_FS_MODULE,
157
+ SAFE_MODULE_ONLY_SCHEMA,
158
+ SAFE_PATH_MODULE,
159
+ SAFE_PROCESS_MODULE,
160
+ insertAboveWithComments,
161
+ isNameAlreadyBound,
162
+ resolveSafeModule,
163
+ withSafeModuleOption,
164
+ };
package/package.json CHANGED
@@ -1,16 +1,22 @@
1
1
  {
2
2
  "name": "@vibe-agent-toolkit/utils",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
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
- "./process": {
22
- "types": "./dist/process.d.ts",
23
- "import": "./dist/process.js"
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
- "zod": "^3.25.0 || ^4.0.0"
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",