@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,265 @@
1
+ /**
2
+ * Shared exemption matcher for local ESLint rule factories.
3
+ *
4
+ * Rules that ban an unsafe primitive have to exempt the ONE file that implements
5
+ * the safe replacement. Doing that with `filename.includes('path-utils.ts')` is a
6
+ * silent hole: any file anywhere — in this repo or in a consumer repo running
7
+ * these rules — whose path merely CONTAINS that string opts itself out. A private
8
+ * `tools/hooks/path-utils.ts` full of raw `tmpdir()` / `realpathSync()` calls
9
+ * linted clean for exactly this reason.
10
+ *
11
+ * Exemptions are therefore repo-relative paths matched at a path-segment
12
+ * boundary: the linted filename must either BE the exempt path, or END WITH
13
+ * `/` + the exempt path. Matching happens on forward slashes — a rule pack whose
14
+ * whole purpose is enforcing cross-platform path handling must not itself be
15
+ * `\` vs `/` dependent.
16
+ *
17
+ * Three shapes of exemption live here, and every local rule must take one of
18
+ * them rather than reaching for `includes()` again:
19
+ *
20
+ * - `createExemptPathMatcher` — "is this THAT file?" (a named implementation file)
21
+ * - `createExemptDirectoryMatcher` — "is this file INSIDE that directory?" (a package
22
+ * that owns a centralized wrapper)
23
+ * - `isTestFile` — "is this a test file?" (a category, anchored on the basename's
24
+ * extension rather than on a path segment)
25
+ *
26
+ * `eslint-rule-factory.cjs`, `path-function-rule-factory.cjs`,
27
+ * `no-command-direct-factory.cjs` and `no-unix-shell-commands.cjs` all use these.
28
+ * Do not re-implement any of them: the sibling-factory copy is how the bug
29
+ * shipped four times in the first place.
30
+ *
31
+ * A fourth export, `createConfigurableExemptPathMatcher`, wires the file-shaped
32
+ * exemption to the rule's own ESLint option so each CONSUMER declares the paths
33
+ * for its own repo. It also lives here (rather than in each factory) for the same
34
+ * reason: two copies is how the bug spread last time.
35
+ */
36
+
37
+ /** Forward-slash a path and drop any leading `./` or `/` noise used for anchoring. */
38
+ function normalizeForMatch(value) {
39
+ return String(value).replaceAll('\\', '/').replace(/^(?:\.\/)+/, '');
40
+ }
41
+
42
+ /**
43
+ * Build a predicate that reports whether a linted filename is one of `exemptPaths`.
44
+ *
45
+ * @param {readonly string[]} exemptPaths - Repo-relative paths, e.g.
46
+ * `['packages/utils/src/path-utils.ts']`. A bare basename is accepted by the
47
+ * matcher but matches that filename ANYWHERE in the tree (ESLint filenames are
48
+ * absolute, so the `endsWith('/' + target)` leg is what fires) — pass the full
49
+ * repo-relative path. Rules surface that mistake via
50
+ * {@link reportUnanchoredExemptEntries}.
51
+ * @returns {(filename: string) => boolean} Anchored, separator-agnostic predicate.
52
+ */
53
+ function createExemptPathMatcher(exemptPaths) {
54
+ const targets = [...exemptPaths]
55
+ .map((exemptPath) => normalizeForMatch(exemptPath).replace(/^\/+/, ''))
56
+ .filter((exemptPath) => exemptPath.length > 0);
57
+
58
+ return function isExemptPath(filename) {
59
+ if (!filename) {
60
+ return false;
61
+ }
62
+ const normalized = normalizeForMatch(filename);
63
+ return targets.some(
64
+ (target) => normalized === target || normalized.endsWith(`/${target}`),
65
+ );
66
+ };
67
+ }
68
+
69
+ /**
70
+ * JSON Schema for the `exemptFiles` rule option, for a rule's `meta.schema`.
71
+ *
72
+ * `additionalProperties: false` on purpose: a typo'd option key must be an ESLint
73
+ * config error, not a silently ignored exemption list (which would read as "the
74
+ * rule stopped firing for no reason").
75
+ */
76
+ const EXEMPT_FILES_SCHEMA = Object.freeze({
77
+ type: 'object',
78
+ properties: {
79
+ exemptFiles: {
80
+ type: 'array',
81
+ items: { type: 'string' },
82
+ uniqueItems: true,
83
+ },
84
+ },
85
+ additionalProperties: false,
86
+ });
87
+
88
+ /**
89
+ * Wire a file-shaped exemption to the rule's `exemptFiles` option.
90
+ *
91
+ * An exemption names the ONE file in a SPECIFIC repo that implements the safe
92
+ * replacement, so it cannot be shipped as a useful default: a package publishing
93
+ * `packages/utils/src/path-utils.ts` as a built-in exemption hands every consumer
94
+ * a hole at that path. Hence the empty default here, and hence REPLACE rather
95
+ * than merge semantics — a consumer's list is the whole list.
96
+ *
97
+ * @param {readonly string[]} [defaultPaths] - Fallback used only when the rule is
98
+ * configured with no `exemptFiles` option. Ship this empty unless the rule
99
+ * itself owns the file (no shipped rule in this package does).
100
+ * @returns {(context: object) => (filename: string) => boolean} Resolver taking an
101
+ * ESLint rule context and returning the anchored predicate for that invocation.
102
+ */
103
+ function createConfigurableExemptPathMatcher(defaultPaths = []) {
104
+ const defaultMatcher = createExemptPathMatcher(defaultPaths);
105
+ const cache = new Map();
106
+
107
+ return function exemptMatcherFor(context) {
108
+ const configured = context.options?.[0]?.exemptFiles;
109
+ if (!Array.isArray(configured)) {
110
+ return defaultMatcher;
111
+ }
112
+ // JSON, not join(): a delimiter cheap enough to be collision-free is a raw
113
+ // NUL, which this repo bans in source (git and ripgrep treat the file as
114
+ // binary and skip its contents), and any printable delimiter can legally
115
+ // appear in a path.
116
+ const key = JSON.stringify(configured);
117
+ let matcher = cache.get(key);
118
+ if (!matcher) {
119
+ matcher = createExemptPathMatcher(configured);
120
+ cache.set(key, matcher);
121
+ }
122
+ return matcher;
123
+ };
124
+ }
125
+
126
+ /**
127
+ * `messageId` every rule that accepts `exemptFiles` must declare, so an
128
+ * unanchored entry is reported through the normal lint channel.
129
+ *
130
+ * Not a JSON Schema `pattern` on the option, which would be the obvious place:
131
+ * the schema sees the RAW string, and `./path-utils.ts` contains a `/` while
132
+ * normalizing to exactly the same repo-wide exemption as `path-utils.ts`. A
133
+ * check that the wrong spelling slips past is worse than none. Not a
134
+ * `process.emitWarning` either — a notice on stderr is not a reported finding
135
+ * and gets scrolled past.
136
+ */
137
+ const UNANCHORED_EXEMPT_FILE = 'unanchoredExemptFile';
138
+
139
+ /**
140
+ * The `meta.messages` entry for {@link UNANCHORED_EXEMPT_FILE}.
141
+ *
142
+ * ESLint filenames are ABSOLUTE, and an exemption matches when the filename ends
143
+ * with `/` + the entry. So a bare basename does not mean "the file at the repo
144
+ * root" (as this module's JSDoc used to claim) — it means EVERY file with that
145
+ * name, anywhere in the tree, including ones added later by someone who never
146
+ * saw the config. That is the same repo-wide hole the anchoring rewrite closed,
147
+ * reopened one config entry at a time.
148
+ */
149
+ const UNANCHORED_EXEMPT_MESSAGE =
150
+ 'exemptFiles entry "{{entry}}" is a bare filename, so it exempts EVERY file named ' +
151
+ '"{{entry}}" anywhere in the repo — including files added later. Give the ' +
152
+ 'repo-relative path instead (e.g. "packages/utils/src/{{entry}}").';
153
+
154
+ /**
155
+ * The configured `exemptFiles` entries that are not anchored to a directory.
156
+ *
157
+ * Runs on the NORMALIZED entry, so `./x.ts` and `x.ts` are both caught.
158
+ *
159
+ * @param {object} context - ESLint rule context.
160
+ * @returns {string[]} Offending entries, as the consumer spelled them.
161
+ */
162
+ function findUnanchoredExemptEntries(context) {
163
+ const configured = context.options?.[0]?.exemptFiles;
164
+ if (!Array.isArray(configured)) {
165
+ return [];
166
+ }
167
+ return configured.filter((entry) => {
168
+ const normalized = normalizeForMatch(entry).replace(/^\/+/, '');
169
+ return normalized.length > 0 && !normalized.includes('/');
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Report every unanchored `exemptFiles` entry against the `Program` node.
175
+ *
176
+ * Deliberately stateless — no "warn once per process" dedupe. ESLint caches
177
+ * results per file, so a rule that remembers having warned goes SILENT on the
178
+ * second run against a warm cache, which is precisely when a stale config is
179
+ * least likely to be noticed.
180
+ *
181
+ * @param {object} context - ESLint rule context.
182
+ * @param {object} node - The `Program` node to anchor the report on.
183
+ */
184
+ function reportUnanchoredExemptEntries(context, node) {
185
+ for (const entry of findUnanchoredExemptEntries(context)) {
186
+ context.report({ node, messageId: UNANCHORED_EXEMPT_FILE, data: { entry } });
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Build a predicate that reports whether a linted filename lives UNDER one of
192
+ * `exemptDirs`.
193
+ *
194
+ * The directory flavor of the same bug: `filename.includes('packages/git/')`
195
+ * also exempted `vendor/copy-packages/git/` and `tools/my-packages/git/` — any
196
+ * directory whose name merely ENDS WITH the exempt one. Anchoring means the
197
+ * directory must start the repo-relative path or be preceded by a `/`.
198
+ *
199
+ * @param {readonly string[]} exemptDirs - Repo-relative directories, with or
200
+ * without a trailing slash, e.g. `['packages/git/']`.
201
+ * @returns {(filename: string) => boolean} Anchored, separator-agnostic predicate.
202
+ */
203
+ function createExemptDirectoryMatcher(exemptDirs) {
204
+ // Normalize each directory to a `<dir>/` PREFIX rather than stripping trailing
205
+ // slashes: a trailing-slash strip needs either `/\/+$/` (flagged by
206
+ // sonarjs/slow-regex) or `.split('/')` (banned repo-wide by this very rule pack).
207
+ const prefixes = [...exemptDirs]
208
+ .map((dir) => normalizeForMatch(dir).replace(/^\/+/, ''))
209
+ .filter((dir) => dir.length > 0)
210
+ .map((dir) => (dir.endsWith('/') ? dir : `${dir}/`));
211
+
212
+ return function isUnderExemptDirectory(filename) {
213
+ if (!filename) {
214
+ return false;
215
+ }
216
+ const normalized = normalizeForMatch(filename);
217
+ return prefixes.some(
218
+ (prefix) => normalized.startsWith(prefix) || normalized.includes(`/${prefix}`),
219
+ );
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Test-file naming convention, anchored to the END of the BASENAME.
225
+ *
226
+ * This is a CATEGORY check, not a path exemption, so `createExemptPathMatcher`
227
+ * is the wrong tool: there is no repo-relative path to compare against. What
228
+ * "anchored" means here is that the extension must terminate the last path
229
+ * segment. `filename.includes('.test.ts')` satisfied neither end — it exempted
230
+ * `example.test.ts.bak`, a directory named `.test.ts-helpers/`, and (a real
231
+ * tracked file in this repo) `tsconfig.test.json` via the `.test.js` spelling.
232
+ *
233
+ * The repo convention is `*.test.ts` (unit, `*.integration.test.ts`, and
234
+ * `*.system.test.ts` all land on it); the js/mjs/cjs/mts/tsx variants are
235
+ * covered because a category predicate that only knew one extension would be
236
+ * the next silent hole. `.spec.` is deliberately absent — this repo has zero
237
+ * such files and vitest's `include` globs would not run them.
238
+ */
239
+ const TEST_FILE_EXTENSION = /\.test\.[cm]?[jt]sx?$/;
240
+
241
+ /**
242
+ * @param {string} filename - Path as ESLint reports it (absolute, any separator).
243
+ * @returns {boolean} True when the file itself is a test file.
244
+ */
245
+ function isTestFile(filename) {
246
+ if (!filename) {
247
+ return false;
248
+ }
249
+ const normalized = normalizeForMatch(filename);
250
+ const basename = normalized.slice(normalized.lastIndexOf('/') + 1);
251
+ return TEST_FILE_EXTENSION.test(basename);
252
+ }
253
+
254
+ module.exports = {
255
+ EXEMPT_FILES_SCHEMA,
256
+ UNANCHORED_EXEMPT_FILE,
257
+ UNANCHORED_EXEMPT_MESSAGE,
258
+ createConfigurableExemptPathMatcher,
259
+ createExemptDirectoryMatcher,
260
+ createExemptPathMatcher,
261
+ findUnanchoredExemptEntries,
262
+ isTestFile,
263
+ normalizeForMatch,
264
+ reportUnanchoredExemptEntries,
265
+ };
@@ -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;