@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.
- package/README.md +26 -15
- 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 +657 -0
- package/dist/crawl-timing.d.ts.map +1 -0
- package/dist/crawl-timing.js +765 -0
- package/dist/crawl-timing.js.map +1 -0
- package/dist/crawl.d.ts +2 -4
- package/dist/crawl.d.ts.map +1 -1
- package/dist/crawl.js +2 -4
- package/dist/crawl.js.map +1 -1
- 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 +12 -4
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +12 -4
- 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 +24 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -30
- package/dist/index.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/project.d.ts +7 -5
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +7 -5
- package/dist/project.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/testing.d.ts +73 -3
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +93 -3
- package/dist/testing.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 +136 -0
- package/dist/timing-dump.d.ts.map +1 -0
- package/dist/timing-dump.js +230 -0
- package/dist/timing-dump.js.map +1 -0
- package/eslint/README.md +105 -3
- package/eslint/index.cjs +50 -0
- package/eslint/index.d.cts +14 -4
- package/eslint/rules/no-bare-symlink-in-tests.cjs +200 -0
- package/eslint/rules/no-process-exit-in-phase.cjs +117 -0
- package/eslint/rules/no-raw-text-decode.cjs +296 -0
- package/eslint/rules/no-self-package-import.cjs +157 -0
- package/package.json +13 -8
- package/dist/link-auth/build-headers.d.ts +0 -34
- package/dist/link-auth/build-headers.d.ts.map +0 -1
- package/dist/link-auth/build-headers.js +0 -58
- package/dist/link-auth/build-headers.js.map +0 -1
- package/dist/link-auth/expand-macro.d.ts +0 -38
- package/dist/link-auth/expand-macro.d.ts.map +0 -1
- package/dist/link-auth/expand-macro.js +0 -133
- package/dist/link-auth/expand-macro.js.map +0 -1
- package/dist/link-auth/macros.yaml +0 -50
- package/dist/link-auth/resolve-token.d.ts +0 -83
- package/dist/link-auth/resolve-token.d.ts.map +0 -1
- package/dist/link-auth/resolve-token.js +0 -101
- package/dist/link-auth/resolve-token.js.map +0 -1
- package/dist/link-auth/resolve.d.ts +0 -102
- package/dist/link-auth/resolve.d.ts.map +0 -1
- package/dist/link-auth/resolve.js +0 -66
- package/dist/link-auth/resolve.js.map +0 -1
- package/dist/link-auth/rewrite.d.ts +0 -52
- package/dist/link-auth/rewrite.d.ts.map +0 -1
- package/dist/link-auth/rewrite.js +0 -102
- package/dist/link-auth/rewrite.js.map +0 -1
- package/dist/link-auth/select-provider.d.ts +0 -30
- package/dist/link-auth/select-provider.d.ts.map +0 -1
- package/dist/link-auth/select-provider.js +0 -55
- package/dist/link-auth/select-provider.js.map +0 -1
- package/dist/link-auth/template.d.ts +0 -40
- package/dist/link-auth/template.d.ts.map +0 -1
- package/dist/link-auth/template.js +0 -89
- package/dist/link-auth/template.js.map +0 -1
- package/dist/link-auth/transforms.d.ts +0 -46
- package/dist/link-auth/transforms.d.ts.map +0 -1
- package/dist/link-auth/transforms.js +0 -52
- package/dist/link-auth/transforms.js.map +0 -1
- package/dist/template-entry.d.ts +0 -10
- package/dist/template-entry.d.ts.map +0 -1
- package/dist/template-entry.js +0 -10
- package/dist/template-entry.js.map +0 -1
- package/dist/template.d.ts +0 -7
- package/dist/template.d.ts.map +0 -1
- package/dist/template.js +0 -18
- 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
|
+
};
|