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

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 +57 -0
  25. package/dist/env-flag.d.ts.map +1 -0
  26. package/dist/env-flag.js +68 -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 +6 -2
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +10 -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 +79 -0
  85. package/dist/path-containment.d.ts.map +1 -0
  86. package/dist/path-containment.js +128 -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 +226 -0
  161. package/dist/text-content.d.ts.map +1 -0
  162. package/dist/text-content.js +418 -0
  163. package/dist/text-content.js.map +1 -0
  164. package/dist/text-file.d.ts +84 -0
  165. package/dist/text-file.d.ts.map +1 -0
  166. package/dist/text-file.js +118 -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 +230 -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,226 @@
1
+ /**
2
+ * **The one way VAT turns file bytes into text.**
3
+ *
4
+ * `local/no-raw-text-decode` enforces that — but only where it is registered,
5
+ * which today is `packages/utils/src` and `packages/resources/src`. Those two
6
+ * own the seam and every corpus-document read; the rest of the repo is a
7
+ * migration ledger, not a covered claim, and `eslint.config.js` carries it in
8
+ * cost order. Do not read this docstring as "nothing else in the repo decodes".
9
+ *
10
+ * ## Where this lives, and why it is a `utils` primitive
11
+ *
12
+ * Bytes-to-text is a pure function of its argument. It knows nothing about
13
+ * content keys, parse caches or the projection — `readContentWithKey` in
14
+ * `@vibe-agent-toolkit/resources` *composes* this with a raw-bytes content key,
15
+ * and the key is the projection concept, not the decode.
16
+ *
17
+ * It has to live here for a harder reason than tidiness. `resources` depends on
18
+ * `utils` and `utils` must never depend on `resources`, so a seam in `resources`
19
+ * with a lint rule shipped from `utils` would flag `utils`' own reads
20
+ * (`gitignore-checker.ts` reads an adopter's `.gitignore`; `project-utils.ts`
21
+ * reads an adopter's `package.json`) while giving them no legal way to comply.
22
+ * The rule would then be widened with exemptions until it meant nothing. Placing
23
+ * the primitive at the bottom of the arrow is what makes the guardrail
24
+ * enforceable.
25
+ *
26
+ * `./text` is also deliberately **pure** — no `node:*` import at all — so bytes
27
+ * from a git blob, an HTTP body or a zip entry decode through the same function
28
+ * as bytes from disk. The file-reading half is in `./fs` (`readTextContent`),
29
+ * where everything that touches `node:fs` lives.
30
+ *
31
+ * ## The defect this exists for
32
+ *
33
+ * `readContentWithKey` (in `@vibe-agent-toolkit/resources`) used to call
34
+ * `bytes.toString('utf-8')` unconditionally. Measured end to end, on a real
35
+ * `working-tree-encoding=UTF-16` checkout (see `resources`'
36
+ * `test/system/git-hostile-config.system.test.ts`):
37
+ *
38
+ * ```text
39
+ * bytes on disk 40 B, BOM fe ff, UTF-16BE
40
+ * decoded "��# \0D\0o\0c\0…" NUL-interleaved mojibake
41
+ * looksBinary true — the decoded string carries NULs
42
+ * projection BLOB_NOT_TEXT, no blob row, 0 sections, 0 references
43
+ * the same doc 1 heading, 1 link, from its UTF-8 bytes
44
+ * ```
45
+ *
46
+ * So VAT could not read a UTF-16 document *at all*, and the reason it matters is
47
+ * not exotic: **PowerShell 5.1's `Out-File` and `>` write UTF-16LE by default**,
48
+ * so a Windows-authored document lands squarely in that hole.
49
+ *
50
+ * ## What is a fact here, and what is an assumption
51
+ *
52
+ * The distinction is carried in the result ({@link TextProvenance.encodingSource})
53
+ * rather than left to prose, because the two are not the same kind of claim:
54
+ *
55
+ * | input | encoding | encodingSource | why |
56
+ * |---|---|---|---|
57
+ * | leading `ef bb bf` | `utf-8` | `bom` | the bytes say so |
58
+ * | leading `ff fe` (not `ff fe 00 00`) | `utf-16le` | `bom` | the bytes say so |
59
+ * | leading `fe ff` | `utf-16be` | `bom` | the bytes say so |
60
+ * | leading `ff fe 00 00` | `utf-32le` | `bom` | the bytes say so |
61
+ * | leading `00 00 fe ff` | `utf-32be` | `bom` | the bytes say so |
62
+ * | anything else | `utf-8` | `assumed` | the defensible default |
63
+ *
64
+ * ## And what proves the assumption WRONG
65
+ *
66
+ * `encoding` says what was guessed; {@link TextProvenance.replacementCharacters}
67
+ * is what the guess cost. A malformed sequence decodes to U+FFFD instead of
68
+ * throwing, so a mis-decoded document arrives as a well-formed JS string full of
69
+ * garbage — and a byte-level BPE tokenizer has no out-of-vocabulary concept, so
70
+ * it embeds and indexes that garbage without erroring anywhere. Counting the
71
+ * substitutions is what turns "we assumed UTF-8" into "we assumed UTF-8 and were
72
+ * demonstrably wrong 3,200 times in this file".
73
+ *
74
+ * It is counted **without paying for it on the clean path**. Every decode runs
75
+ * first through a `fatal: true` decoder, which throws on the first malformed
76
+ * sequence rather than substituting; a file that decodes cleanly — nearly every
77
+ * file — costs exactly one decode and no scan at all. Only a file that actually
78
+ * threw is decoded a second time in substituting mode and scanned for U+FFFD, so
79
+ * the O(n) scan is charged entirely to broken input.
80
+ *
81
+ * That ordering also buys a correctness property a scan alone cannot have: a
82
+ * document that *legitimately contains* U+FFFD is valid input, so the fatal
83
+ * decoder does not throw and it is reported as **0** replacements rather than
84
+ * accused of a bad decode. A bare scan would count its own content against it.
85
+ *
86
+ * 🪤 **The UTF-32LE BOM starts with the UTF-16LE BOM.** `ff fe 00 00` matches
87
+ * `ff fe`, so a table tested shortest-first decodes every UTF-32LE document as
88
+ * NUL-interleaved UTF-16 — the same bug this module exists to fix, one encoding
89
+ * further down. {@link BOMS} is therefore ordered longest-first and the test
90
+ * suite pins that ordering directly.
91
+ *
92
+ * ## The two limitations, recorded rather than guessed around
93
+ *
94
+ * - **BOM-less UTF-16 is not detected.** It is undecidable from bytes alone: the
95
+ * same byte string is a legal, different UTF-8 document. A NUL-density
96
+ * heuristic would decide it *usually* correctly and silently wrongly the rest
97
+ * of the time, and "silently wrongly" is the failure class this whole module
98
+ * is a reaction to. BOM-less input is UTF-8.
99
+ * - **Latin charsets are not detected either, and there is no windows-1252
100
+ * fallback.** "These bytes are not valid UTF-8" is a fact; "therefore they are
101
+ * latin-1" is a guess, and it is equally consistent with a UTF-8 document
102
+ * carrying one corrupt byte. Malformed input gets U+FFFD. In practice the
103
+ * Latin family costs little: every ASCII byte string is valid UTF-8 and
104
+ * decodes correctly, so only high bytes are affected.
105
+ *
106
+ * Both are pinned as tests that state what is given up, so adding a heuristic
107
+ * later has to edit an assertion rather than quietly widen a claim.
108
+ *
109
+ * ## Why `TextDecoder` for three encodings and hand-rolled code for two
110
+ *
111
+ * `TextDecoder` implements the WHATWG Encoding Standard, which **deliberately
112
+ * omits UTF-32** — no engine offers it. Node's `Buffer` is narrower still:
113
+ * `utf8`, `utf16le`/`ucs2`, `latin1`, `ascii` and the binary-to-text codecs, with
114
+ * **no UTF-16BE at all** (the encoding a round trip through git's
115
+ * `working-tree-encoding=UTF-16` actually produces). So `TextDecoder` carries
116
+ * utf-8/utf-16le/utf-16be, and UTF-32 is converted here.
117
+ *
118
+ * Refusing UTF-32 loudly was the alternative, and it was rejected for one
119
+ * reason: the BOM has to be *recognised* regardless (see the trap above), so the
120
+ * choice was never "detect it or not" — only "having detected it, decode it or
121
+ * throw". Decoding is ~20 lines and leaves no hole.
122
+ *
123
+ * ## The BOM is stripped, and that is load-bearing in both directions
124
+ *
125
+ * A surviving leading U+FEFF stops `# Heading` from parsing as a heading. And
126
+ * every offset downstream is a **character** offset over this decoded string —
127
+ * `lineStartOffsets` in `resources`' `projection/blob-sections.ts` derives them
128
+ * from `content.split('\n')`, and `parseMarkdownContent` takes remark's own
129
+ * character positions — so stripping shifts all of them consistently. What must
130
+ * NOT shift is the content key, which is computed over the raw bytes; see
131
+ * `resources`' `content-key.ts`.
132
+ */
133
+ import { VatError } from './errors/vat-error.js';
134
+ /**
135
+ * Thrown when bytes are too many to become one JS string — the engine's refusal,
136
+ * made a coded error so a caller can treat it as a fact about a file.
137
+ *
138
+ * The limit is the engine's, not VAT's: V8 caps a string at
139
+ * `buffer.constants.MAX_STRING_LENGTH` (0x1fffffe8) UTF-16 code units, and
140
+ * decoding past it throws. One near-gigabyte CSV in a corpus used to abort a
141
+ * whole projection population on that throw.
142
+ *
143
+ * Two places raise it, and they agree by construction rather than by a shared
144
+ * literal. The `./fs` read path (`readDecodableBytes` in `text-file.ts`) refuses
145
+ * by `stat` BEFORE reading, against `MAX_DECODABLE_BYTES`, so half a gigabyte is
146
+ * never pulled into memory only to be refused. {@link decodeTextContent} — pure,
147
+ * so it cannot ask `node:buffer` for the limit (`subpath-purity.test.ts`) —
148
+ * translates the engine's own refusal for bytes that arrive by any other route.
149
+ * Dispatch with `isVatError(error, TextTooLargeError.code)`, never on the message.
150
+ */
151
+ export declare class TextTooLargeError extends VatError {
152
+ readonly byteLength: number;
153
+ /** The code every instance carries, for `isVatError(error, TextTooLargeError.code)`. */
154
+ static readonly code = "TEXT_TOO_LARGE";
155
+ /**
156
+ * @param byteLength - How many bytes the refused content holds
157
+ * @param options - `cause`, when an engine refusal is being translated
158
+ */
159
+ constructor(byteLength: number, options?: ErrorOptions);
160
+ }
161
+ /** An encoding this module can decode. */
162
+ export type TextEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | 'utf-32le' | 'utf-32be';
163
+ /**
164
+ * How the encoding was arrived at.
165
+ *
166
+ * `'bom'` is a fact about the bytes. `'assumed'` is a default, and the honest
167
+ * name for it: BOM-less UTF-16 and BOM-less latin-1 both land here and both
168
+ * decode as UTF-8.
169
+ */
170
+ export type EncodingSource = 'bom' | 'assumed';
171
+ /**
172
+ * Everything the decode knew, guessed, and lost — the text itself excluded.
173
+ *
174
+ * Split out from {@link DecodedText} so a consumer that must carry the decode's
175
+ * provenance alongside *other* facts about the same bytes — `KeyedContent` in
176
+ * `@vibe-agent-toolkit/resources` carries it beside a content key and a byte
177
+ * length — can hold exactly these three fields without also re-holding the
178
+ * content, and without restating them one by one at every layer they cross.
179
+ */
180
+ export interface TextProvenance {
181
+ /** The encoding used. */
182
+ readonly encoding: TextEncoding;
183
+ /** Whether {@link encoding} was read off a BOM or assumed. */
184
+ readonly encodingSource: EncodingSource;
185
+ /**
186
+ * How many U+FFFD REPLACEMENT CHARACTERs the decode produced.
187
+ *
188
+ * Zero for a document that decodes cleanly, **including one whose own content
189
+ * legitimately contains U+FFFD** — see the module docstring for why the
190
+ * fatal-first ordering is what makes those two cases distinguishable. A
191
+ * non-zero value is proof, not suspicion: these bytes are not valid in the
192
+ * encoding they were read as.
193
+ */
194
+ readonly replacementCharacters: number;
195
+ }
196
+ /** Text decoded from bytes, and what it was decoded as. */
197
+ export interface DecodedText extends TextProvenance {
198
+ /** The decoded content, BOM removed, exactly as a parser should receive it. */
199
+ readonly text: string;
200
+ }
201
+ /**
202
+ * **The canonical content-decoding seam.** Turn file bytes into the text a
203
+ * parser should see.
204
+ *
205
+ * Takes bytes rather than a path so that a caller which must also key, hash or
206
+ * measure the raw bytes reads the file exactly once — `readContentWithKey` in
207
+ * `@vibe-agent-toolkit/resources` is that caller, and its key must stay over the
208
+ * raw byte preimage whatever this function decides the characters are. For the
209
+ * ordinary "read a file, give me its text" case use `readTextContent` from
210
+ * `@vibe-agent-toolkit/utils/fs`.
211
+ *
212
+ * @param bytes - The exact bytes read from disk
213
+ * @returns The decoded text, the encoding used, whether that was a fact, and how
214
+ * many characters the decode had to replace
215
+ * @throws {@link TextTooLargeError} when the engine refuses a string that long
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * const bytes = await readFile(path);
220
+ * const { text, encoding, encodingSource, replacementCharacters } = decodeTextContent(bytes);
221
+ * // UTF-16BE file: encoding 'utf-16be', encodingSource 'bom', 0 replacements,
222
+ * // text with no BOM and no NULs
223
+ * ```
224
+ */
225
+ export declare function decodeTextContent(bytes: Uint8Array): DecodedText;
226
+ //# sourceMappingURL=text-content.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-content.d.ts","sourceRoot":"","sources":["../src/text-content.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmIG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,iBAAkB,SAAQ,QAAQ;IAS3C,QAAQ,CAAC,UAAU,EAAE,MAAM;IAR7B,wFAAwF;IACxF,MAAM,CAAC,QAAQ,CAAC,IAAI,oBAAoB;IAExC;;;OAGG;gBAEQ,UAAU,EAAE,MAAM,EAC3B,OAAO,CAAC,EAAE,YAAY;CAQzB;AAkBD,0CAA0C;AAC1C,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,CAAC;AAEvF;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,SAAS,CAAC;AAE/C;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC7B,yBAAyB;IACzB,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,8DAA8D;IAC9D,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IACxC;;;;;;;;OAQG;IACH,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;CACxC;AAED,2DAA2D;AAC3D,MAAM,WAAW,WAAY,SAAQ,cAAc;IACjD,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAkLD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAOhE"}
@@ -0,0 +1,418 @@
1
+ /**
2
+ * **The one way VAT turns file bytes into text.**
3
+ *
4
+ * `local/no-raw-text-decode` enforces that — but only where it is registered,
5
+ * which today is `packages/utils/src` and `packages/resources/src`. Those two
6
+ * own the seam and every corpus-document read; the rest of the repo is a
7
+ * migration ledger, not a covered claim, and `eslint.config.js` carries it in
8
+ * cost order. Do not read this docstring as "nothing else in the repo decodes".
9
+ *
10
+ * ## Where this lives, and why it is a `utils` primitive
11
+ *
12
+ * Bytes-to-text is a pure function of its argument. It knows nothing about
13
+ * content keys, parse caches or the projection — `readContentWithKey` in
14
+ * `@vibe-agent-toolkit/resources` *composes* this with a raw-bytes content key,
15
+ * and the key is the projection concept, not the decode.
16
+ *
17
+ * It has to live here for a harder reason than tidiness. `resources` depends on
18
+ * `utils` and `utils` must never depend on `resources`, so a seam in `resources`
19
+ * with a lint rule shipped from `utils` would flag `utils`' own reads
20
+ * (`gitignore-checker.ts` reads an adopter's `.gitignore`; `project-utils.ts`
21
+ * reads an adopter's `package.json`) while giving them no legal way to comply.
22
+ * The rule would then be widened with exemptions until it meant nothing. Placing
23
+ * the primitive at the bottom of the arrow is what makes the guardrail
24
+ * enforceable.
25
+ *
26
+ * `./text` is also deliberately **pure** — no `node:*` import at all — so bytes
27
+ * from a git blob, an HTTP body or a zip entry decode through the same function
28
+ * as bytes from disk. The file-reading half is in `./fs` (`readTextContent`),
29
+ * where everything that touches `node:fs` lives.
30
+ *
31
+ * ## The defect this exists for
32
+ *
33
+ * `readContentWithKey` (in `@vibe-agent-toolkit/resources`) used to call
34
+ * `bytes.toString('utf-8')` unconditionally. Measured end to end, on a real
35
+ * `working-tree-encoding=UTF-16` checkout (see `resources`'
36
+ * `test/system/git-hostile-config.system.test.ts`):
37
+ *
38
+ * ```text
39
+ * bytes on disk 40 B, BOM fe ff, UTF-16BE
40
+ * decoded "��# \0D\0o\0c\0…" NUL-interleaved mojibake
41
+ * looksBinary true — the decoded string carries NULs
42
+ * projection BLOB_NOT_TEXT, no blob row, 0 sections, 0 references
43
+ * the same doc 1 heading, 1 link, from its UTF-8 bytes
44
+ * ```
45
+ *
46
+ * So VAT could not read a UTF-16 document *at all*, and the reason it matters is
47
+ * not exotic: **PowerShell 5.1's `Out-File` and `>` write UTF-16LE by default**,
48
+ * so a Windows-authored document lands squarely in that hole.
49
+ *
50
+ * ## What is a fact here, and what is an assumption
51
+ *
52
+ * The distinction is carried in the result ({@link TextProvenance.encodingSource})
53
+ * rather than left to prose, because the two are not the same kind of claim:
54
+ *
55
+ * | input | encoding | encodingSource | why |
56
+ * |---|---|---|---|
57
+ * | leading `ef bb bf` | `utf-8` | `bom` | the bytes say so |
58
+ * | leading `ff fe` (not `ff fe 00 00`) | `utf-16le` | `bom` | the bytes say so |
59
+ * | leading `fe ff` | `utf-16be` | `bom` | the bytes say so |
60
+ * | leading `ff fe 00 00` | `utf-32le` | `bom` | the bytes say so |
61
+ * | leading `00 00 fe ff` | `utf-32be` | `bom` | the bytes say so |
62
+ * | anything else | `utf-8` | `assumed` | the defensible default |
63
+ *
64
+ * ## And what proves the assumption WRONG
65
+ *
66
+ * `encoding` says what was guessed; {@link TextProvenance.replacementCharacters}
67
+ * is what the guess cost. A malformed sequence decodes to U+FFFD instead of
68
+ * throwing, so a mis-decoded document arrives as a well-formed JS string full of
69
+ * garbage — and a byte-level BPE tokenizer has no out-of-vocabulary concept, so
70
+ * it embeds and indexes that garbage without erroring anywhere. Counting the
71
+ * substitutions is what turns "we assumed UTF-8" into "we assumed UTF-8 and were
72
+ * demonstrably wrong 3,200 times in this file".
73
+ *
74
+ * It is counted **without paying for it on the clean path**. Every decode runs
75
+ * first through a `fatal: true` decoder, which throws on the first malformed
76
+ * sequence rather than substituting; a file that decodes cleanly — nearly every
77
+ * file — costs exactly one decode and no scan at all. Only a file that actually
78
+ * threw is decoded a second time in substituting mode and scanned for U+FFFD, so
79
+ * the O(n) scan is charged entirely to broken input.
80
+ *
81
+ * That ordering also buys a correctness property a scan alone cannot have: a
82
+ * document that *legitimately contains* U+FFFD is valid input, so the fatal
83
+ * decoder does not throw and it is reported as **0** replacements rather than
84
+ * accused of a bad decode. A bare scan would count its own content against it.
85
+ *
86
+ * 🪤 **The UTF-32LE BOM starts with the UTF-16LE BOM.** `ff fe 00 00` matches
87
+ * `ff fe`, so a table tested shortest-first decodes every UTF-32LE document as
88
+ * NUL-interleaved UTF-16 — the same bug this module exists to fix, one encoding
89
+ * further down. {@link BOMS} is therefore ordered longest-first and the test
90
+ * suite pins that ordering directly.
91
+ *
92
+ * ## The two limitations, recorded rather than guessed around
93
+ *
94
+ * - **BOM-less UTF-16 is not detected.** It is undecidable from bytes alone: the
95
+ * same byte string is a legal, different UTF-8 document. A NUL-density
96
+ * heuristic would decide it *usually* correctly and silently wrongly the rest
97
+ * of the time, and "silently wrongly" is the failure class this whole module
98
+ * is a reaction to. BOM-less input is UTF-8.
99
+ * - **Latin charsets are not detected either, and there is no windows-1252
100
+ * fallback.** "These bytes are not valid UTF-8" is a fact; "therefore they are
101
+ * latin-1" is a guess, and it is equally consistent with a UTF-8 document
102
+ * carrying one corrupt byte. Malformed input gets U+FFFD. In practice the
103
+ * Latin family costs little: every ASCII byte string is valid UTF-8 and
104
+ * decodes correctly, so only high bytes are affected.
105
+ *
106
+ * Both are pinned as tests that state what is given up, so adding a heuristic
107
+ * later has to edit an assertion rather than quietly widen a claim.
108
+ *
109
+ * ## Why `TextDecoder` for three encodings and hand-rolled code for two
110
+ *
111
+ * `TextDecoder` implements the WHATWG Encoding Standard, which **deliberately
112
+ * omits UTF-32** — no engine offers it. Node's `Buffer` is narrower still:
113
+ * `utf8`, `utf16le`/`ucs2`, `latin1`, `ascii` and the binary-to-text codecs, with
114
+ * **no UTF-16BE at all** (the encoding a round trip through git's
115
+ * `working-tree-encoding=UTF-16` actually produces). So `TextDecoder` carries
116
+ * utf-8/utf-16le/utf-16be, and UTF-32 is converted here.
117
+ *
118
+ * Refusing UTF-32 loudly was the alternative, and it was rejected for one
119
+ * reason: the BOM has to be *recognised* regardless (see the trap above), so the
120
+ * choice was never "detect it or not" — only "having detected it, decode it or
121
+ * throw". Decoding is ~20 lines and leaves no hole.
122
+ *
123
+ * ## The BOM is stripped, and that is load-bearing in both directions
124
+ *
125
+ * A surviving leading U+FEFF stops `# Heading` from parsing as a heading. And
126
+ * every offset downstream is a **character** offset over this decoded string —
127
+ * `lineStartOffsets` in `resources`' `projection/blob-sections.ts` derives them
128
+ * from `content.split('\n')`, and `parseMarkdownContent` takes remark's own
129
+ * character positions — so stripping shifts all of them consistently. What must
130
+ * NOT shift is the content key, which is computed over the raw bytes; see
131
+ * `resources`' `content-key.ts`.
132
+ */
133
+ import { VatError } from './errors/vat-error.js';
134
+ /**
135
+ * Thrown when bytes are too many to become one JS string — the engine's refusal,
136
+ * made a coded error so a caller can treat it as a fact about a file.
137
+ *
138
+ * The limit is the engine's, not VAT's: V8 caps a string at
139
+ * `buffer.constants.MAX_STRING_LENGTH` (0x1fffffe8) UTF-16 code units, and
140
+ * decoding past it throws. One near-gigabyte CSV in a corpus used to abort a
141
+ * whole projection population on that throw.
142
+ *
143
+ * Two places raise it, and they agree by construction rather than by a shared
144
+ * literal. The `./fs` read path (`readDecodableBytes` in `text-file.ts`) refuses
145
+ * by `stat` BEFORE reading, against `MAX_DECODABLE_BYTES`, so half a gigabyte is
146
+ * never pulled into memory only to be refused. {@link decodeTextContent} — pure,
147
+ * so it cannot ask `node:buffer` for the limit (`subpath-purity.test.ts`) —
148
+ * translates the engine's own refusal for bytes that arrive by any other route.
149
+ * Dispatch with `isVatError(error, TextTooLargeError.code)`, never on the message.
150
+ */
151
+ export class TextTooLargeError extends VatError {
152
+ byteLength;
153
+ /** The code every instance carries, for `isVatError(error, TextTooLargeError.code)`. */
154
+ static code = 'TEXT_TOO_LARGE';
155
+ /**
156
+ * @param byteLength - How many bytes the refused content holds
157
+ * @param options - `cause`, when an engine refusal is being translated
158
+ */
159
+ constructor(byteLength, options) {
160
+ super(TextTooLargeError.code, `${byteLength} bytes is too large to decode into a JavaScript string`, options);
161
+ this.byteLength = byteLength;
162
+ }
163
+ }
164
+ /**
165
+ * The code Node's `TextDecoder` attaches when the decoded string would pass the
166
+ * engine's string-length limit. Matched as a `code`, never as its message.
167
+ */
168
+ const ENGINE_STRING_TOO_LONG = 'ERR_STRING_TOO_LONG';
169
+ /**
170
+ * Whether `error` is the engine refusing to build a string that long.
171
+ *
172
+ * @param error - What a decode threw
173
+ * @returns True for Node's `ERR_STRING_TOO_LONG`
174
+ */
175
+ function isEngineStringTooLong(error) {
176
+ return error instanceof Error && error.code === ENGINE_STRING_TOO_LONG;
177
+ }
178
+ /**
179
+ * Byte-order marks, **longest first**.
180
+ *
181
+ * The ordering is the correctness property, not a formatting choice — see the
182
+ * module docstring's trap. Frozen so a caller cannot reorder it in place.
183
+ */
184
+ const BOMS = Object.freeze([
185
+ { bytes: [0xff, 0xfe, 0x00, 0x00], encoding: 'utf-32le' },
186
+ { bytes: [0x00, 0x00, 0xfe, 0xff], encoding: 'utf-32be' },
187
+ { bytes: [0xef, 0xbb, 0xbf], encoding: 'utf-8' },
188
+ { bytes: [0xff, 0xfe], encoding: 'utf-16le' },
189
+ { bytes: [0xfe, 0xff], encoding: 'utf-16be' },
190
+ ]);
191
+ /**
192
+ * One `TextDecoder` per encoding, built once.
193
+ *
194
+ * Constructing a decoder per call is measurable on a corpus of thousands of
195
+ * files, and these are stateless when `stream: false` (the default for
196
+ * `decode()`), so one instance is safe to share.
197
+ *
198
+ * `ignoreBOM: true` on purpose: this module has already removed the BOM by the
199
+ * time a decoder sees the bytes, and leaving the option at its default would put
200
+ * a second, silent BOM-stripping step behind the deliberate one — so a document
201
+ * whose *content* legitimately begins with U+FEFF would lose a character that
202
+ * nothing here decided to remove.
203
+ */
204
+ const DECODERS = new Map([
205
+ // No `eslint-disable` needed: this file is the `exemptFiles` entry for
206
+ // `local/no-raw-text-decode` in the repo's `eslint.config.js`. If the rule
207
+ // starts firing here, that entry has drifted from this path.
208
+ ['utf-8', new TextDecoder('utf-8', { ignoreBOM: true })],
209
+ ['utf-16le', new TextDecoder('utf-16le', { ignoreBOM: true })],
210
+ ['utf-16be', new TextDecoder('utf-16be', { ignoreBOM: true })],
211
+ ]);
212
+ /**
213
+ * The same three encodings in **fatal** mode — throw rather than substitute.
214
+ *
215
+ * This is the whole cost model for {@link TextProvenance.replacementCharacters}.
216
+ * A substituting decoder cannot tell a caller whether it substituted, so the only
217
+ * other way to know is to scan every decoded string for U+FFFD — an O(n) pass
218
+ * over every file in a corpus to learn that almost none of them needed it. A
219
+ * fatal decoder answers the same question by *not throwing*, at no extra cost on
220
+ * the clean path, and the expensive route is taken only where there is genuinely
221
+ * something to count.
222
+ *
223
+ * Same `ignoreBOM: true` as their substituting twins, for the same reason: the
224
+ * BOM is already gone by the time either sees the bytes.
225
+ */
226
+ const FATAL_DECODERS = new Map([
227
+ ['utf-8', new TextDecoder('utf-8', { ignoreBOM: true, fatal: true })],
228
+ ['utf-16le', new TextDecoder('utf-16le', { ignoreBOM: true, fatal: true })],
229
+ ['utf-16be', new TextDecoder('utf-16be', { ignoreBOM: true, fatal: true })],
230
+ ]);
231
+ /** How many code points to spread into one `String.fromCodePoint` call. */
232
+ const CODE_POINT_CHUNK = 4096;
233
+ /** The last Unicode code point. */
234
+ const MAX_CODE_POINT = 0x10_ff_ff;
235
+ /** First and last UTF-16 surrogate — never a scalar value on their own. */
236
+ const SURROGATE_FIRST = 0xd8_00;
237
+ const SURROGATE_LAST = 0xdf_ff;
238
+ /** U+FFFD REPLACEMENT CHARACTER, what a malformed unit becomes. */
239
+ const REPLACEMENT_CODE_POINT = 0xff_fd;
240
+ /** Does `bytes` begin with `prefix`? */
241
+ function startsWith(bytes, prefix) {
242
+ if (bytes.length < prefix.length)
243
+ return false;
244
+ return prefix.every((byte, index) => bytes[index] === byte);
245
+ }
246
+ /**
247
+ * The encoding a BOM announces, and how many bytes it occupies.
248
+ *
249
+ * @param bytes - Raw bytes, from the start of the file
250
+ * @returns The BOM's encoding and length, or `null` when there is no BOM
251
+ */
252
+ function bomAt(bytes) {
253
+ for (const bom of BOMS) {
254
+ if (startsWith(bytes, bom.bytes)) {
255
+ return { encoding: bom.encoding, length: bom.bytes.length };
256
+ }
257
+ }
258
+ return null;
259
+ }
260
+ /**
261
+ * Is `codePoint` something `String.fromCodePoint` will accept?
262
+ *
263
+ * A lone surrogate and anything past U+10FFFF both throw, and a corrupt document
264
+ * must not surface as an exception out of a decoder.
265
+ */
266
+ function isScalarValue(codePoint) {
267
+ if (codePoint > MAX_CODE_POINT)
268
+ return false;
269
+ return codePoint < SURROGATE_FIRST || codePoint > SURROGATE_LAST;
270
+ }
271
+ /**
272
+ * Build a string from code points, in chunks.
273
+ *
274
+ * `String.fromCodePoint(...points)` blows the engine's argument limit somewhere
275
+ * in the tens of thousands, which a real document reaches — so the spread is
276
+ * bounded rather than whole-array.
277
+ */
278
+ function fromCodePoints(points) {
279
+ if (points.length <= CODE_POINT_CHUNK)
280
+ return String.fromCodePoint(...points);
281
+ const parts = [];
282
+ for (let start = 0; start < points.length; start += CODE_POINT_CHUNK) {
283
+ parts.push(String.fromCodePoint(...points.slice(start, start + CODE_POINT_CHUNK)));
284
+ }
285
+ return parts.join('');
286
+ }
287
+ /**
288
+ * Decode UTF-32, by hand, because no engine does.
289
+ *
290
+ * Every 4-byte unit that is not a Unicode scalar value becomes U+FFFD, as does a
291
+ * trailing run of 1–3 bytes that cannot form a unit. That mirrors what
292
+ * `TextDecoder` does with malformed input, so the two paths fail the same way.
293
+ *
294
+ * There is no fatal-first pass here and none is needed: this loop *decides* each
295
+ * substitution, so it can count them as it makes them — free, and exact. The
296
+ * fatal-decoder trick exists only because `TextDecoder` refuses to say.
297
+ *
298
+ * @param bytes - The content bytes, BOM already removed
299
+ * @param littleEndian - Byte order the BOM announced
300
+ * @returns The decoded string and how many units were replaced
301
+ */
302
+ function decodeUtf32(bytes, littleEndian) {
303
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
304
+ const whole = bytes.byteLength - (bytes.byteLength % 4);
305
+ const points = [];
306
+ let replacementCharacters = 0;
307
+ for (let offset = 0; offset < whole; offset += 4) {
308
+ const codePoint = view.getUint32(offset, littleEndian);
309
+ if (isScalarValue(codePoint)) {
310
+ points.push(codePoint);
311
+ }
312
+ else {
313
+ points.push(REPLACEMENT_CODE_POINT);
314
+ replacementCharacters += 1;
315
+ }
316
+ }
317
+ if (whole !== bytes.byteLength) {
318
+ points.push(REPLACEMENT_CODE_POINT);
319
+ replacementCharacters += 1;
320
+ }
321
+ return { text: fromCodePoints(points), replacementCharacters };
322
+ }
323
+ /**
324
+ * Count the U+FFFD in a string that a fatal decode already refused.
325
+ *
326
+ * Only ever called on input known to be malformed, which is what keeps the O(n)
327
+ * scan off the common path. A scan rather than a regex or a split: it allocates
328
+ * nothing on a string that may be megabytes long. U+FFFD is a BMP character, so
329
+ * the comparison is exact whether the index lands on it or on a surrogate half.
330
+ *
331
+ * @param text - The substituting decoder's output
332
+ * @returns How many replacement characters it contains
333
+ */
334
+ function countReplacementCharacters(text) {
335
+ let count = 0;
336
+ for (let index = 0; index < text.length; index += 1) {
337
+ if (text.codePointAt(index) === REPLACEMENT_CODE_POINT)
338
+ count += 1;
339
+ }
340
+ return count;
341
+ }
342
+ /**
343
+ * **The canonical content-decoding seam.** Turn file bytes into the text a
344
+ * parser should see.
345
+ *
346
+ * Takes bytes rather than a path so that a caller which must also key, hash or
347
+ * measure the raw bytes reads the file exactly once — `readContentWithKey` in
348
+ * `@vibe-agent-toolkit/resources` is that caller, and its key must stay over the
349
+ * raw byte preimage whatever this function decides the characters are. For the
350
+ * ordinary "read a file, give me its text" case use `readTextContent` from
351
+ * `@vibe-agent-toolkit/utils/fs`.
352
+ *
353
+ * @param bytes - The exact bytes read from disk
354
+ * @returns The decoded text, the encoding used, whether that was a fact, and how
355
+ * many characters the decode had to replace
356
+ * @throws {@link TextTooLargeError} when the engine refuses a string that long
357
+ *
358
+ * @example
359
+ * ```typescript
360
+ * const bytes = await readFile(path);
361
+ * const { text, encoding, encodingSource, replacementCharacters } = decodeTextContent(bytes);
362
+ * // UTF-16BE file: encoding 'utf-16be', encodingSource 'bom', 0 replacements,
363
+ * // text with no BOM and no NULs
364
+ * ```
365
+ */
366
+ export function decodeTextContent(bytes) {
367
+ try {
368
+ return decodeWithinEngineLimits(bytes);
369
+ }
370
+ catch (error) {
371
+ if (isEngineStringTooLong(error))
372
+ throw new TextTooLargeError(bytes.byteLength, { cause: error });
373
+ throw error;
374
+ }
375
+ }
376
+ /**
377
+ * {@link decodeTextContent} without the translation of the engine's
378
+ * string-length refusal — split out so the translation wraps BOTH decodes, the
379
+ * fatal first pass and the lenient retry, either of which can reach the limit.
380
+ *
381
+ * @param bytes - The exact bytes read from disk
382
+ * @returns The decoded text and its provenance
383
+ */
384
+ function decodeWithinEngineLimits(bytes) {
385
+ const bom = bomAt(bytes);
386
+ const encoding = bom?.encoding ?? 'utf-8';
387
+ const body = bom === null ? bytes : bytes.subarray(bom.length);
388
+ const encodingSource = bom === null ? 'assumed' : 'bom';
389
+ if (encoding === 'utf-32le' || encoding === 'utf-32be') {
390
+ return { ...decodeUtf32(body, encoding === 'utf-32le'), encoding, encodingSource };
391
+ }
392
+ const decoder = DECODERS.get(encoding);
393
+ const fatalDecoder = FATAL_DECODERS.get(encoding);
394
+ if (decoder === undefined || fatalDecoder === undefined) {
395
+ // Unreachable: BOMS, DECODERS and FATAL_DECODERS cover the same five
396
+ // encodings between them. Thrown rather than defaulted, because a silent fall
397
+ // back to UTF-8 here would reproduce the exact defect this module was written
398
+ // to remove.
399
+ throw new Error(`no decoder for encoding "${encoding}"`);
400
+ }
401
+ try {
402
+ // The clean path, and the only one nearly every file takes: one decode, no
403
+ // scan. A throw here is the ONLY evidence that a substitution happened —
404
+ // see FATAL_DECODERS.
405
+ return { text: fatalDecoder.decode(body), encoding, encodingSource, replacementCharacters: 0 };
406
+ }
407
+ catch (error) {
408
+ // A fatal `TextDecoder` refuses malformed input with a `TypeError` — that is
409
+ // the WHATWG contract, and the only failure the lenient retry answers.
410
+ // Anything else thrown from in here is a bug, and reporting it as a mildly
411
+ // damaged file (a replacement count) would hide it.
412
+ if (!(error instanceof TypeError))
413
+ throw error;
414
+ const text = decoder.decode(body);
415
+ return { text, encoding, encodingSource, replacementCharacters: countReplacementCharacters(text) };
416
+ }
417
+ }
418
+ //# sourceMappingURL=text-content.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-content.js","sourceRoot":"","sources":["../src/text-content.ts"],"names":[],"mappings":"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"}