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

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 (102) hide show
  1. package/README.md +6 -3
  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 +680 -0
  7. package/dist/crawl-timing.d.ts.map +1 -0
  8. package/dist/crawl-timing.js +809 -0
  9. package/dist/crawl-timing.js.map +1 -0
  10. package/dist/file-crawler.d.ts +22 -0
  11. package/dist/file-crawler.d.ts.map +1 -1
  12. package/dist/file-crawler.js +31 -7
  13. package/dist/file-crawler.js.map +1 -1
  14. package/dist/fs-utils.d.ts +17 -0
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +69 -0
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/fs.d.ts +3 -1
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/fs.js +5 -1
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git-run.d.ts +167 -0
  23. package/dist/git-run.d.ts.map +1 -0
  24. package/dist/git-run.js +154 -0
  25. package/dist/git-run.js.map +1 -0
  26. package/dist/git-snapshot.d.ts +181 -0
  27. package/dist/git-snapshot.d.ts.map +1 -0
  28. package/dist/git-snapshot.js +232 -0
  29. package/dist/git-snapshot.js.map +1 -0
  30. package/dist/git-tracker.d.ts +94 -1
  31. package/dist/git-tracker.d.ts.map +1 -1
  32. package/dist/git-tracker.js +135 -3
  33. package/dist/git-tracker.js.map +1 -1
  34. package/dist/git-utils.d.ts +38 -0
  35. package/dist/git-utils.d.ts.map +1 -1
  36. package/dist/git-utils.js +103 -80
  37. package/dist/git-utils.js.map +1 -1
  38. package/dist/git.d.ts +2 -1
  39. package/dist/git.d.ts.map +1 -1
  40. package/dist/git.js +2 -1
  41. package/dist/git.js.map +1 -1
  42. package/dist/gitignore-checker.d.ts.map +1 -1
  43. package/dist/gitignore-checker.js +6 -3
  44. package/dist/gitignore-checker.js.map +1 -1
  45. package/dist/index.d.ts +8 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +42 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/link-auth/expand-macro.d.ts.map +1 -1
  50. package/dist/link-auth/expand-macro.js +7 -1
  51. package/dist/link-auth/expand-macro.js.map +1 -1
  52. package/dist/link-auth/resolve-token.d.ts.map +1 -1
  53. package/dist/link-auth/resolve-token.js +15 -1
  54. package/dist/link-auth/resolve-token.js.map +1 -1
  55. package/dist/path-core.d.ts +1 -1
  56. package/dist/path-core.js +1 -1
  57. package/dist/project-utils.d.ts.map +1 -1
  58. package/dist/project-utils.js +6 -3
  59. package/dist/project-utils.js.map +1 -1
  60. package/dist/safe-exec.d.ts +38 -10
  61. package/dist/safe-exec.d.ts.map +1 -1
  62. package/dist/safe-exec.js +88 -19
  63. package/dist/safe-exec.js.map +1 -1
  64. package/dist/skill-test/index.d.ts +1 -1
  65. package/dist/skill-test/index.d.ts.map +1 -1
  66. package/dist/skill-test/index.js +1 -1
  67. package/dist/skill-test/index.js.map +1 -1
  68. package/dist/skill-test/spawn-claude.d.ts +28 -0
  69. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  70. package/dist/skill-test/spawn-claude.js +51 -2
  71. package/dist/skill-test/spawn-claude.js.map +1 -1
  72. package/dist/skill-test/transcript.d.ts +41 -2
  73. package/dist/skill-test/transcript.d.ts.map +1 -1
  74. package/dist/skill-test/transcript.js +47 -3
  75. package/dist/skill-test/transcript.js.map +1 -1
  76. package/dist/test-helpers.d.ts +185 -13
  77. package/dist/test-helpers.d.ts.map +1 -1
  78. package/dist/test-helpers.js +274 -27
  79. package/dist/test-helpers.js.map +1 -1
  80. package/dist/text-content.d.ts +197 -0
  81. package/dist/text-content.d.ts.map +1 -0
  82. package/dist/text-content.js +348 -0
  83. package/dist/text-content.js.map +1 -0
  84. package/dist/text-file.d.ts +45 -0
  85. package/dist/text-file.d.ts.map +1 -0
  86. package/dist/text-file.js +53 -0
  87. package/dist/text-file.js.map +1 -0
  88. package/dist/text.d.ts +16 -0
  89. package/dist/text.d.ts.map +1 -0
  90. package/dist/text.js +16 -0
  91. package/dist/text.js.map +1 -0
  92. package/dist/timing-dump.d.ts +119 -0
  93. package/dist/timing-dump.d.ts.map +1 -0
  94. package/dist/timing-dump.js +164 -0
  95. package/dist/timing-dump.js.map +1 -0
  96. package/eslint/README.md +105 -3
  97. package/eslint/index.cjs +41 -0
  98. package/eslint/index.d.cts +7 -4
  99. package/eslint/rules/no-bare-symlink-in-tests.cjs +200 -0
  100. package/eslint/rules/no-raw-text-decode.cjs +296 -0
  101. package/eslint/rules/no-self-package-import.cjs +157 -0
  102. package/package.json +9 -2
@@ -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,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
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ESLint rule: no-self-package-import
3
+ *
4
+ * Disallow a file importing the package it already lives in **by that package's
5
+ * own name**. Inside `packages/foo/src`, write `./types.js`, never
6
+ * `@scope/foo`.
7
+ *
8
+ * @example
9
+ * // ❌ BAD — packages/agent-runtime/src/session/file-session-store.ts
10
+ * import type { SessionStore } from '@vibe-agent-toolkit/agent-runtime';
11
+ *
12
+ * // ✅ GOOD
13
+ * import type { SessionStore } from './types.js';
14
+ *
15
+ * ## Why this is a build-breaker and not a style preference
16
+ *
17
+ * A self-import resolves out through `node_modules` to the package's OWN
18
+ * `package.json`, whose `types`/`exports` point at `./dist/index.d.ts` — a file
19
+ * the compiler is in the middle of producing. It appears to work only because of
20
+ * a TypeScript courtesy: while `dist` IS the running project's output path,
21
+ * `dist/index.d.ts` is recognised as this project's own declaration output and
22
+ * the import is redirected back to `src`, so it resolves with no `dist/` on disk
23
+ * at all.
24
+ *
25
+ * That courtesy is conditional, and every condition is one a build script may
26
+ * legitimately change. Compile the package with `--outDir` pointed anywhere else
27
+ * — a staging directory used to make emit atomic, for instance — and `dist` is
28
+ * no longer this program's output, the redirect is gone, tsc looks for a literal
29
+ * `dist/index.d.ts`, and a tree that has never been built has none:
30
+ *
31
+ * error TS2307: Cannot find module '@scope/foo' or its corresponding type
32
+ * declarations.
33
+ *
34
+ * Knock-on `TS2339`s follow wherever a local type extended one of the now
35
+ * unresolved imports, which is what makes the failure read as a type bug in
36
+ * code nobody touched.
37
+ *
38
+ * ## Why the compiler cannot be trusted to find these
39
+ *
40
+ * The failure is invisible on any machine that has built the package before:
41
+ * a stale `dist/` satisfies the literal lookup, so the build passes — by
42
+ * typechecking the package against its PREVIOUS build's declarations. Worse, in
43
+ * a monorepo whose worktrees live inside the main checkout, module resolution
44
+ * walks up past the worktree into the parent checkout's `node_modules` and
45
+ * resolves against a DIFFERENT checkout's `dist/`. Both are green locally and
46
+ * red in CI, which is the only place the tree is genuinely pristine.
47
+ *
48
+ * So a self-import is latent by construction: it costs nothing until the day a
49
+ * build script changes `outDir`, and then it fails somewhere nobody can
50
+ * reproduce. Lint is the only stage that sees it on the author's machine.
51
+ *
52
+ * ## `packageName` is required, and the rule reads no files to get it
53
+ *
54
+ * The obvious implementation walks up from the linted file to the nearest
55
+ * `package.json` and reads its `name`. This rule deliberately does NOT: every
56
+ * module on the `./eslint` subpath is plain data that requires nothing — not
57
+ * `eslint`, not a third-party package, not even a Node builtin — which is what
58
+ * lets the pack ship as a subpath of a runtime package, keeps `eslint` an
59
+ * OPTIONAL peer dependency, and keeps the other subpaths resolving in a tree with
60
+ * no ESLint installed. `test/eslint/subpath-purity.test.ts` enforces that as an
61
+ * empty-set assertion. One `require('node:fs')` here would be the first crack in
62
+ * it, for a convenience the config layer can supply for free.
63
+ *
64
+ * So the caller names the package, and the caller is a config file that already
65
+ * runs in full Node and can read every manifest it likes. See the generated
66
+ * per-package blocks in this repo's `eslint.config.js`.
67
+ *
68
+ * Scoping is the caller's job for the same reason: only files a package actually
69
+ * COMPILES can break the build, and test and example trees commonly import their
70
+ * own package by name ON PURPOSE, to exercise the public entry point exactly as a
71
+ * consumer would.
72
+ */
73
+
74
+ 'use strict';
75
+
76
+ /**
77
+ * Whether `specifier` names `packageName` itself or one of its subpaths.
78
+ *
79
+ * The subpath check is anchored on `/` so `@scope/foo-bar` is not read as a
80
+ * subpath of `@scope/foo`.
81
+ *
82
+ * @param {string} specifier - The import specifier as written.
83
+ * @param {string} packageName - The enclosing package's name.
84
+ * @returns {boolean}
85
+ */
86
+ function isSelfReference(specifier, packageName) {
87
+ return specifier === packageName || specifier.startsWith(`${packageName}/`);
88
+ }
89
+
90
+ module.exports = {
91
+ meta: {
92
+ type: 'problem',
93
+ docs: {
94
+ description:
95
+ "Disallow importing the enclosing package by its own name; use a relative path so the import does not depend on the package's built `dist/`.",
96
+ category: 'Build correctness',
97
+ recommended: true,
98
+ },
99
+ messages: {
100
+ useRelativeImport:
101
+ "Do not import '{{specifier}}' — this file is already inside '{{packageName}}'. That resolves through the package's own `dist/`, which only works while `dist` is the running compiler's output path; change `outDir` (or build a tree that has never been built) and it fails with TS2307. Import the defining module by relative path instead.",
102
+ },
103
+ schema: [
104
+ {
105
+ type: 'object',
106
+ properties: {
107
+ packageName: { type: 'string', minLength: 1 },
108
+ },
109
+ required: ['packageName'],
110
+ additionalProperties: false,
111
+ },
112
+ ],
113
+ },
114
+
115
+ create(context) {
116
+ const { packageName } = context.options[0];
117
+
118
+ /**
119
+ * @param {{ type: string, value?: unknown } | null | undefined} source - A node in source position.
120
+ */
121
+ const check = (source) => {
122
+ if (!source || source.type !== 'Literal') return;
123
+ const specifier = source.value;
124
+ if (typeof specifier !== 'string') return;
125
+ if (!isSelfReference(specifier, packageName)) return;
126
+ context.report({
127
+ node: source,
128
+ messageId: 'useRelativeImport',
129
+ data: { specifier, packageName },
130
+ });
131
+ };
132
+
133
+ return {
134
+ // `import … from 'x'` and `import type … from 'x'`
135
+ ImportDeclaration: (node) => check(node.source),
136
+ // `export … from 'x'` and `export * from 'x'` — a barrel re-exporting
137
+ // through its own package name is the same resolution, one step removed.
138
+ ExportNamedDeclaration: (node) => check(node.source),
139
+ ExportAllDeclaration: (node) => check(node.source),
140
+ // `await import('x')`
141
+ ImportExpression: (node) => check(node.source),
142
+ // `import('x')` in TYPE position, which no other visitor above reaches.
143
+ // The specifier hangs off `source`; typescript-eslint has called this
144
+ // property `parameter` and `argument` in earlier majors, and reading the
145
+ // wrong one costs nothing at lint time — the visitor simply never fires.
146
+ // The `import() in TYPE position` case in `rules.test.ts` is what turns
147
+ // that silence into a red test if a future parser renames it again.
148
+ TSImportType: (node) => check(node.source),
149
+ // `require('x')` in the `.cts`/`.cjs` files this pack also lints.
150
+ CallExpression: (node) => {
151
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') return;
152
+ if (node.arguments.length !== 1) return;
153
+ check(node.arguments[0]);
154
+ },
155
+ };
156
+ },
157
+ };
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@vibe-agent-toolkit/utils",
3
- "version": "0.2.0-rc.1",
3
+ "version": "0.2.0-rc.3",
4
4
  "type": "module",
5
5
  "description": "Core utility functions shared across the vibe-agent-toolkit packages",
6
- "sideEffects": false,
6
+ "sideEffects": [
7
+ "./dist/crawl-timing.js"
8
+ ],
7
9
  "keywords": [
8
10
  "typescript",
9
11
  "utilities",
@@ -32,6 +34,10 @@
32
34
  "types": "./dist/fs.d.ts",
33
35
  "import": "./dist/fs.js"
34
36
  },
37
+ "./text": {
38
+ "types": "./dist/text.d.ts",
39
+ "import": "./dist/text.js"
40
+ },
35
41
  "./process": {
36
42
  "types": "./dist/process.d.ts",
37
43
  "import": "./dist/process.js"
@@ -93,6 +99,7 @@
93
99
  "clean": "rimraf dist *.tsbuildinfo"
94
100
  },
95
101
  "dependencies": {
102
+ "@vibe-validate/git": "0.20.1",
96
103
  "handlebars": "^4.7.8",
97
104
  "ignore": "^7.0.5",
98
105
  "picomatch": "^4.0.3",