@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.
- package/README.md +146 -31
- package/dist/asset.d.ts +9 -0
- package/dist/asset.d.ts.map +1 -0
- package/dist/asset.js +9 -0
- package/dist/asset.js.map +1 -0
- package/dist/crawl.d.ts +19 -0
- package/dist/crawl.d.ts.map +1 -0
- package/dist/crawl.js +19 -0
- package/dist/crawl.js.map +1 -0
- package/dist/file-crawler.d.ts +19 -2
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +19 -1
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs-utils.d.ts +60 -3
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +88 -10
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +7 -6
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -6
- package/dist/fs.js.map +1 -1
- package/dist/git-utils.d.ts +2 -0
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +16 -0
- package/dist/git-utils.js.map +1 -1
- package/dist/git.d.ts +23 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +23 -0
- package/dist/git.js.map +1 -0
- package/dist/gitignore-checker.d.ts +0 -9
- package/dist/gitignore-checker.d.ts.map +1 -1
- package/dist/gitignore-checker.js +0 -12
- package/dist/gitignore-checker.js.map +1 -1
- package/dist/glob/glob-pattern.js +1 -1
- package/dist/glob/glob-pattern.js.map +1 -1
- package/dist/glob.d.ts +8 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +8 -0
- package/dist/glob.js.map +1 -0
- package/dist/path-core.d.ts +196 -0
- package/dist/path-core.d.ts.map +1 -0
- package/dist/path-core.js +251 -0
- package/dist/path-core.js.map +1 -0
- package/dist/path-utils.d.ts +12 -183
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/path-utils.js +13 -237
- package/dist/path-utils.js.map +1 -1
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +12 -0
- package/dist/path.js.map +1 -0
- package/dist/process.d.ts +7 -4
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +7 -4
- package/dist/process.js.map +1 -1
- package/dist/project.d.ts +31 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +31 -0
- package/dist/project.js.map +1 -0
- package/dist/safe-exec.d.ts.map +1 -1
- package/dist/safe-exec.js +8 -4
- package/dist/safe-exec.js.map +1 -1
- package/dist/spawn-hardened.d.ts.map +1 -1
- package/dist/spawn-hardened.js +4 -17
- package/dist/spawn-hardened.js.map +1 -1
- package/dist/template-entry.d.ts +10 -0
- package/dist/template-entry.d.ts.map +1 -0
- package/dist/template-entry.js +10 -0
- package/dist/template-entry.js.map +1 -0
- package/dist/testing.d.ts +8 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +8 -0
- package/dist/testing.js.map +1 -0
- package/dist/windows-shell.d.ts +140 -8
- package/dist/windows-shell.d.ts.map +1 -1
- package/dist/windows-shell.js +218 -11
- package/dist/windows-shell.js.map +1 -1
- package/dist/yaml.d.ts +9 -0
- package/dist/yaml.d.ts.map +1 -0
- package/dist/yaml.js +9 -0
- package/dist/yaml.js.map +1 -0
- package/dist/zod.d.ts +10 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +10 -0
- package/dist/zod.js.map +1 -0
- package/eslint/README.md +191 -0
- package/eslint/index.cjs +161 -0
- package/eslint/index.d.cts +60 -0
- package/eslint/rules/eslint-rule-factory.cjs +241 -0
- package/eslint/rules/exempt-path-matcher.cjs +265 -0
- package/eslint/rules/no-bare-dynamic-import-path.cjs +142 -0
- package/eslint/rules/no-child-process-execSync.cjs +23 -0
- package/eslint/rules/no-command-direct-factory.cjs +246 -0
- package/eslint/rules/no-file-url-string-concat.cjs +77 -0
- package/eslint/rules/no-fs-mkdirSync.cjs +23 -0
- package/eslint/rules/no-fs-promises-cp.cjs +36 -0
- package/eslint/rules/no-fs-realpathSync.cjs +23 -0
- package/eslint/rules/no-hardcoded-path-split.cjs +141 -0
- package/eslint/rules/no-manual-path-normalize.cjs +130 -0
- package/eslint/rules/no-os-tmpdir.cjs +24 -0
- package/eslint/rules/no-path-join.cjs +14 -0
- package/eslint/rules/no-path-operations-in-comparisons.cjs +148 -0
- package/eslint/rules/no-path-relative.cjs +14 -0
- package/eslint/rules/no-path-resolve.cjs +14 -0
- package/eslint/rules/no-path-sep-in-strings.cjs +130 -0
- package/eslint/rules/no-path-startswith.cjs +139 -0
- package/eslint/rules/no-test-scoped-functions.cjs +134 -0
- package/eslint/rules/no-unix-shell-commands.cjs +152 -0
- package/eslint/rules/no-unsafe-root-join.cjs +85 -0
- package/eslint/rules/no-url-pathname-for-fs.cjs +107 -0
- package/eslint/rules/path-function-rule-factory.cjs +216 -0
- package/eslint/rules/prefer-startswith-over-regex.cjs +111 -0
- package/eslint/rules/require-justified-skip.cjs +348 -0
- package/eslint/rules/safe-import.cjs +141 -0
- 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;
|