@vibe-agent-toolkit/utils 0.2.0-rc.1 → 0.2.0-rc.10
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 +69 -23
- package/dist/asset-reference.js +3 -11
- package/dist/asset-reference.js.map +1 -1
- 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/dirent-kind.d.ts +83 -0
- package/dist/dirent-kind.d.ts.map +1 -0
- package/dist/dirent-kind.js +129 -0
- package/dist/dirent-kind.js.map +1 -0
- package/dist/entrypoint.d.ts +54 -0
- package/dist/entrypoint.d.ts.map +1 -0
- package/dist/entrypoint.js +71 -0
- package/dist/entrypoint.js.map +1 -0
- package/dist/env-flag.d.ts +51 -0
- package/dist/env-flag.d.ts.map +1 -0
- package/dist/env-flag.js +62 -0
- package/dist/env-flag.js.map +1 -0
- package/dist/errors/errno.d.ts +51 -0
- package/dist/errors/errno.d.ts.map +1 -0
- package/dist/errors/errno.js +112 -0
- package/dist/errors/errno.js.map +1 -0
- package/dist/errors/vat-error.d.ts +60 -0
- package/dist/errors/vat-error.d.ts.map +1 -0
- package/dist/errors/vat-error.js +86 -0
- package/dist/errors/vat-error.js.map +1 -0
- package/dist/file-crawler.d.ts +60 -0
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +133 -29
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs/file-hash.d.ts.map +1 -1
- package/dist/fs/file-hash.js +0 -1
- package/dist/fs/file-hash.js.map +1 -1
- package/dist/fs-utils.d.ts +422 -191
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +534 -204
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +5 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +9 -7
- 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 +199 -0
- package/dist/git-snapshot.d.ts.map +1 -0
- package/dist/git-snapshot.js +250 -0
- package/dist/git-snapshot.js.map +1 -0
- package/dist/git-tracker.d.ts +166 -3
- package/dist/git-tracker.d.ts.map +1 -1
- package/dist/git-tracker.js +250 -22
- package/dist/git-tracker.js.map +1 -1
- package/dist/git-utils.d.ts +84 -1
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +208 -82
- package/dist/git-utils.js.map +1 -1
- package/dist/git.d.ts +13 -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 +15 -6
- package/dist/gitignore-checker.js.map +1 -1
- package/dist/glob/glob-pattern.js +5 -5
- package/dist/glob/glob-pattern.js.map +1 -1
- package/dist/index.d.ts +32 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +95 -50
- package/dist/index.js.map +1 -1
- package/dist/listing-refusal.d.ts +112 -0
- package/dist/listing-refusal.d.ts.map +1 -0
- package/dist/listing-refusal.js +105 -0
- package/dist/listing-refusal.js.map +1 -0
- package/dist/path-containment.d.ts +64 -0
- package/dist/path-containment.d.ts.map +1 -0
- package/dist/path-containment.js +111 -0
- package/dist/path-containment.js.map +1 -0
- package/dist/path-core.d.ts +113 -18
- package/dist/path-core.d.ts.map +1 -1
- package/dist/path-core.js +139 -23
- package/dist/path-core.js.map +1 -1
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/path-utils.js +42 -47
- package/dist/path-utils.js.map +1 -1
- package/dist/path.d.ts +1 -1
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -1
- package/dist/path.js.map +1 -1
- package/dist/process.d.ts +7 -0
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +7 -0
- package/dist/process.js.map +1 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +15 -9
- 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 +40 -11
- package/dist/safe-exec.d.ts.map +1 -1
- package/dist/safe-exec.js +111 -25
- package/dist/safe-exec.js.map +1 -1
- package/dist/skill-targets.d.ts +2 -2
- package/dist/skill-targets.js +2 -2
- package/dist/skill-test/auth-resolver.d.ts +10 -2
- package/dist/skill-test/auth-resolver.d.ts.map +1 -1
- package/dist/skill-test/auth-resolver.js +26 -11
- package/dist/skill-test/auth-resolver.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 +30 -1
- package/dist/skill-test/spawn-claude.d.ts.map +1 -1
- package/dist/skill-test/spawn-claude.js +71 -5
- 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 +55 -10
- package/dist/skill-test/transcript.js.map +1 -1
- package/dist/spawn-hardened.js +0 -1
- package/dist/spawn-hardened.js.map +1 -1
- package/dist/stdio-blocking.js +13 -14
- package/dist/stdio-blocking.js.map +1 -1
- package/dist/test-helpers.d.ts +135 -100
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +224 -146
- package/dist/test-helpers.js.map +1 -1
- package/dist/testing/executables.d.ts +32 -0
- package/dist/testing/executables.d.ts.map +1 -0
- package/dist/testing/executables.js +70 -0
- package/dist/testing/executables.js.map +1 -0
- package/dist/testing/hostile-tree.d.ts +79 -0
- package/dist/testing/hostile-tree.d.ts.map +1 -0
- package/dist/testing/hostile-tree.js +164 -0
- package/dist/testing/hostile-tree.js.map +1 -0
- package/dist/testing/platform-gates.d.ts +31 -0
- package/dist/testing/platform-gates.d.ts.map +1 -0
- package/dist/testing/platform-gates.js +31 -0
- package/dist/testing/platform-gates.js.map +1 -0
- package/dist/testing/temp-dir.d.ts +255 -0
- package/dist/testing/temp-dir.d.ts.map +1 -0
- package/dist/testing/temp-dir.js +363 -0
- package/dist/testing/temp-dir.js.map +1 -0
- package/dist/testing.d.ts +94 -3
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +119 -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 +354 -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 +51 -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 +229 -0
- package/dist/timing-dump.js.map +1 -0
- package/dist/windows-shell.d.ts +0 -1
- package/dist/windows-shell.d.ts.map +1 -1
- package/dist/windows-shell.js +0 -1
- package/dist/windows-shell.js.map +1 -1
- package/dist/zod-introspection.js +2 -2
- package/dist/zod-introspection.js.map +1 -1
- package/eslint/README.md +223 -49
- package/eslint/index.cjs +111 -97
- package/eslint/index.d.cts +16 -6
- package/eslint/rules/callee-name.cjs +23 -0
- package/eslint/rules/commands-import-boundary.cjs +148 -0
- package/eslint/rules/dead-import.cjs +1 -1
- package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
- package/eslint/rules/eslint-rule-factory.cjs +5 -2
- package/eslint/rules/exempt-path-matcher.cjs +2 -1
- package/eslint/rules/explicit-zod-strictness.cjs +120 -0
- package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
- package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
- package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
- package/eslint/rules/no-blind-catch.cjs +171 -0
- package/eslint/rules/no-child-process-execSync.cjs +8 -0
- package/eslint/rules/no-decaying-referent.cjs +217 -0
- package/eslint/rules/no-dotdot-containment.cjs +160 -0
- package/eslint/rules/no-file-url-string-concat.cjs +4 -1
- package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
- package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
- package/eslint/rules/no-fs-promises-cp.cjs +7 -0
- package/eslint/rules/no-fs-realpathSync.cjs +8 -0
- package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
- package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
- package/eslint/rules/no-literal-process-exit.cjs +129 -0
- package/eslint/rules/no-manual-path-normalize.cjs +136 -107
- package/eslint/rules/no-os-tmpdir.cjs +8 -0
- package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
- package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
- package/eslint/rules/no-path-startswith.cjs +7 -2
- package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
- package/eslint/rules/no-raw-node-path.cjs +563 -0
- package/eslint/rules/no-raw-text-decode.cjs +305 -0
- package/eslint/rules/no-registry-count-pin.cjs +255 -0
- package/eslint/rules/no-self-package-import.cjs +168 -0
- package/eslint/rules/no-test-scoped-functions.cjs +10 -3
- package/eslint/rules/no-unix-shell-commands.cjs +6 -2
- package/eslint/rules/no-unsafe-root-join.cjs +23 -2
- package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
- package/eslint/rules/no-version-literal.cjs +151 -0
- package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
- package/eslint/rules/require-justified-skip.cjs +8 -2
- package/eslint/rules/safe-import.cjs +1 -1
- package/package.json +16 -16
- 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
- package/eslint/rules/no-path-join.cjs +0 -14
- package/eslint/rules/no-path-relative.cjs +0 -14
- package/eslint/rules/no-path-resolve.cjs +0 -14
- package/eslint/rules/path-function-rule-factory.cjs +0 -496
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-dotdot-containment
|
|
3
|
+
*
|
|
4
|
+
* Flags a `..` string test used as a containment check on a path:
|
|
5
|
+
* `rel.startsWith('..')`, `rel.startsWith('../')`, `name.includes('..')`, and
|
|
6
|
+
* `p.split(sep).includes('..')` / `.some(s => …)` / `.indexOf('..')`.
|
|
7
|
+
*
|
|
8
|
+
* A string test answers "does this text contain two dots", which is not the
|
|
9
|
+
* question. The question is "does this resolve INSIDE the root", and the two
|
|
10
|
+
* come apart in every direction that matters:
|
|
11
|
+
*
|
|
12
|
+
* - a symlink inside the root that points outside has no `..` in its name;
|
|
13
|
+
* - `startsWith('..')` also refuses a sibling whose name merely BEGINS with
|
|
14
|
+
* two dots (`..cache`), and `includes('..')` refuses `a..b`;
|
|
15
|
+
* - on Windows a drive letter or a UNC prefix escapes without a single dot;
|
|
16
|
+
* - the relative path was computed lexically, so a root that is itself a
|
|
17
|
+
* symlink gives a different answer from the one the filesystem gives.
|
|
18
|
+
*
|
|
19
|
+
* The sweep behind this rule verified sinks — a delete, a copy, an uninstall —
|
|
20
|
+
* guarded by exactly these tests. The answer is one helper that asks the
|
|
21
|
+
* filesystem: `isUnderRoot(root, candidate)` from `@vibe-agent-toolkit/utils`,
|
|
22
|
+
* realpath-based, and honest about a candidate that does not exist yet.
|
|
23
|
+
*
|
|
24
|
+
* ## Scope
|
|
25
|
+
*
|
|
26
|
+
* The receiver must look like a path: an identifier or member whose name
|
|
27
|
+
* carries a path word (`path`, `dir`, `rel`, `root`, `file`, `name`, `id`,
|
|
28
|
+
* `target`, `dest`, `src`, `location`, `folder`), or any call result (a
|
|
29
|
+
* `relative(root, p)` is a path by construction). A segment normaliser's
|
|
30
|
+
* `segment === '..'` compares one segment and is not a containment test; it
|
|
31
|
+
* is left alone, as is `startsWith('./')` and any literal without a `..`
|
|
32
|
+
* segment.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // BAD — misses a symlink, refuses `..cache`, blind to a drive letter
|
|
36
|
+
* if (safePath.relative(root, p).startsWith('..')) refuse();
|
|
37
|
+
*
|
|
38
|
+
* // GOOD — the filesystem answers
|
|
39
|
+
* if (!isUnderRoot(root, p)) refuse();
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
/** Words that mark a receiver as path-shaped. */
|
|
45
|
+
const PATH_WORD = /path|dir|rel|root|file|name|id|target|dest|src|location|folder/u;
|
|
46
|
+
|
|
47
|
+
/** A `..` segment at the start of a prefix literal: `..`, `../`, `..\`. */
|
|
48
|
+
const DOTDOT_PREFIX = /^\.\.(?:$|[\\/])/u;
|
|
49
|
+
|
|
50
|
+
/** A `..` segment anywhere in a substring literal: `..`, `/../`, `a/..`. */
|
|
51
|
+
const DOTDOT_SEGMENT = /(?:^|[\\/])\.\.(?:$|[\\/])/u;
|
|
52
|
+
|
|
53
|
+
/** Methods that, after a `.split(...)`, hunt for a segment. */
|
|
54
|
+
const MEMBERSHIP_METHODS = new Set(['includes', 'some', 'every', 'indexOf', 'find', 'filter']);
|
|
55
|
+
|
|
56
|
+
/** The non-computed method name of a call, or null. */
|
|
57
|
+
function methodName(call) {
|
|
58
|
+
const { callee } = call;
|
|
59
|
+
if (callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return callee.property.name;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The identifier name a receiver is known by, or null when it has none. */
|
|
66
|
+
function receiverName(receiver) {
|
|
67
|
+
if (receiver.type === 'Identifier') {
|
|
68
|
+
return receiver.name;
|
|
69
|
+
}
|
|
70
|
+
if (receiver.type === 'MemberExpression' && !receiver.computed && receiver.property.type === 'Identifier') {
|
|
71
|
+
return receiver.property.name;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether the receiver is named like a path. */
|
|
77
|
+
function isPathNamed(receiver) {
|
|
78
|
+
const name = receiverName(receiver);
|
|
79
|
+
return name !== null && PATH_WORD.test(name.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The string value of the first argument when it is a literal, or null. */
|
|
83
|
+
function firstStringArg(call) {
|
|
84
|
+
const arg = call.arguments[0];
|
|
85
|
+
return arg?.type === 'Literal' && typeof arg.value === 'string' ? arg.value : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `<path>.startsWith('..')` / `<path>.includes('..')` — a `..` test on a path or a call result. */
|
|
89
|
+
function isDotdotStringTest(call, method) {
|
|
90
|
+
const literal = firstStringArg(call);
|
|
91
|
+
if (literal === null) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const receiver = call.callee.object;
|
|
95
|
+
const pathLike = receiver.type === 'CallExpression' || isPathNamed(receiver);
|
|
96
|
+
if (!pathLike) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (method === 'startsWith') {
|
|
100
|
+
return DOTDOT_PREFIX.test(literal);
|
|
101
|
+
}
|
|
102
|
+
return method === 'includes' && DOTDOT_SEGMENT.test(literal);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A `'..'` literal (as an argument, or anywhere inside a callback). */
|
|
106
|
+
const DOTDOT_LITERAL = /['"`]\.\.['"`]/u;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `<path>.split(...).<membership>(...)` — hunting for a `..` segment. The
|
|
110
|
+
* membership call must actually mention `'..'` (as its argument, or inside
|
|
111
|
+
* its callback): `parts.split('/').filter(Boolean)` is splitting, not hunting.
|
|
112
|
+
*/
|
|
113
|
+
function isSplitSegmentHunt(call, method, sourceCode) {
|
|
114
|
+
if (!MEMBERSHIP_METHODS.has(method) || call.arguments.length === 0) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const receiver = call.callee.object;
|
|
118
|
+
return (
|
|
119
|
+
receiver.type === 'CallExpression' &&
|
|
120
|
+
methodName(receiver) === 'split' &&
|
|
121
|
+
isPathNamed(receiver.callee.object) &&
|
|
122
|
+
DOTDOT_LITERAL.test(sourceCode.getText(call.arguments[0]))
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
meta: {
|
|
128
|
+
type: 'problem',
|
|
129
|
+
docs: {
|
|
130
|
+
description:
|
|
131
|
+
"Disallow startsWith('..') / includes('..') / split-and-hunt as a path containment check — " +
|
|
132
|
+
'use the realpath-based isUnderRoot() helper',
|
|
133
|
+
recommended: false,
|
|
134
|
+
recommendedSeverity: 'warn',
|
|
135
|
+
},
|
|
136
|
+
schema: [],
|
|
137
|
+
messages: {
|
|
138
|
+
dotdotContainment:
|
|
139
|
+
'A `..` string test is not a containment check: it misses a symlink that points outside, ' +
|
|
140
|
+
'refuses a name that merely starts with two dots, and is blind to a Windows drive letter ' +
|
|
141
|
+
'or UNC prefix. Ask the filesystem: isUnderRoot(root, candidate) from ' +
|
|
142
|
+
'@vibe-agent-toolkit/utils.',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
create(context) {
|
|
147
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
148
|
+
return {
|
|
149
|
+
CallExpression(node) {
|
|
150
|
+
const method = methodName(node);
|
|
151
|
+
if (method === null) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (isDotdotStringTest(node, method) || isSplitSegmentHunt(node, method, sourceCode)) {
|
|
155
|
+
context.report({ node, messageId: 'dotdotContainment' });
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
};
|
|
@@ -45,8 +45,11 @@ module.exports = {
|
|
|
45
45
|
docs: {
|
|
46
46
|
description:
|
|
47
47
|
'Disallow constructing `file://` URLs by string concatenation; use `pathToFileURL()` from `node:url`.',
|
|
48
|
-
category: '
|
|
48
|
+
category: 'URLs and dynamic imports',
|
|
49
|
+
bans: '`` `file://${p}` ``',
|
|
50
|
+
useInstead: '`pathToFileURL(p).href`',
|
|
49
51
|
recommended: true,
|
|
52
|
+
recommendedSeverity: 'error',
|
|
50
53
|
},
|
|
51
54
|
messages: {
|
|
52
55
|
useFileUrlBuilder:
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-fragile-entrypoint-guard
|
|
3
|
+
*
|
|
4
|
+
* Ban the two "am I the script Node was asked to run?" idioms that answer
|
|
5
|
+
* **false for the script they are guarding**, so the process exits 0 having done
|
|
6
|
+
* nothing at all.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // ❌ BAD — undefined before Node 24.2 / 22.18
|
|
10
|
+
* if (import.meta.main) { await main(); }
|
|
11
|
+
*
|
|
12
|
+
* // ❌ BAD — raw string compare, no realpath: false through any `.bin` symlink
|
|
13
|
+
* if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { … }
|
|
14
|
+
*
|
|
15
|
+
* // ❌ BAD — the same compare done in path space instead of URL space
|
|
16
|
+
* if (fileURLToPath(import.meta.url) === process.argv[1]) { … }
|
|
17
|
+
*
|
|
18
|
+
* // ✅ GOOD
|
|
19
|
+
* import { isEntrypoint } from '@vibe-agent-toolkit/utils/process';
|
|
20
|
+
* if (isEntrypoint(import.meta.url)) { await main(); }
|
|
21
|
+
*
|
|
22
|
+
* ## Why a lint rule and not a comment
|
|
23
|
+
*
|
|
24
|
+
* Both defects have already shipped in this repo, and both were invisible to
|
|
25
|
+
* every gate:
|
|
26
|
+
*
|
|
27
|
+
* - `import.meta.main` was added in Node **24.2 / 22.18**. Measured on the exact
|
|
28
|
+
* floor this repo declares — `>=22.13.0` — the property is `undefined`:
|
|
29
|
+
*
|
|
30
|
+
* ```
|
|
31
|
+
* $ node-v22.13.0 --input-type=module -e "console.log(import.meta.main)" -> undefined
|
|
32
|
+
* $ node-v24.13.1 --input-type=module -e "console.log(import.meta.main)" -> true
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* So a repository-structure gate guarded this way printed nothing and exited 0
|
|
36
|
+
* on the very Node its own CI job installs. A contributor sitting exactly on
|
|
37
|
+
* the supported floor got a green pre-commit gate that had run no rule.
|
|
38
|
+
*
|
|
39
|
+
* - `import.meta.url === pathToFileURL(process.argv[1]).href` compares two
|
|
40
|
+
* strings with no realpath pass. Invoked through a `node_modules/.bin` shim —
|
|
41
|
+
* i.e. the normal way a package's own bin is run — `process.argv[1]` is the
|
|
42
|
+
* SYMLINK and `import.meta.url` is the resolved target, the strings differ,
|
|
43
|
+
* and the guard is false. Measured false on Node 22.14.0 and 24.13.1 alike,
|
|
44
|
+
* where `isEntrypoint()` is true.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ That defect is about the MISSING REALPATH, not about URLs, so it has a
|
|
47
|
+
* second spelling that is just as common and just as wrong:
|
|
48
|
+
* `fileURLToPath(import.meta.url) === process.argv[1]` converts the module URL
|
|
49
|
+
* to a path instead of converting the invoked path to a URL, and then compares
|
|
50
|
+
* the same two unresolved strings. A matcher that only knew the URL-space form
|
|
51
|
+
* would be a mechanism with a hole in the middle of its own premise, which is
|
|
52
|
+
* the thing this rule exists to refuse. Both directions of both spellings are
|
|
53
|
+
* flagged, under one message, because they are one defect.
|
|
54
|
+
*
|
|
55
|
+
* The first of those was fixed once already and the fix was pinned by nothing:
|
|
56
|
+
* reverting all three call sites to `if (import.meta.main)` left the entire test
|
|
57
|
+
* suite green, because the only thing standing against it was three prose
|
|
58
|
+
* comments addressed to a human. A banner is not a mechanism. This rule is.
|
|
59
|
+
*
|
|
60
|
+
* ## What it does NOT flag
|
|
61
|
+
*
|
|
62
|
+
* `import.meta.url`, `import.meta.dirname` and `import.meta.filename` are all
|
|
63
|
+
* fine and all common — only `.main` is the unavailable one. And
|
|
64
|
+
* `pathToFileURL(x).href` is only a finding when it is being compared to
|
|
65
|
+
* `import.meta.url`; on its own it is just a URL.
|
|
66
|
+
*
|
|
67
|
+
* ## No `exemptFiles`
|
|
68
|
+
*
|
|
69
|
+
* Unlike the wrapper rules in this pack, there is no implementation file that
|
|
70
|
+
* has to call the banned thing: `isEntrypoint()` is written in terms of
|
|
71
|
+
* `process.argv[1]` and a realpath comparison, and touches neither idiom. An
|
|
72
|
+
* exemption option here would only ever be used to opt a file out of a fix.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
'use strict';
|
|
76
|
+
|
|
77
|
+
/** `import.meta`, as the parser sees it. */
|
|
78
|
+
function isImportMeta(node) {
|
|
79
|
+
return (
|
|
80
|
+
node?.type === 'MetaProperty' && node.meta?.name === 'import' && node.property?.name === 'meta'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `import.meta.<name>` — a static, non-computed member of `import.meta`.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} node - Any node.
|
|
88
|
+
* @param {string} name - The property to match, e.g. `main`.
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
function isImportMetaMember(node, name) {
|
|
92
|
+
return (
|
|
93
|
+
node?.type === 'MemberExpression' &&
|
|
94
|
+
node.computed === false &&
|
|
95
|
+
isImportMeta(node.object) &&
|
|
96
|
+
node.property?.type === 'Identifier' &&
|
|
97
|
+
node.property.name === name
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The called function's NAME, whether called bare or off a namespace.
|
|
103
|
+
*
|
|
104
|
+
* `pathToFileURL(p)` and `url.pathToFileURL(p)` are the same call, and a matcher
|
|
105
|
+
* that only understood the bare form would be blind to every file that imports
|
|
106
|
+
* `node:url` as a namespace.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} callee - The `callee` of a CallExpression.
|
|
109
|
+
* @returns {string | undefined}
|
|
110
|
+
*/
|
|
111
|
+
function calleeName(callee) {
|
|
112
|
+
if (callee === null || callee === undefined) return undefined;
|
|
113
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
114
|
+
if (callee.type === 'MemberExpression' && callee.computed === false) {
|
|
115
|
+
return callee.property?.type === 'Identifier' ? callee.property.name : undefined;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `pathToFileURL(…).href`, in either import style. */
|
|
121
|
+
function isPathToFileUrlHref(node) {
|
|
122
|
+
return (
|
|
123
|
+
node?.type === 'MemberExpression' &&
|
|
124
|
+
node.computed === false &&
|
|
125
|
+
node.property?.type === 'Identifier' &&
|
|
126
|
+
node.property.name === 'href' &&
|
|
127
|
+
node.object?.type === 'CallExpression' &&
|
|
128
|
+
calleeName(node.object.callee) === 'pathToFileURL'
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `fileURLToPath(import.meta.url)`, in either import style. */
|
|
133
|
+
function isFileUrlToPathOfImportMetaUrl(node) {
|
|
134
|
+
return (
|
|
135
|
+
node?.type === 'CallExpression' &&
|
|
136
|
+
calleeName(node.callee) === 'fileURLToPath' &&
|
|
137
|
+
isImportMetaMember(node.arguments?.[0], 'url')
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `process.argv[1]` — and the `const { argv } = process` spelling of it.
|
|
143
|
+
*
|
|
144
|
+
* The index is pinned to `1` on purpose: `argv[2]` and up are ordinary CLI
|
|
145
|
+
* arguments, and comparing one of those to anything is not this defect.
|
|
146
|
+
*/
|
|
147
|
+
function isArgvEntry(node) {
|
|
148
|
+
if (node === null || node === undefined) return false;
|
|
149
|
+
if (node.type !== 'MemberExpression' || node.computed !== true) return false;
|
|
150
|
+
if (node.property?.type !== 'Literal' || node.property.value !== 1) return false;
|
|
151
|
+
|
|
152
|
+
const target = node.object;
|
|
153
|
+
if (target?.type === 'Identifier') return target.name === 'argv';
|
|
154
|
+
return (
|
|
155
|
+
target?.type === 'MemberExpression' &&
|
|
156
|
+
target.computed === false &&
|
|
157
|
+
target.property?.type === 'Identifier' &&
|
|
158
|
+
target.property.name === 'argv'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Where THIS module lives, in whichever space the comparison is written in. */
|
|
163
|
+
function isModuleLocation(node) {
|
|
164
|
+
return isImportMetaMember(node, 'url') || isFileUrlToPathOfImportMetaUrl(node);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The script Node was ASKED to run, in whichever space the comparison uses. */
|
|
168
|
+
function isInvokedScript(node) {
|
|
169
|
+
return isArgvEntry(node) || isPathToFileUrlHref(node);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Identity comparisons; `==`/`!=` on these operands never occurs and is not the idiom. */
|
|
173
|
+
const IDENTITY_OPERATORS = new Set(['===', '!==']);
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
meta: {
|
|
177
|
+
type: 'problem',
|
|
178
|
+
docs: {
|
|
179
|
+
description:
|
|
180
|
+
'Ban entrypoint guards that silently answer false — `import.meta.main` (undefined before Node 24.2/22.18) and any raw compare of where the module lives to `process.argv[1]`, in URL space or path space (false through any symlink). Use `isEntrypoint()`.',
|
|
181
|
+
category: 'Entrypoint guards',
|
|
182
|
+
bans: '`import.meta.main`; `import.meta.url === pathToFileURL(process.argv[1]).href`; `fileURLToPath(import.meta.url) === process.argv[1]`',
|
|
183
|
+
useInstead: '`isEntrypoint(import.meta.url)`',
|
|
184
|
+
subpath: '/process',
|
|
185
|
+
// Not in `recommended`: ONE of its two halves is a claim about the CONSUMER's
|
|
186
|
+
// Node floor rather than a portable fact. `import.meta.main` shipped in Node
|
|
187
|
+
// 24.2 / 22.18; an adopter whose floor is at or above that writes it
|
|
188
|
+
// correctly and would get a finding they cannot act on. This package's own
|
|
189
|
+
// floor spans 22.13–22.17 where the property is `undefined`, so the hazard
|
|
190
|
+
// is real for some adopters and absent for others, and only they know which.
|
|
191
|
+
// The other half (`rawEntrypointCompare`) has no such dependency — a raw
|
|
192
|
+
// string compare misses a symlinked entry on every Node there has ever been
|
|
193
|
+
// — but the two share one rule id. VAT enables the whole rule explicitly.
|
|
194
|
+
recommended: false,
|
|
195
|
+
recommendedSeverity: 'error',
|
|
196
|
+
},
|
|
197
|
+
messages: {
|
|
198
|
+
importMetaMain:
|
|
199
|
+
'`import.meta.main` is undefined before Node 24.2 / 22.18, so this guard is FALSE on older supported Node and the script exits 0 having done nothing. Use `isEntrypoint(import.meta.url)` from `@vibe-agent-toolkit/utils/process`.',
|
|
200
|
+
rawEntrypointCompare:
|
|
201
|
+
'Comparing where this module lives to `process.argv[1]` (as `import.meta.url === pathToFileURL(argv[1]).href`, or as `fileURLToPath(import.meta.url) === argv[1]`) is a raw string compare with no realpath pass, so it is FALSE whenever the script is reached through a symlink (any `node_modules/.bin` shim) and the script exits 0 having done nothing. Use `isEntrypoint(import.meta.url)` from `@vibe-agent-toolkit/utils/process`.',
|
|
202
|
+
},
|
|
203
|
+
schema: [],
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
create(context) {
|
|
207
|
+
return {
|
|
208
|
+
MemberExpression(node) {
|
|
209
|
+
if (isImportMetaMember(node, 'main')) {
|
|
210
|
+
context.report({ node, messageId: 'importMetaMain' });
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
BinaryExpression(node) {
|
|
215
|
+
if (!IDENTITY_OPERATORS.has(node.operator)) return;
|
|
216
|
+
|
|
217
|
+
// Either operand order: a matcher keyed on side would be half blind.
|
|
218
|
+
const compares =
|
|
219
|
+
(isModuleLocation(node.left) && isInvokedScript(node.right)) ||
|
|
220
|
+
(isModuleLocation(node.right) && isInvokedScript(node.left));
|
|
221
|
+
|
|
222
|
+
if (compares) {
|
|
223
|
+
context.report({ node, messageId: 'rawEntrypointCompare' });
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
};
|
|
@@ -20,4 +20,12 @@ module.exports = factory({
|
|
|
20
20
|
message: 'Use mkdirSyncReal() from {{safeModule}} instead of fs.mkdirSync() for Windows path normalization',
|
|
21
21
|
// No baked-in exemption: the file that implements mkdirSyncReal() is
|
|
22
22
|
// repo-specific. Consumers declare it as { exemptFiles: [...] }.
|
|
23
|
+
docs: {
|
|
24
|
+
category: 'Filesystem and process',
|
|
25
|
+
bans: '`fs.mkdirSync()`',
|
|
26
|
+
useInstead: '`mkdirSyncReal()`',
|
|
27
|
+
subpath: '/fs',
|
|
28
|
+
recommended: true,
|
|
29
|
+
recommendedSeverity: 'error',
|
|
30
|
+
},
|
|
23
31
|
});
|
|
@@ -33,4 +33,11 @@ module.exports = factory({
|
|
|
33
33
|
'Use cpSync() from {{safeModule}} instead of cp() from node:fs/promises. ' +
|
|
34
34
|
'Node 22 async cp({ recursive: true }) silently drops files in nested directories. ' +
|
|
35
35
|
'cpSync() works correctly across all Node versions.',
|
|
36
|
+
docs: {
|
|
37
|
+
category: 'Filesystem and process',
|
|
38
|
+
bans: '`cp()` from `node:fs/promises` (drops nested files on Node 22)',
|
|
39
|
+
useInstead: '`cpSync()` from `node:fs`',
|
|
40
|
+
recommended: true,
|
|
41
|
+
recommendedSeverity: 'error',
|
|
42
|
+
},
|
|
36
43
|
});
|
|
@@ -20,4 +20,12 @@ module.exports = factory({
|
|
|
20
20
|
message: 'Use normalizePath() from {{safeModule}} instead of fs.realpathSync() for consistent Windows 8.3 path resolution',
|
|
21
21
|
// No baked-in exemption: the file that implements normalizePath() is
|
|
22
22
|
// repo-specific. Consumers declare it as { exemptFiles: [...] }.
|
|
23
|
+
docs: {
|
|
24
|
+
category: 'Filesystem and process',
|
|
25
|
+
bans: '`fs.realpathSync()`',
|
|
26
|
+
useInstead: '`normalizePath()`',
|
|
27
|
+
subpath: '/fs',
|
|
28
|
+
recommended: true,
|
|
29
|
+
recommendedSeverity: 'error',
|
|
30
|
+
},
|
|
23
31
|
});
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* ESLint rule to disallow splitting strings by hardcoded path separators
|
|
3
3
|
*
|
|
4
4
|
* Using .split('/') or .split('\\') on file paths breaks on Windows/Unix.
|
|
5
|
-
* Use path.basename(), path.dirname(), or normalize
|
|
5
|
+
* Use path.basename(), path.dirname(), or normalize first: toForwardSlash() for a
|
|
6
|
+
* NATIVE path (fs, path.*, the VCS — converts only where the host separator is a
|
|
7
|
+
* backslash), toForwardSlashAnyPlatform() for AUTHOR-WRITTEN text (hrefs, globs,
|
|
8
|
+
* config values, archive entries — converts every backslash on every host).
|
|
6
9
|
*
|
|
7
10
|
* This rule is smart enough to detect when paths are already normalized:
|
|
8
|
-
* - Inline: toForwardSlash(path).split('/') ✅
|
|
11
|
+
* - Inline: toForwardSlash(path).split('/') ✅ (either converter)
|
|
9
12
|
* - Variable: const normalized = toForwardSlash(path); normalized.split('/') ✅
|
|
10
13
|
*
|
|
11
14
|
* @example
|
|
@@ -24,6 +27,9 @@
|
|
|
24
27
|
* // ✅ GOOD - normalize then split (variable)
|
|
25
28
|
* const normalizedPath = toForwardSlash(filePath);
|
|
26
29
|
* const parts = normalizedPath.split('/');
|
|
30
|
+
*
|
|
31
|
+
* // ✅ GOOD - authored text (a glob, an href) converts on every host
|
|
32
|
+
* const segments = toForwardSlashAnyPlatform(pattern).split('/');
|
|
27
33
|
*/
|
|
28
34
|
|
|
29
35
|
const {
|
|
@@ -32,37 +38,46 @@ const {
|
|
|
32
38
|
resolveSafeModule,
|
|
33
39
|
} = require('./safe-import.cjs');
|
|
34
40
|
|
|
41
|
+
/** The converters whose result is forward-slashed, so splitting it on `/` is safe. */
|
|
42
|
+
const NORMALIZERS = new Set(['toForwardSlash', 'toForwardSlashAnyPlatform']);
|
|
43
|
+
|
|
44
|
+
/** A direct call to one of {@link NORMALIZERS}? */
|
|
45
|
+
function isNormalizerCall(node) {
|
|
46
|
+
return node?.type === 'CallExpression' && node.callee.type === 'Identifier' && NORMALIZERS.has(node.callee.name);
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
module.exports = {
|
|
36
50
|
meta: {
|
|
37
51
|
type: 'problem',
|
|
38
52
|
docs: {
|
|
39
|
-
description:
|
|
40
|
-
|
|
53
|
+
description:
|
|
54
|
+
'Disallow splitting strings by hardcoded path separators',
|
|
55
|
+
category: 'Path handling',
|
|
56
|
+
bans: "`split('/')` / `split('\\\\')` on a path",
|
|
57
|
+
useInstead: '`path.basename()`, or `toForwardSlash()` / `toForwardSlashAnyPlatform()` first',
|
|
58
|
+
subpath: '/path',
|
|
41
59
|
recommended: true,
|
|
60
|
+
recommendedSeverity: 'error',
|
|
42
61
|
},
|
|
43
62
|
messages: {
|
|
44
63
|
noHardcodedSplit:
|
|
45
64
|
String.raw`Avoid .split('/') or .split('\') on file paths (breaks on Windows/Unix). ` +
|
|
46
|
-
'Use path.basename() to extract filename, or toForwardSlash()
|
|
65
|
+
'Use path.basename() to extract filename, or normalize first with toForwardSlash() (native paths) ' +
|
|
66
|
+
'or toForwardSlashAnyPlatform() (authored text such as globs and hrefs) from {{safeModule}}.',
|
|
47
67
|
},
|
|
48
68
|
schema: [SAFE_MODULE_ONLY_SCHEMA],
|
|
49
69
|
},
|
|
50
70
|
|
|
51
71
|
create(context) {
|
|
52
72
|
const reportData = { safeModule: resolveSafeModule(context, SAFE_PATH_MODULE) };
|
|
53
|
-
// Track variables that were assigned from
|
|
73
|
+
// Track variables that were assigned from a normalizer
|
|
54
74
|
const normalizedVariables = new Set();
|
|
55
75
|
|
|
56
76
|
return {
|
|
57
77
|
// Track variable declarations
|
|
58
78
|
VariableDeclarator(node) {
|
|
59
|
-
// Check if this variable is assigned from
|
|
60
|
-
if (
|
|
61
|
-
node.init?.type === 'CallExpression' &&
|
|
62
|
-
node.init.callee.type === 'Identifier' &&
|
|
63
|
-
node.init.callee.name === 'toForwardSlash' &&
|
|
64
|
-
node.id.type === 'Identifier'
|
|
65
|
-
) {
|
|
79
|
+
// Check if this variable is assigned from a normalizer
|
|
80
|
+
if (isNormalizerCall(node.init) && node.id.type === 'Identifier') {
|
|
66
81
|
normalizedVariables.add(node.id.name);
|
|
67
82
|
}
|
|
68
83
|
},
|
|
@@ -102,11 +117,7 @@ module.exports = {
|
|
|
102
117
|
const object = node.callee.object;
|
|
103
118
|
|
|
104
119
|
// Case 1: Inline normalization - toForwardSlash(...).split('/')
|
|
105
|
-
if (
|
|
106
|
-
object.type === 'CallExpression' &&
|
|
107
|
-
object.callee.type === 'Identifier' &&
|
|
108
|
-
object.callee.name === 'toForwardSlash'
|
|
109
|
-
) {
|
|
120
|
+
if (isNormalizerCall(object)) {
|
|
110
121
|
return; // Safe - normalized inline
|
|
111
122
|
}
|
|
112
123
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-io-in-unit-tier
|
|
3
|
+
*
|
|
4
|
+
* In a UNIT-tier test file, flags an import of `node:child_process` /
|
|
5
|
+
* `child_process` and any call to `mkdtemp`, `mkdtempSync`, `spawn`,
|
|
6
|
+
* `spawnSync`, `execSync` or `execFileSync` — bare or through a namespace.
|
|
7
|
+
*
|
|
8
|
+
* The tiers are a promise about cost and about what is being tested: a unit
|
|
9
|
+
* test exercises logic and finishes in milliseconds; an integration test
|
|
10
|
+
* wires real components; a system test runs the real binary. A `*.test.ts`
|
|
11
|
+
* that spawns `git` or mints a temp directory has silently moved tiers
|
|
12
|
+
* without moving files — it runs under the unit budget, it is coverage-
|
|
13
|
+
* instrumented as if it were pure, and it is the first thing to flake on a
|
|
14
|
+
* loaded CI runner. The audit measured the class at 93 unit files touching
|
|
15
|
+
* the real filesystem and 5 spawning real processes. Moving a file to the
|
|
16
|
+
* tier it belongs to is a rename; this rule makes the rename happen at the
|
|
17
|
+
* desk rather than in a 53-second package run.
|
|
18
|
+
*
|
|
19
|
+
* ## The tier boundary, as this rule reads it
|
|
20
|
+
*
|
|
21
|
+
* A file is unit-tier when it is a test file (`*.test.ts` and friends) under
|
|
22
|
+
* `packages/<pkg>/test/` at any depth, EXCEPT when it sits under an
|
|
23
|
+
* `integration/` or `system/` directory or carries the `.integration.test.`
|
|
24
|
+
* / `.system.test.` suffix. Helpers (`test-helpers.ts`) are not test files
|
|
25
|
+
* and are not checked here; a helper that spawns is caught at the unit file
|
|
26
|
+
* that calls it only if the call is visible there, which is a known floor.
|
|
27
|
+
*
|
|
28
|
+
* Option `allowFiles: string[]` — repo-relative paths of today's offenders,
|
|
29
|
+
* the ratchet. Name files, never directories.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* // BAD in packages/x/test/thing.test.ts — a system test wearing a unit name
|
|
33
|
+
* const out = spawnSync('node', [bin, '--json']);
|
|
34
|
+
*
|
|
35
|
+
* // GOOD — same code, in packages/x/test/system/thing.system.test.ts
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const { calleeName } = require('./callee-name.cjs');
|
|
41
|
+
const { createExemptPathMatcher, isTestFile } = require('./exempt-path-matcher.cjs');
|
|
42
|
+
|
|
43
|
+
const CHILD_PROCESS_MODULES = new Set(['node:child_process', 'child_process']);
|
|
44
|
+
const IO_CALLS = new Set(['mkdtemp', 'mkdtempSync', 'spawn', 'spawnSync', 'execSync', 'execFileSync']);
|
|
45
|
+
|
|
46
|
+
/** `packages/<pkg>/test/` anywhere in the path, with either separator already normalised. */
|
|
47
|
+
const PACKAGE_TEST_DIR = /(?:^|\/)packages\/[^/]+\/test\//u;
|
|
48
|
+
|
|
49
|
+
/** The integration and system tiers, by directory or by suffix. */
|
|
50
|
+
const OTHER_TIER = /\/(?:integration|system)\/|\.(?:integration|system)\.test\./u;
|
|
51
|
+
|
|
52
|
+
/** Whether `filename` is a unit-tier test file under a package's `test/`. */
|
|
53
|
+
function isUnitTierFile(filename) {
|
|
54
|
+
if (!filename) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// eslint-disable-next-line local/no-manual-path-normalize -- the rule pack is standalone CommonJS and cannot import the ESM helper; config paths and linted filenames are matched separator-agnostically on purpose.
|
|
58
|
+
const normalized = String(filename).replaceAll('\\', '/');
|
|
59
|
+
return isTestFile(normalized) && PACKAGE_TEST_DIR.test(normalized) && !OTHER_TIER.test(normalized);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The banned callee name of a call, bare (`spawn(…)`) or namespaced (`cp.spawn(…)`), or null. */
|
|
63
|
+
function bannedCallName(call) {
|
|
64
|
+
const name = calleeName(call);
|
|
65
|
+
return name !== null && IO_CALLS.has(name) ? name : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
meta: {
|
|
70
|
+
type: 'problem',
|
|
71
|
+
docs: {
|
|
72
|
+
description:
|
|
73
|
+
'Disallow child_process imports and mkdtemp/spawn/exec calls in unit-tier test files — ' +
|
|
74
|
+
'a test that spawns or writes to disk belongs in the integration or system tier',
|
|
75
|
+
recommended: false,
|
|
76
|
+
recommendedSeverity: 'warn',
|
|
77
|
+
},
|
|
78
|
+
schema: [
|
|
79
|
+
{
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
allowFiles: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
83
|
+
},
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
messages: {
|
|
88
|
+
childProcessImport:
|
|
89
|
+
"A unit-tier test imports '{{source}}': spawning a process is integration or system work. " +
|
|
90
|
+
'Move the file to test/integration/ (*.integration.test.ts) or test/system/ ' +
|
|
91
|
+
'(*.system.test.ts), or mock the module.',
|
|
92
|
+
ioCall:
|
|
93
|
+
'A unit-tier test calls {{name}}(): real processes and temp directories belong to the ' +
|
|
94
|
+
'integration or system tier. Move the file to that tier, or test the logic without the I/O.',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
create(context) {
|
|
99
|
+
const filename = context.filename ?? context.getFilename();
|
|
100
|
+
const isAllowed = createExemptPathMatcher(context.options?.[0]?.allowFiles ?? []);
|
|
101
|
+
if (!isUnitTierFile(filename) || isAllowed(filename)) {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkModule(node, sourceNode) {
|
|
106
|
+
const source = sourceNode?.type === 'Literal' ? sourceNode.value : null;
|
|
107
|
+
if (typeof source === 'string' && CHILD_PROCESS_MODULES.has(source)) {
|
|
108
|
+
context.report({ node, messageId: 'childProcessImport', data: { source } });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
ImportDeclaration(node) {
|
|
114
|
+
if (node.importKind !== 'type') {
|
|
115
|
+
checkModule(node, node.source);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
ImportExpression(node) {
|
|
119
|
+
checkModule(node, node.source);
|
|
120
|
+
},
|
|
121
|
+
CallExpression(node) {
|
|
122
|
+
const name = bannedCallName(node);
|
|
123
|
+
if (name !== null) {
|
|
124
|
+
context.report({ node, messageId: 'ioCall', data: { name } });
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
};
|