@vibe-agent-toolkit/utils 0.2.0-rc.2 → 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.
- package/README.md +6 -3
- package/dist/compare-code-units.d.ts +28 -0
- package/dist/compare-code-units.d.ts.map +1 -0
- package/dist/compare-code-units.js +33 -0
- package/dist/compare-code-units.js.map +1 -0
- package/dist/crawl-timing.d.ts +680 -0
- package/dist/crawl-timing.d.ts.map +1 -0
- package/dist/crawl-timing.js +809 -0
- package/dist/crawl-timing.js.map +1 -0
- package/dist/file-crawler.d.ts +22 -0
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +31 -7
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs-utils.d.ts +17 -0
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +69 -0
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +3 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +5 -1
- package/dist/fs.js.map +1 -1
- package/dist/git-run.d.ts +167 -0
- package/dist/git-run.d.ts.map +1 -0
- package/dist/git-run.js +154 -0
- package/dist/git-run.js.map +1 -0
- package/dist/git-snapshot.d.ts +181 -0
- package/dist/git-snapshot.d.ts.map +1 -0
- package/dist/git-snapshot.js +232 -0
- package/dist/git-snapshot.js.map +1 -0
- package/dist/git-tracker.d.ts +94 -1
- package/dist/git-tracker.d.ts.map +1 -1
- package/dist/git-tracker.js +135 -3
- package/dist/git-tracker.js.map +1 -1
- package/dist/git-utils.d.ts +38 -0
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +103 -80
- package/dist/git-utils.js.map +1 -1
- package/dist/git.d.ts +2 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +2 -1
- package/dist/git.js.map +1 -1
- package/dist/gitignore-checker.d.ts.map +1 -1
- package/dist/gitignore-checker.js +6 -3
- package/dist/gitignore-checker.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +42 -1
- package/dist/index.js.map +1 -1
- package/dist/link-auth/expand-macro.d.ts.map +1 -1
- package/dist/link-auth/expand-macro.js +7 -1
- package/dist/link-auth/expand-macro.js.map +1 -1
- package/dist/link-auth/resolve-token.d.ts.map +1 -1
- package/dist/link-auth/resolve-token.js +15 -1
- package/dist/link-auth/resolve-token.js.map +1 -1
- package/dist/path-core.d.ts +1 -1
- package/dist/path-core.js +1 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +6 -3
- package/dist/project-utils.js.map +1 -1
- package/dist/safe-exec.d.ts +38 -10
- package/dist/safe-exec.d.ts.map +1 -1
- package/dist/safe-exec.js +88 -19
- package/dist/safe-exec.js.map +1 -1
- package/dist/skill-test/index.d.ts +1 -1
- package/dist/skill-test/index.d.ts.map +1 -1
- package/dist/skill-test/index.js +1 -1
- package/dist/skill-test/index.js.map +1 -1
- package/dist/skill-test/spawn-claude.d.ts +28 -0
- package/dist/skill-test/spawn-claude.d.ts.map +1 -1
- package/dist/skill-test/spawn-claude.js +51 -2
- package/dist/skill-test/spawn-claude.js.map +1 -1
- package/dist/skill-test/transcript.d.ts +41 -2
- package/dist/skill-test/transcript.d.ts.map +1 -1
- package/dist/skill-test/transcript.js +47 -3
- package/dist/skill-test/transcript.js.map +1 -1
- package/dist/test-helpers.d.ts +185 -13
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +274 -27
- package/dist/test-helpers.js.map +1 -1
- package/dist/text-content.d.ts +197 -0
- package/dist/text-content.d.ts.map +1 -0
- package/dist/text-content.js +348 -0
- package/dist/text-content.js.map +1 -0
- package/dist/text-file.d.ts +45 -0
- package/dist/text-file.d.ts.map +1 -0
- package/dist/text-file.js +53 -0
- package/dist/text-file.js.map +1 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +16 -0
- package/dist/text.js.map +1 -0
- package/dist/timing-dump.d.ts +119 -0
- package/dist/timing-dump.d.ts.map +1 -0
- package/dist/timing-dump.js +164 -0
- package/dist/timing-dump.js.map +1 -0
- package/eslint/README.md +105 -3
- package/eslint/index.cjs +41 -0
- package/eslint/index.d.cts +7 -4
- package/eslint/rules/no-bare-symlink-in-tests.cjs +200 -0
- package/eslint/rules/no-raw-text-decode.cjs +296 -0
- package/eslint/rules/no-self-package-import.cjs +157 -0
- 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.
|
|
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":
|
|
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",
|