@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,142 @@
1
+ /**
2
+ * ESLint rule: no-bare-dynamic-import-path
3
+ *
4
+ * Prevents `await import(p)` where `p` is a raw filesystem path.
5
+ * On Windows, ESM dynamic import of a bare path fails — it requires a `file://`
6
+ * URL. Observed bug: `await import('D:\\a\\repo\\config.js')` throws.
7
+ *
8
+ * Fix: `await import(pathToFileURL(p).href)` from `node:url`, or
9
+ * `await dynamicImportPath(p)` from `@vibe-agent-toolkit/utils/fs`.
10
+ *
11
+ * Heuristic (intentionally narrow — some false positives are preferable to
12
+ * false negatives for a shift-left lint rule, but the user can suppress per line):
13
+ * - String literal starting with `/` or `C:\` style absolute path → flag
14
+ * - Call to `path.join`, `path.resolve`, `join`, `resolve`, `safePath.*` → flag
15
+ * - Identifier whose name matches /path|file|config|module|dir/i → flag
16
+ * (will have false positives on well-named variables that already hold
17
+ * file:// URLs; suppress those call-sites with eslint-disable-next-line.)
18
+ * - Template literal that embeds a path-shaped call → flag
19
+ *
20
+ * Does NOT flag:
21
+ * - Relative module specifiers (`./foo.js`, `../bar.js`)
22
+ * - Bare package names (`some-pkg`)
23
+ * - Expressions ending in `.href` (assume correct `pathToFileURL(x).href`)
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const {
29
+ SAFE_FS_MODULE,
30
+ SAFE_MODULE_ONLY_SCHEMA,
31
+ resolveSafeModule,
32
+ } = require('./safe-import.cjs');
33
+
34
+ const PATH_CALL_NAMES = new Set(['join', 'resolve']);
35
+ const PATH_OBJECT_NAMES = new Set(['path', 'safePath']);
36
+ const PATH_SHAPED_IDENTIFIER = /path|file|config|module|dir/i;
37
+ const ABSOLUTE_PATH_LITERAL = /^(\/|[A-Za-z]:[\\/])/;
38
+
39
+ function isPathCallExpression(node) {
40
+ if (node?.type !== 'CallExpression') return false;
41
+ const callee = node.callee;
42
+ // bare call: join(...), resolve(...)
43
+ if (callee.type === 'Identifier' && PATH_CALL_NAMES.has(callee.name)) {
44
+ return true;
45
+ }
46
+ // member call: path.join, path.resolve, safePath.join, safePath.resolve
47
+ if (
48
+ callee.type === 'MemberExpression' &&
49
+ callee.object.type === 'Identifier' &&
50
+ PATH_OBJECT_NAMES.has(callee.object.name) &&
51
+ callee.property.type === 'Identifier'
52
+ ) {
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function isHrefAccess(node) {
59
+ return (
60
+ node?.type === 'MemberExpression' &&
61
+ node.property.type === 'Identifier' &&
62
+ node.property.name === 'href'
63
+ );
64
+ }
65
+
66
+ function isRelativeOrBareSpecifier(value) {
67
+ if (typeof value !== 'string' || value.length === 0) return false;
68
+ if (value.startsWith('./') || value.startsWith('../') || value === '.' || value === '..') {
69
+ return true;
70
+ }
71
+ // Bare package name — no leading slash, no drive letter.
72
+ if (ABSOLUTE_PATH_LITERAL.test(value)) return false;
73
+ return !value.includes('\\');
74
+ }
75
+
76
+ function templateHasPathCall(node) {
77
+ if (node?.type !== 'TemplateLiteral') return false;
78
+ return node.expressions.some((expr) => isPathCallExpression(expr));
79
+ }
80
+
81
+ function classifyImportArgument(arg) {
82
+ if (!arg) return null;
83
+
84
+ // Literal string specifier — flag only if absolute path-shaped.
85
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
86
+ if (isRelativeOrBareSpecifier(arg.value)) return null;
87
+ if (ABSOLUTE_PATH_LITERAL.test(arg.value)) return 'absolute-literal';
88
+ return null;
89
+ }
90
+
91
+ // Template literal with a path call embedded.
92
+ if (arg.type === 'TemplateLiteral') {
93
+ if (templateHasPathCall(arg)) return 'template-with-path-call';
94
+ return null;
95
+ }
96
+
97
+ // `pathToFileURL(x).href` or similar — correct form, do not flag.
98
+ if (isHrefAccess(arg)) return null;
99
+
100
+ // path.join(...) / path.resolve(...) / join(...) / resolve(...)
101
+ if (isPathCallExpression(arg)) return 'path-call';
102
+
103
+ // Bare identifier whose name hints at a filesystem path.
104
+ if (arg.type === 'Identifier' && PATH_SHAPED_IDENTIFIER.test(arg.name)) {
105
+ return 'path-shaped-identifier';
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ module.exports = {
112
+ meta: {
113
+ type: 'problem',
114
+ docs: {
115
+ description:
116
+ 'Disallow dynamic `import()` of a filesystem path; wrap with `pathToFileURL(p).href`.',
117
+ category: 'Cross-platform compatibility',
118
+ recommended: true,
119
+ },
120
+ messages: {
121
+ useFileUrl:
122
+ 'Dynamic `import()` of a filesystem path fails on Windows. ' +
123
+ 'Wrap with `pathToFileURL(p).href` from `node:url`, or use `dynamicImportPath(p)` from `{{safeModule}}`.',
124
+ },
125
+ schema: [SAFE_MODULE_ONLY_SCHEMA],
126
+ },
127
+
128
+ create(context) {
129
+ return {
130
+ ImportExpression(node) {
131
+ const kind = classifyImportArgument(node.source);
132
+ if (kind) {
133
+ context.report({
134
+ node,
135
+ messageId: 'useFileUrl',
136
+ data: { safeModule: resolveSafeModule(context, SAFE_FS_MODULE) },
137
+ });
138
+ }
139
+ },
140
+ };
141
+ },
142
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ESLint rule: no-child-process-execSync
3
+ *
4
+ * Prevents usage of child_process.execSync() in favor of safeExecSync() from `@vibe-agent-toolkit/utils/process`
5
+ *
6
+ * Why: execSync() uses shell interpreter which enables command injection attacks.
7
+ * safeExecSync() uses direct spawn (no shell) with 'which' pattern for security.
8
+ *
9
+ * Auto-fix: Replaces execSync() with safeExecSync() and adds required import.
10
+ */
11
+
12
+ const factory = require('./eslint-rule-factory.cjs');
13
+ const { SAFE_PROCESS_MODULE } = require('./safe-import.cjs');
14
+
15
+ module.exports = factory({
16
+ unsafeFn: 'execSync',
17
+ unsafeModule: 'node:child_process',
18
+ safeFn: 'safeExecSync',
19
+ safeModule: SAFE_PROCESS_MODULE,
20
+ message: 'Use safeExecSync() from {{safeModule}} instead of child_process.execSync() to prevent command injection (security + cross-platform)',
21
+ // No baked-in exemption: the file that implements safeExecSync() is
22
+ // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * ESLint Rule Factory - Command Execution Checking
3
+ *
4
+ * Single source of truth for:
5
+ * 1. How commands can be executed (safeExecSync, spawn, execSync, etc.)
6
+ * 2. How to check those execution patterns
7
+ * 3. Creating rules that enforce command restrictions
8
+ *
9
+ * This module exports both the factory function and helper utilities
10
+ * so all command-checking rules reference the same execution patterns.
11
+ */
12
+
13
+ const { createExemptDirectoryMatcher } = require('./exempt-path-matcher.cjs');
14
+
15
+ // ============================================================================
16
+ // COMMAND EXECUTION PATTERNS - Single Source of Truth
17
+ // ============================================================================
18
+ // If we add new execution methods (e.g., safeExecAsync), update this list
19
+ // and all dependent rules automatically benefit.
20
+
21
+ /**
22
+ * Check if execSync command string starts with the target command
23
+ *
24
+ * @param {Object} firstArg - AST node for first argument
25
+ * @param {string} command - Command to check for (e.g., 'git')
26
+ * @returns {boolean} True if command matches
27
+ */
28
+ function commandMatchesExecSync(firstArg, command) {
29
+ // Check string literals: execSync('git status')
30
+ if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
31
+ return firstArg.value.startsWith(`${command} `) || firstArg.value === command;
32
+ }
33
+
34
+ // Check template literals: execSync(`git ${args}`)
35
+ if (firstArg.type === 'TemplateLiteral' && firstArg.quasis.length > 0) {
36
+ const firstQuasi = firstArg.quasis[0].value.cooked || firstArg.quasis[0].value.raw;
37
+ return firstQuasi.startsWith(`${command} `) || firstQuasi === command;
38
+ }
39
+
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * Check shell-free command execution (safeExecSync, spawn, etc.)
45
+ * @private
46
+ */
47
+ function checkShellFreeExecution(node, context, shouldReport, messageId) {
48
+ const firstArg = node.arguments[0];
49
+
50
+ if (firstArg.type !== 'Literal') {
51
+ return;
52
+ }
53
+
54
+ const result = shouldReport(node, firstArg);
55
+ if (result) {
56
+ context.report({
57
+ node,
58
+ messageId,
59
+ data: typeof result === 'object' ? result : undefined,
60
+ });
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Check execSync command execution (shell-based)
66
+ * @private
67
+ */
68
+ function checkExecSyncExecution(node, context, shouldReport, messageId) {
69
+ const firstArg = node.arguments[0];
70
+ const result = shouldReport(node, firstArg, commandMatchesExecSync);
71
+
72
+ if (result) {
73
+ context.report({
74
+ node,
75
+ messageId,
76
+ data: typeof result === 'object' ? result : undefined,
77
+ });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Check if function is a shell-free execution method
83
+ * @private
84
+ */
85
+ function isShellFreeExecution(functionName) {
86
+ return (
87
+ functionName === 'safeExecSync' ||
88
+ functionName === 'safeExecResult' ||
89
+ functionName === 'spawn' ||
90
+ functionName === 'spawnSync'
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Create a CallExpression checker that validates command execution calls
96
+ *
97
+ * This centralizes the logic for detecting command execution patterns across
98
+ * different ESLint rules, eliminating code duplication.
99
+ *
100
+ * @param {Object} options - Configuration options
101
+ * @param {Object} options.context - ESLint rule context
102
+ * @param {Function} options.shouldReport - Predicate that determines if violation should be reported
103
+ * Can return:
104
+ * - boolean: true to report with default messageData
105
+ * - object: messageData to use for error message
106
+ * - falsy: don't report
107
+ * Signature: (node, firstArg, matchesExecSync) => boolean | object | falsy
108
+ * @param {string} options.messageId - ESLint message ID to report
109
+ * @returns {Object} ESLint CallExpression visitor
110
+ *
111
+ * @example
112
+ * // Simple boolean check:
113
+ * return {
114
+ * CallExpression: createCommandChecker({
115
+ * context,
116
+ * shouldReport: (node, firstArg) => firstArg.value === 'git',
117
+ * messageId: 'noGitDirect',
118
+ * }),
119
+ * };
120
+ *
121
+ * @example
122
+ * // With dynamic messageData:
123
+ * return {
124
+ * CallExpression: createCommandChecker({
125
+ * context,
126
+ * shouldReport: (node, firstArg) => {
127
+ * if (UNIX_COMMANDS.includes(firstArg.value)) {
128
+ * return { command: firstArg.value, alternative: getAlt(firstArg.value) };
129
+ * }
130
+ * return false;
131
+ * },
132
+ * messageId: 'unixCommand',
133
+ * }),
134
+ * };
135
+ */
136
+ function createCommandChecker(options) {
137
+ const { context, shouldReport, messageId } = options;
138
+
139
+ return function CallExpression(node) {
140
+ const functionName = node.callee.name;
141
+
142
+ if (node.arguments.length === 0) {
143
+ return;
144
+ }
145
+
146
+ // Check for shell-free command execution patterns:
147
+ // - safeExecSync('cmd', ...)
148
+ // - safeExecResult('cmd', ...)
149
+ // - spawn('cmd', ...)
150
+ // - spawnSync('cmd', ...)
151
+ if (isShellFreeExecution(functionName)) {
152
+ checkShellFreeExecution(node, context, shouldReport, messageId);
153
+ return;
154
+ }
155
+
156
+ // Check for execSync('cmd ...') or execSync(`cmd ...`)
157
+ // Note: execSync is discouraged for security reasons (uses shell)
158
+ if (functionName === 'execSync') {
159
+ checkExecSyncExecution(node, context, shouldReport, messageId);
160
+ }
161
+ };
162
+ }
163
+
164
+ // ============================================================================
165
+ // FACTORY FUNCTION - Create Rules for Specific Commands
166
+ // ============================================================================
167
+
168
+ /**
169
+ * Factory: Create rule that prevents direct command execution
170
+ *
171
+ * @param {Object} config - Rule configuration
172
+ * @param {string} config.command - Command name to detect (e.g., 'git', 'gh')
173
+ * @param {string} config.packageName - Package containing wrappers (e.g., '@my-org/git')
174
+ * @param {string[]} config.availableFunctions - List of available wrapper functions
175
+ * @param {string} [config.exemptPackage] - Repo-relative directory of the package that
176
+ * OWNS the wrappers, e.g. `'packages/git/'`. Matched at a path-segment boundary by
177
+ * `exempt-path-matcher.cjs` — NOT as a substring, so `vendor/copy-packages/git/` is
178
+ * still linted.
179
+ * @returns {Object} ESLint rule definition
180
+ *
181
+ * @example
182
+ * // no-git-commands-direct.cjs
183
+ * const { createNoCommandDirectRule } = require('./no-command-direct-factory.cjs');
184
+ * module.exports = createNoCommandDirectRule({
185
+ * command: 'git',
186
+ * packageName: '@my-org/git',
187
+ * availableFunctions: ['executeGitCommand()', 'getTreeHash()', 'addNote()'],
188
+ * exemptPackage: 'packages/git/',
189
+ * });
190
+ */
191
+
192
+ function createNoCommandDirectRule(config) {
193
+ const { command, packageName, availableFunctions, exemptPackage } = config;
194
+
195
+ const isUnderExemptPackage = createExemptDirectoryMatcher(exemptPackage ? [exemptPackage] : []);
196
+ const functionList = availableFunctions.join(', ');
197
+ const messageId = `no${command.charAt(0).toUpperCase()}${command.slice(1)}Direct`;
198
+
199
+ return {
200
+ meta: {
201
+ type: 'problem',
202
+ docs: {
203
+ description: `Enforce use of ${packageName} functions instead of direct ${command} command execution`,
204
+ category: 'Architecture',
205
+ recommended: true,
206
+ },
207
+ fixable: null, // No auto-fix - requires manual refactoring
208
+ schema: [],
209
+ messages: {
210
+ [messageId]: `Use functions from ${packageName} instead of calling ${command} commands directly. Available functions: ${functionList}.`,
211
+ },
212
+ },
213
+
214
+ create(context) {
215
+ // Exempt the package itself (where centralization happens)
216
+ if (isUnderExemptPackage(context.getFilename())) {
217
+ return {};
218
+ }
219
+
220
+ return {
221
+ CallExpression: createCommandChecker({
222
+ context,
223
+ shouldReport: (node, firstArg, matchesExecSync) => {
224
+ // For shell-free execution (safeExecSync, spawn, etc.)
225
+ if (!matchesExecSync) {
226
+ return firstArg.value === command;
227
+ }
228
+ // For execSync (shell-based)
229
+ return matchesExecSync(firstArg, command);
230
+ },
231
+ messageId,
232
+ }),
233
+ };
234
+ },
235
+ };
236
+ }
237
+
238
+ // ============================================================================
239
+ // EXPORTS - Factory + Helpers
240
+ // ============================================================================
241
+ // Export factory as default (for backward compatibility with existing rules)
242
+ // and attach helpers as properties (for new rules like no-unix-shell-commands)
243
+
244
+ module.exports = createNoCommandDirectRule;
245
+ module.exports.createCommandChecker = createCommandChecker;
246
+ module.exports.commandMatchesExecSync = commandMatchesExecSync;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * ESLint rule: no-file-url-string-concat
3
+ *
4
+ * Disallow constructing `file://` URLs by string concatenation or template
5
+ * literal interpolation. Filesystem paths on Windows contain `\` and `C:`,
6
+ * and naively prefixing them with `file://` produces non-canonical URLs that
7
+ * never compare equal to `import.meta.url`, never round-trip through `new URL`,
8
+ * and silently break entry-point checks like:
9
+ *
10
+ * if (import.meta.url === `file://${process.argv[1]}`) { ... } // ❌ Windows
11
+ *
12
+ * `pathToFileURL()` is the only correct constructor: it percent-encodes
13
+ * separators, prepends the right number of slashes for drive letters, and
14
+ * matches what Node produces for `import.meta.url`.
15
+ *
16
+ * @example
17
+ * // ❌ BAD — Windows-broken
18
+ * const u = `file://${process.argv[1]}`;
19
+ * const u2 = 'file://' + somePath;
20
+ *
21
+ * // ✅ GOOD
22
+ * import { pathToFileURL } from 'node:url';
23
+ * const u = pathToFileURL(process.argv[1]).href;
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const FILE_URL_PREFIX = 'file://';
29
+
30
+ function literalStartsWithFileUrl(node) {
31
+ if (!node) return false;
32
+ if (node.type === 'Literal') {
33
+ return typeof node.value === 'string' && node.value.startsWith(FILE_URL_PREFIX);
34
+ }
35
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length > 0) {
36
+ const cooked = node.quasis[0].value.cooked;
37
+ return typeof cooked === 'string' && cooked.startsWith(FILE_URL_PREFIX);
38
+ }
39
+ return false;
40
+ }
41
+
42
+ module.exports = {
43
+ meta: {
44
+ type: 'problem',
45
+ docs: {
46
+ description:
47
+ 'Disallow constructing `file://` URLs by string concatenation; use `pathToFileURL()` from `node:url`.',
48
+ category: 'Cross-platform compatibility',
49
+ recommended: true,
50
+ },
51
+ messages: {
52
+ useFileUrlBuilder:
53
+ 'Do not build `file://` URLs by string concatenation — on Windows, paths contain `\\` and drive letters and produce non-canonical URLs that never compare equal to `import.meta.url`. Use `pathToFileURL(path).href` from `node:url`.',
54
+ },
55
+ schema: [],
56
+ },
57
+
58
+ create(context) {
59
+ return {
60
+ TemplateLiteral(node) {
61
+ if (node.expressions.length === 0) return;
62
+ if (node.quasis.length === 0) return;
63
+ const cooked = node.quasis[0].value.cooked;
64
+ if (typeof cooked === 'string' && cooked.startsWith(FILE_URL_PREFIX)) {
65
+ context.report({ node, messageId: 'useFileUrlBuilder' });
66
+ }
67
+ },
68
+
69
+ BinaryExpression(node) {
70
+ if (node.operator !== '+') return;
71
+ if (literalStartsWithFileUrl(node.left) || literalStartsWithFileUrl(node.right)) {
72
+ context.report({ node, messageId: 'useFileUrlBuilder' });
73
+ }
74
+ },
75
+ };
76
+ },
77
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ESLint rule: no-fs-mkdirSync
3
+ *
4
+ * Prevents usage of fs.mkdirSync() in favor of mkdirSyncReal() from `@vibe-agent-toolkit/utils/fs`
5
+ *
6
+ * Why: After mkdirSync(), the path might not match what the filesystem uses on Windows.
7
+ * mkdirSyncReal() returns the real (normalized) path to handle 8.3 short name issues.
8
+ *
9
+ * Auto-fix: Replaces fs.mkdirSync() with mkdirSyncReal() and adds required import.
10
+ */
11
+
12
+ const factory = require('./eslint-rule-factory.cjs');
13
+ const { SAFE_FS_MODULE } = require('./safe-import.cjs');
14
+
15
+ module.exports = factory({
16
+ unsafeFn: 'mkdirSync',
17
+ unsafeModule: 'node:fs',
18
+ safeFn: 'mkdirSyncReal',
19
+ safeModule: SAFE_FS_MODULE,
20
+ message: 'Use mkdirSyncReal() from {{safeModule}} instead of fs.mkdirSync() for Windows path normalization',
21
+ // No baked-in exemption: the file that implements mkdirSyncReal() is
22
+ // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * ESLint rule: no-fs-promises-cp
3
+ *
4
+ * Prevents usage of cp() from node:fs/promises in favor of cpSync() from node:fs.
5
+ *
6
+ * Why: Node 22's async cp() with { recursive: true } silently drops files in
7
+ * nested directories (observed with .mjs files in deeply nested paths).
8
+ * The files are simply missing from the destination with no error thrown.
9
+ * cpSync() from node:fs does not have this bug and works correctly across
10
+ * all Node versions (22, 24+).
11
+ *
12
+ * This is a known Node.js issue, not a VAT bug. Until the Node.js team fixes
13
+ * the async cp() implementation, cpSync() is the safe default.
14
+ *
15
+ * If you need async cp() for a specific use case and have verified it works
16
+ * correctly with your file structure, disable this rule with an eslint-disable
17
+ * comment explaining why async is required.
18
+ *
19
+ * Auto-fix: Replaces cp() with cpSync() and updates import from node:fs/promises to node:fs.
20
+ */
21
+
22
+ const factory = require('./eslint-rule-factory.cjs');
23
+
24
+ module.exports = factory({
25
+ unsafeFn: 'cp',
26
+ unsafeModule: 'node:fs/promises',
27
+ safeFn: 'cpSync',
28
+ safeModule: 'node:fs',
29
+ // `{{safeModule}}` resolves to `node:fs` unless overridden. A builtin always
30
+ // resolves, so this rule has no seam problem — it takes the option only
31
+ // because it shares the factory.
32
+ message:
33
+ 'Use cpSync() from {{safeModule}} instead of cp() from node:fs/promises. ' +
34
+ 'Node 22 async cp({ recursive: true }) silently drops files in nested directories. ' +
35
+ 'cpSync() works correctly across all Node versions.',
36
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ESLint rule: no-fs-realpathSync
3
+ *
4
+ * Prevents usage of fs.realpathSync() in favor of normalizePath() from `@vibe-agent-toolkit/utils/fs`
5
+ *
6
+ * Why: realpathSync() doesn't consistently resolve Windows 8.3 short paths across Node versions.
7
+ * normalizePath() uses realpathSync.native() with fallbacks for better cross-platform compatibility.
8
+ *
9
+ * Auto-fix: Replaces fs.realpathSync() with normalizePath() and adds required import.
10
+ */
11
+
12
+ const factory = require('./eslint-rule-factory.cjs');
13
+ const { SAFE_FS_MODULE } = require('./safe-import.cjs');
14
+
15
+ module.exports = factory({
16
+ unsafeFn: 'realpathSync',
17
+ unsafeModule: 'node:fs',
18
+ safeFn: 'normalizePath',
19
+ safeModule: SAFE_FS_MODULE,
20
+ message: 'Use normalizePath() from {{safeModule}} instead of fs.realpathSync() for consistent Windows 8.3 path resolution',
21
+ // No baked-in exemption: the file that implements normalizePath() is
22
+ // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ });