@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,139 @@
1
+ /**
2
+ * ESLint rule to enforce using toForwardSlash() before path.startsWith()
3
+ *
4
+ * Cross-platform path comparisons fail on Windows when mixing backslashes and forward slashes.
5
+ * This rule enforces using toForwardSlash() from `@vibe-agent-toolkit/utils/path` for consistent behavior.
6
+ *
7
+ * @example
8
+ * // ❌ BAD - fails on Windows
9
+ * if (pluginDir.startsWith(marketplaceDir)) { ... }
10
+ *
11
+ * // ✅ GOOD - works cross-platform
12
+ * import { toForwardSlash } from '@vibe-agent-toolkit/utils/path';
13
+ * const normalizedPlugin = toForwardSlash(pluginDir);
14
+ * const normalizedMarketplace = toForwardSlash(marketplaceDir);
15
+ * if (normalizedPlugin.startsWith(normalizedMarketplace)) { ... }
16
+ */
17
+
18
+ /**
19
+ * Check if the comparison argument is a URL scheme or absolute path indicator
20
+ * These don't need normalization: 'http://', 'https://', 'file://', '/', '#'
21
+ */
22
+ function isUrlSchemeOrAbsoluteCheck(node) {
23
+ if (node.arguments.length === 0 || node.arguments[0]?.type !== 'Literal') {
24
+ return false;
25
+ }
26
+
27
+ const compareValue = node.arguments[0].value;
28
+ if (typeof compareValue !== 'string') {
29
+ return false;
30
+ }
31
+
32
+ return (
33
+ compareValue === '/' ||
34
+ compareValue === '#' ||
35
+ compareValue.startsWith('http://') ||
36
+ compareValue.startsWith('https://') ||
37
+ compareValue.startsWith('file://')
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Check if a variable name contains path-related keywords
43
+ */
44
+ function hasPathKeyword(name) {
45
+ const lowerName = name.toLowerCase();
46
+ return (
47
+ lowerName.includes('path') ||
48
+ lowerName.includes('dir') ||
49
+ lowerName.includes('file') ||
50
+ lowerName.includes('location')
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Check if a variable name indicates it's already normalized
56
+ */
57
+ function isNormalizedVariable(name) {
58
+ return name.toLowerCase().startsWith('normalized');
59
+ }
60
+
61
+ const {
62
+ SAFE_MODULE_ONLY_SCHEMA,
63
+ SAFE_PATH_MODULE,
64
+ resolveSafeModule,
65
+ } = require('./safe-import.cjs');
66
+
67
+ module.exports = {
68
+ meta: {
69
+ type: 'problem',
70
+ docs: {
71
+ description: 'Disallow direct path.startsWith() without normalization',
72
+ category: 'Cross-platform compatibility',
73
+ recommended: true,
74
+ },
75
+ messages: {
76
+ useNormalizeHelper:
77
+ 'Use toForwardSlash() from {{safeModule}} before path.startsWith() for cross-platform compatibility. ' +
78
+ 'Direct string comparison fails on Windows with mixed separators.',
79
+ },
80
+ schema: [SAFE_MODULE_ONLY_SCHEMA],
81
+ },
82
+
83
+ create(context) {
84
+ const reportData = { safeModule: resolveSafeModule(context, SAFE_PATH_MODULE) };
85
+ return {
86
+ CallExpression(node) {
87
+ // Check if this is a .startsWith() call
88
+ if (
89
+ node.callee.type !== 'MemberExpression' ||
90
+ node.callee.property.type !== 'Identifier' ||
91
+ node.callee.property.name !== 'startsWith'
92
+ ) {
93
+ return;
94
+ }
95
+
96
+ // Skip URL scheme and absolute path checks
97
+ if (isUrlSchemeOrAbsoluteCheck(node)) {
98
+ return;
99
+ }
100
+
101
+ const objectNode = node.callee.object;
102
+
103
+ // Check identifier variables (e.g., pluginPath.startsWith(...))
104
+ if (objectNode.type === 'Identifier') {
105
+ const varName = objectNode.name;
106
+
107
+ if (isNormalizedVariable(varName)) {
108
+ return;
109
+ }
110
+
111
+ if (hasPathKeyword(varName)) {
112
+ context.report({
113
+ node,
114
+ messageId: 'useNormalizeHelper',
115
+ data: reportData,
116
+ });
117
+ }
118
+ }
119
+
120
+ // Check member expressions (e.g., resource.filePath.startsWith(...))
121
+ if (objectNode.type === 'MemberExpression' && objectNode.property.type === 'Identifier') {
122
+ const propName = objectNode.property.name;
123
+
124
+ if (isNormalizedVariable(propName)) {
125
+ return;
126
+ }
127
+
128
+ if (hasPathKeyword(propName)) {
129
+ context.report({
130
+ node,
131
+ messageId: 'useNormalizeHelper',
132
+ data: reportData,
133
+ });
134
+ }
135
+ }
136
+ },
137
+ };
138
+ },
139
+ };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * ESLint rule to disallow function declarations inside test blocks
3
+ *
4
+ * Catches the SonarQube code smell: "Move function to the outer scope" (S1515)
5
+ * Functions defined inside describe/it/test blocks should be moved to module scope
6
+ * for better reusability and to prevent the code smell.
7
+ *
8
+ * Why: Helper functions inside test blocks are harder to:
9
+ * - Reuse across test files
10
+ * - Test independently
11
+ * - Understand (hidden inside blocks)
12
+ * - Maintain (scattered throughout test suites)
13
+ *
14
+ * @example
15
+ * // BAD - Function inside describe block
16
+ * describe('validate command', () => {
17
+ * function setupWorkingDirectoryMocks(configDir, treeHash) {
18
+ * // ... setup code
19
+ * }
20
+ *
21
+ * it('should work', () => {
22
+ * setupWorkingDirectoryMocks('/path', 'abc123');
23
+ * });
24
+ * });
25
+ *
26
+ * // GOOD - Function at module scope
27
+ * function setupWorkingDirectoryMocks(configDir, treeHash) {
28
+ * // ... setup code
29
+ * }
30
+ *
31
+ * describe('validate command', () => {
32
+ * it('should work', () => {
33
+ * setupWorkingDirectoryMocks('/path', 'abc123');
34
+ * });
35
+ * });
36
+ */
37
+
38
+ const TEST_BLOCK_PATTERN = /^(describe|it|test|before|after|beforeEach|afterEach|beforeAll|afterAll)$/i;
39
+
40
+ /**
41
+ * Check if a node is a test-related call expression
42
+ * (describe, it, test, beforeEach, afterEach, beforeAll, afterAll, etc.)
43
+ */
44
+ function isTestBlock(node) {
45
+ if (node.type !== 'CallExpression') {
46
+ return false;
47
+ }
48
+
49
+ const callee = node.callee;
50
+
51
+ // Direct calls: describe(), it(), test(), etc.
52
+ if (callee.type === 'Identifier') {
53
+ return TEST_BLOCK_PATTERN.test(callee.name);
54
+ }
55
+
56
+ // Member calls: test.describe(), test.it(), etc. (Playwright)
57
+ if (
58
+ callee.type === 'MemberExpression' &&
59
+ callee.property.type === 'Identifier'
60
+ ) {
61
+ return TEST_BLOCK_PATTERN.test(callee.property.name);
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ module.exports = {
68
+ meta: {
69
+ type: 'suggestion',
70
+ docs: {
71
+ description: 'Disallow function declarations inside test blocks (SonarQube S1515)',
72
+ category: 'Best Practices',
73
+ recommended: true,
74
+ },
75
+ messages: {
76
+ moveToModuleScope:
77
+ 'Move function \'{{name}}\' to module scope (outside describe/it/test blocks). ' +
78
+ 'Helper functions inside test blocks are harder to reuse and maintain. ' +
79
+ 'This matches SonarQube rule S1515 (Intentionality/Maintainability).',
80
+ },
81
+ schema: [
82
+ {
83
+ type: 'object',
84
+ properties: {
85
+ allowedFunctionNames: {
86
+ type: 'array',
87
+ items: { type: 'string' },
88
+ description: 'Function names that are allowed inside test blocks',
89
+ },
90
+ },
91
+ additionalProperties: false,
92
+ },
93
+ ],
94
+ },
95
+
96
+ create(context) {
97
+ let testBlockDepth = 0;
98
+ const allowedNames = new Set(
99
+ context.options[0]?.allowedFunctionNames || []
100
+ );
101
+
102
+ return {
103
+ CallExpression(node) {
104
+ if (isTestBlock(node)) {
105
+ testBlockDepth++;
106
+ }
107
+ },
108
+
109
+ 'CallExpression:exit'(node) {
110
+ if (isTestBlock(node)) {
111
+ testBlockDepth--;
112
+ }
113
+ },
114
+
115
+ FunctionDeclaration(node) {
116
+ if (testBlockDepth > 0) {
117
+ const functionName = node.id?.name || '<anonymous>';
118
+
119
+ if (allowedNames.has(functionName)) {
120
+ return;
121
+ }
122
+
123
+ context.report({
124
+ node,
125
+ messageId: 'moveToModuleScope',
126
+ data: {
127
+ name: functionName,
128
+ },
129
+ });
130
+ }
131
+ },
132
+ };
133
+ },
134
+ };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ESLint rule: no-unix-shell-commands
3
+ *
4
+ * Prevents usage of Unix-specific commands in exec/spawn calls that break cross-platform compatibility.
5
+ *
6
+ * Why: Commands like 'tar', 'ls', 'touch', 'grep' are Unix-specific and fail on Windows.
7
+ * Use cross-platform alternatives: Node.js fs APIs, or cross-platform npm packages.
8
+ *
9
+ * Detects Unix commands in:
10
+ * - safeExecSync('tar', ...)
11
+ * - safeExecResult('ls', ...)
12
+ * - spawn('grep', ...)
13
+ * - spawnSync('find', ...)
14
+ * - execSync('tar xzf file.tar.gz')
15
+ */
16
+
17
+ const { isTestFile } = require('./exempt-path-matcher.cjs');
18
+
19
+ /**
20
+ * Unix-specific commands that should not be used
21
+ * Categorized for better documentation
22
+ */
23
+ const UNIX_COMMANDS = {
24
+ // File operations
25
+ fileOps: ['ls', 'touch', 'rm', 'mv', 'cp', 'ln', 'chmod', 'chown', 'chgrp'],
26
+
27
+ // Archiving/compression
28
+ archive: ['tar', 'gzip', 'gunzip', 'zip', 'unzip', 'bzip2'],
29
+
30
+ // Text processing
31
+ // Note on `echo`: on Windows `echo` is a shell builtin (cmd.exe / PowerShell),
32
+ // not an .EXE on PATH — except inside Git Bash's MSYS, where `/usr/bin/echo.exe`
33
+ // ships from coreutils. So `safeExecSync('echo', …)` works only when the
34
+ // launching shell happens to have `C:\Program Files\Git\usr\bin` on PATH
35
+ // (e.g., Git Bash, or GitHub-hosted windows-latest runners that add it).
36
+ // Use Node (`safeExecSync('node', [echoFixturePath, …])`) instead, pointing at
37
+ // a committed one-line script fixture that prints its argv.
38
+ text: ['grep', 'sed', 'awk', 'cat', 'head', 'tail', 'wc', 'cut', 'sort', 'uniq', 'tr', 'echo'],
39
+
40
+ // File searching
41
+ search: ['find', 'locate'],
42
+
43
+ // System info
44
+ system: ['ps', 'top', 'kill', 'df', 'du', 'who', 'uname'],
45
+
46
+ // Networking
47
+ network: ['curl', 'wget', 'ping', 'netstat', 'ifconfig'],
48
+
49
+ // Shell utilities
50
+ shell: ['sh', 'bash', 'zsh', 'source', 'export', 'env'],
51
+ };
52
+
53
+ // Flatten to single array for easier checking
54
+ const ALL_UNIX_COMMANDS = Object.values(UNIX_COMMANDS).flat();
55
+
56
+ /**
57
+ * Get cross-platform alternatives for common Unix commands
58
+ */
59
+ function getAlternatives(command) {
60
+ const alternatives = {
61
+ ls: 'fs.readdirSync() or Glob tool',
62
+ touch: 'fs.writeFileSync(path, "") or fs.utimesSync()',
63
+ rm: 'fs.unlinkSync() or fs.rmSync()',
64
+ mv: 'fs.renameSync()',
65
+ cp: 'fs.copyFileSync()',
66
+ cat: 'fs.readFileSync() or Read tool',
67
+ grep: 'Grep tool or string.includes()',
68
+ find: 'Glob tool or fs.readdirSync() with recursion',
69
+ tar: 'tar-fs or tar-stream npm package',
70
+ gzip: 'zlib module',
71
+ chmod: 'fs.chmodSync()',
72
+ chown: 'fs.chownSync()',
73
+ head: 'Read tool with limit parameter',
74
+ tail: 'Read tool with offset parameter',
75
+ sed: 'Edit tool or string.replace()',
76
+ awk: 'string.split() and array methods',
77
+ echo: 'safeExecSync(\'node\', [echoFixturePath, ...args]) using a committed script fixture that prints its argv — `echo` is only present on Windows when Git\'s coreutils are on PATH',
78
+ };
79
+
80
+
81
+ return alternatives[command] || 'Node.js fs module or cross-platform npm package';
82
+ }
83
+
84
+ module.exports = {
85
+ meta: {
86
+ type: 'problem',
87
+ docs: {
88
+ description: 'Prevent Unix-specific commands that break Windows compatibility',
89
+ category: 'Cross-Platform',
90
+ recommended: true,
91
+ },
92
+ fixable: null, // No auto-fix - requires manual refactoring
93
+ schema: [],
94
+ messages: {
95
+ noUnixCommand:
96
+ 'Unix-specific command "{{command}}" breaks Windows compatibility. Use: {{alternative}}',
97
+ },
98
+ },
99
+
100
+ create(context) {
101
+ // Exempt test files that specifically test Unix command detection.
102
+ // Anchored on the basename by `exempt-path-matcher.cjs` — a substring check
103
+ // also exempted `example.test.ts.bak`, a `.test.ts-helpers/` directory, and
104
+ // `tsconfig.test.json` (via the `.test.js` spelling).
105
+ if (isTestFile(context.getFilename())) {
106
+ return {};
107
+ }
108
+
109
+ const factory = require('./no-command-direct-factory.cjs');
110
+ const { createCommandChecker } = factory;
111
+
112
+ return {
113
+ CallExpression: createCommandChecker({
114
+ context,
115
+ shouldReport: (node, firstArg, matchesExecSync) => {
116
+ // For shell-free execution (safeExecSync, spawn, etc.)
117
+ if (!matchesExecSync) {
118
+ if (typeof firstArg.value === 'string' && ALL_UNIX_COMMANDS.includes(firstArg.value)) {
119
+ return {
120
+ command: firstArg.value,
121
+ alternative: getAlternatives(firstArg.value),
122
+ };
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ // For execSync (shell-based) - check if any Unix command is in the string
128
+ for (const unixCmd of ALL_UNIX_COMMANDS) {
129
+ if (matchesExecSync(firstArg, unixCmd)) {
130
+ // Extract command name for better error message
131
+ let command = unixCmd;
132
+ if (firstArg.type === 'Literal') {
133
+ command = firstArg.value.trim().split(/\s+/)[0];
134
+ } else if (firstArg.type === 'TemplateLiteral') {
135
+ const firstQuasi = firstArg.quasis[0].value.cooked || firstArg.quasis[0].value.raw;
136
+ command = firstQuasi.trim().split(/\s+/)[0];
137
+ }
138
+
139
+ return {
140
+ command,
141
+ alternative: getAlternatives(command),
142
+ };
143
+ }
144
+ }
145
+
146
+ return undefined;
147
+ },
148
+ messageId: 'noUnixCommand',
149
+ }),
150
+ };
151
+ },
152
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ESLint rule: no-unsafe-root-join
3
+ *
4
+ * Flags `safePath.join(<root>, …)` and `safePath.resolve(<root>, …)` where
5
+ * the first argument is an identifier whose name ends in "root" (case-insensitive,
6
+ * e.g. harnessRoot, stagedRoot, pluginRoot). These are security-root path joins
7
+ * that should use `safePath.joinUnderRoot()` to prevent caller-controlled segments
8
+ * from escaping the root on Windows via drive-letter or absolute path injection.
9
+ *
10
+ * Rule is intentionally narrow: only *Root-named first arguments are flagged to
11
+ * avoid false positives on ordinary joins.
12
+ *
13
+ * @example
14
+ * // ❌ BAD — silent escape on Windows if item.name is 'C:\evil'
15
+ * const dest = safePath.join(harnessRoot, stagedDirName(item.name));
16
+ *
17
+ * // ✅ GOOD — throws on Windows drive-letter or absolute segment
18
+ * const dest = safePath.joinUnderRoot(harnessRoot, stagedDirName(item.name));
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ /** Return true when the identifier name ends with 'root' (case-insensitive). */
24
+ function isRootIdentifier(name) {
25
+ return name.toLowerCase().endsWith('root');
26
+ }
27
+
28
+ /**
29
+ * Return true when the first argument of a CallExpression is an Identifier
30
+ * whose name ends in 'root' (case-insensitive).
31
+ */
32
+ function firstArgIsRootIdentifier(node) {
33
+ if (node.arguments.length === 0) return false;
34
+ const first = node.arguments[0];
35
+ return first.type === 'Identifier' && isRootIdentifier(first.name);
36
+ }
37
+
38
+ module.exports = {
39
+ meta: {
40
+ type: 'problem',
41
+ docs: {
42
+ description:
43
+ 'Enforce safePath.joinUnderRoot() for joins whose first arg is a security root variable (name ends in "Root").',
44
+ category: 'Security',
45
+ recommended: true,
46
+ },
47
+ messages: {
48
+ useJoinUnderRoot:
49
+ 'Use safePath.joinUnderRoot({{root}}, …) instead of safePath.{{method}}({{root}}, …) ' +
50
+ 'when the first argument is a security root. ' +
51
+ 'safePath.{{method}}() does not prevent caller-controlled segments from escaping the root on Windows.',
52
+ },
53
+ schema: [],
54
+ },
55
+
56
+ create(context) {
57
+ return {
58
+ CallExpression(node) {
59
+ // Must be a MemberExpression: safePath.<method>(...)
60
+ if (node.callee.type !== 'MemberExpression') return;
61
+
62
+ const obj = node.callee.object;
63
+ const prop = node.callee.property;
64
+
65
+ // Object must be the identifier 'safePath'
66
+ if (obj.type !== 'Identifier' || obj.name !== 'safePath') return;
67
+
68
+ // Method must be 'join' or 'resolve'
69
+ if (prop.type !== 'Identifier') return;
70
+ const method = prop.name;
71
+ if (method !== 'join' && method !== 'resolve') return;
72
+
73
+ // First argument must be an identifier ending in 'root'
74
+ if (!firstArgIsRootIdentifier(node)) return;
75
+
76
+ const rootName = node.arguments[0].name;
77
+ context.report({
78
+ node,
79
+ messageId: 'useJoinUnderRoot',
80
+ data: { root: rootName, method },
81
+ });
82
+ },
83
+ };
84
+ },
85
+ };
@@ -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
+ };