@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,191 @@
1
+ # `@vibe-agent-toolkit/utils/eslint`
2
+
3
+ ESLint rules that enforce the cross-platform and agentic-code safety helpers in the rest of [`@vibe-agent-toolkit/utils`](https://www.npmjs.com/package/@vibe-agent-toolkit/utils).
4
+
5
+ Twenty-one rules, all of them derived from a bug that actually shipped: `os.tmpdir()` returning an 8.3 short path on a Windows CI runner, `path.join()` producing backslashes that then failed a string comparison, `await import(absolutePath)` throwing on Windows without a `file://` URL, `execSync()` interpolating a caller-controlled string into a shell. Most auto-fix.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @vibe-agent-toolkit/utils
11
+ ```
12
+
13
+ There is no separate plugin package: the rules are a subpath of the package whose helpers they enforce, so the two can never drift to different versions. Requires ESLint 9+ (flat config) and Node >= 22.
14
+
15
+ `eslint` is an **optional** peer dependency, and this subpath adds no dependency to the others. An ESLint plugin is data, not code that runs — every rule module here exports a plain object and none of them `require('eslint')` — so installing `utils` for `safePath.join()` alone pulls in nothing extra and warns about nothing.
16
+
17
+ ## Usage
18
+
19
+ ```js
20
+ // eslint.config.js
21
+ import vat from '@vibe-agent-toolkit/utils/eslint';
22
+
23
+ export default [
24
+ vat.configs.recommended,
25
+ ];
26
+ ```
27
+
28
+ `configs.recommended` registers the plugin under the `@vibe-agent-toolkit` namespace and enables the **cross-platform safety core** — 18 of the 21 rules, most at `error` and three at `warn` (see [Severities](#severities)). The other three are opt-in; the [rule tables](#rules) mark each rule's `recommended` severity, and `—` means not in `recommended`.
29
+
30
+ To pick rules yourself, register the plugin and name them:
31
+
32
+ ```js
33
+ import vat from '@vibe-agent-toolkit/utils/eslint';
34
+
35
+ export default [
36
+ {
37
+ files: ['**/*.{ts,tsx,js,cjs,mjs}'],
38
+ plugins: { '@vibe-agent-toolkit': vat },
39
+ rules: {
40
+ '@vibe-agent-toolkit/no-path-join': 'error',
41
+ '@vibe-agent-toolkit/no-os-tmpdir': 'error',
42
+ },
43
+ },
44
+ ];
45
+ ```
46
+
47
+ ### Exempting the file that implements the wrapper
48
+
49
+ A rule that bans `os.tmpdir()` has to let *something* call it — the file that implements your `normalizedTmpdir()`. That file's path is a fact about your repo, so the plugin ships **no** built-in exemptions; you declare them:
50
+
51
+ ```js
52
+ '@vibe-agent-toolkit/no-os-tmpdir': ['error', { exemptFiles: ['src/paths.ts'] }],
53
+ '@vibe-agent-toolkit/no-path-join': ['error', { exemptFiles: [
54
+ 'src/paths.ts',
55
+ 'test/paths.test.ts', // asserts the platform-native behavior the wrapper hides
56
+ ] }],
57
+ ```
58
+
59
+ Paths are repo-relative and matched at a **path-segment boundary**, never as a substring: declaring `src/paths.ts` does not exempt `tools/hooks/paths.ts`, `src/my-paths.ts`, or `src/paths.ts.bak`. (It used to be a substring check. A private `tools/hooks/path-utils.ts` full of raw `tmpdir()` calls linted clean for months.) Matching is separator-agnostic, so the same list works on Windows.
60
+
61
+ The match is anchored at the **end** of the path, so a declaration is a suffix: `src/paths.ts` also exempts `packages/anything/src/paths.ts`. Name enough leading segments to be unambiguous in your tree (this repo declares `packages/utils/src/path-utils.ts`, not `path-utils.ts`).
62
+
63
+ Taken to its limit, a **bare filename with no `/` exempts that filename everywhere in the repo** — including files added later by someone who never read the config. The rules report that as `unanchoredExemptFile` rather than letting it pass, because it is the same repo-wide hole the segment-boundary matching exists to close. `./paths.ts` is the same hole with a slash in it, so the check runs on the normalized entry, not the raw string.
64
+
65
+ The option **replaces** any default rather than merging with it, and unknown option keys are a config error — a typo'd `exemptFile` must fail loudly rather than quietly exempt nothing.
66
+
67
+ The rules taking `exemptFiles` are `no-path-join`, `no-path-resolve`, `no-path-relative`, `no-os-tmpdir`, `no-fs-mkdirSync`, `no-fs-realpathSync`, `no-child-process-execSync`, and `no-fs-promises-cp`.
68
+
69
+ ## Rules
70
+
71
+ The "use instead" column names the `@vibe-agent-toolkit/utils` subpath the replacement lives on; ✓ marks an auto-fix. The **`recommended`** column is the severity `configs.recommended` assigns — `—` means the rule is **not** in `recommended` and must be enabled explicitly.
72
+
73
+ **The auto-fix writes the import to the subpath in that column**, not to the barrel — `--fix` on a raw `path.join()` inserts `import { safePath } from '@vibe-agent-toolkit/utils/path'`. A file that already reaches the helper through the barrel keeps its existing import and only has the call rewritten: adding a second binding of the same name is a `SyntaxError`, not a redundant import.
74
+
75
+ ### Pointing the fix at your own re-export seam — `safeModule`
76
+
77
+ If your repo re-exports these helpers through its own module, the defaults above are wrong for you, and not merely stylistically: in a workspace with isolated `node_modules` (pnpm, Yarn PnP), an import of a package the receiving package does not declare **fails to resolve**. One adopter measured 620 files across 52 such packages. Point the rule at the module that resolves where the fix lands:
78
+
79
+ ```js
80
+ '@vibe-agent-toolkit/no-path-join': ['warn', { safeModule: '@acme/dev-tools/paths' }],
81
+ '@vibe-agent-toolkit/no-os-tmpdir': ['error', { safeModule: '@acme/dev-tools/fs' }],
82
+ ```
83
+
84
+ It is **per-rule**, not one shared `settings` key, because a seam need not split its symbols the way this package does — an adopter whose narrow entry carried `normalizedTmpdir()` but not `safePath` needed the two rules pointed at different modules, which a single key cannot express. Note the two lines above therefore name *different* modules. It composes with `exemptFiles`, and it changes the error message as well as the fix, so the advice never names a module you don't use. Every rule that names a module accepts it, including the ones that only advise and never fix.
85
+
86
+ > **Point each rule at a module that exports the symbol *that rule writes*, not merely one that resolves.** This is the failure mode worth spending a minute on, because it is the quiet one. A `safeModule` that doesn't resolve fails loudly and immediately. A `safeModule` that resolves but lacks the symbol passes every resolution check — including an explicit `import()` probe — and then throws `Cannot read properties of undefined` at each call site, once per fixed file. Aim `no-path-join` at an entry without `safePath` and you get a green `--fix`, a green install, and thousands of latent `TypeError`s. The rules cannot check this for you: verifying it would mean resolving and importing your module from inside the linter, which is neither its job nor reliable from wherever ESLint happens to be running.
87
+
88
+ Two ways your target can be wrong, which surface differently: `ERR_MODULE_NOT_FOUND` means the receiving package doesn't declare it at all, while `ERR_PACKAGE_PATH_NOT_EXPORTED` means it does — but the version resolved *at that location* doesn't export the subpath. In a monorepo mid-upgrade those differ, so the question is never "does the package declare it" but "does the resolved version there export it."
89
+
90
+ ### Path handling
91
+
92
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
93
+ |---|---|---|---|---|---|
94
+ | `no-path-join` | `path.join()` | `safePath.join()` | `/path` | ✓ | `warn` |
95
+ | `no-path-resolve` | `path.resolve()` | `safePath.resolve()` | `/path` | ✓ | `warn` |
96
+ | `no-path-relative` | `path.relative()` | `safePath.relative()` | `/path` | ✓ | `warn` |
97
+ | `no-path-startswith` | `path.startsWith()` on a raw path | `toForwardSlash()` first | `/path` | | `error` |
98
+ | `no-hardcoded-path-split` | `split('/')` / `split('\\')` on a path | `path.basename()`, or `toForwardSlash()` first | `/path` | | `error` |
99
+ | `no-path-sep-in-strings` | `path.sep` embedded in a string literal | `toForwardSlash()` | `/path` | | `error` |
100
+ | `no-manual-path-normalize` | hand-rolled `.replace(/\\/g, '/')` | `toForwardSlash()` | `/path` | ✓ | `error` |
101
+ | `no-path-operations-in-comparisons` | raw `path.*()` results in string comparisons | wrap in `toForwardSlash()` | `/path` | | `error` |
102
+ | `no-unsafe-root-join` | `safePath.join(someRoot, x)` where `x` can escape | `safePath.joinUnderRoot()` | `/path` | | — |
103
+
104
+ ### Filesystem and process
105
+
106
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
107
+ |---|---|---|---|---|---|
108
+ | `no-os-tmpdir` | `os.tmpdir()` (8.3 short names on Windows) | `normalizedTmpdir()` | `/fs` | ✓ | `error` |
109
+ | `no-fs-realpathSync` | `fs.realpathSync()` | `normalizePath()` | `/fs` | ✓ | `error` |
110
+ | `no-fs-mkdirSync` | `fs.mkdirSync()` | `mkdirSyncReal()` | `/fs` | ✓ | `error` |
111
+ | `no-fs-promises-cp` | `cp()` from `node:fs/promises` (drops nested files on Node 22) | `cpSync()` from `node:fs` | — | ✓ | `error` |
112
+ | `no-child-process-execSync` | `child_process.execSync()` | `safeExecSync()` | `/process` | ✓ | `error` |
113
+ | `no-unix-shell-commands` | `tar`, `grep`, `rm`, `echo`, … spawned directly | Node APIs, or a portable script fixture | — | | `error` |
114
+
115
+ ### URLs and dynamic imports
116
+
117
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
118
+ |---|---|---|---|---|---|
119
+ | `no-url-pathname-for-fs` | `new URL(x, import.meta.url).pathname` as a filesystem path | `resolveFromImportMeta()` / `fileURLToPath()` | `/fs` | | `error` |
120
+ | `no-bare-dynamic-import-path` | `await import(absolutePath)` | `dynamicImportPath()` / `pathToFileURL(p).href` | `/fs` | | `error` |
121
+ | `no-file-url-string-concat` | `` `file://${p}` `` | `pathToFileURL(p).href` | — | | `error` |
122
+
123
+ ### Code and test hygiene
124
+
125
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
126
+ |---|---|---|---|---|---|
127
+ | `prefer-startswith-over-regex` | `/^foo/.test(s)` | `s.startsWith('foo')` | — | | `error` |
128
+ | `no-test-scoped-functions` | helper functions declared inside `describe`/`it` | module scope | — | | — |
129
+ | `require-justified-skip` | unannotated `it.skip`/`it.todo`, tautological assertions, empty test bodies | a `SKIP(#123): reason` annotation, or a real assertion | — | | — |
130
+
131
+ ### What `recommended` deliberately leaves out
132
+
133
+ Three rules ship without riding in `recommended`, for two different reasons.
134
+
135
+ **Test-style opinions** — `no-test-scoped-functions` (where a helper may be declared) and `require-justified-skip` (the annotation grammar for a disabled test). Neither is a portability or correctness fact, and installing this package for `safePath.join()` should not also import someone else's test conventions. Both are worth turning on deliberately.
136
+
137
+ **Unsound, pending a rewrite** — `no-unsafe-root-join`. It keys on whether an identifier's *name* ends in `root` rather than on whether any segment is caller-controlled, which makes it noisy and blind at the same time. Measured against 4,670 files of real adopter source: 108 findings, none autofixable. Verified behaviour:
138
+
139
+ ```js
140
+ safePath.join(repoRoot, 'docs', 'product') // FIRES — all literals, nothing can escape
141
+ safePath.resolve(packageRoot, '..', '..') // FIRES — escaping is the intent; the fix breaks it
142
+ safePath.join(repoRoot) // FIRES — one argument, no segment at all
143
+ safePath.join(base, userInput) // SILENT — the shape it exists to catch
144
+ ```
145
+
146
+ A rule that misses its own target does not belong in a config named `recommended` at any severity: a safety core that cries wolf teaches people to ignore it, and that costs you the true positives too. It still ships, and it still earns `error` when scoped to directories where a path escape is a security boundary — which is how this repo uses it, on its skill-test staging code. It will return to `recommended` when it keys on taint rather than on naming.
147
+
148
+ Enable any of the three by naming it:
149
+
150
+ ```js
151
+ import vat from '@vibe-agent-toolkit/utils/eslint';
152
+
153
+ export default [
154
+ vat.configs.recommended,
155
+ {
156
+ rules: {
157
+ '@vibe-agent-toolkit/no-test-scoped-functions': 'error',
158
+ '@vibe-agent-toolkit/require-justified-skip': 'error',
159
+ },
160
+ },
161
+ {
162
+ // Scope it to where an escape is a security boundary, not repo-wide.
163
+ files: ['src/staging/**/*.ts'],
164
+ rules: { '@vibe-agent-toolkit/no-unsafe-root-join': 'error' },
165
+ },
166
+ ];
167
+ ```
168
+
169
+ ### Severities
170
+
171
+ Within `recommended`, `error` is the default; three rules are `warn`:
172
+
173
+ - **`no-path-join`, `no-path-resolve`, `no-path-relative`** — by far the highest-churn rules; they fire on every raw `node:path` call in an existing codebase. Measured on a 4,670-file adopter tree: 3,963 + 372 + 1 findings, **every one of them autofixable**. `warn` lets a project run `--fix` and burn the list down incrementally instead of blocking CI on day one.
174
+
175
+ Raise all three to `error` once the backlog is clear. That is what this repo does.
176
+
177
+ The criterion for `warn` is **migration volume**, not how real the finding is — a rule whose findings we doubted would be out of `recommended` entirely, not demoted. Everything at `error` either prevents a bug or moves a static-analysis finding left of a merge.
178
+
179
+ ## Why custom rules
180
+
181
+ A cross-platform safety helper is only as good as its enforcement. `safePath.join()` prevents a class of Windows bug precisely once — the moment someone writes `path.join()` instead, the helper's existence has bought nothing. Publishing the API without the lint rule ships half a product: the fix is available, and nothing directs anyone to it.
182
+
183
+ What these rules buy is *when* you find out. Without them, a raw `os.tmpdir()` is found by a Windows CI job — a different machine, twenty minutes later, on someone else's push, with a failure message about a path that doesn't obviously relate to the line that caused it. With them, it is a red squiggle under the call, on the author's machine, with the replacement named in the message and usually applied by `--fix`. The bug never leaves the editor. That is the whole argument, and it is why the rules are written to name the replacement rather than merely to forbid the primitive.
184
+
185
+ The second reason is agentic. When most code in a repo is written by an LLM, a convention that lives only in a style guide or a CLAUDE.md gets re-violated constantly — the model is confident, `path.join` is what it saw a million times in training, and the guidance was three thousand tokens back. A lint rule is feedback the model receives at the moment it is wrong, which is the only moment it can act on. Treat rules as the durable form of any convention you'd otherwise repeat in a prompt.
186
+
187
+ Most projects will want rules the ones here don't cover, for invariants only that project has: a deprecated internal API, a logging call that must carry a request ID, a module boundary nothing may import across. These rules are readable, small, and built on two shared factories plus a segment-anchored path matcher — copy the shape. `eslint-rule-factory.cjs` handles "ban function X from module Y, suggest Z, fix the import"; `exempt-path-matcher.cjs` handles the exemption question every such rule eventually asks, and is worth reading before you write `filename.includes(...)`.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `@vibe-agent-toolkit/utils/eslint` — cross-platform and agentic-code safety rules.
3
+ *
4
+ * These rules enforce the safe helpers published by the rest of this package
5
+ * (`safePath.*` on `/path`, `normalizedTmpdir()`, `mkdirSyncReal()`,
6
+ * `normalizePath()` on `/fs`, `safeExecSync()` on `/process`). The helpers exist
7
+ * because the raw primitives they wrap have platform potholes; the rules exist so
8
+ * a call to the raw primitive fails at lint time on the author's machine rather
9
+ * than in CI on a different OS.
10
+ *
11
+ * They ship as a SUBPATH rather than a separate package because an ESLint plugin
12
+ * is data, not code that runs: every module below exports a plain rule object and
13
+ * none of them `require('eslint')`. So this entry resolves — and the other twelve
14
+ * subpaths keep resolving — whether or not ESLint is installed, which is why
15
+ * `eslint` is declared as an OPTIONAL peer dependency. One install, one version,
16
+ * no way for the rules to drift from the helpers they name.
17
+ *
18
+ * CommonJS on purpose, in an ESM package: the rule modules are `.cjs`, Node keys
19
+ * module format off the extension regardless of the package's `"type"`, and a CJS
20
+ * entry point can be both `require()`d from an `eslint.config.cjs` and `import`ed
21
+ * from an `eslint.config.js`/`.mjs`.
22
+ *
23
+ * Rules whose exemptions name a file (the ONE implementation file allowed to call
24
+ * the primitive) take an `exemptFiles` option — see README.md. The shipped
25
+ * defaults are EMPTY: an exemption is a claim about a specific repo's layout, and
26
+ * inheriting another repo's claim is how a same-named file silently opts itself
27
+ * out of a rule.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const rules = {
33
+ 'no-child-process-execSync': require('./rules/no-child-process-execSync.cjs'),
34
+ 'no-hardcoded-path-split': require('./rules/no-hardcoded-path-split.cjs'),
35
+ 'no-path-startswith': require('./rules/no-path-startswith.cjs'),
36
+ 'no-unix-shell-commands': require('./rules/no-unix-shell-commands.cjs'),
37
+ 'no-os-tmpdir': require('./rules/no-os-tmpdir.cjs'),
38
+ 'no-fs-mkdirSync': require('./rules/no-fs-mkdirSync.cjs'),
39
+ 'no-fs-realpathSync': require('./rules/no-fs-realpathSync.cjs'),
40
+ 'no-manual-path-normalize': require('./rules/no-manual-path-normalize.cjs'),
41
+ 'no-path-sep-in-strings': require('./rules/no-path-sep-in-strings.cjs'),
42
+ 'no-path-operations-in-comparisons': require('./rules/no-path-operations-in-comparisons.cjs'),
43
+ 'no-path-join': require('./rules/no-path-join.cjs'),
44
+ 'no-path-resolve': require('./rules/no-path-resolve.cjs'),
45
+ 'no-path-relative': require('./rules/no-path-relative.cjs'),
46
+ 'no-test-scoped-functions': require('./rules/no-test-scoped-functions.cjs'),
47
+ 'no-fs-promises-cp': require('./rules/no-fs-promises-cp.cjs'),
48
+ 'no-url-pathname-for-fs': require('./rules/no-url-pathname-for-fs.cjs'),
49
+ 'no-bare-dynamic-import-path': require('./rules/no-bare-dynamic-import-path.cjs'),
50
+ 'no-file-url-string-concat': require('./rules/no-file-url-string-concat.cjs'),
51
+ 'prefer-startswith-over-regex': require('./rules/prefer-startswith-over-regex.cjs'),
52
+ 'no-unsafe-root-join': require('./rules/no-unsafe-root-join.cjs'),
53
+ 'require-justified-skip': require('./rules/require-justified-skip.cjs'),
54
+ };
55
+
56
+ /**
57
+ * Rules deliberately LEFT OUT of `configs.recommended`.
58
+ *
59
+ * `recommended` is the cross-platform-safety core: every rule in it flags a call
60
+ * that is wrong (or unportable) regardless of how the adopting project likes to
61
+ * write tests. The two below are neither — they encode a position on TEST STYLE:
62
+ *
63
+ * - `require-justified-skip` — a specific annotation grammar (`SKIP(#123): reason`)
64
+ * for a disabled test, plus a view on what counts as a tautological assertion.
65
+ * - `no-test-scoped-functions` — a view on WHERE a test helper may be declared
66
+ * (module scope, never inside `describe`/`it`).
67
+ *
68
+ * Someone installing this package for `safePath.join()` should not silently
69
+ * inherit either. Both ship in `rules` and stay enabled explicitly:
70
+ *
71
+ * '@vibe-agent-toolkit/require-justified-skip': 'error',
72
+ *
73
+ * That is exactly what VAT's own `eslint.config.js` does — it does not consume
74
+ * `configs.recommended` at all, so this exclusion changes nothing about how this
75
+ * repo lints itself.
76
+ */
77
+ const RECOMMENDED_EXCLUDE = new Set([
78
+ 'require-justified-skip',
79
+ 'no-test-scoped-functions',
80
+ // Excluded for a DIFFERENT reason than the two above: not a style opinion, but
81
+ // an unsound heuristic. It keys on whether an identifier's name ends in `root`
82
+ // rather than on whether any segment is caller-controlled, which makes it
83
+ // simultaneously noisy and blind. Measured on a 4,670-file adopter tree: 108
84
+ // findings, 0 autofixable, and every one of these verified by execution here:
85
+ //
86
+ // FIRES safePath.join(repoRoot, 'docs', 'product') <- all literals, cannot escape
87
+ // FIRES safePath.resolve(packageRoot, '..', '..') <- escaping IS the intent; the fix breaks it
88
+ // FIRES safePath.join(repoRoot) <- one argument, no segment at all
89
+ // silent safePath.join(base, userInput) <- THE dangerous shape, missed
90
+ //
91
+ // A rule that misses the case it exists to catch must not ride in a config
92
+ // named `recommended` at any severity — a safety core that cries wolf teaches
93
+ // people to ignore it, which costs the true positives too. It still ships, and
94
+ // it still earns `error` where scoped to directories in which a path escape is
95
+ // a security boundary (this repo scopes it to the skill-test staging code).
96
+ // Re-include it when it keys on taint rather than on naming.
97
+ 'no-unsafe-root-join',
98
+ ]);
99
+
100
+ /**
101
+ * Default severities for `configs.recommended`.
102
+ *
103
+ * `error` is the default: every rule below flags a call whose replacement is a
104
+ * one-line swap, and a wrong answer is a real bug on some platform.
105
+ *
106
+ * `warn` is reserved for the case where a fresh adopter's first run would
107
+ * otherwise be a wall of blocking errors they cannot triage in one sitting:
108
+ * `no-path-join` / `no-path-resolve` / `no-path-relative`, the highest-churn
109
+ * rules by far (they fire on every raw `node:path` call in the codebase).
110
+ * Measured on a 4,670-file adopter tree: 3,963 + 372 + 1 findings, **all
111
+ * autofixable**. All three auto-fix, so `warn` lets a project run `--fix` and
112
+ * burn the list down incrementally instead of blocking CI on day one.
113
+ *
114
+ * Raise them to `error` once the backlog is clear — that is what VAT itself does.
115
+ *
116
+ * The criterion for `warn` is MIGRATION VOLUME, not how real the finding is. Every
117
+ * rule in this pack either prevents a bug or shifts a static-analysis finding left of
118
+ * a merge, and both are worth blocking on; what `warn` buys is a first run that reads
119
+ * as a backlog to `--fix` rather than a wall. `prefer-startswith-over-regex` was
120
+ * briefly graded on a different axis ("style, not a defect") and demoted — that was
121
+ * wrong twice over: avoiding a SonarQube S6557 at lint time instead of at merge time
122
+ * is a real saving, and the rule's matcher rejects any regex containing a
123
+ * metacharacter, so it only fires on true literal prefixes and has near-zero churn.
124
+ * It is `error`.
125
+ */
126
+ const RECOMMENDED_WARN = new Set([
127
+ 'no-path-join',
128
+ 'no-path-resolve',
129
+ 'no-path-relative',
130
+ ]);
131
+
132
+ /**
133
+ * Plugin namespace an adopter gets from `configs.recommended`, and therefore the
134
+ * prefix on every rule id (`@vibe-agent-toolkit/no-path-join`).
135
+ *
136
+ * Deliberately the SCOPE, not the full subpath specifier: rule ids are the surface
137
+ * adopters type into `rules`, `eslint-disable` comments and CI baselines, and they
138
+ * should not have to change if the pack ever moves house again. (This repo's own
139
+ * config registers the same object under `local` for exactly that reason.)
140
+ */
141
+ const NAMESPACE = '@vibe-agent-toolkit';
142
+
143
+ const plugin = {
144
+ meta: {
145
+ name: '@vibe-agent-toolkit/utils/eslint',
146
+ },
147
+ rules,
148
+ configs: {},
149
+ };
150
+
151
+ plugin.configs.recommended = {
152
+ name: '@vibe-agent-toolkit/utils/eslint/recommended',
153
+ plugins: { [NAMESPACE]: plugin },
154
+ rules: Object.fromEntries(
155
+ Object.keys(rules)
156
+ .filter((name) => !RECOMMENDED_EXCLUDE.has(name))
157
+ .map((name) => [`${NAMESPACE}/${name}`, RECOMMENDED_WARN.has(name) ? 'warn' : 'error']),
158
+ ),
159
+ };
160
+
161
+ module.exports = plugin;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Types for `@vibe-agent-toolkit/utils/eslint`.
3
+ *
4
+ * Hand-written, and deliberately structural rather than `import type { ESLint }
5
+ * from 'eslint'`: `eslint` is an OPTIONAL peer dependency, so a consumer who took
6
+ * this package for `safePath.join()` has no `eslint` types installed, and a
7
+ * reference to them here would turn this entry into a type error for exactly the
8
+ * people the optional peer exists to spare. The shapes below are the subset flat
9
+ * config consumes, and are structurally assignable to `ESLint.Plugin`.
10
+ *
11
+ * `export =`, not `export default`: the implementation ends in
12
+ * `module.exports = plugin`, and `export =` is the only declaration form that says
13
+ * so. Consumers with `esModuleInterop` (the default for this repo's base config)
14
+ * still write `import vat from '@vibe-agent-toolkit/utils/eslint'`.
15
+ *
16
+ * `.d.cts`, not `.d.ts`: under `moduleResolution: node16`/`nodenext` TypeScript
17
+ * matches the declaration's extension to the module format of the file it
18
+ * describes, and `index.cjs` is CommonJS inside a `"type": "module"` package.
19
+ *
20
+ * This file exists because `eslint.config.ts` is supported from ESLint 9.18.
21
+ * Without it, an adopter writing one gets TS7016 on the import. It is NOT covered
22
+ * by `bun run typecheck` (utils' tsconfig includes only `src/**‍/*.ts`), so the
23
+ * packaged-artifact integration test compiles a consumer fixture against it
24
+ * instead — see `test/integration/eslint-recommended-config.integration.test.ts`.
25
+ */
26
+
27
+ declare const plugin: plugin.Plugin;
28
+
29
+ declare namespace plugin {
30
+ /** A rule module, opaque here — adopters hand these to ESLint, never call them. */
31
+ interface RuleModule {
32
+ meta?: Record<string, unknown>;
33
+ create: (context: unknown) => Record<string, unknown>;
34
+ }
35
+
36
+ /** A flat-config object, as returned by `configs.recommended`. */
37
+ interface FlatConfig {
38
+ name: string;
39
+ plugins: Record<string, Plugin>;
40
+ /** Rule id → severity. Keys are namespaced: `@vibe-agent-toolkit/no-path-join`. */
41
+ rules: Record<string, 'error' | 'warn' | 'off'>;
42
+ }
43
+
44
+ interface Plugin {
45
+ meta: { name: string };
46
+ /** Rule name WITHOUT the namespace prefix, e.g. `no-path-join`. */
47
+ rules: Record<string, RuleModule>;
48
+ configs: {
49
+ /**
50
+ * The cross-platform safety core: 18 of the 21 rules, 15 `error` / 3 `warn`.
51
+ * `no-test-scoped-functions` and `require-justified-skip` are excluded — they
52
+ * are positions on test style, not portability facts. Both still ship in
53
+ * `rules` and are enabled by naming them.
54
+ */
55
+ recommended: FlatConfig;
56
+ };
57
+ }
58
+ }
59
+
60
+ export = plugin;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * ESLint Rule Factory - Shared implementation for unsafe operation rules
3
+ *
4
+ * Creates ESLint rules that detect unsafe operations and suggest safe alternatives.
5
+ * Supports auto-fixing with import management.
6
+ *
7
+ * @param {Object} config - Rule configuration
8
+ * @param {string} config.unsafeFn - Name of unsafe function (e.g., 'tmpdir', 'mkdirSync')
9
+ * @param {string} config.unsafeModule - Module containing unsafe function (e.g., 'node:os', 'node:fs')
10
+ * @param {string} config.safeFn - Name of safe replacement function (e.g., 'normalizedTmpdir')
11
+ * @param {string} config.safeModule - Module containing safe function. Pass the
12
+ * NARROW subpath that owns the symbol (`@vibe-agent-toolkit/utils/fs`), never
13
+ * the barrel — see `safe-import.cjs` for why.
14
+ * @param {string} config.message - Error message to display
15
+ * @param {readonly string[]} [config.exemptFiles] - FALLBACK repo-relative paths
16
+ * allowed to call the unsafe function, used only when the consuming config
17
+ * passes no `exemptFiles` option. Ship this empty: an exemption names one file
18
+ * in one repo, so a baked-in default is a hole in every OTHER repo. Consumers
19
+ * declare their own via the rule option (see `exempt-path-matcher.cjs`).
20
+ * @param {boolean} [config.checkMemberExpression] - Check for obj.method() calls (default: false)
21
+ * @returns {Object} ESLint rule definition
22
+ *
23
+ * @example
24
+ * // no-os-tmpdir.cjs
25
+ * const factory = require('./eslint-rule-factory.cjs');
26
+ * module.exports = factory({
27
+ * unsafeFn: 'tmpdir',
28
+ * unsafeModule: 'node:os',
29
+ * safeFn: 'normalizedTmpdir',
30
+ * safeModule: SAFE_FS_MODULE,
31
+ * message: 'Use normalizedTmpdir() for Windows compatibility',
32
+ * });
33
+ *
34
+ * // …and in the consumer's eslint.config.js, naming ITS implementation file:
35
+ * // '@vibe-agent-toolkit/no-os-tmpdir': ['error', { exemptFiles: ['src/paths.ts'] }]
36
+ */
37
+
38
+ const {
39
+ UNANCHORED_EXEMPT_FILE,
40
+ UNANCHORED_EXEMPT_MESSAGE,
41
+ createConfigurableExemptPathMatcher,
42
+ reportUnanchoredExemptEntries,
43
+ } = require('./exempt-path-matcher.cjs');
44
+ const {
45
+ EXEMPT_AND_SAFE_MODULE_SCHEMA,
46
+ isNameAlreadyBound,
47
+ resolveSafeModule,
48
+ } = require('./safe-import.cjs');
49
+
50
+ /**
51
+ * Helper function to filter unsafe import specifiers
52
+ * Extracted to reduce nesting depth for code quality
53
+ */
54
+ function filterUnsafeSpecifiers(importNode, unsafeFn) {
55
+ return importNode.specifiers.filter((s) => s.imported && s.imported.name === unsafeFn);
56
+ }
57
+
58
+ /**
59
+ * Helper function to remove unsafe import specifiers
60
+ * Extracted to reduce nesting depth for code quality
61
+ */
62
+ function removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeImportNode, unsafeSpecs) {
63
+ const fixes = [];
64
+ for (const spec of unsafeSpecs) {
65
+ const comma = sourceCode.getTokenAfter(spec);
66
+ if (comma?.value === ',') {
67
+ fixes.push(fixer.removeRange([spec.range[0], comma.range[1]]));
68
+ } else {
69
+ const commaBefore = sourceCode.getTokenBefore(spec);
70
+ if (commaBefore?.value === ',') {
71
+ fixes.push(fixer.removeRange([commaBefore.range[0], spec.range[1]]));
72
+ } else {
73
+ fixes.push(fixer.remove(spec));
74
+ }
75
+ }
76
+ }
77
+ return fixes;
78
+ }
79
+
80
+ module.exports = function createNoUnsafeRule(config) {
81
+ const {
82
+ unsafeFn,
83
+ unsafeModule,
84
+ safeFn,
85
+ safeModule,
86
+ message,
87
+ exemptFiles = [],
88
+ checkMemberExpression = false,
89
+ } = config;
90
+
91
+ const exemptMatcherFor = createConfigurableExemptPathMatcher(exemptFiles);
92
+
93
+ // Normalize module names (support both 'node:os' and 'os')
94
+ const moduleVariants = [unsafeModule];
95
+ if (unsafeModule.startsWith('node:')) {
96
+ moduleVariants.push(unsafeModule.replace('node:', ''));
97
+ } else {
98
+ moduleVariants.push(`node:${unsafeModule}`);
99
+ }
100
+
101
+ return {
102
+ meta: {
103
+ type: 'problem',
104
+ docs: {
105
+ description: `Enforce use of ${safeFn}() instead of ${unsafeFn}()`,
106
+ category: 'Best Practices',
107
+ recommended: true,
108
+ },
109
+ fixable: 'code',
110
+ schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
111
+ messages: {
112
+ noUnsafeOperation: message,
113
+ [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
114
+ },
115
+ },
116
+
117
+ create(context) {
118
+ const sourceCode = context.getSourceCode();
119
+ // Resolved per invocation, not at factory-construction time: the option is
120
+ // the consuming repo's, and one repo can configure the same rule
121
+ // differently across config blocks.
122
+ const targetModule = resolveSafeModule(context, safeModule);
123
+
124
+ // Only the declared implementation file(s) may call the unsafe function.
125
+ if (exemptMatcherFor(context)(context.getFilename())) {
126
+ // Still surface a malformed exemption list: the file we are standing in
127
+ // may be exempt only BECAUSE the entry is unanchored.
128
+ return {
129
+ Program(node) {
130
+ reportUnanchoredExemptEntries(context, node);
131
+ },
132
+ };
133
+ }
134
+
135
+ let hasUnsafeImport = false;
136
+ // Seeded from SCOPE, not from "did I see an import from safeModule?".
137
+ // A file already importing `safeFn` from the barrel needs the call
138
+ // rewritten but must NOT gain a second binding of the same name — that is
139
+ // a SyntaxError, not a redundant import. See `safe-import.cjs`.
140
+ let hasSafeImport = isNameAlreadyBound(sourceCode, safeFn);
141
+ let unsafeImportNode = null;
142
+ let safeImportNode = null;
143
+
144
+ return {
145
+ Program(node) {
146
+ reportUnanchoredExemptEntries(context, node);
147
+ },
148
+
149
+ ImportDeclaration(node) {
150
+ // Track unsafe module imports
151
+ if (moduleVariants.includes(node.source.value)) {
152
+ unsafeImportNode = node;
153
+ for (const spec of node.specifiers) {
154
+ if (spec.type === 'ImportSpecifier' && spec.imported.name === unsafeFn) {
155
+ hasUnsafeImport = true;
156
+ }
157
+ }
158
+ }
159
+
160
+ // Track safe module imports
161
+ if (node.source.value === targetModule) {
162
+ safeImportNode = node;
163
+ for (const spec of node.specifiers) {
164
+ if (spec.type === 'ImportSpecifier' && spec.imported.name === safeFn) {
165
+ hasSafeImport = true;
166
+ }
167
+ }
168
+ }
169
+ },
170
+
171
+ CallExpression(node) {
172
+ let isUnsafeCall = false;
173
+
174
+ // Check for direct function call: unsafeFn()
175
+ if (node.callee.name === unsafeFn) {
176
+ isUnsafeCall = true;
177
+ }
178
+
179
+ // Check for member expression: obj.unsafeFn()
180
+ if (
181
+ checkMemberExpression &&
182
+ node.callee.type === 'MemberExpression' &&
183
+ node.callee.property.name === unsafeFn
184
+ ) {
185
+ isUnsafeCall = true;
186
+ }
187
+
188
+ if (!isUnsafeCall) {
189
+ return;
190
+ }
191
+
192
+ context.report({
193
+ node,
194
+ messageId: 'noUnsafeOperation',
195
+ data: { safeModule: targetModule },
196
+ fix(fixer) {
197
+ const fixes = [];
198
+
199
+ // Replace unsafe call with safe call
200
+ if (node.callee.type === 'MemberExpression') {
201
+ // For obj.method(), replace just the method name
202
+ fixes.push(fixer.replaceText(node.callee.property, safeFn));
203
+ } else {
204
+ // For method(), replace the whole callee
205
+ fixes.push(fixer.replaceText(node.callee, safeFn));
206
+ }
207
+
208
+ // Add import if needed
209
+ if (!hasSafeImport) {
210
+ if (safeImportNode) {
211
+ // Add to existing safe module import
212
+ const lastSpecifier = safeImportNode.specifiers.at(-1);
213
+ fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${safeFn}`));
214
+ } else {
215
+ // Create new import after unsafe import or at the top
216
+ const targetNode = unsafeImportNode || sourceCode.ast.body[0];
217
+ const newImport = `import { ${safeFn} } from '${targetModule}';\n`;
218
+ fixes.push(fixer.insertTextAfter(targetNode, newImport));
219
+ }
220
+ }
221
+
222
+ // Remove unsafe import if it's the only specifier
223
+ if (hasUnsafeImport && unsafeImportNode) {
224
+ const unsafeSpecs = filterUnsafeSpecifiers(unsafeImportNode, unsafeFn);
225
+ if (unsafeImportNode.specifiers.length === 1 && unsafeSpecs.length === 1) {
226
+ // Remove entire import
227
+ fixes.push(fixer.remove(unsafeImportNode));
228
+ } else if (unsafeSpecs.length > 0) {
229
+ // Remove just the unsafe specifier
230
+ fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeImportNode, unsafeSpecs));
231
+ }
232
+ }
233
+
234
+ return fixes;
235
+ },
236
+ });
237
+ },
238
+ };
239
+ },
240
+ };
241
+ };