@vibe-agent-toolkit/utils 0.2.0-rc.1 → 0.2.0-rc.10

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 (270) hide show
  1. package/README.md +69 -23
  2. package/dist/asset-reference.js +3 -11
  3. package/dist/asset-reference.js.map +1 -1
  4. package/dist/compare-code-units.d.ts +28 -0
  5. package/dist/compare-code-units.d.ts.map +1 -0
  6. package/dist/compare-code-units.js +33 -0
  7. package/dist/compare-code-units.js.map +1 -0
  8. package/dist/crawl-timing.d.ts +657 -0
  9. package/dist/crawl-timing.d.ts.map +1 -0
  10. package/dist/crawl-timing.js +765 -0
  11. package/dist/crawl-timing.js.map +1 -0
  12. package/dist/crawl.d.ts +2 -4
  13. package/dist/crawl.d.ts.map +1 -1
  14. package/dist/crawl.js +2 -4
  15. package/dist/crawl.js.map +1 -1
  16. package/dist/dirent-kind.d.ts +83 -0
  17. package/dist/dirent-kind.d.ts.map +1 -0
  18. package/dist/dirent-kind.js +129 -0
  19. package/dist/dirent-kind.js.map +1 -0
  20. package/dist/entrypoint.d.ts +54 -0
  21. package/dist/entrypoint.d.ts.map +1 -0
  22. package/dist/entrypoint.js +71 -0
  23. package/dist/entrypoint.js.map +1 -0
  24. package/dist/env-flag.d.ts +51 -0
  25. package/dist/env-flag.d.ts.map +1 -0
  26. package/dist/env-flag.js +62 -0
  27. package/dist/env-flag.js.map +1 -0
  28. package/dist/errors/errno.d.ts +51 -0
  29. package/dist/errors/errno.d.ts.map +1 -0
  30. package/dist/errors/errno.js +112 -0
  31. package/dist/errors/errno.js.map +1 -0
  32. package/dist/errors/vat-error.d.ts +60 -0
  33. package/dist/errors/vat-error.d.ts.map +1 -0
  34. package/dist/errors/vat-error.js +86 -0
  35. package/dist/errors/vat-error.js.map +1 -0
  36. package/dist/file-crawler.d.ts +60 -0
  37. package/dist/file-crawler.d.ts.map +1 -1
  38. package/dist/file-crawler.js +133 -29
  39. package/dist/file-crawler.js.map +1 -1
  40. package/dist/fs/file-hash.d.ts.map +1 -1
  41. package/dist/fs/file-hash.js +0 -1
  42. package/dist/fs/file-hash.js.map +1 -1
  43. package/dist/fs-utils.d.ts +422 -191
  44. package/dist/fs-utils.d.ts.map +1 -1
  45. package/dist/fs-utils.js +534 -204
  46. package/dist/fs-utils.js.map +1 -1
  47. package/dist/fs.d.ts +5 -2
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +9 -7
  50. package/dist/fs.js.map +1 -1
  51. package/dist/git-run.d.ts +167 -0
  52. package/dist/git-run.d.ts.map +1 -0
  53. package/dist/git-run.js +154 -0
  54. package/dist/git-run.js.map +1 -0
  55. package/dist/git-snapshot.d.ts +199 -0
  56. package/dist/git-snapshot.d.ts.map +1 -0
  57. package/dist/git-snapshot.js +250 -0
  58. package/dist/git-snapshot.js.map +1 -0
  59. package/dist/git-tracker.d.ts +166 -3
  60. package/dist/git-tracker.d.ts.map +1 -1
  61. package/dist/git-tracker.js +250 -22
  62. package/dist/git-tracker.js.map +1 -1
  63. package/dist/git-utils.d.ts +84 -1
  64. package/dist/git-utils.d.ts.map +1 -1
  65. package/dist/git-utils.js +208 -82
  66. package/dist/git-utils.js.map +1 -1
  67. package/dist/git.d.ts +13 -4
  68. package/dist/git.d.ts.map +1 -1
  69. package/dist/git.js +12 -4
  70. package/dist/git.js.map +1 -1
  71. package/dist/gitignore-checker.d.ts.map +1 -1
  72. package/dist/gitignore-checker.js +15 -6
  73. package/dist/gitignore-checker.js.map +1 -1
  74. package/dist/glob/glob-pattern.js +5 -5
  75. package/dist/glob/glob-pattern.js.map +1 -1
  76. package/dist/index.d.ts +32 -20
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +95 -50
  79. package/dist/index.js.map +1 -1
  80. package/dist/listing-refusal.d.ts +112 -0
  81. package/dist/listing-refusal.d.ts.map +1 -0
  82. package/dist/listing-refusal.js +105 -0
  83. package/dist/listing-refusal.js.map +1 -0
  84. package/dist/path-containment.d.ts +64 -0
  85. package/dist/path-containment.d.ts.map +1 -0
  86. package/dist/path-containment.js +111 -0
  87. package/dist/path-containment.js.map +1 -0
  88. package/dist/path-core.d.ts +113 -18
  89. package/dist/path-core.d.ts.map +1 -1
  90. package/dist/path-core.js +139 -23
  91. package/dist/path-core.js.map +1 -1
  92. package/dist/path-utils.d.ts.map +1 -1
  93. package/dist/path-utils.js +42 -47
  94. package/dist/path-utils.js.map +1 -1
  95. package/dist/path.d.ts +1 -1
  96. package/dist/path.d.ts.map +1 -1
  97. package/dist/path.js +1 -1
  98. package/dist/path.js.map +1 -1
  99. package/dist/process.d.ts +7 -0
  100. package/dist/process.d.ts.map +1 -1
  101. package/dist/process.js +7 -0
  102. package/dist/process.js.map +1 -1
  103. package/dist/project-utils.d.ts.map +1 -1
  104. package/dist/project-utils.js +15 -9
  105. package/dist/project-utils.js.map +1 -1
  106. package/dist/project.d.ts +7 -5
  107. package/dist/project.d.ts.map +1 -1
  108. package/dist/project.js +7 -5
  109. package/dist/project.js.map +1 -1
  110. package/dist/safe-exec.d.ts +40 -11
  111. package/dist/safe-exec.d.ts.map +1 -1
  112. package/dist/safe-exec.js +111 -25
  113. package/dist/safe-exec.js.map +1 -1
  114. package/dist/skill-targets.d.ts +2 -2
  115. package/dist/skill-targets.js +2 -2
  116. package/dist/skill-test/auth-resolver.d.ts +10 -2
  117. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  118. package/dist/skill-test/auth-resolver.js +26 -11
  119. package/dist/skill-test/auth-resolver.js.map +1 -1
  120. package/dist/skill-test/index.d.ts +1 -1
  121. package/dist/skill-test/index.d.ts.map +1 -1
  122. package/dist/skill-test/index.js +1 -1
  123. package/dist/skill-test/index.js.map +1 -1
  124. package/dist/skill-test/spawn-claude.d.ts +30 -1
  125. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  126. package/dist/skill-test/spawn-claude.js +71 -5
  127. package/dist/skill-test/spawn-claude.js.map +1 -1
  128. package/dist/skill-test/transcript.d.ts +41 -2
  129. package/dist/skill-test/transcript.d.ts.map +1 -1
  130. package/dist/skill-test/transcript.js +55 -10
  131. package/dist/skill-test/transcript.js.map +1 -1
  132. package/dist/spawn-hardened.js +0 -1
  133. package/dist/spawn-hardened.js.map +1 -1
  134. package/dist/stdio-blocking.js +13 -14
  135. package/dist/stdio-blocking.js.map +1 -1
  136. package/dist/test-helpers.d.ts +135 -100
  137. package/dist/test-helpers.d.ts.map +1 -1
  138. package/dist/test-helpers.js +224 -146
  139. package/dist/test-helpers.js.map +1 -1
  140. package/dist/testing/executables.d.ts +32 -0
  141. package/dist/testing/executables.d.ts.map +1 -0
  142. package/dist/testing/executables.js +70 -0
  143. package/dist/testing/executables.js.map +1 -0
  144. package/dist/testing/hostile-tree.d.ts +79 -0
  145. package/dist/testing/hostile-tree.d.ts.map +1 -0
  146. package/dist/testing/hostile-tree.js +164 -0
  147. package/dist/testing/hostile-tree.js.map +1 -0
  148. package/dist/testing/platform-gates.d.ts +31 -0
  149. package/dist/testing/platform-gates.d.ts.map +1 -0
  150. package/dist/testing/platform-gates.js +31 -0
  151. package/dist/testing/platform-gates.js.map +1 -0
  152. package/dist/testing/temp-dir.d.ts +255 -0
  153. package/dist/testing/temp-dir.d.ts.map +1 -0
  154. package/dist/testing/temp-dir.js +363 -0
  155. package/dist/testing/temp-dir.js.map +1 -0
  156. package/dist/testing.d.ts +94 -3
  157. package/dist/testing.d.ts.map +1 -1
  158. package/dist/testing.js +119 -3
  159. package/dist/testing.js.map +1 -1
  160. package/dist/text-content.d.ts +197 -0
  161. package/dist/text-content.d.ts.map +1 -0
  162. package/dist/text-content.js +354 -0
  163. package/dist/text-content.js.map +1 -0
  164. package/dist/text-file.d.ts +45 -0
  165. package/dist/text-file.d.ts.map +1 -0
  166. package/dist/text-file.js +51 -0
  167. package/dist/text-file.js.map +1 -0
  168. package/dist/text.d.ts +16 -0
  169. package/dist/text.d.ts.map +1 -0
  170. package/dist/text.js +16 -0
  171. package/dist/text.js.map +1 -0
  172. package/dist/timing-dump.d.ts +136 -0
  173. package/dist/timing-dump.d.ts.map +1 -0
  174. package/dist/timing-dump.js +229 -0
  175. package/dist/timing-dump.js.map +1 -0
  176. package/dist/windows-shell.d.ts +0 -1
  177. package/dist/windows-shell.d.ts.map +1 -1
  178. package/dist/windows-shell.js +0 -1
  179. package/dist/windows-shell.js.map +1 -1
  180. package/dist/zod-introspection.js +2 -2
  181. package/dist/zod-introspection.js.map +1 -1
  182. package/eslint/README.md +223 -49
  183. package/eslint/index.cjs +111 -97
  184. package/eslint/index.d.cts +16 -6
  185. package/eslint/rules/callee-name.cjs +23 -0
  186. package/eslint/rules/commands-import-boundary.cjs +148 -0
  187. package/eslint/rules/dead-import.cjs +1 -1
  188. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  189. package/eslint/rules/eslint-rule-factory.cjs +5 -2
  190. package/eslint/rules/exempt-path-matcher.cjs +2 -1
  191. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  192. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  193. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  194. package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
  195. package/eslint/rules/no-blind-catch.cjs +171 -0
  196. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  197. package/eslint/rules/no-decaying-referent.cjs +217 -0
  198. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  199. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  200. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  201. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  202. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  203. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  204. package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
  205. package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
  206. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  207. package/eslint/rules/no-manual-path-normalize.cjs +136 -107
  208. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  209. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  210. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  211. package/eslint/rules/no-path-startswith.cjs +7 -2
  212. package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
  213. package/eslint/rules/no-raw-node-path.cjs +563 -0
  214. package/eslint/rules/no-raw-text-decode.cjs +305 -0
  215. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  216. package/eslint/rules/no-self-package-import.cjs +168 -0
  217. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  218. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  219. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  220. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  221. package/eslint/rules/no-version-literal.cjs +151 -0
  222. package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
  223. package/eslint/rules/require-justified-skip.cjs +8 -2
  224. package/eslint/rules/safe-import.cjs +1 -1
  225. package/package.json +16 -16
  226. package/dist/link-auth/build-headers.d.ts +0 -34
  227. package/dist/link-auth/build-headers.d.ts.map +0 -1
  228. package/dist/link-auth/build-headers.js +0 -58
  229. package/dist/link-auth/build-headers.js.map +0 -1
  230. package/dist/link-auth/expand-macro.d.ts +0 -38
  231. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  232. package/dist/link-auth/expand-macro.js +0 -133
  233. package/dist/link-auth/expand-macro.js.map +0 -1
  234. package/dist/link-auth/macros.yaml +0 -50
  235. package/dist/link-auth/resolve-token.d.ts +0 -83
  236. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  237. package/dist/link-auth/resolve-token.js +0 -101
  238. package/dist/link-auth/resolve-token.js.map +0 -1
  239. package/dist/link-auth/resolve.d.ts +0 -102
  240. package/dist/link-auth/resolve.d.ts.map +0 -1
  241. package/dist/link-auth/resolve.js +0 -66
  242. package/dist/link-auth/resolve.js.map +0 -1
  243. package/dist/link-auth/rewrite.d.ts +0 -52
  244. package/dist/link-auth/rewrite.d.ts.map +0 -1
  245. package/dist/link-auth/rewrite.js +0 -102
  246. package/dist/link-auth/rewrite.js.map +0 -1
  247. package/dist/link-auth/select-provider.d.ts +0 -30
  248. package/dist/link-auth/select-provider.d.ts.map +0 -1
  249. package/dist/link-auth/select-provider.js +0 -55
  250. package/dist/link-auth/select-provider.js.map +0 -1
  251. package/dist/link-auth/template.d.ts +0 -40
  252. package/dist/link-auth/template.d.ts.map +0 -1
  253. package/dist/link-auth/template.js +0 -89
  254. package/dist/link-auth/template.js.map +0 -1
  255. package/dist/link-auth/transforms.d.ts +0 -46
  256. package/dist/link-auth/transforms.d.ts.map +0 -1
  257. package/dist/link-auth/transforms.js +0 -52
  258. package/dist/link-auth/transforms.js.map +0 -1
  259. package/dist/template-entry.d.ts +0 -10
  260. package/dist/template-entry.d.ts.map +0 -1
  261. package/dist/template-entry.js +0 -10
  262. package/dist/template-entry.js.map +0 -1
  263. package/dist/template.d.ts +0 -7
  264. package/dist/template.d.ts.map +0 -1
  265. package/dist/template.js +0 -18
  266. package/dist/template.js.map +0 -1
  267. package/eslint/rules/no-path-join.cjs +0 -14
  268. package/eslint/rules/no-path-relative.cjs +0 -14
  269. package/eslint/rules/no-path-resolve.cjs +0 -14
  270. package/eslint/rules/path-function-rule-factory.cjs +0 -496
@@ -0,0 +1,305 @@
1
+ /**
2
+ * ESLint rule: no-raw-text-decode
3
+ *
4
+ * **One seam turns file bytes into text; every other route is an error.**
5
+ *
6
+ * ## What it is for
7
+ *
8
+ * `bytes.toString('utf-8')` is a guess dressed as a conversion. It ignores every
9
+ * byte-order mark, cannot express UTF-16BE at all (Node's `Buffer` has no such
10
+ * encoding), and turns a perfectly ordinary UTF-16 document into NUL-interleaved
11
+ * mojibake — which downstream code then classifies as *binary*. Measured in VAT:
12
+ * a `working-tree-encoding=UTF-16` checkout of a markdown file yielding one
13
+ * heading and one link produced **no blob row, no section and no reference**,
14
+ * because the read decoded it wrong and the binary sniff believed the result.
15
+ * PowerShell 5.1's `Out-File` and `>` write UTF-16LE by default, so this is a
16
+ * Windows-authored document, not an exotic one.
17
+ *
18
+ * The fix is a single decoding seam that reads the encoding off the BOM and says
19
+ * so. This rule is what keeps a second, private decoder from growing beside it.
20
+ *
21
+ * ## The three shapes it catches
22
+ *
23
+ * | shape | example |
24
+ * |---|---|
25
+ * | `toString(<encoding>)` on any value | `buf.toString('utf-8')` |
26
+ * | constructing a decoder | `new TextDecoder('utf-16le')` |
27
+ * | letting `fs` decode | `readFile(p, 'utf-8')`, `readFileSync(p, { encoding: 'latin1' })` |
28
+ *
29
+ * ## The line this rule is drawing: whose choice was the encoding?
30
+ *
31
+ * **Not every `'utf-8'` read is a content read**, and the distinction is the
32
+ * whole difference between a rule that survives and one that gets widened until
33
+ * it means nothing. Three categories, and every call site is in exactly one:
34
+ *
35
+ * 1. **A document from the corpus** — an adopter's markdown, HTML, `SKILL.md`,
36
+ * config file, JSON schema or `package.json`. Nobody in this codebase chose
37
+ * the encoding; it has to be **discovered** from the bytes. *This is the
38
+ * rule's target*, and the seam is the only correct reader.
39
+ * 2. **An artifact this project wrote** — its own cache entry, its own emitted
40
+ * manifest, an asset it publishes beside its own code. The encoding was
41
+ * **chosen at the write**, so reading it back the same way is a closed loop,
42
+ * not a decode.
43
+ * 3. **Bytes that were never a file** — a subprocess's stdout, an HTTP response
44
+ * body, a Buffer this process built. There is no file encoding to discover;
45
+ * the **producer's contract** decides.
46
+ *
47
+ * Static analysis cannot tell the three apart — `buf.toString('utf8')` looks
48
+ * identical whether the Buffer came from `readFile` or from `spawn`. So the rule
49
+ * reports all three, and 2 and 3 are settled at the call site with a one-line
50
+ * `eslint-disable-next-line` that **names the writer or the producer**.
51
+ *
52
+ * That gives a reviewer a falsifiable test, which is the point: *a justification
53
+ * that cannot name who wrote the bytes is a category-1 call wearing a disable
54
+ * comment.* "It's always UTF-8 in practice" names nobody and does not qualify.
55
+ * Do not settle these by adding paths to `exemptFiles` until lint goes quiet —
56
+ * `exemptFiles` is for the seam's own implementation file, nothing else.
57
+ *
58
+ * ## Why the encoding test is an EXCLUSION and not an inclusion list
59
+ *
60
+ * An inclusion list of character encodings would have to enumerate every legal
61
+ * spelling — `utf8`, `utf-8`, `utf16le`, `ucs2`, `ucs-2`, `latin1`, `binary`,
62
+ * `ascii`, the `iso-8859-*` family, the `windows-125*` family, and every
63
+ * `TextDecoder` label alias beyond those — and a spelling it missed would pass
64
+ * silently. That is a rule that fails OPEN, in the one direction that matters.
65
+ *
66
+ * Node's `Buffer` encodings are a CLOSED set, and exactly three of them are
67
+ * binary-to-text codecs rather than character encodings: `base64`, `base64url`
68
+ * and `hex`. So "a string argument that is not one of those three" is both
69
+ * exhaustive over `Buffer` and correct for `TextDecoder` (whose labels are all
70
+ * character encodings). Excluding three known-safe values fails CLOSED: an
71
+ * encoding nobody anticipated still fires.
72
+ *
73
+ * ## What it does NOT catch, stated so the coverage is not overclaimed
74
+ *
75
+ * - **A computed encoding** — `buf.toString(enc)`, `readFile(p, enc)`. Firing on
76
+ * a non-literal second argument is untenable without type information:
77
+ * `n.toString(radix)` with a variable radix is ordinary code, and
78
+ * `readFile(p, callback)` in callback style has the same shape as
79
+ * `readFile(p, encoding)`. A rule that fires on both teaches people to disable
80
+ * it, which costs its true positives too — this repo has already demoted one
81
+ * rule (`no-unsafe-root-join`) for precisely that. So the trigger is a string
82
+ * LITERAL, and a variable holding `'utf-8'` is a bypass a reviewer can see and
83
+ * a linter cannot.
84
+ * - `createReadStream(path, 'utf-8')` and other streaming decodes. No VAT code
85
+ * does this; it is a real hole, not a decision that it is safe.
86
+ * - A subprocess's stdout decoded with `.toString('utf8')` IS caught, and that
87
+ * is usually a false positive — process output is not file content and its
88
+ * encoding is the child's business. Nothing here can tell the two apart
89
+ * statically, which is why the rule is registered against the directories that
90
+ * read corpus documents rather than repo-wide. See the consuming config.
91
+ * - `require()`/`import()` of a JSON or data file, which decode below the public
92
+ * `fs` surface entirely.
93
+ *
94
+ * @example
95
+ * ```javascript
96
+ * // ❌ BAD
97
+ * const text = (await readFile(p)).toString('utf-8');
98
+ * const text = await readFile(p, 'utf-8');
99
+ * const text = new TextDecoder().decode(bytes);
100
+ *
101
+ * // ✅ GOOD — category 1, the corpus document
102
+ * const { text } = await readTextContent(p);
103
+ * const { text } = decodeTextContent(bytes);
104
+ *
105
+ * // ✅ GOOD — category 3, and the justification names the producer
106
+ * // eslint-disable-next-line local/no-raw-text-decode -- subprocess stdout; producer is the credential helper spawned above
107
+ * const out = result.stdout.toString('utf8');
108
+ * ```
109
+ */
110
+
111
+ 'use strict';
112
+
113
+ const {
114
+ EXEMPT_FILES_SCHEMA,
115
+ UNANCHORED_EXEMPT_FILE,
116
+ UNANCHORED_EXEMPT_MESSAGE,
117
+ createConfigurableExemptPathMatcher,
118
+ reportUnanchoredExemptEntries,
119
+ } = require('./exempt-path-matcher.cjs');
120
+ const { resolveSafeModule, withSafeModuleOption } = require('./safe-import.cjs');
121
+
122
+ /**
123
+ * The default module named in the advice text.
124
+ *
125
+ * A placeholder, and deliberately not this package: the seam lives in the
126
+ * consuming repo, so `safeModule` is the option every real config sets. Named
127
+ * rather than left blank so the message is still a sentence when it is not.
128
+ */
129
+ const DEFAULT_SEAM_MODULE = 'your content-decoding module';
130
+
131
+ /**
132
+ * The three `Buffer` encodings that are binary-to-text codecs, not character
133
+ * encodings. Everything else is a decode. See the module docstring for why the
134
+ * test is shaped this way round.
135
+ */
136
+ const BINARY_TO_TEXT_CODECS = new Set(['base64', 'base64url', 'hex']);
137
+
138
+ /** The `fs` readers that will decode for you if handed an encoding. */
139
+ const DECODING_READERS = new Set(['readFile', 'readFileSync']);
140
+
141
+ /**
142
+ * Is this node a string literal (or a template with no substitutions)?
143
+ *
144
+ * @param {object} node - AST node
145
+ * @returns {string | null} Its value, or `null` when it is not a plain string
146
+ */
147
+ function stringValueOf(node) {
148
+ if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
149
+ if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) {
150
+ return node.quasis[0]?.value?.cooked ?? null;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Does this argument spell out a character encoding?
157
+ *
158
+ * Only a string LITERAL counts — see the module docstring for why a computed
159
+ * encoding is deliberately out of reach. A numeric radix (`n.toString(16)`) is
160
+ * not a string and so is never a decode.
161
+ *
162
+ * @param {object} node - The argument node
163
+ * @returns {boolean} True when the argument names a character encoding
164
+ */
165
+ function isCharacterEncodingArgument(node) {
166
+ const literal = stringValueOf(node);
167
+ if (literal === null) return false;
168
+ return !BINARY_TO_TEXT_CODECS.has(literal.toLowerCase());
169
+ }
170
+
171
+ /**
172
+ * The `{ encoding: '...' }` option object's encoding argument, if it selects a
173
+ * character encoding.
174
+ *
175
+ * @param {object} node - The argument node
176
+ * @returns {boolean} True when the options object asks `fs` to decode
177
+ */
178
+ function optionsObjectDecodes(node) {
179
+ if (node?.type !== 'ObjectExpression') return false;
180
+ for (const property of node.properties) {
181
+ if (property.type !== 'Property') continue;
182
+ const key = property.key?.type === 'Identifier'
183
+ ? property.key.name
184
+ : stringValueOf(property.key);
185
+ if (key !== 'encoding') continue;
186
+ // An explicit `encoding: null` / `encoding: undefined` asks for a Buffer.
187
+ if (property.value?.type === 'Literal' && property.value.value === null) return false;
188
+ return isCharacterEncodingArgument(property.value);
189
+ }
190
+ return false;
191
+ }
192
+
193
+ /** The called function's name, whether it is bare or a member call. */
194
+ function calleeName(node) {
195
+ if (node.callee?.type === 'Identifier') return node.callee.name;
196
+ if (node.callee?.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
197
+ return node.callee.property.name;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ const exemptMatcherFor = createConfigurableExemptPathMatcher([]);
203
+
204
+ module.exports = {
205
+ meta: {
206
+ type: 'problem',
207
+ docs: {
208
+ description:
209
+ 'Disallow decoding bytes to text outside the one content-decoding seam; '
210
+ + 'raw `toString(encoding)` / `TextDecoder` / `readFile(path, encoding)` '
211
+ + 'ignore byte-order marks and cannot express UTF-16BE at all.',
212
+ category: 'Content decoding',
213
+ bans: "`buf.toString('utf-8')`, `new TextDecoder(…)`, `readFile(p, 'utf-8')`",
214
+ useInstead: 'one project-owned decoding seam',
215
+ // Not in `recommended`: it names a SEAM that does not exist until a consumer
216
+ // builds one. `decodeTextContent()` is VAT's module, in VAT's repo; an adopter
217
+ // who installed this package for `safePath.join()` has no content-decoding
218
+ // seam to be pointed at, so every message would advise importing from `your
219
+ // content-decoding module`. Enabled — with `safeModule` naming the real seam
220
+ // and `exemptFiles` naming the file that implements it — by whoever has one.
221
+ recommended: false,
222
+ recommendedSeverity: 'error',
223
+ },
224
+ schema: [withSafeModuleOption(EXEMPT_FILES_SCHEMA)],
225
+ messages: {
226
+ bufferTextDecode:
227
+ 'Do not decode bytes with `.toString({{encoding}})` — it ignores any byte-order mark and '
228
+ + 'cannot express UTF-16BE. Use `decodeTextContent()` from {{safeModule}}.',
229
+ textDecoderConstruct:
230
+ 'Do not construct a `TextDecoder` here — the encoding has to be decided from the bytes, '
231
+ + 'in one place. Use `decodeTextContent()` from {{safeModule}}.',
232
+ fsReadTextEncoding:
233
+ 'Do not let `{{reader}}` decode — it applies the encoding you name with no BOM handling. '
234
+ + 'Read bytes and use `readTextContent()` / `decodeTextContent()` from {{safeModule}}.',
235
+ [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
236
+ },
237
+ },
238
+
239
+ create(context) {
240
+ const seamModule = resolveSafeModule(context, DEFAULT_SEAM_MODULE);
241
+
242
+ // The seam itself must be able to call the primitives it wraps.
243
+ if (exemptMatcherFor(context)(context.getFilename())) {
244
+ // Still surface a malformed exemption list: the file we are standing in
245
+ // may be exempt only BECAUSE the entry is unanchored.
246
+ return {
247
+ Program(node) {
248
+ reportUnanchoredExemptEntries(context, node);
249
+ },
250
+ };
251
+ }
252
+
253
+ return {
254
+ Program(node) {
255
+ reportUnanchoredExemptEntries(context, node);
256
+ },
257
+
258
+ NewExpression(node) {
259
+ if (node.callee?.type !== 'Identifier' || node.callee.name !== 'TextDecoder') return;
260
+ context.report({
261
+ node,
262
+ messageId: 'textDecoderConstruct',
263
+ data: { safeModule: seamModule },
264
+ });
265
+ },
266
+
267
+ CallExpression(node) {
268
+ const name = calleeName(node);
269
+ if (name === null) return;
270
+
271
+ if (
272
+ name === 'toString'
273
+ && node.callee.type === 'MemberExpression'
274
+ && node.arguments.length > 0
275
+ && isCharacterEncodingArgument(node.arguments[0])
276
+ ) {
277
+ context.report({
278
+ node,
279
+ messageId: 'bufferTextDecode',
280
+ data: {
281
+ safeModule: seamModule,
282
+ encoding: context.getSourceCode().getText(node.arguments[0]),
283
+ },
284
+ });
285
+ return;
286
+ }
287
+
288
+ if (!DECODING_READERS.has(name) || node.arguments.length < 2) return;
289
+ const second = node.arguments[1];
290
+ // Object form first: `{ encoding: 'hex' }` is an ObjectExpression, and
291
+ // asking the positional test about it would answer the wrong question.
292
+ const decodes = second?.type === 'ObjectExpression'
293
+ ? optionsObjectDecodes(second)
294
+ : isCharacterEncodingArgument(second);
295
+ if (decodes) {
296
+ context.report({
297
+ node,
298
+ messageId: 'fsReadTextEncoding',
299
+ data: { reader: name, safeModule: seamModule },
300
+ });
301
+ }
302
+ },
303
+ };
304
+ },
305
+ };
@@ -0,0 +1,255 @@
1
+ /**
2
+ * ESLint rule: no-registry-count-pin
3
+ *
4
+ * In a test file, flags `expect(<x>).toHaveLength(<n>)` and
5
+ * `expect(<x>).toBe(<n>)` when `n` is a literal at or above `minLiteral` and
6
+ * `x` is derived from something the file IMPORTED — a registry, a manifest, a
7
+ * rule table pulled from src — rather than from a fixture the test built.
8
+ *
9
+ * Such a pin is a change detector and only a change detector: it fails on
10
+ * every legitimate addition, is fixed by retyping the number, and proves
11
+ * nothing about the set it stands for. The audit found the same four numbers
12
+ * retyped in two files and a prose sentence, all three wrong at once. The
13
+ * assertion that means something is the SET — `toEqual([...names].sort())` —
14
+ * or a derivation from the same source the registry is built from.
15
+ *
16
+ * ## The heuristic, and what it deliberately misses
17
+ *
18
+ * `x` is registry-derived when its value chain reaches an import binding used
19
+ * as a VALUE: `REGISTRY`, `REGISTRY.length`, `Object.keys(REGISTRY)`,
20
+ * `[...REGISTRY]`, `REGISTRY.filter(…)`, a local `const` that holds one of
21
+ * those, or a call to an imported function whose every argument is itself
22
+ * registry-derived (so a zero-argument accessor like `allSpecs()` counts).
23
+ *
24
+ * An imported function applied to LOCAL data — `parse(fixtureText).links` —
25
+ * is NOT registry-derived: the count is the fixture's. That includes a literal
26
+ * argument, so `loadModule('../index.cjs')` is missed on purpose; a literal
27
+ * module path and a literal fixture are indistinguishable syntactically, and a
28
+ * rule that fired on `parse('# heading')` would be switched off within a week.
29
+ * The same holds for a method on an imported receiver: `safePath.join(tmp,
30
+ * 'x')` is an imported namespace of FUNCTIONS fed local data, so the result is
31
+ * local — only a method whose arguments are all derived (or callbacks) keeps
32
+ * the receiver's derivation. Measured on this tree: the receiver-only reading
33
+ * flagged `hash.toHaveLength(64)` on a hash of a local file.
34
+ *
35
+ * Option `minLiteral: number` (default 5) — the smallest literal that reads as
36
+ * a count of a registry rather than a shape assertion on a small fixture.
37
+ *
38
+ * @example
39
+ * // BAD — fails on every addition, fixed by retyping 27 → 28
40
+ * expect(Object.keys(plugin.rules)).toHaveLength(27);
41
+ *
42
+ * // GOOD — says WHICH, and fails only when the set actually changes
43
+ * expect(Object.keys(plugin.rules).sort()).toEqual(readdirSync(rulesDir).map(stripExt).sort());
44
+ */
45
+
46
+ 'use strict';
47
+
48
+ const { isTestFile } = require('./exempt-path-matcher.cjs');
49
+
50
+ const COUNT_MATCHERS = new Set(['toHaveLength', 'toBe']);
51
+ const DEFAULT_MIN_LITERAL = 5;
52
+ const FUNCTION_ARGUMENT_TYPES = new Set(['ArrowFunctionExpression', 'FunctionExpression']);
53
+
54
+ /** The variable an identifier resolves to, walking up the scope chain, or null. */
55
+ function resolveVariable(sourceCode, identifier) {
56
+ let scope = sourceCode.getScope(identifier);
57
+ while (scope) {
58
+ const variable = scope.set.get(identifier.name);
59
+ if (variable) {
60
+ return variable;
61
+ }
62
+ scope = scope.upper;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /** Whether `expr` is `expect(...)` or `expect.soft(...)`. */
68
+ function isExpectCall(expr) {
69
+ if (expr.type !== 'CallExpression') {
70
+ return false;
71
+ }
72
+ const { callee } = expr;
73
+ if (callee.type === 'Identifier') {
74
+ return callee.name === 'expect';
75
+ }
76
+ return (
77
+ callee.type === 'MemberExpression' &&
78
+ callee.object.type === 'Identifier' &&
79
+ callee.object.name === 'expect' &&
80
+ callee.property.type === 'Identifier' &&
81
+ callee.property.name === 'soft'
82
+ );
83
+ }
84
+
85
+ /**
86
+ * The subject of the `expect(...)` a matcher chain hangs off, descending
87
+ * through modifiers like `.not` / `.resolves`, or null when the chain does not
88
+ * start at `expect`.
89
+ */
90
+ function expectSubject(matcherCallee) {
91
+ let current = matcherCallee.object;
92
+ while (current.type === 'MemberExpression') {
93
+ current = current.object;
94
+ }
95
+ return isExpectCall(current) ? (current.arguments[0] ?? null) : null;
96
+ }
97
+
98
+ /** Whether a numeric literal argument at or above `min` was passed. */
99
+ function countLiteral(node, min) {
100
+ const arg = node.arguments[0];
101
+ if (node.arguments.length !== 1 || arg.type !== 'Literal' || typeof arg.value !== 'number') {
102
+ return null;
103
+ }
104
+ return arg.value >= min ? arg : null;
105
+ }
106
+
107
+ /** Whether every element of a call/array/new argument list is registry-derived. */
108
+ function createDerivationCheck(sourceCode) {
109
+ const visiting = new Set();
110
+
111
+ function variableIsDerived(variable) {
112
+ const def = variable.defs[0];
113
+ if (!def) {
114
+ return false;
115
+ }
116
+ if (def.type === 'ImportBinding') {
117
+ return true;
118
+ }
119
+ if (def.type !== 'Variable' || !def.node.init || visiting.has(variable)) {
120
+ return false;
121
+ }
122
+ visiting.add(variable);
123
+ const result = isDerived(def.node.init);
124
+ visiting.delete(variable);
125
+ return result;
126
+ }
127
+
128
+ function elementIsDerived(element) {
129
+ return element?.type === 'SpreadElement' ? isDerived(element.argument) : isDerived(element);
130
+ }
131
+
132
+ /**
133
+ * Whether no LOCAL data enters a call: every argument is registry-derived,
134
+ * a callback (`.filter((r) => …)` transforms, it does not supply data), or
135
+ * absent. A literal counts as local data — see the module comment.
136
+ */
137
+ function argumentsCarryNoLocalData(node) {
138
+ return node.arguments.every(
139
+ (arg) => FUNCTION_ARGUMENT_TYPES.has(arg.type) || elementIsDerived(arg),
140
+ );
141
+ }
142
+
143
+ function callIsDerived(node) {
144
+ const { callee } = node;
145
+ if (callee.type === 'MemberExpression') {
146
+ // `Object.keys(x)` — a global transform: look at what went in.
147
+ // `x.filter(cb)` / `safePath.join(tmp, 'x')` — a method on a value: the
148
+ // receiver must be derived AND nothing local may enter, or `safePath`
149
+ // (an imported namespace of FUNCTIONS) would make every path derived.
150
+ const receiver = callee.object;
151
+ const receiverVar = receiver.type === 'Identifier' ? resolveVariable(sourceCode, receiver) : null;
152
+ const receiverIsGlobal = receiver.type === 'Identifier' && (receiverVar?.defs.length ?? 0) === 0;
153
+ if (receiverIsGlobal) {
154
+ return node.arguments.some(elementIsDerived);
155
+ }
156
+ return isDerived(receiver) && argumentsCarryNoLocalData(node);
157
+ }
158
+ if (callee.type !== 'Identifier') {
159
+ return false;
160
+ }
161
+ const variable = resolveVariable(sourceCode, callee);
162
+ const isImportedFn = variable?.defs[0]?.type === 'ImportBinding';
163
+ return isImportedFn && argumentsCarryNoLocalData(node);
164
+ }
165
+
166
+ function isDerived(expr) {
167
+ if (!expr) {
168
+ return false;
169
+ }
170
+ switch (expr.type) {
171
+ case 'Identifier': {
172
+ const variable = resolveVariable(sourceCode, expr);
173
+ return variable !== null && variableIsDerived(variable);
174
+ }
175
+ case 'MemberExpression':
176
+ return isDerived(expr.object);
177
+ case 'CallExpression':
178
+ return callIsDerived(expr);
179
+ case 'NewExpression':
180
+ return expr.arguments.some(elementIsDerived);
181
+ case 'ArrayExpression':
182
+ return expr.elements.some(elementIsDerived);
183
+ case 'AwaitExpression':
184
+ return isDerived(expr.argument);
185
+ case 'ChainExpression':
186
+ case 'TSAsExpression':
187
+ case 'TSNonNullExpression':
188
+ return isDerived(expr.expression);
189
+ default:
190
+ return false;
191
+ }
192
+ }
193
+
194
+ return isDerived;
195
+ }
196
+
197
+ module.exports = {
198
+ meta: {
199
+ type: 'suggestion',
200
+ docs: {
201
+ description:
202
+ 'Disallow pinning the size of an imported registry with a literal in tests — ' +
203
+ 'toHaveLength(27) on something pulled from src is a change detector fixed by retyping',
204
+ recommended: false,
205
+ recommendedSeverity: 'warn',
206
+ },
207
+ schema: [
208
+ {
209
+ type: 'object',
210
+ properties: {
211
+ minLiteral: { type: 'integer', minimum: 0 },
212
+ },
213
+ additionalProperties: false,
214
+ },
215
+ ],
216
+ messages: {
217
+ registryCountPin:
218
+ 'This pins the size of something imported from src to the literal {{literal}}. It fails on ' +
219
+ 'every legitimate addition and is fixed by retyping the number, so it detects change ' +
220
+ 'without checking anything. Assert the SET (toEqual([...names].sort())) or derive the ' +
221
+ 'expected count from the same source the registry is built from.',
222
+ },
223
+ },
224
+
225
+ create(context) {
226
+ const filename = context.filename ?? context.getFilename();
227
+ if (!isTestFile(filename)) {
228
+ return {};
229
+ }
230
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
231
+ const min = context.options?.[0]?.minLiteral ?? DEFAULT_MIN_LITERAL;
232
+ const isDerived = createDerivationCheck(sourceCode);
233
+
234
+ return {
235
+ CallExpression(node) {
236
+ const { callee } = node;
237
+ if (
238
+ callee.type !== 'MemberExpression' ||
239
+ callee.property.type !== 'Identifier' ||
240
+ !COUNT_MATCHERS.has(callee.property.name)
241
+ ) {
242
+ return;
243
+ }
244
+ const literal = countLiteral(node, min);
245
+ if (literal === null) {
246
+ return;
247
+ }
248
+ const subject = expectSubject(callee);
249
+ if (subject !== null && isDerived(subject)) {
250
+ context.report({ node, messageId: 'registryCountPin', data: { literal: String(literal.raw) } });
251
+ }
252
+ },
253
+ };
254
+ },
255
+ };