@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.
Files changed (115) 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 +191 -0
  87. package/eslint/index.cjs +161 -0
  88. package/eslint/index.d.cts +60 -0
  89. package/eslint/rules/eslint-rule-factory.cjs +241 -0
  90. package/eslint/rules/exempt-path-matcher.cjs +265 -0
  91. package/eslint/rules/no-bare-dynamic-import-path.cjs +142 -0
  92. package/eslint/rules/no-child-process-execSync.cjs +23 -0
  93. package/eslint/rules/no-command-direct-factory.cjs +246 -0
  94. package/eslint/rules/no-file-url-string-concat.cjs +77 -0
  95. package/eslint/rules/no-fs-mkdirSync.cjs +23 -0
  96. package/eslint/rules/no-fs-promises-cp.cjs +36 -0
  97. package/eslint/rules/no-fs-realpathSync.cjs +23 -0
  98. package/eslint/rules/no-hardcoded-path-split.cjs +141 -0
  99. package/eslint/rules/no-manual-path-normalize.cjs +130 -0
  100. package/eslint/rules/no-os-tmpdir.cjs +24 -0
  101. package/eslint/rules/no-path-join.cjs +14 -0
  102. package/eslint/rules/no-path-operations-in-comparisons.cjs +148 -0
  103. package/eslint/rules/no-path-relative.cjs +14 -0
  104. package/eslint/rules/no-path-resolve.cjs +14 -0
  105. package/eslint/rules/no-path-sep-in-strings.cjs +130 -0
  106. package/eslint/rules/no-path-startswith.cjs +139 -0
  107. package/eslint/rules/no-test-scoped-functions.cjs +134 -0
  108. package/eslint/rules/no-unix-shell-commands.cjs +152 -0
  109. package/eslint/rules/no-unsafe-root-join.cjs +85 -0
  110. package/eslint/rules/no-url-pathname-for-fs.cjs +107 -0
  111. package/eslint/rules/path-function-rule-factory.cjs +216 -0
  112. package/eslint/rules/prefer-startswith-over-regex.cjs +111 -0
  113. package/eslint/rules/require-justified-skip.cjs +348 -0
  114. package/eslint/rules/safe-import.cjs +141 -0
  115. 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
+ };