@vibe-agent-toolkit/utils 0.2.0-rc.10 → 0.2.0-rc.12
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 +112 -10
- package/dist/archive/archive.d.ts +129 -0
- package/dist/archive/archive.d.ts.map +1 -0
- package/dist/archive/archive.js +223 -0
- package/dist/archive/archive.js.map +1 -0
- package/dist/archive/entries.d.ts +132 -0
- package/dist/archive/entries.d.ts.map +1 -0
- package/dist/archive/entries.js +223 -0
- package/dist/archive/entries.js.map +1 -0
- package/dist/archive/limits.d.ts +39 -0
- package/dist/archive/limits.d.ts.map +1 -0
- package/dist/archive/limits.js +59 -0
- package/dist/archive/limits.js.map +1 -0
- package/dist/archive/records.d.ts +14 -0
- package/dist/archive/records.d.ts.map +1 -0
- package/dist/archive/records.js +3 -0
- package/dist/archive/records.js.map +1 -0
- package/dist/archive/tar-reader.d.ts +18 -0
- package/dist/archive/tar-reader.d.ts.map +1 -0
- package/dist/archive/tar-reader.js +173 -0
- package/dist/archive/tar-reader.js.map +1 -0
- package/dist/archive/zip-reader.d.ts +76 -0
- package/dist/archive/zip-reader.d.ts.map +1 -0
- package/dist/archive/zip-reader.js +376 -0
- package/dist/archive/zip-reader.js.map +1 -0
- package/dist/archive.d.ts +14 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +12 -0
- package/dist/archive.js.map +1 -0
- package/dist/asset-reference.d.ts +17 -1
- package/dist/asset-reference.d.ts.map +1 -1
- package/dist/asset-reference.js +31 -7
- package/dist/asset-reference.js.map +1 -1
- package/dist/dirent-kind.d.ts +13 -40
- package/dist/dirent-kind.d.ts.map +1 -1
- package/dist/dirent-kind.js +21 -69
- package/dist/dirent-kind.js.map +1 -1
- package/dist/entrypoint.d.ts +2 -2
- package/dist/entrypoint.d.ts.map +1 -1
- package/dist/entrypoint.js +4 -3
- package/dist/entrypoint.js.map +1 -1
- package/dist/env-flag.d.ts +13 -5
- package/dist/env-flag.d.ts.map +1 -1
- package/dist/env-flag.js +13 -5
- package/dist/env-flag.js.map +1 -1
- package/dist/errors/confirmed-absent.d.ts +41 -0
- package/dist/errors/confirmed-absent.d.ts.map +1 -0
- package/dist/errors/confirmed-absent.js +43 -0
- package/dist/errors/confirmed-absent.js.map +1 -0
- package/dist/errors/errno-table.d.ts +177 -0
- package/dist/errors/errno-table.d.ts.map +1 -0
- package/dist/errors/errno-table.js +343 -0
- package/dist/errors/errno-table.js.map +1 -0
- package/dist/errors/fs-boundary.d.ts +36 -0
- package/dist/errors/fs-boundary.d.ts.map +1 -0
- package/dist/errors/fs-boundary.js +121 -0
- package/dist/errors/fs-boundary.js.map +1 -0
- package/dist/errors/fs-fault.d.ts +176 -0
- package/dist/errors/fs-fault.d.ts.map +1 -0
- package/dist/errors/fs-fault.js +215 -0
- package/dist/errors/fs-fault.js.map +1 -0
- package/dist/errors/git-errno.d.ts +13 -0
- package/dist/errors/git-errno.d.ts.map +1 -0
- package/dist/errors/git-errno.js +45 -0
- package/dist/errors/git-errno.js.map +1 -0
- package/dist/errors/path-present.d.ts +72 -0
- package/dist/errors/path-present.d.ts.map +1 -0
- package/dist/errors/path-present.js +110 -0
- package/dist/errors/path-present.js.map +1 -0
- package/dist/errors/rag-index-empty.d.ts +16 -0
- package/dist/errors/rag-index-empty.d.ts.map +1 -0
- package/dist/errors/rag-index-empty.js +16 -0
- package/dist/errors/rag-index-empty.js.map +1 -0
- package/dist/errors/suppressed-faults.d.ts +34 -0
- package/dist/errors/suppressed-faults.d.ts.map +1 -0
- package/dist/errors/suppressed-faults.js +66 -0
- package/dist/errors/suppressed-faults.js.map +1 -0
- package/dist/file-crawler.d.ts +13 -18
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +140 -24
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs-utils.d.ts +75 -50
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +148 -158
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +5 -4
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +5 -4
- package/dist/fs.js.map +1 -1
- package/dist/git-snapshot.d.ts +25 -17
- package/dist/git-snapshot.d.ts.map +1 -1
- package/dist/git-snapshot.js +65 -11
- package/dist/git-snapshot.js.map +1 -1
- package/dist/git-tracker.d.ts +11 -3
- package/dist/git-tracker.d.ts.map +1 -1
- package/dist/git-tracker.js +29 -6
- package/dist/git-tracker.js.map +1 -1
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +7 -35
- package/dist/git-utils.js.map +1 -1
- package/dist/git.d.ts +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +1 -1
- package/dist/git.js.map +1 -1
- package/dist/gitignore-checker.d.ts.map +1 -1
- package/dist/gitignore-checker.js +5 -4
- package/dist/gitignore-checker.js.map +1 -1
- package/dist/in-order.d.ts +93 -0
- package/dist/in-order.d.ts.map +1 -0
- package/dist/in-order.js +165 -0
- package/dist/in-order.js.map +1 -0
- package/dist/index.d.ts +20 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/kill-process-tree.d.ts +43 -0
- package/dist/kill-process-tree.d.ts.map +1 -0
- package/dist/kill-process-tree.js +48 -0
- package/dist/kill-process-tree.js.map +1 -0
- package/dist/link-following.d.ts +115 -0
- package/dist/link-following.d.ts.map +1 -0
- package/dist/link-following.js +283 -0
- package/dist/link-following.js.map +1 -0
- package/dist/listing-refusal.d.ts +44 -0
- package/dist/listing-refusal.d.ts.map +1 -1
- package/dist/listing-refusal.js +79 -2
- package/dist/listing-refusal.js.map +1 -1
- package/dist/name-fold.d.ts +25 -0
- package/dist/name-fold.d.ts.map +1 -0
- package/dist/name-fold.js +40 -0
- package/dist/name-fold.js.map +1 -0
- package/dist/path-containment.d.ts +17 -0
- package/dist/path-containment.d.ts.map +1 -1
- package/dist/path-containment.js +28 -5
- package/dist/path-containment.js.map +1 -1
- package/dist/path-utils.d.ts +14 -17
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/path-utils.js +37 -55
- package/dist/path-utils.js.map +1 -1
- package/dist/process.d.ts +2 -0
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +2 -0
- package/dist/process.js.map +1 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +8 -9
- package/dist/project-utils.js.map +1 -1
- package/dist/safe-exec.d.ts +6 -2
- package/dist/safe-exec.d.ts.map +1 -1
- package/dist/safe-exec.js +1 -1
- package/dist/safe-exec.js.map +1 -1
- package/dist/skill-targets.d.ts.map +1 -1
- package/dist/skill-targets.js +3 -1
- package/dist/skill-targets.js.map +1 -1
- package/dist/skill-test/spawn-claude.d.ts +0 -15
- package/dist/skill-test/spawn-claude.d.ts.map +1 -1
- package/dist/skill-test/spawn-claude.js +11 -50
- package/dist/skill-test/spawn-claude.js.map +1 -1
- package/dist/special-file.d.ts +18 -0
- package/dist/special-file.d.ts.map +1 -0
- package/dist/special-file.js +21 -0
- package/dist/special-file.js.map +1 -0
- package/dist/terminating-signals.d.ts +129 -0
- package/dist/terminating-signals.d.ts.map +1 -0
- package/dist/terminating-signals.js +210 -0
- package/dist/terminating-signals.js.map +1 -0
- package/dist/test-helpers.d.ts +9 -5
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +18 -21
- package/dist/test-helpers.js.map +1 -1
- package/dist/test-scratch-guard.d.ts +34 -0
- package/dist/test-scratch-guard.d.ts.map +1 -0
- package/dist/test-scratch-guard.js +45 -0
- package/dist/test-scratch-guard.js.map +1 -0
- package/dist/testing/archive-fixtures.d.ts +147 -0
- package/dist/testing/archive-fixtures.d.ts.map +1 -0
- package/dist/testing/archive-fixtures.js +449 -0
- package/dist/testing/archive-fixtures.js.map +1 -0
- package/dist/testing/executables.d.ts +6 -0
- package/dist/testing/executables.d.ts.map +1 -1
- package/dist/testing/executables.js +20 -9
- package/dist/testing/executables.js.map +1 -1
- package/dist/testing/fault-fs-preload.d.ts +16 -0
- package/dist/testing/fault-fs-preload.d.ts.map +1 -0
- package/dist/testing/fault-fs-preload.js +22 -0
- package/dist/testing/fault-fs-preload.js.map +1 -0
- package/dist/testing/fault-fs.d.ts +115 -0
- package/dist/testing/fault-fs.d.ts.map +1 -0
- package/dist/testing/fault-fs.js +411 -0
- package/dist/testing/fault-fs.js.map +1 -0
- package/dist/testing/fault-spec.d.ts +83 -0
- package/dist/testing/fault-spec.d.ts.map +1 -0
- package/dist/testing/fault-spec.js +132 -0
- package/dist/testing/fault-spec.js.map +1 -0
- package/dist/testing/hostile-tree.d.ts.map +1 -1
- package/dist/testing/hostile-tree.js +3 -4
- package/dist/testing/hostile-tree.js.map +1 -1
- package/dist/testing/platform-gates.d.ts +8 -0
- package/dist/testing/platform-gates.d.ts.map +1 -1
- package/dist/testing/platform-gates.js +24 -0
- package/dist/testing/platform-gates.js.map +1 -1
- package/dist/testing/temp-dir.d.ts +31 -0
- package/dist/testing/temp-dir.d.ts.map +1 -1
- package/dist/testing/temp-dir.js +63 -0
- package/dist/testing/temp-dir.js.map +1 -1
- package/dist/testing/tier-timeout.d.ts +34 -0
- package/dist/testing/tier-timeout.d.ts.map +1 -0
- package/dist/testing/tier-timeout.js +45 -0
- package/dist/testing/tier-timeout.js.map +1 -0
- package/dist/testing/tree-snapshot.d.ts +71 -0
- package/dist/testing/tree-snapshot.d.ts.map +1 -0
- package/dist/testing/tree-snapshot.js +163 -0
- package/dist/testing/tree-snapshot.js.map +1 -0
- package/dist/testing.d.ts +6 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +8 -1
- package/dist/testing.js.map +1 -1
- package/dist/text-content.d.ts +29 -0
- package/dist/text-content.d.ts.map +1 -1
- package/dist/text-content.js +64 -0
- package/dist/text-content.js.map +1 -1
- package/dist/text-file.d.ts +58 -6
- package/dist/text-file.d.ts.map +1 -1
- package/dist/text-file.js +111 -11
- package/dist/text-file.js.map +1 -1
- package/dist/text.d.ts +1 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +1 -1
- package/dist/text.js.map +1 -1
- package/dist/timing-dump.d.ts.map +1 -1
- package/dist/timing-dump.js +2 -10
- package/dist/timing-dump.js.map +1 -1
- package/dist/tree-change/apply-decisions.d.ts +101 -0
- package/dist/tree-change/apply-decisions.d.ts.map +1 -0
- package/dist/tree-change/apply-decisions.js +139 -0
- package/dist/tree-change/apply-decisions.js.map +1 -0
- package/dist/tree-change/apply.d.ts +98 -0
- package/dist/tree-change/apply.d.ts.map +1 -0
- package/dist/tree-change/apply.js +364 -0
- package/dist/tree-change/apply.js.map +1 -0
- package/dist/tree-change/copy-decisions.d.ts +58 -0
- package/dist/tree-change/copy-decisions.d.ts.map +1 -0
- package/dist/tree-change/copy-decisions.js +66 -0
- package/dist/tree-change/copy-decisions.js.map +1 -0
- package/dist/tree-change/copy-tree.d.ts +125 -0
- package/dist/tree-change/copy-tree.d.ts.map +1 -0
- package/dist/tree-change/copy-tree.js +279 -0
- package/dist/tree-change/copy-tree.js.map +1 -0
- package/dist/tree-change/files.d.ts +167 -0
- package/dist/tree-change/files.d.ts.map +1 -0
- package/dist/tree-change/files.js +423 -0
- package/dist/tree-change/files.js.map +1 -0
- package/dist/tree-change/followed-walk.d.ts +54 -0
- package/dist/tree-change/followed-walk.d.ts.map +1 -0
- package/dist/tree-change/followed-walk.js +81 -0
- package/dist/tree-change/followed-walk.js.map +1 -0
- package/dist/tree-change/host-chain.d.ts +48 -0
- package/dist/tree-change/host-chain.d.ts.map +1 -0
- package/dist/tree-change/host-chain.js +85 -0
- package/dist/tree-change/host-chain.js.map +1 -0
- package/dist/tree-change/identity-compare.d.ts +61 -0
- package/dist/tree-change/identity-compare.d.ts.map +1 -0
- package/dist/tree-change/identity-compare.js +115 -0
- package/dist/tree-change/identity-compare.js.map +1 -0
- package/dist/tree-change/identity.d.ts +62 -0
- package/dist/tree-change/identity.d.ts.map +1 -0
- package/dist/tree-change/identity.js +152 -0
- package/dist/tree-change/identity.js.map +1 -0
- package/dist/tree-change/index.d.ts +24 -0
- package/dist/tree-change/index.d.ts.map +1 -0
- package/dist/tree-change/index.js +14 -0
- package/dist/tree-change/index.js.map +1 -0
- package/dist/tree-change/link-chain.d.ts +180 -0
- package/dist/tree-change/link-chain.d.ts.map +1 -0
- package/dist/tree-change/link-chain.js +377 -0
- package/dist/tree-change/link-chain.js.map +1 -0
- package/dist/tree-change/link-containment.d.ts +105 -0
- package/dist/tree-change/link-containment.d.ts.map +1 -0
- package/dist/tree-change/link-containment.js +196 -0
- package/dist/tree-change/link-containment.js.map +1 -0
- package/dist/tree-change/link-walk.d.ts +49 -0
- package/dist/tree-change/link-walk.d.ts.map +1 -0
- package/dist/tree-change/link-walk.js +69 -0
- package/dist/tree-change/link-walk.js.map +1 -0
- package/dist/tree-change/plan.d.ts +206 -0
- package/dist/tree-change/plan.d.ts.map +1 -0
- package/dist/tree-change/plan.js +320 -0
- package/dist/tree-change/plan.js.map +1 -0
- package/dist/tree-change/readable-tree.d.ts +44 -0
- package/dist/tree-change/readable-tree.d.ts.map +1 -0
- package/dist/tree-change/readable-tree.js +52 -0
- package/dist/tree-change/readable-tree.js.map +1 -0
- package/dist/tree-change/rollback-error.d.ts +29 -0
- package/dist/tree-change/rollback-error.d.ts.map +1 -0
- package/dist/tree-change/rollback-error.js +31 -0
- package/dist/tree-change/rollback-error.js.map +1 -0
- package/dist/tree-change/staging-names.d.ts +27 -0
- package/dist/tree-change/staging-names.d.ts.map +1 -0
- package/dist/tree-change/staging-names.js +40 -0
- package/dist/tree-change/staging-names.js.map +1 -0
- package/dist/tree-change/tree-walk.d.ts +81 -0
- package/dist/tree-change/tree-walk.d.ts.map +1 -0
- package/dist/tree-change/tree-walk.js +212 -0
- package/dist/tree-change/tree-walk.js.map +1 -0
- package/dist/win32-namespace.d.ts +24 -0
- package/dist/win32-namespace.d.ts.map +1 -0
- package/dist/win32-namespace.js +31 -0
- package/dist/win32-namespace.js.map +1 -0
- package/dist/yaml/surgical-yaml.d.ts +12 -3
- package/dist/yaml/surgical-yaml.d.ts.map +1 -1
- package/dist/yaml/surgical-yaml.js +16 -6
- package/dist/yaml/surgical-yaml.js.map +1 -1
- package/eslint/README.md +12 -4
- package/eslint/rules/fs-binding-scan.cjs +278 -0
- package/eslint/rules/no-adhoc-errno.cjs +364 -0
- package/eslint/rules/no-bare-symlink-in-tests.cjs +2 -3
- package/eslint/rules/no-blind-catch.cjs +1 -1
- package/eslint/rules/no-destructive-fs.cjs +359 -0
- package/eslint/rules/no-existssync.cjs +70 -0
- package/eslint/rules/no-fragile-entrypoint-guard.cjs +3 -2
- package/eslint/rules/no-link-following-fs.cjs +68 -0
- package/eslint/rules/no-literal-process-exit.cjs +96 -8
- package/eslint/rules/no-literal-test-timeout.cjs +214 -0
- package/eslint/rules/no-raw-signal-listener.cjs +224 -0
- package/eslint/rules/no-stdout-outside-writer.cjs +135 -0
- package/package.json +10 -2
- package/dist/errors/errno.d.ts +0 -51
- package/dist/errors/errno.d.ts.map +0 -1
- package/dist/errors/errno.js +0 -112
- package/dist/errors/errno.js.map +0 -1
- package/eslint/rules/no-fs-promises-cp.cjs +0 -43
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-literal-test-timeout
|
|
3
|
+
*
|
|
4
|
+
* Flags a per-test, per-suite or per-hook timeout written as a number:
|
|
5
|
+
* `it(name, fn, 60_000)`, `beforeAll(fn, 3_600_000)`, `it(name, { timeout: 30_000 }, fn)`.
|
|
6
|
+
* A test runner's tier config scales its default to the platform — this repo's
|
|
7
|
+
* integration tier allows 15 minutes per test on Windows and 1 on Unix — and a
|
|
8
|
+
* literal at the call site replaces that with one number for every machine. So
|
|
9
|
+
* the literal that is generous on a fast runner is the timeout on a slow one: a
|
|
10
|
+
* `60_000` that was 1× the Unix default was 1/15 of the Windows one, and failed
|
|
11
|
+
* there on runner speed alone, with no code change.
|
|
12
|
+
*
|
|
13
|
+
* A multiple of the default (`inject('tierTestTimeout') * 60`) fails the other
|
|
14
|
+
* way: it scales an hour-sized Unix budget by the Windows factor too — 15 hours —
|
|
15
|
+
* so a hang burns the CI job's whole allowance and names no test.
|
|
16
|
+
*
|
|
17
|
+
* The allowed shapes: omit it (the tier default applies), pass the tier's own
|
|
18
|
+
* value (`inject('tierTestTimeout')`, `vitest.shared.ts`), or ask for a budget
|
|
19
|
+
* over it with `atLeastTierTimeout(inject('tierTestTimeout'), 3_600_000)` from
|
|
20
|
+
* `@vibe-agent-toolkit/utils/testing` — the default is the floor and the budget
|
|
21
|
+
* a ceiling over it. A number is accepted ONLY as that helper's budget; its first
|
|
22
|
+
* argument (the floor) is walked like any other timeout.
|
|
23
|
+
*
|
|
24
|
+
* A literal is found anywhere in the timeout expression — arithmetic, either arm
|
|
25
|
+
* of a conditional, another call's arguments, a cast or parentheses — and behind
|
|
26
|
+
* a same-file `const`. A parameter, an import, a member read or a call result
|
|
27
|
+
* with no literal argument is accepted.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ STILL BLIND (syntax-only rule): a literal behind an IMPORTED constant, a
|
|
30
|
+
* `let`, an object property, or an options object held in a `const`. There is
|
|
31
|
+
* deliberately no escape-hatch comment: a hang guard that must stay short is a
|
|
32
|
+
* property of the code under test, and belongs in that code's own timeout (a
|
|
33
|
+
* spawn's `timeout`, an `AbortSignal`), not in the runner's wall clock.
|
|
34
|
+
*
|
|
35
|
+
* Not in `recommended`: the remedy names a value the adopter's runner config
|
|
36
|
+
* must provide, which this pack cannot supply.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // BAD — one number for every machine
|
|
40
|
+
* it('populates the corpus', async () => { ... }, 60_000);
|
|
41
|
+
*
|
|
42
|
+
* // GOOD — the tier default
|
|
43
|
+
* it('populates the corpus', async () => { ... });
|
|
44
|
+
*
|
|
45
|
+
* // GOOD — a measured budget, never below this machine's allowance
|
|
46
|
+
* beforeAll(async () => { ... }, atLeastTierTimeout(inject('tierHookTimeout'), 3_600_000));
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
'use strict';
|
|
50
|
+
|
|
51
|
+
/** Runner globals whose call takes a timeout. */
|
|
52
|
+
const TIMED_ROOTS = new Set(['it', 'test', 'describe', 'suite']);
|
|
53
|
+
const HOOK_ROOTS = new Set(['beforeAll', 'afterAll', 'beforeEach', 'afterEach']);
|
|
54
|
+
|
|
55
|
+
/** Members that return a test function after taking a table or a condition. */
|
|
56
|
+
const FACTORY_MEMBERS = new Set(['each', 'for', 'skipIf', 'runIf']);
|
|
57
|
+
|
|
58
|
+
/** Members that ARE a test function. */
|
|
59
|
+
const MODIFIER_MEMBERS = new Set(['concurrent', 'sequential', 'only', 'skip', 'fails', 'todo', 'shuffle']);
|
|
60
|
+
|
|
61
|
+
/** Wrappers that change a value's type or grouping, not its value. */
|
|
62
|
+
const TRANSPARENT = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression']);
|
|
63
|
+
|
|
64
|
+
function unwrap(node) {
|
|
65
|
+
let current = node;
|
|
66
|
+
while (current && TRANSPARENT.has(current.type)) current = current.expression;
|
|
67
|
+
return current;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The root runner name a callee calls as a test function, or null.
|
|
72
|
+
*
|
|
73
|
+
* `it`, `it.concurrent`, `it.skipIf(c)`, `it.each(t)`, `it.each\`…\`` all qualify; `it.each`
|
|
74
|
+
* itself (the table call) does not, because its arguments are not a timeout.
|
|
75
|
+
*/
|
|
76
|
+
function testFunctionRoot(callee) {
|
|
77
|
+
const node = unwrap(callee);
|
|
78
|
+
if (!node) return null;
|
|
79
|
+
if (node.type === 'Identifier') return TIMED_ROOTS.has(node.name) || HOOK_ROOTS.has(node.name) ? node.name : null;
|
|
80
|
+
if (node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier'
|
|
81
|
+
&& MODIFIER_MEMBERS.has(node.property.name)) {
|
|
82
|
+
return testFunctionRoot(node.object);
|
|
83
|
+
}
|
|
84
|
+
if (node.type === 'CallExpression' || node.type === 'TaggedTemplateExpression') {
|
|
85
|
+
const factory = unwrap(node.type === 'CallExpression' ? node.callee : node.tag);
|
|
86
|
+
if (factory?.type === 'MemberExpression' && !factory.computed
|
|
87
|
+
&& factory.property.type === 'Identifier' && FACTORY_MEMBERS.has(factory.property.name)) {
|
|
88
|
+
return testFunctionRoot(factory.object);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The `const` initializer an identifier names in this file, or null. */
|
|
95
|
+
function constInitializer(sourceCode, identifier) {
|
|
96
|
+
let scope = sourceCode.getScope(identifier);
|
|
97
|
+
while (scope) {
|
|
98
|
+
const variable = scope.set.get(identifier.name);
|
|
99
|
+
if (variable) {
|
|
100
|
+
const definition = variable.defs[0];
|
|
101
|
+
if (definition?.type === 'Variable' && definition.parent.kind === 'const'
|
|
102
|
+
&& definition.node.id.type === 'Identifier') {
|
|
103
|
+
return definition.node.init;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
scope = scope.upper;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The one call whose numeric argument is accepted: its budget, over the tier's floor. */
|
|
113
|
+
const BUDGET_HELPER = 'atLeastTierTimeout';
|
|
114
|
+
|
|
115
|
+
function isBudgetHelper(callee) {
|
|
116
|
+
const node = unwrap(callee);
|
|
117
|
+
if (!node) return false;
|
|
118
|
+
if (node.type === 'Identifier') return node.name === BUDGET_HELPER;
|
|
119
|
+
return node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier'
|
|
120
|
+
&& node.property.name === BUDGET_HELPER;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The first numeric literal a timeout expression decides its value with, or null.
|
|
125
|
+
*
|
|
126
|
+
* Walks arithmetic, both arms of a conditional (not its test), call arguments and
|
|
127
|
+
* same-file `const` initializers. The budget helper contributes only its first
|
|
128
|
+
* argument: its second is the one place a number is the contract.
|
|
129
|
+
*/
|
|
130
|
+
function findLiteral(sourceCode, node, seen) {
|
|
131
|
+
const value = unwrap(node);
|
|
132
|
+
if (!value) return null;
|
|
133
|
+
switch (value.type) {
|
|
134
|
+
case 'Literal':
|
|
135
|
+
return typeof value.value === 'number' ? value : null;
|
|
136
|
+
case 'UnaryExpression':
|
|
137
|
+
return findLiteral(sourceCode, value.argument, seen);
|
|
138
|
+
case 'BinaryExpression':
|
|
139
|
+
case 'LogicalExpression':
|
|
140
|
+
return findLiteral(sourceCode, value.left, seen) ?? findLiteral(sourceCode, value.right, seen);
|
|
141
|
+
case 'ConditionalExpression':
|
|
142
|
+
return findLiteral(sourceCode, value.consequent, seen) ?? findLiteral(sourceCode, value.alternate, seen);
|
|
143
|
+
case 'CallExpression': {
|
|
144
|
+
const walked = isBudgetHelper(value.callee) ? value.arguments.slice(0, 1) : value.arguments;
|
|
145
|
+
for (const argument of walked) {
|
|
146
|
+
const found = findLiteral(sourceCode, argument, seen);
|
|
147
|
+
if (found !== null) return found;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
case 'Identifier': {
|
|
152
|
+
if (seen.has(value.name)) return null;
|
|
153
|
+
seen.add(value.name);
|
|
154
|
+
const init = constInitializer(sourceCode, value);
|
|
155
|
+
return init === null ? null : findLiteral(sourceCode, init, seen);
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
// A function (the test body), a member read, a template: not a number decided here.
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The literal a timeout argument carries — in itself, or in its `timeout` property. */
|
|
164
|
+
function literalTimeoutIn(sourceCode, argument) {
|
|
165
|
+
const node = unwrap(argument);
|
|
166
|
+
if (!node) return null;
|
|
167
|
+
if (node.type !== 'ObjectExpression') return findLiteral(sourceCode, node, new Set());
|
|
168
|
+
for (const property of node.properties) {
|
|
169
|
+
if (property.type === 'Property' && !property.computed && property.key.type === 'Identifier'
|
|
170
|
+
&& property.key.name === 'timeout') {
|
|
171
|
+
return findLiteral(sourceCode, property.value, new Set());
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
meta: {
|
|
179
|
+
type: 'problem',
|
|
180
|
+
docs: {
|
|
181
|
+
description:
|
|
182
|
+
'Disallow a numeric per-test or per-hook timeout — omit it, or pass a budget through ' +
|
|
183
|
+
'atLeastTierTimeout(tier, budget), so the platform-scaled default is the floor and the budget a ceiling',
|
|
184
|
+
category: 'Code and test hygiene',
|
|
185
|
+
bans: '`it(name, fn, 60_000)`, `beforeAll(fn, tier * 12)`, `{ timeout: isWin ? 900_000 : 60_000 }`',
|
|
186
|
+
useInstead: 'the tier default, or `atLeastTierTimeout(inject(\'tierTestTimeout\'), budget)`',
|
|
187
|
+
// Not recommended: the remedy names a value the adopter's runner config must provide.
|
|
188
|
+
recommended: false,
|
|
189
|
+
},
|
|
190
|
+
schema: [],
|
|
191
|
+
messages: {
|
|
192
|
+
literalTimeout:
|
|
193
|
+
'A number in a timeout replaces or scales the tier\'s platform-scaled default. Omit it, or write ' +
|
|
194
|
+
'atLeastTierTimeout(inject(\'tierTestTimeout\' | \'tierHookTimeout\'), budget).',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
create(context) {
|
|
199
|
+
const sourceCode = context.sourceCode;
|
|
200
|
+
return {
|
|
201
|
+
CallExpression(node) {
|
|
202
|
+
const root = testFunctionRoot(node.callee);
|
|
203
|
+
if (root === null) return;
|
|
204
|
+
// A hook's timeout is its second argument; a test's or suite's is any
|
|
205
|
+
// argument after the name (vitest takes options before or after the body).
|
|
206
|
+
const candidates = HOOK_ROOTS.has(root) ? node.arguments.slice(1, 2) : node.arguments.slice(1);
|
|
207
|
+
for (const argument of candidates) {
|
|
208
|
+
const literal = literalTimeoutIn(sourceCode, argument);
|
|
209
|
+
if (literal !== null) context.report({ node: literal, messageId: 'literalTimeout' });
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-raw-signal-listener
|
|
3
|
+
*
|
|
4
|
+
* Flags every way of adding — or removing — a listener for a SIGNAL on the process by hand. The
|
|
5
|
+
* event is a signal when it is a string literal (or template) naming one, or any event name that
|
|
6
|
+
* is not a literal at all (a loop variable over a signal list, a parameter). Caught shapes:
|
|
7
|
+
*
|
|
8
|
+
* - `process.on` / `once` / `addListener` / `prependListener` / `prependOnceListener` — and
|
|
9
|
+
* `off` / `removeListener` / `removeAllListeners`, which can strip a guard (`removeAllListeners()`
|
|
10
|
+
* with no event strips every one);
|
|
11
|
+
* - the same method reached as `process['on']`, through `globalThis.process` / `global.process`,
|
|
12
|
+
* through a local alias (`const p = process`), through a `node:process` (or `process`) default
|
|
13
|
+
* or namespace import, or as a named import of the method from it (`import { on } from
|
|
14
|
+
* 'node:process'`);
|
|
15
|
+
* - `once(process, <signal>)` / `on(process, <signal>)` — `node:events`' promise and iterator
|
|
16
|
+
* forms, called bare or as a member (`events.once`, `EventEmitter.on`) — with any of the process
|
|
17
|
+
* references above as the emitter.
|
|
18
|
+
*
|
|
19
|
+
* NOT caught: a process reference that only reaches the call through a function's parameter, an
|
|
20
|
+
* object property, or any value the rule cannot follow to its declaration in the same file.
|
|
21
|
+
*
|
|
22
|
+
* Why: a terminating-signal handler written by hand is the shape that leaked a clone and a lock.
|
|
23
|
+
* It removed itself on the first signal and started an asynchronous cleanup, so the second — a
|
|
24
|
+
* terminal Ctrl-C under a wrapper that forwards signals delivers two — met the default disposition
|
|
25
|
+
* and killed the process half-way through the cleanup. `handleTerminatingSignals` (on
|
|
26
|
+
* `@vibe-agent-toolkit/utils/process`) is the one installation: it swallows the duplicate that
|
|
27
|
+
* arrives with the first signal, composes with every other live guard, and dies of the signal. A
|
|
28
|
+
* removal by hand can strip that guard's listener out from under it.
|
|
29
|
+
*
|
|
30
|
+
* An event name that is not a literal is flagged because a signal list spread into a loop is how
|
|
31
|
+
* the second-commonest hand-written handler looks, and nothing at the call site says whether the
|
|
32
|
+
* variable holds a signal. Name the event literally when it is not one.
|
|
33
|
+
*
|
|
34
|
+
* Option `allow: string[]` — repo-relative paths of the files permitted to listen directly (the one
|
|
35
|
+
* file that implements the helper). Matched at a path-segment boundary via `exempt-path-matcher.cjs`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // BAD
|
|
39
|
+
* process.on('SIGINT', () => cleanup().then(() => process.exit(130)));
|
|
40
|
+
* for (const signal of TERMINATING_SIGNALS) process.on(signal, onSignal);
|
|
41
|
+
* await once(process, 'SIGTERM');
|
|
42
|
+
*
|
|
43
|
+
* // GOOD
|
|
44
|
+
* const guard = handleTerminatingSignals({ cleanup, fallbackExitCode: ExitCode.ERROR });
|
|
45
|
+
* // ...
|
|
46
|
+
* guard.release();
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
'use strict';
|
|
50
|
+
|
|
51
|
+
const { createExemptPathMatcher } = require('./exempt-path-matcher.cjs');
|
|
52
|
+
|
|
53
|
+
const ADD_METHODS = new Set(['on', 'once', 'addListener', 'prependListener', 'prependOnceListener']);
|
|
54
|
+
const REMOVE_METHODS = new Set(['off', 'removeListener', 'removeAllListeners']);
|
|
55
|
+
/** `node:events`' static forms that take the emitter as their first argument. */
|
|
56
|
+
const EVENTS_STATIC_METHODS = new Set(['on', 'once']);
|
|
57
|
+
const PROCESS_MODULES = new Set(['process', 'node:process']);
|
|
58
|
+
const GLOBAL_OBJECTS = new Set(['globalThis', 'global']);
|
|
59
|
+
/** How many aliases deep a process reference is followed — `const a = process; const b = a;`. */
|
|
60
|
+
const MAX_ALIAS_DEPTH = 8;
|
|
61
|
+
|
|
62
|
+
/** The member name of `node` — `x.name` or `x['name']` — or null when it is not static. */
|
|
63
|
+
function memberName(node) {
|
|
64
|
+
if (node.type !== 'MemberExpression') return null;
|
|
65
|
+
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
|
|
66
|
+
if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') {
|
|
67
|
+
return node.property.value;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The variable `identifier` refers to, through the scope chain, or null for an unresolved global. */
|
|
73
|
+
function resolveVariable(sourceCode, identifier) {
|
|
74
|
+
let scope = sourceCode.getScope(identifier);
|
|
75
|
+
while (scope) {
|
|
76
|
+
const variable = scope.set.get(identifier.name);
|
|
77
|
+
if (variable) return variable;
|
|
78
|
+
scope = scope.upper;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The import a variable is bound to: `{ module, imported }`, imported being 'default', '*' or a name. */
|
|
84
|
+
function importBinding(variable) {
|
|
85
|
+
const def = variable?.defs?.[0];
|
|
86
|
+
if (def?.type !== 'ImportBinding') return null;
|
|
87
|
+
switch (def.node.type) {
|
|
88
|
+
case 'ImportNamespaceSpecifier':
|
|
89
|
+
return { module: def.parent?.source?.value, imported: '*' };
|
|
90
|
+
case 'ImportDefaultSpecifier':
|
|
91
|
+
return { module: def.parent?.source?.value, imported: 'default' };
|
|
92
|
+
case 'ImportSpecifier': {
|
|
93
|
+
const { imported } = def.node;
|
|
94
|
+
return { module: def.parent?.source?.value, imported: imported.type === 'Identifier' ? imported.name : imported.value };
|
|
95
|
+
}
|
|
96
|
+
// `import p = require('node:process')` — the module object itself.
|
|
97
|
+
case 'TSImportEqualsDeclaration':
|
|
98
|
+
return { module: def.node.moduleReference?.expression?.value, imported: 'default' };
|
|
99
|
+
default:
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether `node` evaluates to the `process` object, as far as this file can tell. */
|
|
105
|
+
function isProcessRef(sourceCode, node, depth = 0) {
|
|
106
|
+
if (depth > MAX_ALIAS_DEPTH) return false;
|
|
107
|
+
if (node.type === 'MemberExpression') {
|
|
108
|
+
return node.object.type === 'Identifier' && GLOBAL_OBJECTS.has(node.object.name) && memberName(node) === 'process';
|
|
109
|
+
}
|
|
110
|
+
if (node.type !== 'Identifier') return false;
|
|
111
|
+
const variable = resolveVariable(sourceCode, node);
|
|
112
|
+
if (variable === null || variable.defs.length === 0) return node.name === 'process';
|
|
113
|
+
const binding = importBinding(variable);
|
|
114
|
+
if (binding !== null) return PROCESS_MODULES.has(binding.module) && (binding.imported === 'default' || binding.imported === '*');
|
|
115
|
+
const def = variable.defs[0];
|
|
116
|
+
if (def.type !== 'Variable' || def.node.id.type !== 'Identifier' || def.node.init == null) return false;
|
|
117
|
+
return isProcessRef(sourceCode, def.node.init, depth + 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The process method `callee` names — `process.on`, `p['once']`, an imported `on` — or null. */
|
|
121
|
+
function processMethod(sourceCode, callee) {
|
|
122
|
+
const name = memberName(callee);
|
|
123
|
+
if (name !== null) return isProcessRef(sourceCode, callee.object) ? name : null;
|
|
124
|
+
if (callee.type !== 'Identifier') return null;
|
|
125
|
+
const binding = importBinding(resolveVariable(sourceCode, callee));
|
|
126
|
+
return binding !== null && PROCESS_MODULES.has(binding.module) ? binding.imported : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The literal text of an event-name argument, or null when it is not literal. */
|
|
130
|
+
function literalEventName(arg) {
|
|
131
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string') return arg.value;
|
|
132
|
+
if (arg.type === 'TemplateLiteral' && arg.expressions.length === 0) return arg.quasis[0].value.cooked;
|
|
133
|
+
if (arg.type === 'TemplateLiteral') return arg.quasis[0].value.cooked.startsWith('SIG') ? 'SIG' : null;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What `node` does to a signal listener — `{ method, eventArg, removal }` — or null when it is
|
|
139
|
+
* not a listener call on the process at all.
|
|
140
|
+
*/
|
|
141
|
+
function listenerCall(sourceCode, node) {
|
|
142
|
+
const { callee } = node;
|
|
143
|
+
const method = processMethod(sourceCode, callee);
|
|
144
|
+
if (method !== null && (ADD_METHODS.has(method) || REMOVE_METHODS.has(method))) {
|
|
145
|
+
return { method, eventArg: node.arguments[0], removal: REMOVE_METHODS.has(method) };
|
|
146
|
+
}
|
|
147
|
+
const staticName = callee.type === 'Identifier' ? callee.name : memberName(callee);
|
|
148
|
+
const [emitter, eventArg] = node.arguments;
|
|
149
|
+
if (staticName !== null && EVENTS_STATIC_METHODS.has(staticName) && emitter !== undefined && isProcessRef(sourceCode, emitter)) {
|
|
150
|
+
return { method: staticName, eventArg, removal: false };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
meta: {
|
|
157
|
+
type: 'problem',
|
|
158
|
+
docs: {
|
|
159
|
+
description:
|
|
160
|
+
'Disallow adding or removing a process listener for a signal (or for any event not named ' +
|
|
161
|
+
'literally) by hand — install a terminating-signal handler through handleTerminatingSignals',
|
|
162
|
+
category: 'Filesystem and process',
|
|
163
|
+
bans: '`process.on(<signal>, …)`, `process.off(<signal>, …)`, `once(process, <signal>)` and `process.on(<non-literal>, …)`',
|
|
164
|
+
useInstead: '`handleTerminatingSignals()`',
|
|
165
|
+
subpath: '/process',
|
|
166
|
+
// Not recommended: it names a helper an adopter may not use, and the one file allowed to
|
|
167
|
+
// listen directly is a fact about each repo's layout (the `allow` option).
|
|
168
|
+
recommended: false,
|
|
169
|
+
recommendedSeverity: 'error',
|
|
170
|
+
},
|
|
171
|
+
schema: [
|
|
172
|
+
{
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
allow: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
176
|
+
},
|
|
177
|
+
additionalProperties: false,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
messages: {
|
|
181
|
+
signalListener:
|
|
182
|
+
'process.{{method}}(\'{{event}}\') installs a signal handler by hand. Use ' +
|
|
183
|
+
'handleTerminatingSignals from @vibe-agent-toolkit/utils/process: it swallows the ' +
|
|
184
|
+
'duplicate that arrives with the first signal, composes with every other guard, and dies ' +
|
|
185
|
+
'of the signal.',
|
|
186
|
+
computedEvent:
|
|
187
|
+
'process.{{method}}(<non-literal>) may be a signal handler installed by hand. Name the ' +
|
|
188
|
+
'event literally, or — for a signal — use handleTerminatingSignals from ' +
|
|
189
|
+
'@vibe-agent-toolkit/utils/process.',
|
|
190
|
+
signalRemoval:
|
|
191
|
+
'process.{{method}}({{event}}) removes a signal listener by hand, which can strip a ' +
|
|
192
|
+
'handleTerminatingSignals guard out from under it. Release the guard instead.',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
create(context) {
|
|
197
|
+
const allow = context.options?.[0]?.allow ?? [];
|
|
198
|
+
const filename = context.filename ?? context.getFilename();
|
|
199
|
+
if (createExemptPathMatcher(allow)(filename)) return {};
|
|
200
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
CallExpression(node) {
|
|
204
|
+
const call = listenerCall(sourceCode, node);
|
|
205
|
+
if (call === null) return;
|
|
206
|
+
const { method, eventArg, removal } = call;
|
|
207
|
+
if (eventArg === undefined) {
|
|
208
|
+
// `removeAllListeners()` strips every listener, every guard's included.
|
|
209
|
+
if (method === 'removeAllListeners') context.report({ node, messageId: 'signalRemoval', data: { method, event: '' } });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const event = literalEventName(eventArg);
|
|
213
|
+
if (event !== null && !event.startsWith('SIG')) return;
|
|
214
|
+
if (removal) {
|
|
215
|
+
context.report({ node, messageId: 'signalRemoval', data: { method, event: event === null ? '<non-literal>' : `'${event}'` } });
|
|
216
|
+
} else if (event === null) {
|
|
217
|
+
context.report({ node, messageId: 'computedEvent', data: { method } });
|
|
218
|
+
} else {
|
|
219
|
+
context.report({ node, messageId: 'signalListener', data: { method, event } });
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-stdout-outside-writer
|
|
3
|
+
*
|
|
4
|
+
* Under the configured `paths` (a CLI's command directory), stdout belongs to
|
|
5
|
+
* ONE writer. A command that writes its own bytes publishes a shape nothing
|
|
6
|
+
* validates, in a format it chose, beside an exit code it decided — which is
|
|
7
|
+
* how a CLI grows one document contract per verb. The writer validates the
|
|
8
|
+
* document against its registered schema before a byte leaves and derives the
|
|
9
|
+
* exit code from what it wrote; this rule makes it the only way out.
|
|
10
|
+
*
|
|
11
|
+
* Reported under `paths` (test files excepted):
|
|
12
|
+
*
|
|
13
|
+
* - `process.stdout.write(…)`;
|
|
14
|
+
* - `console.log|info|debug|dir|table(…)` — the console methods that write
|
|
15
|
+
* stdout (`console.error` / `console.warn` are stderr, the human channel,
|
|
16
|
+
* and are not this rule's business);
|
|
17
|
+
* - a call to a stdout-writing helper BY NAME — `writeStdoutSync`,
|
|
18
|
+
* `writeAllSync`, bare or as a member — and one HANDED ON as an argument
|
|
19
|
+
* (`render(result, writeStdoutSync)`), which writes stdout just the
|
|
20
|
+
* same. A rule that saw only the primitives was blind to every command that
|
|
21
|
+
* writes through a helper.
|
|
22
|
+
*
|
|
23
|
+
* Option `allowFiles: string[]` — repo-relative files whose stdout is not a
|
|
24
|
+
* document (a protocol leaf). Asserted both ways: a listed file with no
|
|
25
|
+
* violation left is itself an error (`staleAllow`), so the list never holds a
|
|
26
|
+
* dead entry.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* // BAD — a shape nothing validates, beside a code decided here
|
|
30
|
+
* writeStdoutSync(yaml.stringify(result));
|
|
31
|
+
* process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
32
|
+
*
|
|
33
|
+
* // GOOD — validated against the registry, exit derived from what was written
|
|
34
|
+
* endWithReport('okf validate', report, format);
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const { createExemptDirectoryMatcher, createExemptPathMatcher, isTestFile } = require('./exempt-path-matcher.cjs');
|
|
40
|
+
|
|
41
|
+
/** The `console` methods that write stdout. */
|
|
42
|
+
const STDOUT_CONSOLE_METHODS = new Set(['log', 'info', 'debug', 'dir', 'table']);
|
|
43
|
+
|
|
44
|
+
/** Helpers that write stdout, recognised by name wherever they are imported from. */
|
|
45
|
+
const STDOUT_HELPERS = new Set([
|
|
46
|
+
'writeStdoutSync',
|
|
47
|
+
'writeAllSync',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** Whether `node` is `<object>.<property>` (non-computed) with an identifier object. */
|
|
51
|
+
function isMember(node, object, property) {
|
|
52
|
+
return (
|
|
53
|
+
node.type === 'MemberExpression' &&
|
|
54
|
+
!node.computed &&
|
|
55
|
+
node.property.type === 'Identifier' &&
|
|
56
|
+
(property === undefined || node.property.name === property) &&
|
|
57
|
+
(object === undefined || (node.object.type === 'Identifier' && node.object.name === object))
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether `callee` is `process.stdout.write`. */
|
|
62
|
+
function isProcessStdoutWrite(callee) {
|
|
63
|
+
return isMember(callee, undefined, 'write') && isMember(callee.object, 'process', 'stdout');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The called function's own name — `f(…)` or `x.f(…)` — or null. */
|
|
67
|
+
function calleeName(callee) {
|
|
68
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
69
|
+
if (isMember(callee)) return callee.property.name;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether a call writes stdout by one of the routes this rule reports. */
|
|
74
|
+
function writesStdout(callee) {
|
|
75
|
+
if (isProcessStdoutWrite(callee)) return true;
|
|
76
|
+
if (isMember(callee, 'console') && STDOUT_CONSOLE_METHODS.has(callee.property.name)) return true;
|
|
77
|
+
return STDOUT_HELPERS.has(calleeName(callee));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
meta: {
|
|
82
|
+
type: 'problem',
|
|
83
|
+
docs: {
|
|
84
|
+
description:
|
|
85
|
+
'Disallow writing stdout under a command directory except through the one document writer — ' +
|
|
86
|
+
'process.stdout.write, console.log and the stdout helpers publish a shape nothing validates',
|
|
87
|
+
// A position on one CLI's architecture: it names that CLI's command
|
|
88
|
+
// directory and its writer, which no adopter's layout shares.
|
|
89
|
+
recommended: false,
|
|
90
|
+
},
|
|
91
|
+
schema: [
|
|
92
|
+
{
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
paths: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
96
|
+
allowFiles: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
97
|
+
},
|
|
98
|
+
required: ['paths', 'allowFiles'],
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
messages: {
|
|
103
|
+
stdoutOutsideWriter:
|
|
104
|
+
'stdout is written by the document writer only (endWithReport / endWithRefusal / ' +
|
|
105
|
+
'writeExternalDocument / writeArtifact). A command that writes its own bytes publishes a ' +
|
|
106
|
+
'shape nothing validates, beside an exit code it decided.',
|
|
107
|
+
staleAllow:
|
|
108
|
+
'This file is listed in no-stdout-outside-writer allowFiles but writes stdout by no ' +
|
|
109
|
+
'reported route any more. Remove it from the list — the ratchet only shrinks.',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
create(context) {
|
|
114
|
+
const options = context.options?.[0] ?? { paths: [], allowFiles: [] };
|
|
115
|
+
const filename = context.filename ?? context.getFilename();
|
|
116
|
+
if (isTestFile(filename) || !createExemptDirectoryMatcher(options.paths)(filename)) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
const allowed = createExemptPathMatcher(options.allowFiles)(filename);
|
|
120
|
+
let violations = 0;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
CallExpression(node) {
|
|
124
|
+
const handedOn = node.arguments.filter((arg) => arg.type === 'Identifier' && STDOUT_HELPERS.has(arg.name));
|
|
125
|
+
const sites = writesStdout(node.callee) ? [node, ...handedOn] : handedOn;
|
|
126
|
+
violations += sites.length;
|
|
127
|
+
if (allowed) return;
|
|
128
|
+
for (const site of sites) context.report({ node: site, messageId: 'stdoutOutsideWriter' });
|
|
129
|
+
},
|
|
130
|
+
'Program:exit'(node) {
|
|
131
|
+
if (allowed && violations === 0) context.report({ node, messageId: 'staleAllow' });
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-agent-toolkit/utils",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
3
|
+
"version": "0.2.0-rc.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core utility functions shared across the vibe-agent-toolkit packages",
|
|
6
6
|
"sideEffects": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"author": "Jeff Dutton",
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=22.
|
|
20
|
+
"node": ">=22.16.0"
|
|
21
21
|
},
|
|
22
22
|
"main": "./dist/index.js",
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
@@ -66,6 +66,10 @@
|
|
|
66
66
|
"types": "./dist/testing.d.ts",
|
|
67
67
|
"import": "./dist/testing.js"
|
|
68
68
|
},
|
|
69
|
+
"./archive": {
|
|
70
|
+
"types": "./dist/archive.d.ts",
|
|
71
|
+
"import": "./dist/archive.js"
|
|
72
|
+
},
|
|
69
73
|
"./asset": {
|
|
70
74
|
"types": "./dist/asset.d.ts",
|
|
71
75
|
"import": "./dist/asset.js"
|
|
@@ -93,14 +97,17 @@
|
|
|
93
97
|
"build": "tsx ../dev-tools/src/tsc-clean-build.ts",
|
|
94
98
|
"test:unit": "vitest run",
|
|
95
99
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
100
|
+
"test:system": "vitest run --config vitest.system.config.ts",
|
|
96
101
|
"test:watch": "vitest",
|
|
97
102
|
"typecheck": "tsc --noEmit",
|
|
98
103
|
"clean": "rimraf --glob dist \"*.tsbuildinfo\" .tsc-staging"
|
|
99
104
|
},
|
|
100
105
|
"dependencies": {
|
|
101
106
|
"@vibe-validate/git": "0.20.1",
|
|
107
|
+
"adm-zip": "^0.6.1",
|
|
102
108
|
"ignore": "^7.0.5",
|
|
103
109
|
"picomatch": "^4.0.3",
|
|
110
|
+
"tar": "^7.5.7",
|
|
104
111
|
"which": "^5.0.0",
|
|
105
112
|
"yaml": "^2.9.0"
|
|
106
113
|
},
|
|
@@ -113,6 +120,7 @@
|
|
|
113
120
|
}
|
|
114
121
|
},
|
|
115
122
|
"devDependencies": {
|
|
123
|
+
"@types/adm-zip": "^0.5.8",
|
|
116
124
|
"@types/which": "^3.0.4",
|
|
117
125
|
"zod": "^3.25.0"
|
|
118
126
|
},
|
package/dist/errors/errno.d.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Errno predicates — the two questions every catch site asks of an OS error,
|
|
3
|
-
* answered by the `code` on the error or down its `cause` chain.
|
|
4
|
-
*
|
|
5
|
-
* ⚠️ This module imports NOTHING. It is the leaf that `fs-utils.ts`,
|
|
6
|
-
* `path-utils.ts`, `dirent-kind.ts`, `path-containment.ts` and the rest of the
|
|
7
|
-
* package share; it lived in `fs-utils.ts` until that put three of them in an
|
|
8
|
-
* import cycle (`fs-utils → path-utils → fs-utils` and two more), and a leaf
|
|
9
|
-
* is the one place a shared predicate cannot re-open one. (The cycle was once
|
|
10
|
-
* blamed for a platform-dependent knip verdict on these two names; breaking it
|
|
11
|
-
* changed nothing there — the cause was a missing knip entry, see
|
|
12
|
-
* `docs/contributing/traps.md`, "A subpath module's re-exports flap by platform".)
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* Whether `error` is the filesystem refusing a path rather than a bug.
|
|
16
|
-
*
|
|
17
|
-
* Deliberately NOT `error instanceof Error`: the point of every caller is to
|
|
18
|
-
* degrade on a hostile tree, and a `TypeError` from a validator is not that.
|
|
19
|
-
* Treating one as environmental turns a real defect into a warning about
|
|
20
|
-
* whichever file it happened on — which makes a tool quietest exactly when it is
|
|
21
|
-
* most wrong.
|
|
22
|
-
*
|
|
23
|
-
* Walks `cause`, because the errno is routinely re-wrapped on its way up. The CLI
|
|
24
|
-
* config loader turns a read failure into `new Error('Failed to load config: …')`;
|
|
25
|
-
* without following the chain the predicate answered "not a filesystem error" for
|
|
26
|
-
* a plain `EACCES`, and an unreadable config aborted a whole `vat audit` run. Any
|
|
27
|
-
* layer that adds context to an OS error defeats a `code`-only check, so the check
|
|
28
|
-
* cannot be `code`-only.
|
|
29
|
-
*/
|
|
30
|
-
export declare function isFilesystemAccessError(error: unknown): boolean;
|
|
31
|
-
/**
|
|
32
|
-
* Whether `error` means **there is nothing at this path** — `ENOENT`, or
|
|
33
|
-
* `ENOTDIR` for a path whose component turned out to be a file — and nothing
|
|
34
|
-
* else.
|
|
35
|
-
*
|
|
36
|
-
* This is the narrowing a `try { stat(p) } catch { return null }` is rewritten
|
|
37
|
-
* to under the `no-blind-catch` lint rule: the sentinel stands for *absent*,
|
|
38
|
-
* so only an absence may produce it; a refusal (`EACCES`, `EPERM`, `ELOOP`) or
|
|
39
|
-
* a bug (`TypeError`) is rethrown and stays loud.
|
|
40
|
-
*
|
|
41
|
-
* ⚠️ Deliberately NOT {@link isFilesystemAccessError}. That predicate answers
|
|
42
|
-
* "is this the environment's fault?" and to answer it groups `ENOENT` with
|
|
43
|
-
* `EACCES` — the exact conflation that once turned an unreadable directory
|
|
44
|
-
* into an empty one. The two questions have two predicates on purpose; see
|
|
45
|
-
* also {@link listingFailure}, which makes the same split for `readdir`.
|
|
46
|
-
*
|
|
47
|
-
* Walks `cause` for the same reason its sibling does: the errno is routinely
|
|
48
|
-
* re-wrapped on its way up.
|
|
49
|
-
*/
|
|
50
|
-
export declare function isPathAbsentError(error: unknown): boolean;
|
|
51
|
-
//# sourceMappingURL=errno.d.ts.map
|