@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,171 @@
1
+ /**
2
+ * ESLint rule: no-blind-catch
3
+ *
4
+ * Flags a `catch` clause that never LOOKS at its error and never THROWS. Such
5
+ * a clause answers every failure the same way — a sentinel `return null`, a
6
+ * `continue`, an empty body, a fallback assignment — so the case it was
7
+ * written for (a file that is not there) becomes indistinguishable from the
8
+ * cases it was not (a permission refusal, a corrupt artifact, a `TypeError`
9
+ * from a bug two frames down). The result is a tool that is quietest exactly
10
+ * when it is most wrong: a directory the OS refused to list reports as empty,
11
+ * and a build that examined nothing exits 0.
12
+ *
13
+ * This is the class behind the worst finding in seven consecutive adversarial
14
+ * review rounds on one branch. A crawler learned to REFUSE an unreadable
15
+ * directory instead of skipping it; one caller's `catch { return null }`
16
+ * absorbed the refusal, and `vat audit` silently ran with no population. The
17
+ * fix was type-forcing the seam so `tsc` enumerated the callers — but a blind
18
+ * `catch` is the one seam `tsc` cannot see, because it compiles for every
19
+ * callee that ever learns to throw.
20
+ *
21
+ * ## What counts as handling
22
+ *
23
+ * The rule is a FLOOR, deliberately syntactic: the clause is fine if either
24
+ *
25
+ * 1. the error binding is REFERENCED anywhere in the body — narrowing on it
26
+ * (`isFilesystemAccessError(e)`, `e instanceof X`, `e.code === 'ENOENT'`),
27
+ * carrying it into a report (`errors.push(String(e))`, `{ ok: false, error }`),
28
+ * or logging it — or
29
+ * 2. the body THROWS (a rethrow, or a translation into a louder error), at the
30
+ * clause's own level — a `throw` inside a nested function is a promise to
31
+ * fail later, not a rethrow from this catch.
32
+ *
33
+ * A reference is a reference: the rule cannot tell `narrow(e)` from `log(e)`,
34
+ * and does not try. What it guarantees is the weaker, enforceable property —
35
+ * *the error was looked at before being discarded* — which is the property
36
+ * every one of the shipped defects lacked.
37
+ *
38
+ * ## What this rule does NOT catch
39
+ *
40
+ * - `catch (e) { void e; return null; }` — a reference written to dodge the
41
+ * rule. Code review's job.
42
+ * - A narrowing that is wrong: `if (e instanceof Error) return null` looks at
43
+ * the error and still absorbs everything. The rule sees a reference.
44
+ * - A catch whose body references the binding only to build a message it
45
+ * then throws away.
46
+ *
47
+ * There is deliberately NO annotation escape hatch. Every site this rule
48
+ * flags has a legitimate rewrite — narrow and rethrow, or carry the error into
49
+ * the result — and an `eslint-disable` with a reason is already the escape
50
+ * hatch ESLint provides, visible in the diff and countable with
51
+ * `rg 'eslint-disable.*no-blind-catch'`.
52
+ *
53
+ * @example
54
+ * // BAD — "not there" and "refused" and "bug" all become null
55
+ * try { return statSync(p); } catch { return null; }
56
+ *
57
+ * // GOOD — narrowed to the case the sentinel means, everything else stays loud
58
+ * try { return statSync(p); } catch (e) {
59
+ * if (isPathAbsentError(e)) return null;
60
+ * throw e;
61
+ * }
62
+ *
63
+ * // GOOD — the error reaches the report instead of vanishing
64
+ * try { return parse(text); } catch (e) {
65
+ * return { ok: false, reason: String(e) };
66
+ * }
67
+ */
68
+
69
+ 'use strict';
70
+
71
+ /** Function-boundary node types: a `throw` inside one is not this clause's throw. */
72
+ const FUNCTION_TYPES = new Set([
73
+ 'FunctionDeclaration',
74
+ 'FunctionExpression',
75
+ 'ArrowFunctionExpression',
76
+ ]);
77
+
78
+ /** Keys on an ESTree node that point somewhere other than a child. */
79
+ const NON_CHILD_KEYS = new Set(['parent', 'loc', 'range']);
80
+
81
+ /** Whether `value` is an ESTree node (as opposed to a token, a literal, or null). */
82
+ function isNode(value) {
83
+ return value !== null && typeof value === 'object' && typeof value.type === 'string';
84
+ }
85
+
86
+ /** Every child node of `node`, in no particular order. */
87
+ function childNodes(node) {
88
+ const children = [];
89
+ for (const key of Object.keys(node)) {
90
+ if (NON_CHILD_KEYS.has(key)) {
91
+ continue;
92
+ }
93
+ const value = node[key];
94
+ const values = Array.isArray(value) ? value : [value];
95
+ children.push(...values.filter((child) => isNode(child)));
96
+ }
97
+ return children;
98
+ }
99
+
100
+ /**
101
+ * Whether `body` contains a `ThrowStatement` reachable without crossing a
102
+ * function boundary. Iterative so a deeply nested body cannot blow the stack.
103
+ */
104
+ function throwsAtOwnLevel(body) {
105
+ const stack = [body];
106
+ while (stack.length > 0) {
107
+ const node = stack.pop();
108
+ if (node.type === 'ThrowStatement') {
109
+ return true;
110
+ }
111
+ if (!FUNCTION_TYPES.has(node.type)) {
112
+ stack.push(...childNodes(node));
113
+ }
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Whether any binding the clause declares (`catch (e)`, `catch ({ code })`) is
120
+ * read in the body. A clause with no param declares nothing and reads nothing.
121
+ */
122
+ function readsErrorBinding(sourceCode, node) {
123
+ if (node.param === null) {
124
+ return false;
125
+ }
126
+ const declared = sourceCode.getDeclaredVariables(node);
127
+ return declared.some((variable) => variable.references.length > 0);
128
+ }
129
+
130
+ module.exports = {
131
+ meta: {
132
+ type: 'problem',
133
+ docs: {
134
+ description:
135
+ 'Disallow a catch clause that neither reads its error nor throws — it absorbs a bug, a ' +
136
+ 'permission refusal, or a corrupt artifact into the same answer as the case it was written for',
137
+ category: 'Error handling',
138
+ bans: 'a `catch` that neither reads its error nor throws',
139
+ useInstead: 'narrow on the error and rethrow the rest, or carry it into the result',
140
+ // `warn`, not `error`: the same migration-volume criterion as
141
+ // `no-raw-node-path`, without the autofix. A blind `catch` has no
142
+ // mechanical rewrite — each site is a decision about WHICH failure the
143
+ // sentinel stands for — and the first run on an existing tree is a long
144
+ // list (202 sites on this repo's own ~200k lines). `warn` makes that a
145
+ // backlog to burn down rather than a wall; the hazard is real at every one.
146
+ recommended: true,
147
+ recommendedSeverity: 'warn',
148
+ },
149
+ schema: [],
150
+ messages: {
151
+ blindCatch:
152
+ 'This catch discards the error without looking at it, so a bug, a permission refusal, or a ' +
153
+ 'corrupt artifact is absorbed into the same result as the case it was written for. Narrow it ' +
154
+ "to that case and rethrow the rest — `if (isPathAbsentError(e)) return null; throw e;` — or " +
155
+ 'carry the error into the result so the report can show it.',
156
+ },
157
+ },
158
+
159
+ create(context) {
160
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
161
+
162
+ return {
163
+ CatchClause(node) {
164
+ if (readsErrorBinding(sourceCode, node) || throwsAtOwnLevel(node.body)) {
165
+ return;
166
+ }
167
+ context.report({ node, messageId: 'blindCatch' });
168
+ },
169
+ };
170
+ },
171
+ };
@@ -20,4 +20,12 @@ module.exports = factory({
20
20
  message: 'Use safeExecSync() from {{safeModule}} instead of child_process.execSync() to prevent command injection (security + cross-platform)',
21
21
  // No baked-in exemption: the file that implements safeExecSync() is
22
22
  // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ docs: {
24
+ category: 'Filesystem and process',
25
+ bans: '`child_process.execSync()`',
26
+ useInstead: '`safeExecSync()`',
27
+ subpath: '/process',
28
+ recommended: true,
29
+ recommendedSeverity: 'error',
30
+ },
23
31
  });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * ESLint rule: no-decaying-referent
3
+ *
4
+ * Flags, in comments under `src/`, three kinds of referent that rot in place:
5
+ *
6
+ * - an issue or PR number (`#145`) — the issue closes, the PR merges, and the
7
+ * comment keeps pointing at a discussion nobody reopens;
8
+ * - an ISO date (`2026-08-22`, `2026-08`) — "re-derived on" is a claim about
9
+ * freshness that is false the day after it is written;
10
+ * - a named person (`Jeff`, configurable) — a ruling attributed to someone is a
11
+ * ruling nobody else feels entitled to revisit.
12
+ *
13
+ * None of these is the RULE the comment exists to state. The rule stays; the
14
+ * history goes where history is kept — the commit message, the CHANGELOG, or
15
+ * `docs/contributing/` — and `git log -L` holds the rest without decaying.
16
+ *
17
+ * Scope: files with a `/src/` segment that are not test files. Tests and
18
+ * scripts are not linted here; Markdown never reaches ESLint. One report per
19
+ * comment block, on the FIRST referent found, whichever kind it is — the fix is
20
+ * to rewrite the comment, not to hunt tokens one at a time.
21
+ *
22
+ * Options:
23
+ * - `names: string[]` — whole-word, case-sensitive names to treat as referents
24
+ * (default `['Jeff']`). Pass `[]` to disable the class.
25
+ * - `allowDates: boolean` — leave dates alone (default `false`).
26
+ *
27
+ * One line shape is exempt without an option: `@vendor-claim reviewed=<date>
28
+ * verify=<how>`. That date is READ by a freshness gate that fails the build when
29
+ * it ages past its budget — a date with a mechanism behind it is the opposite of
30
+ * a decaying one. The exemption covers only the line carrying the tag.
31
+ *
32
+ * @example
33
+ * // BAD — the reader must open #145 to learn what the code does
34
+ * // Fixed in #145 (Jeff, 2026-08-22): skip the second crawl.
35
+ *
36
+ * // GOOD — the rule, stated; the history is in `git log -L`
37
+ * // The second crawl is skipped: the first already answered from git.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { isTestFile } = require('./exempt-path-matcher.cjs');
43
+
44
+ /** `#12` … `#1234`, not preceded by a word/URL/entity character and not part of a longer number. */
45
+ const ISSUE_REF = /(?<![\w/&#])#\d{2,4}(?!\d)/u;
46
+
47
+ /**
48
+ * `20YY-MM` or `20YY-MM-DD`, standing alone. Glued to a word, a hyphen or a
49
+ * path separator it names a thing (`sweep-2026-09-12/`, a JSON Schema dialect's
50
+ * `draft/2020-12/schema`), not a day something was true.
51
+ */
52
+ const ISO_DATE = /(?<![\w/-])20\d\d-\d\d(?:-\d\d)?(?![\w-])/u;
53
+
54
+ /** A character that continues a word, for the whole-word check on names. */
55
+ const WORD_CHAR = /\w/u;
56
+
57
+ /** The one annotation whose date is machine-checked rather than left to rot. */
58
+ const VENDOR_CLAIM_TAG = '@vendor-claim';
59
+
60
+ /**
61
+ * `text` with every line carrying {@link VENDOR_CLAIM_TAG} blanked to spaces
62
+ * of the same length, so match indices still map onto the original comment.
63
+ */
64
+ function blankVendorClaimLines(text) {
65
+ if (!text.includes(VENDOR_CLAIM_TAG)) {
66
+ return text;
67
+ }
68
+ return text
69
+ .split('\n')
70
+ .map((line) => (line.includes(VENDOR_CLAIM_TAG) ? ' '.repeat(line.length) : line))
71
+ .join('\n');
72
+ }
73
+
74
+ /**
75
+ * The first whole-word, case-sensitive occurrence of any of `names` in `text`,
76
+ * as a `{ index, match }` pair, or null. A hand search rather than a built
77
+ * RegExp so a configured name needs no escaping and the rule stays free of a
78
+ * non-literal RegExp constructor.
79
+ */
80
+ function findWholeWordName(text, names) {
81
+ let best = null;
82
+ for (const name of names) {
83
+ let from = 0;
84
+ for (;;) {
85
+ const index = text.indexOf(name, from);
86
+ if (index === -1 || (best !== null && index >= best.index)) {
87
+ break;
88
+ }
89
+ const boundedBefore = index === 0 || !WORD_CHAR.test(text[index - 1]);
90
+ const boundedAfter = !WORD_CHAR.test(text[index + name.length] ?? '');
91
+ if (boundedBefore && boundedAfter) {
92
+ best = { index, match: name };
93
+ break;
94
+ }
95
+ from = index + 1;
96
+ }
97
+ }
98
+ return best;
99
+ }
100
+
101
+ /** Whether a linted filename is a non-test file under a `src/` directory. */
102
+ function isSourceFile(filename) {
103
+ if (!filename) {
104
+ return false;
105
+ }
106
+ const normalized = String(filename).replaceAll('\\', '/');
107
+ return normalized.includes('/src/') && !isTestFile(normalized);
108
+ }
109
+
110
+ /**
111
+ * The earliest referent in `text` across the enabled classes, or null.
112
+ * Each class contributes at most its first match; the earliest of those wins.
113
+ */
114
+ function firstReferent(text, matchers) {
115
+ let best = null;
116
+ for (const { kind, find } of matchers) {
117
+ const found = find(text);
118
+ if (found !== null && (best === null || found.index < best.index)) {
119
+ best = { kind, referent: found.match, index: found.index };
120
+ }
121
+ }
122
+ return best;
123
+ }
124
+
125
+ /** A matcher backed by a RegExp literal. */
126
+ function regexMatcher(kind, pattern) {
127
+ return {
128
+ kind,
129
+ find(text) {
130
+ const match = pattern.exec(text);
131
+ return match === null ? null : { index: match.index, match: match[0] };
132
+ },
133
+ };
134
+ }
135
+
136
+ /**
137
+ * The source location of `index` within a comment's value, accounting for the
138
+ * two-character opener (`//`, `/*`, `#!`) and any newlines before the match.
139
+ */
140
+ function locationInComment(comment, index) {
141
+ const before = comment.value.slice(0, index);
142
+ const lastNewline = before.lastIndexOf('\n');
143
+ const newlines = before.match(/\n/gu)?.length ?? 0;
144
+ const line = comment.loc.start.line + newlines;
145
+ const column = lastNewline === -1 ? comment.loc.start.column + 2 + index : index - lastNewline - 1;
146
+ return { line, column };
147
+ }
148
+
149
+ /** Build the enabled matcher table from the rule options. */
150
+ function buildMatchers(options) {
151
+ const matchers = [regexMatcher('an issue or PR number', ISSUE_REF)];
152
+ if (options.allowDates !== true) {
153
+ matchers.push(regexMatcher('a date', ISO_DATE));
154
+ }
155
+ const names = (options.names ?? ['Jeff']).filter((name) => name.length > 0);
156
+ if (names.length > 0) {
157
+ matchers.push({ kind: 'a person', find: (text) => findWholeWordName(text, names) });
158
+ }
159
+ return matchers;
160
+ }
161
+
162
+ module.exports = {
163
+ meta: {
164
+ type: 'suggestion',
165
+ docs: {
166
+ description:
167
+ 'Disallow issue/PR numbers, ISO dates and named people in src comments — they decay in ' +
168
+ 'place; the rule belongs in the comment and the history in the commit, CHANGELOG or docs',
169
+ recommended: false,
170
+ recommendedSeverity: 'warn',
171
+ },
172
+ schema: [
173
+ {
174
+ type: 'object',
175
+ properties: {
176
+ names: { type: 'array', items: { type: 'string' }, uniqueItems: true },
177
+ allowDates: { type: 'boolean' },
178
+ },
179
+ additionalProperties: false,
180
+ },
181
+ ],
182
+ messages: {
183
+ decayingReferent:
184
+ 'This comment cites {{kind}} ({{referent}}), which decays in place: nobody rewrites the ' +
185
+ 'comment when the issue closes, the date passes, or the person moves on. Keep the rule the ' +
186
+ 'comment states and move the history to the commit message, the CHANGELOG, or ' +
187
+ 'docs/contributing/ — `git log -L` keeps the rest.',
188
+ },
189
+ },
190
+
191
+ create(context) {
192
+ const filename = context.filename ?? context.getFilename();
193
+ if (!isSourceFile(filename)) {
194
+ return {};
195
+ }
196
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
197
+ const matchers = buildMatchers(context.options?.[0] ?? {});
198
+
199
+ return {
200
+ Program() {
201
+ for (const comment of sourceCode.getAllComments()) {
202
+ const found = firstReferent(blankVendorClaimLines(comment.value), matchers);
203
+ if (found === null) {
204
+ continue;
205
+ }
206
+ const start = locationInComment(comment, found.index);
207
+ context.report({
208
+ loc: { start, end: { line: start.line, column: start.column + found.referent.length } },
209
+ messageId: 'decayingReferent',
210
+ data: { kind: found.kind, referent: found.referent },
211
+ });
212
+ }
213
+ },
214
+ };
215
+ },
216
+ };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * ESLint rule: no-dotdot-containment
3
+ *
4
+ * Flags a `..` string test used as a containment check on a path:
5
+ * `rel.startsWith('..')`, `rel.startsWith('../')`, `name.includes('..')`, and
6
+ * `p.split(sep).includes('..')` / `.some(s => …)` / `.indexOf('..')`.
7
+ *
8
+ * A string test answers "does this text contain two dots", which is not the
9
+ * question. The question is "does this resolve INSIDE the root", and the two
10
+ * come apart in every direction that matters:
11
+ *
12
+ * - a symlink inside the root that points outside has no `..` in its name;
13
+ * - `startsWith('..')` also refuses a sibling whose name merely BEGINS with
14
+ * two dots (`..cache`), and `includes('..')` refuses `a..b`;
15
+ * - on Windows a drive letter or a UNC prefix escapes without a single dot;
16
+ * - the relative path was computed lexically, so a root that is itself a
17
+ * symlink gives a different answer from the one the filesystem gives.
18
+ *
19
+ * The sweep behind this rule verified sinks — a delete, a copy, an uninstall —
20
+ * guarded by exactly these tests. The answer is one helper that asks the
21
+ * filesystem: `isUnderRoot(root, candidate)` from `@vibe-agent-toolkit/utils`,
22
+ * realpath-based, and honest about a candidate that does not exist yet.
23
+ *
24
+ * ## Scope
25
+ *
26
+ * The receiver must look like a path: an identifier or member whose name
27
+ * carries a path word (`path`, `dir`, `rel`, `root`, `file`, `name`, `id`,
28
+ * `target`, `dest`, `src`, `location`, `folder`), or any call result (a
29
+ * `relative(root, p)` is a path by construction). A segment normaliser's
30
+ * `segment === '..'` compares one segment and is not a containment test; it
31
+ * is left alone, as is `startsWith('./')` and any literal without a `..`
32
+ * segment.
33
+ *
34
+ * @example
35
+ * // BAD — misses a symlink, refuses `..cache`, blind to a drive letter
36
+ * if (safePath.relative(root, p).startsWith('..')) refuse();
37
+ *
38
+ * // GOOD — the filesystem answers
39
+ * if (!isUnderRoot(root, p)) refuse();
40
+ */
41
+
42
+ 'use strict';
43
+
44
+ /** Words that mark a receiver as path-shaped. */
45
+ const PATH_WORD = /path|dir|rel|root|file|name|id|target|dest|src|location|folder/u;
46
+
47
+ /** A `..` segment at the start of a prefix literal: `..`, `../`, `..\`. */
48
+ const DOTDOT_PREFIX = /^\.\.(?:$|[\\/])/u;
49
+
50
+ /** A `..` segment anywhere in a substring literal: `..`, `/../`, `a/..`. */
51
+ const DOTDOT_SEGMENT = /(?:^|[\\/])\.\.(?:$|[\\/])/u;
52
+
53
+ /** Methods that, after a `.split(...)`, hunt for a segment. */
54
+ const MEMBERSHIP_METHODS = new Set(['includes', 'some', 'every', 'indexOf', 'find', 'filter']);
55
+
56
+ /** The non-computed method name of a call, or null. */
57
+ function methodName(call) {
58
+ const { callee } = call;
59
+ if (callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') {
60
+ return null;
61
+ }
62
+ return callee.property.name;
63
+ }
64
+
65
+ /** The identifier name a receiver is known by, or null when it has none. */
66
+ function receiverName(receiver) {
67
+ if (receiver.type === 'Identifier') {
68
+ return receiver.name;
69
+ }
70
+ if (receiver.type === 'MemberExpression' && !receiver.computed && receiver.property.type === 'Identifier') {
71
+ return receiver.property.name;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** Whether the receiver is named like a path. */
77
+ function isPathNamed(receiver) {
78
+ const name = receiverName(receiver);
79
+ return name !== null && PATH_WORD.test(name.toLowerCase());
80
+ }
81
+
82
+ /** The string value of the first argument when it is a literal, or null. */
83
+ function firstStringArg(call) {
84
+ const arg = call.arguments[0];
85
+ return arg?.type === 'Literal' && typeof arg.value === 'string' ? arg.value : null;
86
+ }
87
+
88
+ /** `<path>.startsWith('..')` / `<path>.includes('..')` — a `..` test on a path or a call result. */
89
+ function isDotdotStringTest(call, method) {
90
+ const literal = firstStringArg(call);
91
+ if (literal === null) {
92
+ return false;
93
+ }
94
+ const receiver = call.callee.object;
95
+ const pathLike = receiver.type === 'CallExpression' || isPathNamed(receiver);
96
+ if (!pathLike) {
97
+ return false;
98
+ }
99
+ if (method === 'startsWith') {
100
+ return DOTDOT_PREFIX.test(literal);
101
+ }
102
+ return method === 'includes' && DOTDOT_SEGMENT.test(literal);
103
+ }
104
+
105
+ /** A `'..'` literal (as an argument, or anywhere inside a callback). */
106
+ const DOTDOT_LITERAL = /['"`]\.\.['"`]/u;
107
+
108
+ /**
109
+ * `<path>.split(...).<membership>(...)` — hunting for a `..` segment. The
110
+ * membership call must actually mention `'..'` (as its argument, or inside
111
+ * its callback): `parts.split('/').filter(Boolean)` is splitting, not hunting.
112
+ */
113
+ function isSplitSegmentHunt(call, method, sourceCode) {
114
+ if (!MEMBERSHIP_METHODS.has(method) || call.arguments.length === 0) {
115
+ return false;
116
+ }
117
+ const receiver = call.callee.object;
118
+ return (
119
+ receiver.type === 'CallExpression' &&
120
+ methodName(receiver) === 'split' &&
121
+ isPathNamed(receiver.callee.object) &&
122
+ DOTDOT_LITERAL.test(sourceCode.getText(call.arguments[0]))
123
+ );
124
+ }
125
+
126
+ module.exports = {
127
+ meta: {
128
+ type: 'problem',
129
+ docs: {
130
+ description:
131
+ "Disallow startsWith('..') / includes('..') / split-and-hunt as a path containment check — " +
132
+ 'use the realpath-based isUnderRoot() helper',
133
+ recommended: false,
134
+ recommendedSeverity: 'warn',
135
+ },
136
+ schema: [],
137
+ messages: {
138
+ dotdotContainment:
139
+ 'A `..` string test is not a containment check: it misses a symlink that points outside, ' +
140
+ 'refuses a name that merely starts with two dots, and is blind to a Windows drive letter ' +
141
+ 'or UNC prefix. Ask the filesystem: isUnderRoot(root, candidate) from ' +
142
+ '@vibe-agent-toolkit/utils.',
143
+ },
144
+ },
145
+
146
+ create(context) {
147
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
148
+ return {
149
+ CallExpression(node) {
150
+ const method = methodName(node);
151
+ if (method === null) {
152
+ return;
153
+ }
154
+ if (isDotdotStringTest(node, method) || isSplitSegmentHunt(node, method, sourceCode)) {
155
+ context.report({ node, messageId: 'dotdotContainment' });
156
+ }
157
+ },
158
+ };
159
+ },
160
+ };
@@ -45,8 +45,11 @@ module.exports = {
45
45
  docs: {
46
46
  description:
47
47
  'Disallow constructing `file://` URLs by string concatenation; use `pathToFileURL()` from `node:url`.',
48
- category: 'Cross-platform compatibility',
48
+ category: 'URLs and dynamic imports',
49
+ bans: '`` `file://${p}` ``',
50
+ useInstead: '`pathToFileURL(p).href`',
49
51
  recommended: true,
52
+ recommendedSeverity: 'error',
50
53
  },
51
54
  messages: {
52
55
  useFileUrlBuilder:
@@ -178,8 +178,21 @@ module.exports = {
178
178
  docs: {
179
179
  description:
180
180
  'Ban entrypoint guards that silently answer false — `import.meta.main` (undefined before Node 24.2/22.18) and any raw compare of where the module lives to `process.argv[1]`, in URL space or path space (false through any symlink). Use `isEntrypoint()`.',
181
- category: 'Cross-Platform',
181
+ category: 'Entrypoint guards',
182
+ bans: '`import.meta.main`; `import.meta.url === pathToFileURL(process.argv[1]).href`; `fileURLToPath(import.meta.url) === process.argv[1]`',
183
+ useInstead: '`isEntrypoint(import.meta.url)`',
184
+ subpath: '/process',
185
+ // Not in `recommended`: ONE of its two halves is a claim about the CONSUMER's
186
+ // Node floor rather than a portable fact. `import.meta.main` shipped in Node
187
+ // 24.2 / 22.18; an adopter whose floor is at or above that writes it
188
+ // correctly and would get a finding they cannot act on. This package's own
189
+ // floor spans 22.13–22.17 where the property is `undefined`, so the hazard
190
+ // is real for some adopters and absent for others, and only they know which.
191
+ // The other half (`rawEntrypointCompare`) has no such dependency — a raw
192
+ // string compare misses a symlinked entry on every Node there has ever been
193
+ // — but the two share one rule id. VAT enables the whole rule explicitly.
182
194
  recommended: false,
195
+ recommendedSeverity: 'error',
183
196
  },
184
197
  messages: {
185
198
  importMetaMain:
@@ -20,4 +20,12 @@ module.exports = factory({
20
20
  message: 'Use mkdirSyncReal() from {{safeModule}} instead of fs.mkdirSync() for Windows path normalization',
21
21
  // No baked-in exemption: the file that implements mkdirSyncReal() is
22
22
  // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ docs: {
24
+ category: 'Filesystem and process',
25
+ bans: '`fs.mkdirSync()`',
26
+ useInstead: '`mkdirSyncReal()`',
27
+ subpath: '/fs',
28
+ recommended: true,
29
+ recommendedSeverity: 'error',
30
+ },
23
31
  });
@@ -33,4 +33,11 @@ module.exports = factory({
33
33
  'Use cpSync() from {{safeModule}} instead of cp() from node:fs/promises. ' +
34
34
  'Node 22 async cp({ recursive: true }) silently drops files in nested directories. ' +
35
35
  'cpSync() works correctly across all Node versions.',
36
+ docs: {
37
+ category: 'Filesystem and process',
38
+ bans: '`cp()` from `node:fs/promises` (drops nested files on Node 22)',
39
+ useInstead: '`cpSync()` from `node:fs`',
40
+ recommended: true,
41
+ recommendedSeverity: 'error',
42
+ },
36
43
  });
@@ -20,4 +20,12 @@ module.exports = factory({
20
20
  message: 'Use normalizePath() from {{safeModule}} instead of fs.realpathSync() for consistent Windows 8.3 path resolution',
21
21
  // No baked-in exemption: the file that implements normalizePath() is
22
22
  // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ docs: {
24
+ category: 'Filesystem and process',
25
+ bans: '`fs.realpathSync()`',
26
+ useInstead: '`normalizePath()`',
27
+ subpath: '/fs',
28
+ recommended: true,
29
+ recommendedSeverity: 'error',
30
+ },
23
31
  });
@@ -36,9 +36,14 @@ module.exports = {
36
36
  meta: {
37
37
  type: 'problem',
38
38
  docs: {
39
- description: 'Disallow splitting strings by hardcoded path separators',
40
- category: 'Cross-platform compatibility',
39
+ description:
40
+ 'Disallow splitting strings by hardcoded path separators',
41
+ category: 'Path handling',
42
+ bans: "`split('/')` / `split('\\\\')` on a path",
43
+ useInstead: '`path.basename()`, or `toForwardSlash()` first',
44
+ subpath: '/path',
41
45
  recommended: true,
46
+ recommendedSeverity: 'error',
42
47
  },
43
48
  messages: {
44
49
  noHardcodedSplit: