willfire 0.1.19 → 0.1.21

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.
@@ -1,5 +1,7 @@
1
1
  import { parse as parseYaml } from "yaml";
2
+ import { evaluateValue } from "../expr/evaluateValue.js";
2
3
  import { UNKNOWN } from "../expr/val.js";
4
+ import { renderTemplate } from "../execute.js";
3
5
  import { expandMatrixDetailed } from "../matrix/expandMatrixDetailed.js";
4
6
  import { jobDisplayName } from "../names/jobDisplayName.js";
5
7
  import { skippedDisplayName } from "../names/skippedDisplayName.js";
@@ -7,25 +9,30 @@ import { parseUses } from "../uses/parseUses.js";
7
9
  import { evalIf } from "./evalIf.js";
8
10
  import { prScope } from "./prScope.js";
9
11
  /**
10
- * A `with:` value as the callee will see it.
11
- *
12
- * A value carrying `${{ }}` is left unknown rather than evaluated. Resolving
13
- * it would mean evaluating the caller's own expression context, and every
14
- * caller in practice passes plain literals — so the reach that would buy is
15
- * not worth the surface. Unknown here is the same unknown as before this
16
- * existed; nothing regresses.
12
+ * A `with:` value as the callee will see it, evaluated in the caller's scope.
13
+ * A whole-expression value keeps its evaluated type; mixed text renders to a
14
+ * string, all or nothing; anything unresolvable stays unknown.
17
15
  */
18
- function inputLiteral(raw) {
19
- if (raw == null) {
16
+ function inputValue(raw, scope) {
17
+ if (raw === null || raw === undefined) {
20
18
  return { kind: "value", v: "" };
21
19
  }
22
20
  if (typeof raw === "boolean" || typeof raw === "number") {
23
21
  return { kind: "value", v: raw };
24
22
  }
25
- if (typeof raw === "string") {
26
- return raw.includes("${{") ? UNKNOWN : { kind: "value", v: raw };
23
+ if (typeof raw !== "string") {
24
+ return UNKNOWN;
25
+ }
26
+ if (!raw.includes("${{")) {
27
+ return { kind: "value", v: raw };
27
28
  }
28
- return UNKNOWN;
29
+ const t = raw.trim();
30
+ // `${{a}} x ${{b}}` fails this test and takes the render path instead.
31
+ if (t.startsWith("${{") && t.indexOf("}}") === t.length - 2) {
32
+ return evaluateValue(t.slice(3, -2), prScope(scope));
33
+ }
34
+ const rendered = renderTemplate(raw, prScope(scope));
35
+ return rendered === null ? UNKNOWN : { kind: "value", v: rendered };
29
36
  }
30
37
  /** The `on.workflow_call.inputs` block, tolerating the YAML 1.1 `on` -> true key. */
31
38
  function workflowCallInputs(wf) {
@@ -49,17 +56,18 @@ function workflowCallInputs(wf) {
49
56
  * workflow would be invalid if it were required, and guessing empty would
50
57
  * silently decide guards that are not decided.
51
58
  */
52
- function calleeInputs(withBlock, subWf) {
59
+ function calleeInputs(withBlock, subWf, scope) {
53
60
  const out = {};
54
61
  for (const [name, decl] of Object.entries(workflowCallInputs(subWf))) {
55
62
  out[name] =
56
63
  decl != null && typeof decl === "object" && "default" in decl
57
- ? inputLiteral(decl["default"])
64
+ ? // Defaults live in the callee, out of the caller's context's reach.
65
+ inputValue(decl["default"], {})
58
66
  : UNKNOWN;
59
67
  }
60
68
  if (withBlock != null && typeof withBlock === "object") {
61
69
  for (const [name, raw] of Object.entries(withBlock)) {
62
- out[name] = inputLiteral(raw);
70
+ out[name] = inputValue(raw, scope);
63
71
  }
64
72
  }
65
73
  return out;
@@ -76,35 +84,44 @@ const MAX_REUSABLE_DEPTH = 4;
76
84
  /** `ref` is already a commit id, so resolving it is a no-op. */
77
85
  const SHA_RE = /^[0-9a-f]{40}$/i;
78
86
  const isSha = (ref) => SHA_RE.test(ref);
87
+ const NEEDS_OUTPUTS_RE = /needs\s*\.\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\.\s*outputs\b/g;
88
+ /**
89
+ * The jobs some sibling reads outputs from — the only jobs worth executing.
90
+ * Matching over the serialized job catches every read site without modelling
91
+ * any; a false positive costs one wasted run, never a verdict.
92
+ */
93
+ function neededJobIds(jobs) {
94
+ const needed = new Set();
95
+ for (const job of Object.values(jobs)) {
96
+ for (const m of JSON.stringify(job ?? {}).matchAll(NEEDS_OUTPUTS_RE)) {
97
+ needed.add(m[1]);
98
+ }
99
+ }
100
+ return needed;
101
+ }
79
102
  export async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}, executor) {
80
103
  const entries = [];
81
104
  const jobs = wf.jobs ?? {};
82
105
  const statuses = {};
83
- // Execute what the caller granted, before anything reads `needs`. The
84
- // grant names the repo the workflow *file* lives in, so a granted callee
85
- // job fires here in the recursion, where `source` is that repo. The guard
86
- // is the same `evalIf` the main loop applies — same scope, same verdict —
87
- // so a job is executed exactly when it is predicted to run. A job that
88
- // would not run is not executed; a job that fails to execute contributes
89
- // nothing but its reason, which `execNote` threads into the entries that
90
- // needed it.
106
+ // Selection is derived, never configured: execute exactly the jobs some
107
+ // sibling's `needs.*.outputs` read depends on, under the same `evalIf`
108
+ // verdict the main loop applies.
91
109
  let scoped = scope;
92
110
  const execFailures = {};
93
111
  if (executor != null) {
112
+ const needed = neededJobIds(jobs);
94
113
  for (const [jobId, jobRaw] of Object.entries(jobs)) {
95
- if (!executor.granted(source, jobId)) {
96
- continue;
97
- }
98
114
  const job = jobRaw ?? {};
99
- if (evalIf(job.if, scoped) !== "run") {
100
- continue;
101
- }
102
- const res = await executor.executeJob(jobId, job, wf, scoped);
103
- if (res.ok) {
104
- scoped = { ...scoped, needs: { ...scoped.needs, [jobId]: { outputs: res.outputs } } };
105
- }
106
- else {
107
- execFailures[jobId] = res.reason;
115
+ // A reusable-call job has no steps of its own to run.
116
+ const runnable = needed.has(jobId) && !("uses" in job) && evalIf(job.if, scoped) === "run";
117
+ if (runnable) {
118
+ const res = await executor.executeJob(jobId, job, wf, scoped);
119
+ if (res.ok) {
120
+ scoped = { ...scoped, needs: { ...scoped.needs, [jobId]: { outputs: res.outputs } } };
121
+ }
122
+ else {
123
+ execFailures[jobId] = res.reason;
124
+ }
108
125
  }
109
126
  }
110
127
  }
@@ -206,7 +223,10 @@ export async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = ""
206
223
  // `inputs.*` changes at the call boundary; `github.*` does not.
207
224
  // A callee's jobs run in the caller's repo, so the facts seeded
208
225
  // at the top of the prediction stay true all the way down.
209
- subScope = { inputs: calleeInputs(job.with, subWf ?? {}), github: scoped.github };
226
+ subScope = {
227
+ inputs: calleeInputs(job.with, subWf ?? {}, scoped),
228
+ github: scoped.github,
229
+ };
210
230
  }
211
231
  catch (e) {
212
232
  failure = `YAML parse error in ${uses}: ${e}`;
@@ -21,6 +21,26 @@ function axisValues(v, scope) {
21
21
  }
22
22
  return val.v;
23
23
  }
24
+ /**
25
+ * An `include:`/`exclude:` block: a literal list or an expression evaluating
26
+ * to one. Absent means empty; anything unresolvable fails the expansion.
27
+ */
28
+ function comboList(v, scope) {
29
+ if (v === null || v === undefined) {
30
+ return [];
31
+ }
32
+ if (Array.isArray(v)) {
33
+ return v;
34
+ }
35
+ if (typeof v !== "string") {
36
+ return null;
37
+ }
38
+ const val = evaluateValue(v, scope);
39
+ if (val.kind !== "json" || !Array.isArray(val.v)) {
40
+ return null;
41
+ }
42
+ return val.v;
43
+ }
24
44
  export function expandMatrixDetailed(strategy, scope = {}) {
25
45
  const matrix = strategy?.matrix;
26
46
  if (matrix == null) {
@@ -32,9 +52,9 @@ export function expandMatrixDetailed(strategy, scope = {}) {
32
52
  if (typeof matrix === "string") {
33
53
  return null;
34
54
  }
35
- const include = matrix.include ?? [];
36
- const exclude = matrix.exclude ?? [];
37
- if (typeof include === "string" || typeof exclude === "string") {
55
+ const include = comboList(matrix.include, scope);
56
+ const exclude = comboList(matrix.exclude, scope);
57
+ if (include === null || exclude === null) {
38
58
  return null;
39
59
  }
40
60
  const axes = {};
@@ -0,0 +1,20 @@
1
+ import type { Octokit } from "@octokit/rest";
2
+ import { type JobExecutor, type RunCommand } from "../execute.js";
3
+ import type { ResolveRef, WorkflowSource } from "../types.js";
4
+ export interface LiveExecutorOpts {
5
+ /** How steps run; the hermetic docker sandbox by default. */
6
+ runCommand?: RunCommand;
7
+ /**
8
+ * Auth for history clones. `undefined` reads `GH_TOKEN` / `GITHUB_TOKEN`
9
+ * from the environment; `null` clones anonymously.
10
+ */
11
+ token?: string | null;
12
+ /** Where clones come from — a seam for tests that serve `file://` fixtures. */
13
+ remoteUrl?: (source: WorkflowSource) => string;
14
+ }
15
+ /**
16
+ * The executor `predict` uses by default. Repo-authored steps run in the
17
+ * docker sandbox; infrastructure subprocesses (`tar`, `git`) run on the host,
18
+ * since the clone needs the network the sandbox denies.
19
+ */
20
+ export declare function makeLiveExecutor(octokit: Octokit, workspace: WorkflowSource, resolveRef: ResolveRef, opts?: LiveExecutorOpts): JobExecutor;
@@ -0,0 +1,38 @@
1
+ import { makeCloneProvider, makeExecutor, makeTreeProvider, runShell, } from "../execute.js";
2
+ import { makeSandboxRunner, SANDBOX_NODE_MAJOR } from "../sandbox.js";
3
+ /**
4
+ * The executor `predict` uses by default. Repo-authored steps run in the
5
+ * docker sandbox; infrastructure subprocesses (`tar`, `git`) run on the host,
6
+ * since the clone needs the network the sandbox denies.
7
+ */
8
+ export function makeLiveExecutor(octokit, workspace, resolveRef, opts = {}) {
9
+ const download = async (src) => {
10
+ try {
11
+ const { data } = await octokit.rest.repos.downloadTarballArchive({
12
+ owner: src.owner,
13
+ repo: src.repo,
14
+ ref: src.sha,
15
+ });
16
+ return new Uint8Array(data);
17
+ }
18
+ catch {
19
+ // Private, deleted, rate limit, network: one answer.
20
+ return null;
21
+ }
22
+ };
23
+ const token = opts.token !== undefined
24
+ ? opts.token
25
+ : (process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null);
26
+ const tarballs = makeTreeProvider(download, runShell);
27
+ const clones = makeCloneProvider(runShell, token, opts.remoteUrl === undefined ? {} : { remoteUrl: opts.remoteUrl });
28
+ const provideTree = (src, o) => o?.history === true ? clones(src, o) : tarballs(src, o);
29
+ return makeExecutor({
30
+ workspace,
31
+ deps: {
32
+ provideTree,
33
+ runCommand: opts.runCommand ?? makeSandboxRunner(),
34
+ resolveRef,
35
+ nodeMajor: SANDBOX_NODE_MAJOR,
36
+ },
37
+ });
38
+ }
@@ -7,10 +7,10 @@
7
7
  // src/names.test.ts.
8
8
  import { parse as parseYaml } from "yaml";
9
9
  import { jobName } from "../entries/jobName.js";
10
- import { makeExecutor, makeTreeProvider, runShell } from "../execute.js";
11
10
  import { expandJobs } from "../jobs/expandJobs.js";
12
11
  import { workflowDispatches } from "../triggers/workflowDispatches.js";
13
12
  import { finalizePrediction } from "./finalizePrediction.js";
13
+ import { makeLiveExecutor } from "./makeLiveExecutor.js";
14
14
  import { sourceKey } from "./sourceKey.js";
15
15
  import { stackTargetRef } from "./stackTargetRef.js";
16
16
  const SKIP_RE = /\[(skip ci|ci skip|no ci|skip actions|actions skip)\]/i;
@@ -114,36 +114,10 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
114
114
  return content;
115
115
  };
116
116
  const reader = { fetchWorkflow, resolveRef };
117
- // The executor exists only when the caller granted something. Trees come
118
- // from the tarball endpoint at the resolved commit, and every subprocess —
119
- // `tar` included — goes through the one `runShell` seam.
120
- let executor;
121
- if (opts.execute !== undefined && opts.execute.length > 0) {
122
- const download = async (src) => {
123
- try {
124
- const { data } = await octokit.rest.repos.downloadTarballArchive({
125
- owner: src.owner,
126
- repo: src.repo,
127
- ref: src.sha,
128
- });
129
- return new Uint8Array(data);
130
- }
131
- catch {
132
- // Private, deleted, rate limit, network: one answer, and the entries
133
- // behind it stay unresolved with the failure named.
134
- return null;
135
- }
136
- };
137
- executor = makeExecutor({
138
- grants: opts.execute,
139
- workspace: headSource,
140
- deps: {
141
- provideTree: makeTreeProvider(download, runShell),
142
- runCommand: runShell,
143
- resolveRef,
144
- },
145
- });
146
- }
117
+ // Execution is on by default and costs nothing until a workflow needs it.
118
+ const executor = opts.executor === undefined
119
+ ? makeLiveExecutor(octokit, headSource, resolveRef)
120
+ : (opts.executor ?? undefined);
147
121
  const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
148
122
  ...base,
149
123
  per_page: 100,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A `RunCommand` that runs each step inside a hermetic docker container: no
3
+ * network, no capabilities, a read-only root, and only the host paths in
4
+ * `RunSpec.mounts`, bound at their own paths. Code that can reach nothing and
5
+ * keep nothing needs no per-repo grant — this is what lets execution be on by
6
+ * default instead of configured.
7
+ */
8
+ import type { RunCommand, RunSpec } from "./execute.js";
9
+ /**
10
+ * The node major the image ships — also the refusal boundary for `setup-node`
11
+ * and `node2x` runtimes asking for any other major.
12
+ */
13
+ export declare const SANDBOX_NODE_MAJOR = 24;
14
+ export declare const DOCKERFILE = "FROM node:24-slim\nRUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates python3 && rm -rf /var/lib/apt/lists/*\n";
15
+ export interface SandboxConfig {
16
+ dockerBin: string;
17
+ uid: number;
18
+ gid: number;
19
+ dockerfile: string;
20
+ }
21
+ export declare function sandboxConfig(opts?: Partial<SandboxConfig>): SandboxConfig;
22
+ /** The tag names the dockerfile that built it, so a change is a new image. */
23
+ export declare function imageTag(dockerfile: string): string;
24
+ /**
25
+ * The complete `docker run` argv for one step. `PATH` and `HOME` in
26
+ * `spec.env` are host facts; the container gets its image's PATH and a
27
+ * writable `HOME=/tmp` instead.
28
+ */
29
+ export declare function sandboxArgv(spec: RunSpec, cfg: SandboxConfig): string[];
30
+ /**
31
+ * Provisions the image lazily, once, and remembers a failure: every later
32
+ * spec gets 125 (docker's "could not start" band) with the reason rather
33
+ * than retrying a build that already failed.
34
+ */
35
+ export declare function makeSandboxRunner(opts?: Partial<SandboxConfig>): RunCommand;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * A `RunCommand` that runs each step inside a hermetic docker container: no
3
+ * network, no capabilities, a read-only root, and only the host paths in
4
+ * `RunSpec.mounts`, bound at their own paths. Code that can reach nothing and
5
+ * keep nothing needs no per-repo grant — this is what lets execution be on by
6
+ * default instead of configured.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ /**
11
+ * The node major the image ships — also the refusal boundary for `setup-node`
12
+ * and `node2x` runtimes asking for any other major.
13
+ */
14
+ export const SANDBOX_NODE_MAJOR = 24;
15
+ // git and python3: checkout's postcondition and the interpreters a script on
16
+ // a GitHub-hosted runner takes for granted.
17
+ export const DOCKERFILE = `FROM node:${SANDBOX_NODE_MAJOR}-slim
18
+ RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates python3 && rm -rf /var/lib/apt/lists/*
19
+ `;
20
+ export function sandboxConfig(opts = {}) {
21
+ return {
22
+ dockerBin: opts.dockerBin ?? "docker",
23
+ uid: opts.uid ?? process.getuid(),
24
+ gid: opts.gid ?? process.getgid(),
25
+ dockerfile: opts.dockerfile ?? DOCKERFILE,
26
+ };
27
+ }
28
+ /** The tag names the dockerfile that built it, so a change is a new image. */
29
+ export function imageTag(dockerfile) {
30
+ const hash = createHash("sha256").update(dockerfile).digest("hex");
31
+ return `willfire-sandbox:${hash.slice(0, 12)}`;
32
+ }
33
+ /**
34
+ * The complete `docker run` argv for one step. `PATH` and `HOME` in
35
+ * `spec.env` are host facts; the container gets its image's PATH and a
36
+ * writable `HOME=/tmp` instead.
37
+ */
38
+ export function sandboxArgv(spec, cfg) {
39
+ const argv = [
40
+ "run",
41
+ "--rm",
42
+ "--network",
43
+ "none",
44
+ "--cap-drop",
45
+ "ALL",
46
+ "--security-opt",
47
+ "no-new-privileges",
48
+ "--read-only",
49
+ "--tmpfs",
50
+ "/tmp",
51
+ "--user",
52
+ `${cfg.uid}:${cfg.gid}`,
53
+ ];
54
+ for (const m of spec.mounts ?? []) {
55
+ argv.push("-v", `${m.path}:${m.path}${m.writable ? "" : ":ro"}`);
56
+ }
57
+ argv.push("-w", spec.cwd);
58
+ for (const [k, v] of Object.entries(spec.env)) {
59
+ if (k !== "PATH" && k !== "HOME") {
60
+ argv.push("-e", `${k}=${v}`);
61
+ }
62
+ }
63
+ argv.push("-e", "HOME=/tmp");
64
+ argv.push(imageTag(cfg.dockerfile));
65
+ if (spec.shell === "bash") {
66
+ argv.push("bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", spec.script);
67
+ }
68
+ else {
69
+ argv.push("sh", "-e", "-c", spec.script);
70
+ }
71
+ return argv;
72
+ }
73
+ // The client itself runs with the host environment — it needs the host PATH
74
+ // and any DOCKER_HOST to find the daemon.
75
+ function runDocker(bin, argv, stdin) {
76
+ return new Promise((resolvePromise) => {
77
+ const child = spawn(bin, argv, {
78
+ env: process.env,
79
+ stdio: [stdin === undefined ? "ignore" : "pipe", "ignore", "pipe"],
80
+ });
81
+ let stderr = "";
82
+ child.stderr.on("data", (d) => {
83
+ stderr += String(d);
84
+ if (stderr.length > 4096) {
85
+ stderr = stderr.slice(-4096);
86
+ }
87
+ });
88
+ child.on("spawn", () => {
89
+ if (stdin !== undefined) {
90
+ child.stdin.write(stdin);
91
+ child.stdin.end();
92
+ }
93
+ });
94
+ child.on("error", () => resolvePromise({ code: 127, stderr }));
95
+ child.on("close", (code) => resolvePromise({ code: code ?? 1, stderr }));
96
+ });
97
+ }
98
+ /**
99
+ * Provisions the image lazily, once, and remembers a failure: every later
100
+ * spec gets 125 (docker's "could not start" band) with the reason rather
101
+ * than retrying a build that already failed.
102
+ */
103
+ export function makeSandboxRunner(opts = {}) {
104
+ const cfg = sandboxConfig(opts);
105
+ const tag = imageTag(cfg.dockerfile);
106
+ let ensured = null;
107
+ const ensureImage = () => {
108
+ ensured ??= (async () => {
109
+ const inspect = await runDocker(cfg.dockerBin, ["image", "inspect", tag]);
110
+ if (inspect.code === 0) {
111
+ return null;
112
+ }
113
+ const build = await runDocker(cfg.dockerBin, ["build", "-t", tag, "-"], cfg.dockerfile);
114
+ if (build.code === 0) {
115
+ return null;
116
+ }
117
+ const trimmed = build.stderr.trim();
118
+ const tail = trimmed.slice(trimmed.lastIndexOf("\n") + 1);
119
+ return `cannot build sandbox image ${tag}${tail === "" ? "" : ` (${tail})`}`;
120
+ })();
121
+ return ensured;
122
+ };
123
+ return async (spec) => {
124
+ const failure = await ensureImage();
125
+ if (failure !== null) {
126
+ return { code: 125, stderr: failure };
127
+ }
128
+ return runDocker(cfg.dockerBin, sandboxArgv(spec, cfg));
129
+ };
130
+ }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExecutionGrant } from "./execute.js";
1
+ import type { JobExecutor } from "./execute.js";
2
2
  export interface EntryBase {
3
3
  workflow: string;
4
4
  reason: string;
@@ -117,16 +117,11 @@ export interface PredictOptions {
117
117
  */
118
118
  action?: PrEventAction;
119
119
  /**
120
- * Jobs willfire may *execute* to resolve what reading cannot the fleet's
121
- * `detect` job, whose outputs feed every dynamic matrix downstream of it.
122
- *
123
- * Off by default, and mechanism only: willfire has no opinion about which
124
- * jobs are safe to run. The caller that knows names them, one repo and job
125
- * id at a time (see {@link ExecutionGrant}), and an execution that fails
126
- * for any reason leaves the dependent entries exactly as unresolved as
127
- * they were — with the failure spelled into their reasons.
120
+ * The executor that resolves what reading cannot. Omitted, prediction
121
+ * builds the live sandboxed one; `null` disables execution; passing a
122
+ * {@link JobExecutor} is a test seam, not configuration.
128
123
  */
129
- execute?: ExecutionGrant[];
124
+ executor?: JobExecutor | null;
130
125
  }
131
126
  export interface Ctx {
132
127
  action: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.build.json",
39
+ "lint": "eslint .",
39
40
  "predict": "tsx src/cli.ts",
40
41
  "prepare": "pnpm build",
41
42
  "test": "vitest run",
@@ -51,9 +52,11 @@
51
52
  "devDependencies": {
52
53
  "@types/node": "^22.0.0",
53
54
  "@vitest/coverage-v8": "^4.1.10",
55
+ "eslint": "^10.9.1",
54
56
  "testing-conventions": "^0.0.91",
55
57
  "tsx": "^4.19.0",
56
58
  "typescript": "^5.6.0",
59
+ "typescript-eslint": "^8.68.0",
57
60
  "vitest": "^4.1.10"
58
61
  }
59
62
  }