@vibe-agent-toolkit/utils 0.2.0-rc.2 → 0.2.0-rc.4

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 (150) hide show
  1. package/README.md +26 -15
  2. package/dist/compare-code-units.d.ts +28 -0
  3. package/dist/compare-code-units.d.ts.map +1 -0
  4. package/dist/compare-code-units.js +33 -0
  5. package/dist/compare-code-units.js.map +1 -0
  6. package/dist/crawl-timing.d.ts +657 -0
  7. package/dist/crawl-timing.d.ts.map +1 -0
  8. package/dist/crawl-timing.js +765 -0
  9. package/dist/crawl-timing.js.map +1 -0
  10. package/dist/crawl.d.ts +2 -4
  11. package/dist/crawl.d.ts.map +1 -1
  12. package/dist/crawl.js +2 -4
  13. package/dist/crawl.js.map +1 -1
  14. package/dist/file-crawler.d.ts +22 -0
  15. package/dist/file-crawler.d.ts.map +1 -1
  16. package/dist/file-crawler.js +31 -7
  17. package/dist/file-crawler.js.map +1 -1
  18. package/dist/fs-utils.d.ts +17 -0
  19. package/dist/fs-utils.d.ts.map +1 -1
  20. package/dist/fs-utils.js +69 -0
  21. package/dist/fs-utils.js.map +1 -1
  22. package/dist/fs.d.ts +3 -1
  23. package/dist/fs.d.ts.map +1 -1
  24. package/dist/fs.js +5 -1
  25. package/dist/fs.js.map +1 -1
  26. package/dist/git-run.d.ts +167 -0
  27. package/dist/git-run.d.ts.map +1 -0
  28. package/dist/git-run.js +154 -0
  29. package/dist/git-run.js.map +1 -0
  30. package/dist/git-snapshot.d.ts +181 -0
  31. package/dist/git-snapshot.d.ts.map +1 -0
  32. package/dist/git-snapshot.js +232 -0
  33. package/dist/git-snapshot.js.map +1 -0
  34. package/dist/git-tracker.d.ts +94 -1
  35. package/dist/git-tracker.d.ts.map +1 -1
  36. package/dist/git-tracker.js +135 -3
  37. package/dist/git-tracker.js.map +1 -1
  38. package/dist/git-utils.d.ts +38 -0
  39. package/dist/git-utils.d.ts.map +1 -1
  40. package/dist/git-utils.js +103 -80
  41. package/dist/git-utils.js.map +1 -1
  42. package/dist/git.d.ts +12 -4
  43. package/dist/git.d.ts.map +1 -1
  44. package/dist/git.js +12 -4
  45. package/dist/git.js.map +1 -1
  46. package/dist/gitignore-checker.d.ts.map +1 -1
  47. package/dist/gitignore-checker.js +6 -3
  48. package/dist/gitignore-checker.js.map +1 -1
  49. package/dist/index.d.ts +24 -18
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +49 -30
  52. package/dist/index.js.map +1 -1
  53. package/dist/path-core.d.ts +1 -1
  54. package/dist/path-core.js +1 -1
  55. package/dist/project-utils.d.ts.map +1 -1
  56. package/dist/project-utils.js +6 -3
  57. package/dist/project-utils.js.map +1 -1
  58. package/dist/project.d.ts +7 -5
  59. package/dist/project.d.ts.map +1 -1
  60. package/dist/project.js +7 -5
  61. package/dist/project.js.map +1 -1
  62. package/dist/safe-exec.d.ts +38 -10
  63. package/dist/safe-exec.d.ts.map +1 -1
  64. package/dist/safe-exec.js +88 -19
  65. package/dist/safe-exec.js.map +1 -1
  66. package/dist/skill-test/index.d.ts +1 -1
  67. package/dist/skill-test/index.d.ts.map +1 -1
  68. package/dist/skill-test/index.js +1 -1
  69. package/dist/skill-test/index.js.map +1 -1
  70. package/dist/skill-test/spawn-claude.d.ts +28 -0
  71. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  72. package/dist/skill-test/spawn-claude.js +51 -2
  73. package/dist/skill-test/spawn-claude.js.map +1 -1
  74. package/dist/skill-test/transcript.d.ts +41 -2
  75. package/dist/skill-test/transcript.d.ts.map +1 -1
  76. package/dist/skill-test/transcript.js +47 -3
  77. package/dist/skill-test/transcript.js.map +1 -1
  78. package/dist/test-helpers.d.ts +185 -13
  79. package/dist/test-helpers.d.ts.map +1 -1
  80. package/dist/test-helpers.js +274 -27
  81. package/dist/test-helpers.js.map +1 -1
  82. package/dist/testing.d.ts +73 -3
  83. package/dist/testing.d.ts.map +1 -1
  84. package/dist/testing.js +93 -3
  85. package/dist/testing.js.map +1 -1
  86. package/dist/text-content.d.ts +197 -0
  87. package/dist/text-content.d.ts.map +1 -0
  88. package/dist/text-content.js +348 -0
  89. package/dist/text-content.js.map +1 -0
  90. package/dist/text-file.d.ts +45 -0
  91. package/dist/text-file.d.ts.map +1 -0
  92. package/dist/text-file.js +53 -0
  93. package/dist/text-file.js.map +1 -0
  94. package/dist/text.d.ts +16 -0
  95. package/dist/text.d.ts.map +1 -0
  96. package/dist/text.js +16 -0
  97. package/dist/text.js.map +1 -0
  98. package/dist/timing-dump.d.ts +136 -0
  99. package/dist/timing-dump.d.ts.map +1 -0
  100. package/dist/timing-dump.js +230 -0
  101. package/dist/timing-dump.js.map +1 -0
  102. package/eslint/README.md +105 -3
  103. package/eslint/index.cjs +50 -0
  104. package/eslint/index.d.cts +14 -4
  105. package/eslint/rules/no-bare-symlink-in-tests.cjs +200 -0
  106. package/eslint/rules/no-process-exit-in-phase.cjs +117 -0
  107. package/eslint/rules/no-raw-text-decode.cjs +296 -0
  108. package/eslint/rules/no-self-package-import.cjs +157 -0
  109. package/package.json +13 -8
  110. package/dist/link-auth/build-headers.d.ts +0 -34
  111. package/dist/link-auth/build-headers.d.ts.map +0 -1
  112. package/dist/link-auth/build-headers.js +0 -58
  113. package/dist/link-auth/build-headers.js.map +0 -1
  114. package/dist/link-auth/expand-macro.d.ts +0 -38
  115. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  116. package/dist/link-auth/expand-macro.js +0 -133
  117. package/dist/link-auth/expand-macro.js.map +0 -1
  118. package/dist/link-auth/macros.yaml +0 -50
  119. package/dist/link-auth/resolve-token.d.ts +0 -83
  120. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  121. package/dist/link-auth/resolve-token.js +0 -101
  122. package/dist/link-auth/resolve-token.js.map +0 -1
  123. package/dist/link-auth/resolve.d.ts +0 -102
  124. package/dist/link-auth/resolve.d.ts.map +0 -1
  125. package/dist/link-auth/resolve.js +0 -66
  126. package/dist/link-auth/resolve.js.map +0 -1
  127. package/dist/link-auth/rewrite.d.ts +0 -52
  128. package/dist/link-auth/rewrite.d.ts.map +0 -1
  129. package/dist/link-auth/rewrite.js +0 -102
  130. package/dist/link-auth/rewrite.js.map +0 -1
  131. package/dist/link-auth/select-provider.d.ts +0 -30
  132. package/dist/link-auth/select-provider.d.ts.map +0 -1
  133. package/dist/link-auth/select-provider.js +0 -55
  134. package/dist/link-auth/select-provider.js.map +0 -1
  135. package/dist/link-auth/template.d.ts +0 -40
  136. package/dist/link-auth/template.d.ts.map +0 -1
  137. package/dist/link-auth/template.js +0 -89
  138. package/dist/link-auth/template.js.map +0 -1
  139. package/dist/link-auth/transforms.d.ts +0 -46
  140. package/dist/link-auth/transforms.d.ts.map +0 -1
  141. package/dist/link-auth/transforms.js +0 -52
  142. package/dist/link-auth/transforms.js.map +0 -1
  143. package/dist/template-entry.d.ts +0 -10
  144. package/dist/template-entry.d.ts.map +0 -1
  145. package/dist/template-entry.js +0 -10
  146. package/dist/template-entry.js.map +0 -1
  147. package/dist/template.d.ts +0 -7
  148. package/dist/template.d.ts.map +0 -1
  149. package/dist/template.js +0 -18
  150. package/dist/template.js.map +0 -1
@@ -0,0 +1,200 @@
1
+ /**
2
+ * ESLint rule: no-bare-symlink-in-tests
3
+ *
4
+ * Bans unguarded `fs.symlinkSync()` / `fs.promises.symlink()`, with a different
5
+ * remedy on each side of the test boundary:
6
+ *
7
+ * - **test files** → route through `createSymlink()` / `createSymlinkAsync()`
8
+ * from `@vibe-agent-toolkit/utils`, which require a probed capability token.
9
+ * - **shipped code** → there is no wrapper to route through, and there must not
10
+ * be: `createSymlink()` lives on the `utils/testing` subpath, so pointing
11
+ * production code at it would be worse advice than the bare call. Prefer a
12
+ * junction for a directory link on win32, or catch the failure and name the
13
+ * missing privilege. An `eslint-disable` justification is the sanctioned way
14
+ * to say "this platform is deliberately out of scope" — see
15
+ * `cli/src/commands/agent/install.ts`, where `--dev` is knowingly unavailable
16
+ * on an unprivileged Windows and fails saying exactly that.
17
+ *
18
+ * ⚠️ **The name is now narrower than the rule.** It covers shipped code too;
19
+ * renaming it is a public-API change to `@vibe-agent-toolkit/utils/eslint` and
20
+ * has not been done.
21
+ *
22
+ * Why: creating a symlink on Windows needs Developer Mode or
23
+ * `SeCreateSymbolicLinkPrivilege`, which most dev boxes and CI agents lack. A
24
+ * bare `symlinkSync()` call throws `EPERM` there with no visible skip — the
25
+ * test just fails, or (worse) the failure is masked by a `try`/`catch` that
26
+ * swallows it silently. `createSymlink()`/`createSymlinkAsync()` require a
27
+ * `SymlinkCapability` token as their first argument, and the only way to mint
28
+ * one is `symlinkCapability()`, which performs the real probe. That token
29
+ * requirement is what this rule enforces structurally: a test cannot reach the
30
+ * real syscall without first proving — or explicitly declining via vitest's
31
+ * `skip()` — that the host supports it.
32
+ *
33
+ * No auto-fix: unlike a straight rename, the replacement needs a capability
34
+ * token threaded from a probe call, which is a judgment call about where that
35
+ * probe belongs in the surrounding test (per-test via `{ skip }`, or hoisted
36
+ * to a shared `beforeAll`) that a mechanical fixer cannot make safely.
37
+ */
38
+
39
+ const {
40
+ EXEMPT_FILES_SCHEMA,
41
+ UNANCHORED_EXEMPT_FILE,
42
+ UNANCHORED_EXEMPT_MESSAGE,
43
+ createConfigurableExemptPathMatcher,
44
+ isTestFile,
45
+ reportUnanchoredExemptEntries,
46
+ } = require('./exempt-path-matcher.cjs');
47
+
48
+ /** `node:fs` names carrying the sync primitive. */
49
+ const SYNC_MODULES = new Set(['node:fs', 'fs']);
50
+ /** `node:fs/promises` names carrying the async primitive. */
51
+ const ASYNC_MODULES = new Set(['node:fs/promises', 'fs/promises']);
52
+
53
+ module.exports = {
54
+ meta: {
55
+ type: 'problem',
56
+ docs: {
57
+ description: 'Ban unguarded fs.symlinkSync()/fs.symlink() — route tests through createSymlink()/createSymlinkAsync(), and guard shipped code against the Windows privilege requirement',
58
+ category: 'Cross-Platform',
59
+ recommended: true,
60
+ },
61
+ fixable: null,
62
+ schema: [EXEMPT_FILES_SCHEMA],
63
+ messages: {
64
+ noBareSymlink:
65
+ 'Bare {{fn}}() in a test file can throw EPERM on Windows without Developer Mode. ' +
66
+ 'Probe with symlinkCapability() and call {{safeFn}}(cap, ...) from @vibe-agent-toolkit/utils, ' +
67
+ "routing a missing capability through vitest's skip() rather than a silent return.",
68
+ // Deliberately a different remedy, not a reworded version of the same one.
69
+ // Production code cannot `skip()`, and `createSymlink()` lives on the
70
+ // `@vibe-agent-toolkit/utils/testing` subpath — telling shipped code to
71
+ // import a test helper would be worse advice than the bare call.
72
+ unguardedSymlink:
73
+ 'Unguarded {{fn}}() will throw EPERM on Windows unless the process holds ' +
74
+ 'SeCreateSymbolicLinkPrivilege (Developer Mode or an elevated shell) — most user machines ' +
75
+ 'and CI agents do not. For a DIRECTORY link prefer a junction on win32 ' +
76
+ "(`process.platform === 'win32' ? 'junction' : 'dir'`, absolute target), which needs no " +
77
+ 'elevation; otherwise catch the failure and say what privilege is missing, or degrade to a copy. ' +
78
+ 'If this platform is deliberately out of scope, say so in an eslint-disable justification.',
79
+ [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
80
+ },
81
+ },
82
+
83
+ create(context) {
84
+ const filename = context.getFilename();
85
+
86
+ const exemptMatcherFor = createConfigurableExemptPathMatcher([]);
87
+ if (exemptMatcherFor(context)(filename)) {
88
+ // Still surface a malformed exemption list: the file we are standing in
89
+ // may be exempt only BECAUSE the entry is unanchored.
90
+ return {
91
+ Program(node) {
92
+ reportUnanchoredExemptEntries(context, node);
93
+ },
94
+ };
95
+ }
96
+
97
+ // Test files and shipped code are both covered, with different remedies.
98
+ //
99
+ // The rule was test-only for one revision, and that left two blind spots
100
+ // that mattered: VAT's own `src/` fixture builders (`trap-corpus.ts` had to
101
+ // be migrated by hand precisely because no rule could see it), and — since
102
+ // this rule ships publicly on `@vibe-agent-toolkit/utils/eslint` — every
103
+ // adopter's production code, which faces the identical Windows hazard with
104
+ // none of the test lane's ability to skip.
105
+ //
106
+ // ⚠️ Extending it is what MAKES `exemptFiles` load-bearing:
107
+ // `packages/utils/src/test-helpers.ts` holds the one sanctioned
108
+ // `symlinkSync` call and is not a test file, so it was previously excluded
109
+ // for free. It now needs a real exemption entry in eslint.config.js.
110
+ const messageId = isTestFile(filename) ? 'noBareSymlink' : 'unguardedSymlink';
111
+
112
+ // Local names bound to each module's default/namespace import, e.g.
113
+ // `import fs from 'node:fs/promises'` binds `fs` to ASYNC_MODULES.
114
+ const syncNamespaceNames = new Set();
115
+ const asyncNamespaceNames = new Set();
116
+ // Whether `symlinkSync` / `symlink` were pulled in as bare named imports.
117
+ let syncNamedImported = false;
118
+ let asyncNamedImported = false;
119
+
120
+ function namespaceLocalName(importNode) {
121
+ const spec = importNode.specifiers.find(
122
+ (candidate) => candidate.type === 'ImportDefaultSpecifier' || candidate.type === 'ImportNamespaceSpecifier',
123
+ );
124
+ return spec ? spec.local.name : null;
125
+ }
126
+
127
+ function importsNamed(importNode, name) {
128
+ return importNode.specifiers.some(
129
+ (spec) => spec.type === 'ImportSpecifier' && spec.imported.name === name,
130
+ );
131
+ }
132
+
133
+ return {
134
+ Program(node) {
135
+ reportUnanchoredExemptEntries(context, node);
136
+ },
137
+
138
+ ImportDeclaration(node) {
139
+ const source = node.source.value;
140
+ if (SYNC_MODULES.has(source)) {
141
+ const local = namespaceLocalName(node);
142
+ if (local) syncNamespaceNames.add(local);
143
+ syncNamedImported = syncNamedImported || importsNamed(node, 'symlinkSync');
144
+ }
145
+ if (ASYNC_MODULES.has(source)) {
146
+ const local = namespaceLocalName(node);
147
+ if (local) asyncNamespaceNames.add(local);
148
+ asyncNamedImported = asyncNamedImported || importsNamed(node, 'symlink');
149
+ }
150
+ },
151
+
152
+ CallExpression(node) {
153
+ const { callee } = node;
154
+
155
+ // Bare `symlinkSync(...)` from a named import.
156
+ if (syncNamedImported && callee.type === 'Identifier' && callee.name === 'symlinkSync') {
157
+ context.report({ node, messageId, data: { fn: 'symlinkSync', safeFn: 'createSymlink' } });
158
+ return;
159
+ }
160
+
161
+ // Bare `symlink(...)` from a named `node:fs/promises` import.
162
+ if (asyncNamedImported && callee.type === 'Identifier' && callee.name === 'symlink') {
163
+ context.report({ node, messageId, data: { fn: 'symlink', safeFn: 'createSymlinkAsync' } });
164
+ return;
165
+ }
166
+
167
+ // `fs.promises.symlink(...)` — the two-hop shape reached via a tracked
168
+ // sync namespace import (`import fs from 'node:fs'`).
169
+ if (
170
+ callee.type === 'MemberExpression' &&
171
+ callee.property.name === 'symlink' &&
172
+ callee.object.type === 'MemberExpression' &&
173
+ callee.object.property.name === 'promises' &&
174
+ callee.object.object.type === 'Identifier' &&
175
+ syncNamespaceNames.has(callee.object.object.name)
176
+ ) {
177
+ context.report({ node, messageId, data: { fn: 'symlink', safeFn: 'createSymlinkAsync' } });
178
+ return;
179
+ }
180
+
181
+ if (callee.type !== 'MemberExpression' || callee.object.type !== 'Identifier') {
182
+ return;
183
+ }
184
+ const receiver = callee.object.name;
185
+
186
+ // `fs.symlinkSync(...)` / `nodeFs.symlinkSync(...)` on a tracked sync namespace.
187
+ if (syncNamespaceNames.has(receiver) && callee.property.name === 'symlinkSync') {
188
+ context.report({ node, messageId, data: { fn: 'symlinkSync', safeFn: 'createSymlink' } });
189
+ return;
190
+ }
191
+
192
+ // `fs.symlink(...)` on a tracked async namespace (the common
193
+ // `import fs from 'node:fs/promises'` shape).
194
+ if (asyncNamespaceNames.has(receiver) && callee.property.name === 'symlink') {
195
+ context.report({ node, messageId, data: { fn: 'symlink', safeFn: 'createSymlinkAsync' } });
196
+ }
197
+ },
198
+ };
199
+ },
200
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ESLint Rule: no-process-exit-in-phase
3
+ *
4
+ * Forbid `process.exit()` inside a phase entry point — a function whose name
5
+ * ends in `Phase`.
6
+ *
7
+ * ## Why this rule exists
8
+ *
9
+ * `vat validate`, `vat verify` and `vat build` used to run each phase as a
10
+ * SEPARATE CHILD PROCESS. A `process.exit()` inside a phase was then perfectly
11
+ * safe: it ended that child, the parent read the exit code, and the run carried
12
+ * on to the next phase. Nothing about the code said so — the safety came
13
+ * entirely from the process boundary.
14
+ *
15
+ * That boundary is gone. Phases now run in the orchestrator's own process, so a
16
+ * surviving `process.exit()` ends THE WHOLE RUN: every later phase is silently
17
+ * skipped, the parent's aggregation never happens, and the process exits 0-or-1
18
+ * having done half the work with nothing in the document to say so. It is the
19
+ * worst shape of failure this codebase has — a confident, well-formed, wrong
20
+ * answer — and it cannot be caught by a type, because `process.exit()`
21
+ * typechecks anywhere.
22
+ *
23
+ * A phase must RETURN `{ document, exitCode }` and let its caller decide whether
24
+ * to print and exit (a command-line run) or to fold the result into the run (an
25
+ * orchestrated one).
26
+ *
27
+ * ## Why the name is the marker
28
+ *
29
+ * The `…Phase` suffix is the convention every phase entry point already follows
30
+ * (`runResourcesValidatePhase`, `runSkillsBuildPhase`, …). Keying the rule to it
31
+ * means a new phase is protected the moment it is named like one, with no list
32
+ * to keep in sync — a list is the thing that goes stale silently, which is the
33
+ * defect class this rule exists to prevent in the first place.
34
+ *
35
+ * The thin Commander wrappers that CALL these functions keep their
36
+ * `process.exit()`: deciding how the process ends is exactly their job, and they
37
+ * are not named `…Phase`.
38
+ */
39
+
40
+ /** Does this call expression read as `process.exit(...)`? */
41
+ function isProcessExitCall(node) {
42
+ const { callee } = node;
43
+ return (
44
+ callee.type === 'MemberExpression' &&
45
+ !callee.computed &&
46
+ callee.object.type === 'Identifier' &&
47
+ callee.object.name === 'process' &&
48
+ callee.property.type === 'Identifier' &&
49
+ callee.property.name === 'exit'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The declared name of a function-ish node, however it was declared.
55
+ *
56
+ * Covers the three spellings a phase entry point can legitimately take: a
57
+ * function declaration, a `const x = function () {}`, and a `const x = () => {}`.
58
+ * A rule that only understood declarations would be silently inert against an
59
+ * arrow const — the same blind spot `no-test-scoped-functions` documents.
60
+ */
61
+ function functionName(node) {
62
+ if (node.id && node.id.type === 'Identifier') return node.id.name;
63
+
64
+ const { parent } = node;
65
+ if (parent && parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
66
+ return parent.id.name;
67
+ }
68
+ if (parent && parent.type === 'Property' && parent.key.type === 'Identifier') {
69
+ return parent.key.name;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ const FUNCTION_TYPES = new Set([
75
+ 'FunctionDeclaration',
76
+ 'FunctionExpression',
77
+ 'ArrowFunctionExpression',
78
+ ]);
79
+
80
+ /** The nearest enclosing function named like a phase entry point, if any. */
81
+ function enclosingPhaseName(node) {
82
+ for (let current = node.parent; current; current = current.parent) {
83
+ if (!FUNCTION_TYPES.has(current.type)) continue;
84
+ const name = functionName(current);
85
+ if (name !== undefined && name.endsWith('Phase')) return name;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ module.exports = {
91
+ meta: {
92
+ type: 'problem',
93
+ docs: {
94
+ description:
95
+ 'Forbid process.exit() inside a phase entry point, where it would end the whole orchestrated run',
96
+ category: 'Agentic Code Safety',
97
+ recommended: true,
98
+ },
99
+ fixable: null,
100
+ schema: [],
101
+ messages: {
102
+ exitInPhase:
103
+ "'{{name}}' is a phase entry point, so process.exit() here ends the ENTIRE run — every later phase is skipped and the orchestrator never aggregates. Return { document, exitCode } instead and let the caller decide how the process ends.",
104
+ },
105
+ },
106
+
107
+ create(context) {
108
+ return {
109
+ CallExpression(node) {
110
+ if (!isProcessExitCall(node)) return;
111
+ const name = enclosingPhaseName(node);
112
+ if (name === undefined) return;
113
+ context.report({ node, messageId: 'exitInPhase', data: { name } });
114
+ },
115
+ };
116
+ },
117
+ };
@@ -0,0 +1,296 @@
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: 'Correctness',
213
+ recommended: false,
214
+ },
215
+ schema: [withSafeModuleOption(EXEMPT_FILES_SCHEMA)],
216
+ messages: {
217
+ bufferTextDecode:
218
+ 'Do not decode bytes with `.toString({{encoding}})` — it ignores any byte-order mark and '
219
+ + 'cannot express UTF-16BE. Use `decodeTextContent()` from {{safeModule}}.',
220
+ textDecoderConstruct:
221
+ 'Do not construct a `TextDecoder` here — the encoding has to be decided from the bytes, '
222
+ + 'in one place. Use `decodeTextContent()` from {{safeModule}}.',
223
+ fsReadTextEncoding:
224
+ 'Do not let `{{reader}}` decode — it applies the encoding you name with no BOM handling. '
225
+ + 'Read bytes and use `readTextContent()` / `decodeTextContent()` from {{safeModule}}.',
226
+ [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
227
+ },
228
+ },
229
+
230
+ create(context) {
231
+ const seamModule = resolveSafeModule(context, DEFAULT_SEAM_MODULE);
232
+
233
+ // The seam itself must be able to call the primitives it wraps.
234
+ if (exemptMatcherFor(context)(context.getFilename())) {
235
+ // Still surface a malformed exemption list: the file we are standing in
236
+ // may be exempt only BECAUSE the entry is unanchored.
237
+ return {
238
+ Program(node) {
239
+ reportUnanchoredExemptEntries(context, node);
240
+ },
241
+ };
242
+ }
243
+
244
+ return {
245
+ Program(node) {
246
+ reportUnanchoredExemptEntries(context, node);
247
+ },
248
+
249
+ NewExpression(node) {
250
+ if (node.callee?.type !== 'Identifier' || node.callee.name !== 'TextDecoder') return;
251
+ context.report({
252
+ node,
253
+ messageId: 'textDecoderConstruct',
254
+ data: { safeModule: seamModule },
255
+ });
256
+ },
257
+
258
+ CallExpression(node) {
259
+ const name = calleeName(node);
260
+ if (name === null) return;
261
+
262
+ if (
263
+ name === 'toString'
264
+ && node.callee.type === 'MemberExpression'
265
+ && node.arguments.length > 0
266
+ && isCharacterEncodingArgument(node.arguments[0])
267
+ ) {
268
+ context.report({
269
+ node,
270
+ messageId: 'bufferTextDecode',
271
+ data: {
272
+ safeModule: seamModule,
273
+ encoding: context.getSourceCode().getText(node.arguments[0]),
274
+ },
275
+ });
276
+ return;
277
+ }
278
+
279
+ if (!DECODING_READERS.has(name) || node.arguments.length < 2) return;
280
+ const second = node.arguments[1];
281
+ // Object form first: `{ encoding: 'hex' }` is an ObjectExpression, and
282
+ // asking the positional test about it would answer the wrong question.
283
+ const decodes = second?.type === 'ObjectExpression'
284
+ ? optionsObjectDecodes(second)
285
+ : isCharacterEncodingArgument(second);
286
+ if (decodes) {
287
+ context.report({
288
+ node,
289
+ messageId: 'fsReadTextEncoding',
290
+ data: { reader: name, safeModule: seamModule },
291
+ });
292
+ }
293
+ },
294
+ };
295
+ },
296
+ };