@vibe-agent-toolkit/utils 0.2.0-rc.7 → 0.2.0-rc.8

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 (175) hide show
  1. package/README.md +22 -3
  2. package/dist/asset-reference.js +3 -11
  3. package/dist/asset-reference.js.map +1 -1
  4. package/dist/crawl-timing.d.ts +3 -3
  5. package/dist/crawl-timing.js +3 -3
  6. package/dist/dirent-kind.d.ts +83 -0
  7. package/dist/dirent-kind.d.ts.map +1 -0
  8. package/dist/dirent-kind.js +129 -0
  9. package/dist/dirent-kind.js.map +1 -0
  10. package/dist/errors/errno.d.ts +51 -0
  11. package/dist/errors/errno.d.ts.map +1 -0
  12. package/dist/errors/errno.js +112 -0
  13. package/dist/errors/errno.js.map +1 -0
  14. package/dist/errors/vat-error.d.ts +60 -0
  15. package/dist/errors/vat-error.d.ts.map +1 -0
  16. package/dist/errors/vat-error.js +86 -0
  17. package/dist/errors/vat-error.js.map +1 -0
  18. package/dist/file-crawler.js +9 -9
  19. package/dist/file-crawler.js.map +1 -1
  20. package/dist/fs/file-hash.d.ts.map +1 -1
  21. package/dist/fs/file-hash.js +0 -1
  22. package/dist/fs/file-hash.js.map +1 -1
  23. package/dist/fs-utils.d.ts +16 -18
  24. package/dist/fs-utils.d.ts.map +1 -1
  25. package/dist/fs-utils.js +56 -80
  26. package/dist/fs-utils.js.map +1 -1
  27. package/dist/fs.d.ts +2 -1
  28. package/dist/fs.d.ts.map +1 -1
  29. package/dist/fs.js +2 -1
  30. package/dist/fs.js.map +1 -1
  31. package/dist/git-run.d.ts +1 -1
  32. package/dist/git-run.js +1 -1
  33. package/dist/git-tracker.d.ts +1 -1
  34. package/dist/git-tracker.d.ts.map +1 -1
  35. package/dist/git-tracker.js +1 -2
  36. package/dist/git-tracker.js.map +1 -1
  37. package/dist/git-utils.d.ts.map +1 -1
  38. package/dist/git-utils.js +0 -1
  39. package/dist/git-utils.js.map +1 -1
  40. package/dist/gitignore-checker.d.ts.map +1 -1
  41. package/dist/gitignore-checker.js +9 -3
  42. package/dist/gitignore-checker.js.map +1 -1
  43. package/dist/index.d.ts +7 -3
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +19 -4
  46. package/dist/index.js.map +1 -1
  47. package/dist/listing-refusal.d.ts +2 -1
  48. package/dist/listing-refusal.d.ts.map +1 -1
  49. package/dist/listing-refusal.js +3 -3
  50. package/dist/listing-refusal.js.map +1 -1
  51. package/dist/path-containment.d.ts +64 -0
  52. package/dist/path-containment.d.ts.map +1 -0
  53. package/dist/path-containment.js +111 -0
  54. package/dist/path-containment.js.map +1 -0
  55. package/dist/path-core.d.ts +77 -3
  56. package/dist/path-core.d.ts.map +1 -1
  57. package/dist/path-core.js +96 -6
  58. package/dist/path-core.js.map +1 -1
  59. package/dist/path-utils.d.ts.map +1 -1
  60. package/dist/path-utils.js +42 -47
  61. package/dist/path-utils.js.map +1 -1
  62. package/dist/path.d.ts +1 -1
  63. package/dist/path.d.ts.map +1 -1
  64. package/dist/path.js +1 -1
  65. package/dist/path.js.map +1 -1
  66. package/dist/project-utils.d.ts.map +1 -1
  67. package/dist/project-utils.js +9 -6
  68. package/dist/project-utils.js.map +1 -1
  69. package/dist/safe-exec.d.ts +2 -1
  70. package/dist/safe-exec.d.ts.map +1 -1
  71. package/dist/safe-exec.js +25 -7
  72. package/dist/safe-exec.js.map +1 -1
  73. package/dist/skill-targets.d.ts +2 -2
  74. package/dist/skill-targets.js +2 -2
  75. package/dist/skill-test/auth-resolver.d.ts +10 -2
  76. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  77. package/dist/skill-test/auth-resolver.js +26 -11
  78. package/dist/skill-test/auth-resolver.js.map +1 -1
  79. package/dist/skill-test/spawn-claude.d.ts +2 -1
  80. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  81. package/dist/skill-test/spawn-claude.js +20 -3
  82. package/dist/skill-test/spawn-claude.js.map +1 -1
  83. package/dist/skill-test/transcript.d.ts.map +1 -1
  84. package/dist/skill-test/transcript.js +18 -17
  85. package/dist/skill-test/transcript.js.map +1 -1
  86. package/dist/stdio-blocking.js +13 -14
  87. package/dist/stdio-blocking.js.map +1 -1
  88. package/dist/test-helpers.d.ts +47 -204
  89. package/dist/test-helpers.d.ts.map +1 -1
  90. package/dist/test-helpers.js +105 -310
  91. package/dist/test-helpers.js.map +1 -1
  92. package/dist/testing/executables.d.ts +32 -0
  93. package/dist/testing/executables.d.ts.map +1 -0
  94. package/dist/testing/executables.js +70 -0
  95. package/dist/testing/executables.js.map +1 -0
  96. package/dist/testing/hostile-tree.d.ts +79 -0
  97. package/dist/testing/hostile-tree.d.ts.map +1 -0
  98. package/dist/testing/hostile-tree.js +164 -0
  99. package/dist/testing/hostile-tree.js.map +1 -0
  100. package/dist/testing/platform-gates.d.ts +31 -0
  101. package/dist/testing/platform-gates.d.ts.map +1 -0
  102. package/dist/testing/platform-gates.js +31 -0
  103. package/dist/testing/platform-gates.js.map +1 -0
  104. package/dist/testing/temp-dir.d.ts +255 -0
  105. package/dist/testing/temp-dir.d.ts.map +1 -0
  106. package/dist/testing/temp-dir.js +363 -0
  107. package/dist/testing/temp-dir.js.map +1 -0
  108. package/dist/testing.d.ts +5 -0
  109. package/dist/testing.d.ts.map +1 -1
  110. package/dist/testing.js +10 -1
  111. package/dist/testing.js.map +1 -1
  112. package/dist/text-content.d.ts.map +1 -1
  113. package/dist/text-content.js +7 -1
  114. package/dist/text-content.js.map +1 -1
  115. package/dist/text-file.d.ts.map +1 -1
  116. package/dist/text-file.js +0 -2
  117. package/dist/text-file.js.map +1 -1
  118. package/dist/timing-dump.d.ts +1 -1
  119. package/dist/timing-dump.d.ts.map +1 -1
  120. package/dist/timing-dump.js +1 -2
  121. package/dist/timing-dump.js.map +1 -1
  122. package/dist/windows-shell.d.ts +0 -1
  123. package/dist/windows-shell.d.ts.map +1 -1
  124. package/dist/windows-shell.js +0 -1
  125. package/dist/windows-shell.js.map +1 -1
  126. package/dist/zod-introspection.js +2 -2
  127. package/dist/zod-introspection.js.map +1 -1
  128. package/eslint/README.md +125 -67
  129. package/eslint/index.cjs +111 -162
  130. package/eslint/index.d.cts +14 -17
  131. package/eslint/rules/callee-name.cjs +23 -0
  132. package/eslint/rules/commands-import-boundary.cjs +148 -0
  133. package/eslint/rules/dead-import.cjs +1 -1
  134. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  135. package/eslint/rules/eslint-rule-factory.cjs +5 -2
  136. package/eslint/rules/exempt-path-matcher.cjs +1 -1
  137. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  138. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  139. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  140. package/eslint/rules/no-bare-symlink-in-tests.cjs +16 -3
  141. package/eslint/rules/no-blind-catch.cjs +171 -0
  142. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  143. package/eslint/rules/no-decaying-referent.cjs +216 -0
  144. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  145. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  146. package/eslint/rules/no-fragile-entrypoint-guard.cjs +14 -1
  147. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  148. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  149. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  150. package/eslint/rules/no-hardcoded-path-split.cjs +7 -2
  151. package/eslint/rules/no-io-in-unit-tier.cjs +128 -0
  152. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  153. package/eslint/rules/no-manual-path-normalize.cjs +7 -2
  154. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  155. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  156. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  157. package/eslint/rules/no-path-startswith.cjs +7 -2
  158. package/eslint/rules/no-process-exit-in-phase.cjs +12 -2
  159. package/eslint/rules/no-raw-node-path.cjs +563 -0
  160. package/eslint/rules/no-raw-text-decode.cjs +10 -1
  161. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  162. package/eslint/rules/no-self-package-import.cjs +13 -2
  163. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  164. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  165. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  166. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  167. package/eslint/rules/no-version-literal.cjs +151 -0
  168. package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
  169. package/eslint/rules/require-justified-skip.cjs +8 -2
  170. package/eslint/rules/safe-import.cjs +1 -1
  171. package/package.json +4 -9
  172. package/eslint/rules/no-path-join.cjs +0 -14
  173. package/eslint/rules/no-path-relative.cjs +0 -14
  174. package/eslint/rules/no-path-resolve.cjs +0 -14
  175. package/eslint/rules/path-function-rule-factory.cjs +0 -496
@@ -0,0 +1,282 @@
1
+ /**
2
+ * ESLint rule: dirent-type-needs-symlink-check
3
+ *
4
+ * Flags `.isFile()` / `.isDirectory()` on a Dirent whose binding is never
5
+ * asked `.isSymbolicLink()`.
6
+ *
7
+ * A Dirent describes the entry itself, not what it points at, so for a
8
+ * symlink BOTH `isFile()` and `isDirectory()` are false. A walk written as
9
+ * `if (e.isDirectory()) recurse(); else if (e.isFile()) read();` therefore
10
+ * does not refuse a link, and does not follow it either — it drops the entry
11
+ * on the floor without a word. The sweep found staged skill trees, size
12
+ * accounting and packaging walks with exactly that shape, and every one
13
+ * reported a tree with a symlink in it as clean. The decision about a link —
14
+ * refuse, follow, record — has to be made where it can be seen, on the same
15
+ * binding, before the type test.
16
+ *
17
+ * ## How a binding is known to be a Dirent
18
+ *
19
+ * - the result of `readdir` / `readdirSync` called with `{ withFileTypes: true }`
20
+ * (any receiver: bare, `fs.`, `fs.promises.`), stored in a variable, assigned
21
+ * later, or iterated directly by `for…of`;
22
+ * - the result of `opendir` / `opendirSync`, iterated by `for await`;
23
+ * - an element of such a collection, via `for…of` or a callback to `filter` /
24
+ * `map` / `some` / `every` / `find` / `forEach` / `flatMap` — whether the
25
+ * collection is a variable or the `readdir` call itself, chained
26
+ * (`readdirSync(d, { withFileTypes: true }).filter(e => e.isDirectory())`),
27
+ * including through a `filter` / `toSorted` / `slice` that keeps the
28
+ * collection a Dirent collection;
29
+ * - a parameter or variable annotated `Dirent`, `Dirent[]`,
30
+ * `readonly Dirent[]` or `Array<Dirent>`.
31
+ *
32
+ * A Stats object from `stat()` / `lstat()` is NOT a Dirent and is never
33
+ * flagged: `stat` already followed the link, and `lstat` callers are asking a
34
+ * different question. A `readdir` without `withFileTypes` yields strings.
35
+ *
36
+ * The guard must be on the SAME binding: an `lstat(e.name).isSymbolicLink()`
37
+ * beside an unguarded `e.isDirectory()` is two questions about two objects,
38
+ * and the rule cannot know they agree.
39
+ *
40
+ * @example
41
+ * // BAD — a symlinked directory is neither, so it silently vanishes
42
+ * for (const e of readdirSync(dir, { withFileTypes: true })) {
43
+ * if (e.isDirectory()) walk(e); else if (e.isFile()) read(e);
44
+ * }
45
+ *
46
+ * // GOOD — the link is decided first, visibly
47
+ * for (const e of readdirSync(dir, { withFileTypes: true })) {
48
+ * if (e.isSymbolicLink()) { refuse(e); continue; }
49
+ * if (e.isDirectory()) walk(e); else if (e.isFile()) read(e);
50
+ * }
51
+ */
52
+
53
+ 'use strict';
54
+
55
+ const { calleeName } = require('./callee-name.cjs');
56
+
57
+ const READDIR_FUNCTIONS = new Set(['readdir', 'readdirSync']);
58
+ const OPENDIR_FUNCTIONS = new Set(['opendir', 'opendirSync']);
59
+ const ELEMENT_CALLBACK_METHODS = new Set([
60
+ 'filter', 'map', 'some', 'every', 'find', 'forEach', 'flatMap', 'findIndex', 'findLast',
61
+ ]);
62
+ /** Array methods whose RESULT is still the same Dirent collection (a `map` is not: it produced something else). */
63
+ const COLLECTION_PRESERVING_METHODS = new Set(['filter', 'toSorted', 'toReversed', 'slice', 'reverse', 'sort']);
64
+ const TYPE_TESTS = new Set(['isFile', 'isDirectory']);
65
+ const FUNCTION_TYPES = ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'];
66
+
67
+ /** Whether some argument is an object literal carrying `withFileTypes: true`. */
68
+ function hasWithFileTypes(call) {
69
+ return call.arguments.some(
70
+ (arg) =>
71
+ arg.type === 'ObjectExpression' &&
72
+ arg.properties.some(
73
+ (prop) =>
74
+ prop.type === 'Property' &&
75
+ !prop.computed &&
76
+ (prop.key.name ?? prop.key.value) === 'withFileTypes' &&
77
+ prop.value.type === 'Literal' &&
78
+ prop.value.value === true,
79
+ ),
80
+ );
81
+ }
82
+
83
+ /** Whether `expr` (possibly awaited) produces Dirents when stored or iterated. */
84
+ function isDirentCollectionCall(expr) {
85
+ const call = expr?.type === 'AwaitExpression' ? expr.argument : expr;
86
+ if (call?.type !== 'CallExpression') {
87
+ return false;
88
+ }
89
+ const name = calleeName(call);
90
+ return OPENDIR_FUNCTIONS.has(name) || (READDIR_FUNCTIONS.has(name) && hasWithFileTypes(call));
91
+ }
92
+
93
+ /** Whether a TS type node is the bare `Dirent` reference. */
94
+ function isDirentType(typeNode) {
95
+ return (
96
+ typeNode?.type === 'TSTypeReference' &&
97
+ typeNode.typeName.type === 'Identifier' &&
98
+ typeNode.typeName.name === 'Dirent'
99
+ );
100
+ }
101
+
102
+ /**
103
+ * 'element' for `Dirent`, 'collection' for `Dirent[]` / `readonly Dirent[]` /
104
+ * `Array<Dirent>` / `ReadonlyArray<Dirent>`, null otherwise.
105
+ */
106
+ function direntKindOfAnnotation(annotation) {
107
+ const typeNode = annotation?.typeAnnotation;
108
+ if (!typeNode) {
109
+ return null;
110
+ }
111
+ if (isDirentType(typeNode)) {
112
+ return 'element';
113
+ }
114
+ if (typeNode.type === 'TSTypeOperator') {
115
+ return direntKindOfAnnotation({ typeAnnotation: typeNode.typeAnnotation }) === 'collection' ? 'collection' : null;
116
+ }
117
+ if (typeNode.type === 'TSArrayType') {
118
+ return isDirentType(typeNode.elementType) ? 'collection' : null;
119
+ }
120
+ const generic = typeNode.type === 'TSTypeReference' ? typeNode.typeArguments ?? typeNode.typeParameters : null;
121
+ return generic && isDirentType(generic.params[0]) ? 'collection' : null;
122
+ }
123
+
124
+ /** The kind of member call a reference participates in: `{ name, call }` or null. */
125
+ function memberCallOf(identifier) {
126
+ const member = identifier.parent;
127
+ if (member?.type !== 'MemberExpression' || member.object !== identifier || member.computed) {
128
+ return null;
129
+ }
130
+ const call = member.parent;
131
+ if (call?.type !== 'CallExpression' || call.callee !== member || member.property.type !== 'Identifier') {
132
+ return null;
133
+ }
134
+ return { name: member.property.name, call };
135
+ }
136
+
137
+ module.exports = {
138
+ meta: {
139
+ type: 'problem',
140
+ docs: {
141
+ description:
142
+ 'Require an isSymbolicLink() check on a Dirent before isFile()/isDirectory() — both are ' +
143
+ 'false for a symlink, so an unchecked walk drops links silently',
144
+ recommended: false,
145
+ recommendedSeverity: 'warn',
146
+ },
147
+ schema: [],
148
+ messages: {
149
+ direntTypeWithoutSymlinkCheck:
150
+ 'A symlink answers false to BOTH isFile() and isDirectory(), so this walk drops every link ' +
151
+ 'without a word. Decide the link on this same binding first — ' +
152
+ 'if ({{name}}.isSymbolicLink()) { refuse / follow / record } — then test the type.',
153
+ },
154
+ },
155
+
156
+ create(context) {
157
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
158
+ const collections = new Set();
159
+ const elements = new Set();
160
+
161
+ /** The variable an identifier REFERENCE resolves to, or null. */
162
+ function resolvedVariable(identifier) {
163
+ const reference = sourceCode.getScope(identifier).references.find((ref) => ref.identifier === identifier);
164
+ return reference?.resolved ?? null;
165
+ }
166
+
167
+ function markDeclared(node, kind) {
168
+ for (const variable of sourceCode.getDeclaredVariables(node)) {
169
+ (kind === 'element' ? elements : collections).add(variable);
170
+ }
171
+ }
172
+
173
+ function markAnnotatedParams(fn) {
174
+ const declared = sourceCode.getDeclaredVariables(fn);
175
+ for (const param of fn.params) {
176
+ const kind = param.type === 'Identifier' ? direntKindOfAnnotation(param.typeAnnotation) : null;
177
+ const variable = kind ? declared.find((candidate) => candidate.name === param.name) : null;
178
+ if (variable) {
179
+ (kind === 'element' ? elements : collections).add(variable);
180
+ }
181
+ }
182
+ }
183
+
184
+ function isCollectionIdentifier(expr) {
185
+ return expr.type === 'Identifier' && collections.has(resolvedVariable(expr));
186
+ }
187
+
188
+ /**
189
+ * Whether `expr` is a Dirent collection: a bound variable, the `readdir`
190
+ * call itself (possibly awaited or parenthesised), or a chain of
191
+ * collection-preserving array methods on one of those.
192
+ */
193
+ function isDirentCollection(expr) {
194
+ if (!expr) {
195
+ return false;
196
+ }
197
+ if (isCollectionIdentifier(expr) || isDirentCollectionCall(expr)) {
198
+ return true;
199
+ }
200
+ const { callee } = expr.type === 'CallExpression' ? expr : {};
201
+ return (
202
+ callee?.type === 'MemberExpression' &&
203
+ callee.property.type === 'Identifier' &&
204
+ COLLECTION_PRESERVING_METHODS.has(callee.property.name) &&
205
+ isDirentCollection(callee.object)
206
+ );
207
+ }
208
+
209
+ function reportUnguarded(variable) {
210
+ let guarded = false;
211
+ const typeTests = [];
212
+ for (const reference of variable.references) {
213
+ const use = memberCallOf(reference.identifier);
214
+ if (use?.name === 'isSymbolicLink') {
215
+ guarded = true;
216
+ } else if (use && TYPE_TESTS.has(use.name)) {
217
+ typeTests.push(use.call);
218
+ }
219
+ }
220
+ if (guarded) {
221
+ return;
222
+ }
223
+ for (const call of typeTests) {
224
+ context.report({ node: call, messageId: 'direntTypeWithoutSymlinkCheck', data: { name: variable.name } });
225
+ }
226
+ }
227
+
228
+ return {
229
+ VariableDeclarator(node) {
230
+ if (isDirentCollectionCall(node.init)) {
231
+ markDeclared(node, 'collection');
232
+ } else if (node.id.type === 'Identifier') {
233
+ const kind = direntKindOfAnnotation(node.id.typeAnnotation);
234
+ if (kind) {
235
+ markDeclared(node, kind);
236
+ }
237
+ }
238
+ },
239
+ AssignmentExpression(node) {
240
+ if (node.left.type === 'Identifier' && isDirentCollectionCall(node.right)) {
241
+ const variable = resolvedVariable(node.left);
242
+ if (variable) {
243
+ collections.add(variable);
244
+ }
245
+ }
246
+ },
247
+ ForOfStatement(node) {
248
+ if (node.left.type !== 'VariableDeclaration') {
249
+ return;
250
+ }
251
+ if (isDirentCollection(node.right)) {
252
+ markDeclared(node.left, 'element');
253
+ }
254
+ },
255
+ CallExpression(node) {
256
+ const { callee } = node;
257
+ const callback = node.arguments[0];
258
+ if (
259
+ callee.type === 'MemberExpression' &&
260
+ callee.property.type === 'Identifier' &&
261
+ ELEMENT_CALLBACK_METHODS.has(callee.property.name) &&
262
+ isDirentCollection(callee.object) &&
263
+ callback &&
264
+ FUNCTION_TYPES.includes(callback.type) &&
265
+ callback.params[0]?.type === 'Identifier'
266
+ ) {
267
+ const declared = sourceCode.getDeclaredVariables(callback);
268
+ const first = declared.find((variable) => variable.name === callback.params[0].name);
269
+ if (first) {
270
+ elements.add(first);
271
+ }
272
+ }
273
+ },
274
+ [FUNCTION_TYPES.join(',')]: markAnnotatedParams,
275
+ 'Program:exit'() {
276
+ for (const variable of elements) {
277
+ reportUnguarded(variable);
278
+ }
279
+ },
280
+ };
281
+ },
282
+ };
@@ -18,6 +18,9 @@
18
18
  * in one repo, so a baked-in default is a hole in every OTHER repo. Consumers
19
19
  * declare their own via the rule option (see `exempt-path-matcher.cjs`).
20
20
  * @param {boolean} [config.checkMemberExpression] - Check for obj.method() calls (default: false)
21
+ * @param {Object} config.docs - The rule's `meta.docs` manifest entry: `category`,
22
+ * `bans`, `useInstead`, optional `subpath`, and `recommended` /
23
+ * `recommendedSeverity` (what `index.cjs` builds `configs.recommended` from).
21
24
  * @returns {Object} ESLint rule definition
22
25
  *
23
26
  * @example
@@ -147,6 +150,7 @@ module.exports = function createNoUnsafeRule(config) {
147
150
  safeFn,
148
151
  safeModule,
149
152
  message,
153
+ docs,
150
154
  exemptFiles = [],
151
155
  checkMemberExpression = false,
152
156
  } = config;
@@ -166,8 +170,7 @@ module.exports = function createNoUnsafeRule(config) {
166
170
  type: 'problem',
167
171
  docs: {
168
172
  description: `Enforce use of ${safeFn}() instead of ${unsafeFn}()`,
169
- category: 'Best Practices',
170
- recommended: true,
173
+ ...docs,
171
174
  },
172
175
  fixable: 'code',
173
176
  schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
@@ -23,7 +23,7 @@
23
23
  * - `isTestFile` — "is this a test file?" (a category, anchored on the basename's
24
24
  * extension rather than on a path segment)
25
25
  *
26
- * `eslint-rule-factory.cjs`, `path-function-rule-factory.cjs`,
26
+ * `eslint-rule-factory.cjs`, `no-raw-node-path.cjs`,
27
27
  * `no-command-direct-factory.cjs` and `no-unix-shell-commands.cjs` all use these.
28
28
  * Do not re-implement any of them: the sibling-factory copy is how the bug
29
29
  * shipped four times in the first place.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ESLint rule: explicit-zod-strictness
3
+ *
4
+ * Every `z.object({...})` must say, in the same method chain, what it does
5
+ * with a key it does not declare: `.strict()` (refuse), `.passthrough()` /
6
+ * `.loose()` (keep), or `.strip()` (drop — said out loud, for the write-side
7
+ * whitelist whose whole point is dropping). Zod's DEFAULT is that third answer
8
+ * left unsaid, and that is the one this repo cannot afford: CLAUDE.md retired
9
+ * every hand-bumped version integer on the promise that a strict schema is
10
+ * what refuses stale stored data, and a default-strip schema keeps none of that
11
+ * promise. It also hides the adopter-config trap the other way round: a key
12
+ * the schema strips on the way in is a key the adopter thinks is honoured.
13
+ *
14
+ * `z.strictObject()` and `z.looseObject()` are explicit by construction and
15
+ * pass. The marker may sit anywhere later in the chain (`.describe('…')
16
+ * .strict()`, `.strict().optional()`), and `.extend()` / `.partial()` /
17
+ * `.pick()` inherit the policy of what they were called on, so a chain that
18
+ * reaches a marker through them is explicit too.
19
+ *
20
+ * Option `allowDefaultStripIn: string[]` — repo-relative paths of files whose
21
+ * `z.object` calls may keep the default. This is a ratchet list, not a scope:
22
+ * name files, not directories, so a new default-strip schema cannot arrive
23
+ * unannounced under an allowed prefix.
24
+ *
25
+ * @example
26
+ * // BAD — an unknown key vanishes without a word
27
+ * const S = z.object({ version: z.string() });
28
+ *
29
+ * // GOOD — the policy is written where the shape is
30
+ * const S = z.object({ version: z.string() }).strict();
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const { createExemptPathMatcher } = require('./exempt-path-matcher.cjs');
36
+
37
+ /** Chain methods that decide the unknown-key policy. */
38
+ const EXPLICIT_METHODS = new Set(['strict', 'passthrough', 'loose', 'strip']);
39
+
40
+ /** Whether `node` is a `z.object(...)` call. */
41
+ function isZodObjectCall(node) {
42
+ const { callee } = node;
43
+ return (
44
+ callee.type === 'MemberExpression' &&
45
+ !callee.computed &&
46
+ callee.object.type === 'Identifier' &&
47
+ callee.object.name === 'z' &&
48
+ callee.property.type === 'Identifier' &&
49
+ callee.property.name === 'object'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Whether the method chain that starts at `call` reaches an explicit
55
+ * unknown-key method. Follows `<call>.<method>(...)` links only: the moment
56
+ * the value is stored, passed, or returned the chain has ended and no later
57
+ * `.strict()` on some other expression counts for this object.
58
+ */
59
+ function chainDeclaresPolicy(call) {
60
+ let current = call;
61
+ for (;;) {
62
+ const member = current.parent;
63
+ if (member?.type !== 'MemberExpression' || member.object !== current || member.computed) {
64
+ return false;
65
+ }
66
+ const outer = member.parent;
67
+ if (outer?.type !== 'CallExpression' || outer.callee !== member) {
68
+ return false;
69
+ }
70
+ if (member.property.type === 'Identifier' && EXPLICIT_METHODS.has(member.property.name)) {
71
+ return true;
72
+ }
73
+ current = outer;
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ meta: {
79
+ type: 'problem',
80
+ docs: {
81
+ description:
82
+ 'Require every z.object({...}) to declare its unknown-key policy in the same chain — ' +
83
+ '.strict(), .passthrough(), .loose() or an explicit .strip() — because the default silently strips keys',
84
+ recommended: false,
85
+ recommendedSeverity: 'warn',
86
+ },
87
+ schema: [
88
+ {
89
+ type: 'object',
90
+ properties: {
91
+ allowDefaultStripIn: { type: 'array', items: { type: 'string' }, uniqueItems: true },
92
+ },
93
+ additionalProperties: false,
94
+ },
95
+ ],
96
+ messages: {
97
+ defaultStrip:
98
+ 'This z.object() keeps Zod\'s default of silently DROPPING unknown keys. Say what you mean: ' +
99
+ '.strict() refuses them (the default for anything VAT stores or reads back), ' +
100
+ '.passthrough() keeps them (only for a document some other party owns — say who, beside it), ' +
101
+ '.strip() drops them on purpose (a write-side whitelist — say why, beside it).',
102
+ },
103
+ },
104
+
105
+ create(context) {
106
+ const allowed = context.options?.[0]?.allowDefaultStripIn ?? [];
107
+ const filename = context.filename ?? context.getFilename();
108
+ if (createExemptPathMatcher(allowed)(filename)) {
109
+ return {};
110
+ }
111
+
112
+ return {
113
+ CallExpression(node) {
114
+ if (isZodObjectCall(node) && !chainDeclaresPolicy(node)) {
115
+ context.report({ node, messageId: 'defaultStrip' });
116
+ }
117
+ },
118
+ };
119
+ },
120
+ };
@@ -114,8 +114,12 @@ module.exports = {
114
114
  docs: {
115
115
  description:
116
116
  'Disallow dynamic `import()` of a filesystem path; wrap with `pathToFileURL(p).href`.',
117
- category: 'Cross-platform compatibility',
117
+ category: 'URLs and dynamic imports',
118
+ bans: '`await import(absolutePath)`',
119
+ useInstead: '`dynamicImportPath()` / `pathToFileURL(p).href`',
120
+ subpath: '/fs',
118
121
  recommended: true,
122
+ recommendedSeverity: 'error',
119
123
  },
120
124
  messages: {
121
125
  useFileUrl:
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ESLint rule: no-bare-executable-spawn
3
+ *
4
+ * Flags `spawn`, `spawnSync`, `execFile`, `execFileSync` or `execSync` whose
5
+ * first argument is the string literal `'git'` or `'node'` — bare or through
6
+ * a namespace (`cp.spawnSync('git', …)`).
7
+ *
8
+ * A bare name asks the OS to search `PATH` at spawn time, so a writable
9
+ * directory on `PATH` is a place to plant a binary (SonarCloud S4036), and
10
+ * `node` by name may not be the node running the caller. Resolve once and
11
+ * spawn the absolute path: `process.execPath` for node in source;
12
+ * `NODE_EXECUTABLE` / `gitExecutable()` from `@vibe-agent-toolkit/utils/testing`
13
+ * in tests. Sonar sees new code only and named 13 of 84 sites; this rule sees
14
+ * every file, so the class cannot regrow one PR at a time.
15
+ *
16
+ * @example
17
+ * // BAD
18
+ * spawnSync('git', ['status'], { cwd });
19
+ *
20
+ * // GOOD
21
+ * spawnSync(gitExecutable(), ['status'], { cwd });
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const { calleeName } = require('./callee-name.cjs');
27
+
28
+ const SPAWN_CALLS = new Set(['spawn', 'spawnSync', 'execFile', 'execFileSync', 'execSync']);
29
+ const BARE_NAMES = new Set(['git', 'node']);
30
+
31
+ /** The spawn-family callee name of a call, bare or namespaced, or null. */
32
+ function spawnCallName(call) {
33
+ const name = calleeName(call);
34
+ return name !== null && SPAWN_CALLS.has(name) ? name : null;
35
+ }
36
+
37
+ module.exports = {
38
+ meta: {
39
+ type: 'problem',
40
+ docs: {
41
+ description:
42
+ "Disallow spawning 'git' or 'node' by bare name — resolve the executable once " +
43
+ '(process.execPath; NODE_EXECUTABLE / gitExecutable() in tests) and spawn the absolute path',
44
+ category: 'Filesystem and process',
45
+ recommended: true,
46
+ recommendedSeverity: 'error',
47
+ },
48
+ schema: [],
49
+ messages: {
50
+ bareName:
51
+ "{{call}}('{{name}}', …) searches PATH at spawn time. Spawn an absolute path: " +
52
+ "process.execPath for node; NODE_EXECUTABLE / gitExecutable() from '@vibe-agent-toolkit/utils/testing' in tests.",
53
+ },
54
+ },
55
+
56
+ create(context) {
57
+ return {
58
+ CallExpression(node) {
59
+ const call = spawnCallName(node);
60
+ if (call === null) {
61
+ return;
62
+ }
63
+ const first = node.arguments[0];
64
+ if (first?.type === 'Literal' && typeof first.value === 'string' && BARE_NAMES.has(first.value)) {
65
+ context.report({ node: first, messageId: 'bareName', data: { call, name: first.value } });
66
+ }
67
+ },
68
+ };
69
+ },
70
+ };
@@ -54,9 +54,22 @@ module.exports = {
54
54
  meta: {
55
55
  type: 'problem',
56
56
  docs: {
57
- description: 'Ban unguarded fs.symlinkSync()/fs.symlink() — route tests through createSymlink()/createSymlinkAsync(), and guard shipped code against the Windows privilege requirement',
58
- category: 'Cross-Platform',
59
- recommended: true,
57
+ description:
58
+ 'Ban unguarded fs.symlinkSync()/fs.symlink() — route tests through createSymlink()/createSymlinkAsync(), and guard shipped code against the Windows privilege requirement',
59
+ category: 'Filesystem and process',
60
+ bans: 'unguarded `fs.symlinkSync()` / `fs.promises.symlink()`',
61
+ useInstead: 'in tests: `createSymlink(cap, …)` / `createSymlinkAsync(cap, …)`; in shipped code: a win32 junction, or a `catch` naming the privilege',
62
+ subpath: '/testing',
63
+ // Not in `recommended`: half its advice is unreachable without a helper the
64
+ // consumer may not have. In a test file it points at `createSymlink()` /
65
+ // `createSymlinkAsync()` on THIS package's `./testing` subpath, routed through
66
+ // a probed capability token and the vitest-specific `skip()` idiom; an adopter
67
+ // on a different runner should not silently inherit that. Its shipped-code
68
+ // half (`unguardedSymlink`) is portable advice on its own — the two share one
69
+ // rule id and cannot be enabled separately, and the test half is what keeps
70
+ // the pair out. VAT enables the whole rule explicitly.
71
+ recommended: false,
72
+ recommendedSeverity: 'error',
60
73
  },
61
74
  fixable: null,
62
75
  schema: [EXEMPT_FILES_SCHEMA],