diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 3b0f475e7..d9f083dc3 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: Cut an Inspector v2 release — bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on. +description: Cut an Inspector v2 release — run npm audit and bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on. disable-model-invocation: true --- @@ -27,10 +27,11 @@ job or the coverage gate red: There is **one version number** (only the root `package.json` has one — the clients carry none), so the flow is three steps. -## 1. Bump on `v2/main`, before the milestone merge +## 1. `npm audit`, then bump, on `v2/main` — before the milestone merge -The bump is part of the milestone's work, so it belongs on the develop branch -and flows into `main` with everything else. +Both are part of the milestone's work, so both belong on the develop branch and +flow into `main` together, in the same PR — audit first, so the bump sits on top +of a tree you have just checked. ```sh # Branch from the REMOTE ref, and read the version only once you are on it. @@ -39,11 +40,45 @@ and flows into `main` with everything else. # you are bumping from (Copilot). git fetch origin v2/main git checkout -b v2/chore/-bump- origin/v2/main + +# Audit every install that has its own lockfile — root and each client. +# REPORT ONLY. Read the output; do not let npm mutate the tree (see below). +npm audit --audit-level=high +for c in web cli tui launcher; do (cd "clients/$c" && npm audit --audit-level=high); done + node -p "require('./package.json').version" # what is on v2/main now npm version minor --no-git-tag-version # or major / patch; bump only, no tag node -p "require('./package.json').version" # confirm, then PR → v2/main ``` +Anything it reports is fixed **deliberately** — a direct bump, or an +`overrides` entry — and each fix is its own commit, gated by +`npm run local:gate` before the version bump goes on top. + +⚠️ **Do not run `npm audit fix`, with or without `--force`.** +[Dependency placement](../../../AGENTS.md#dependency-placement) rules it out, +and the reason is not `--force`: plain `audit fix` resolves an advisory that has +no *upward* escape inside a declared range by silently **downgrading**. That is +not hypothetical here — `tsup@8.5.1` declares `esbuild: ^0.27.0` against an +advisory covering `0.27.3 - 0.28.0`, and `audit fix` walked three installs back +to `0.27.2` (~700 lines of lockfile churn for a low-severity dev-only advisory; +tried and reverted in #2058, written up in the `local-dev` skill). `local:gate` +does not detect a version regression, so nothing downstream would have caught +it. `--force` is worse again — it applies fixes *outside* the declared range, +trading a known vulnerability for an unvetted major. + +So the release step is the **report**, and the judgment stays with a person. +Where `audit` names something with no in-range fix, pin it with `overrides`; +where it needs a major, that is its own issue and its own PR, not a release-day +edit. If something can't be resolved before the release ships, say so in the +release notes and leave it to the alert-driven pipeline (#2229) rather than +forcing it here. + +This step is a **backstop, not a substitute** for #2229's alert-driven issues — +those are what surface a transitive vulnerability well before a release is cut, +tracked and fixed as their own PRs. This exists so a release is never gated on +remembering to check `npm audit` separately. + The branch name carries the version you are bumping **to**, so it is named after that second reading. If you want it before branching: `git show origin/v2/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version"`. diff --git a/.github/workflows/dependency-refresh.yml b/.github/workflows/dependency-refresh.yml new file mode 100644 index 000000000..4412fa7c3 --- /dev/null +++ b/.github/workflows/dependency-refresh.yml @@ -0,0 +1,52 @@ +# Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs. +# +# A Dependabot version-update PR carries no issue and no board card, so npm +# version updates are being switched off in `.github/dependabot.yml`. That file +# is changed in #2235, not here — until it lands, Dependabot's npm PRs and this +# sweep overlap, which is duplicate signal rather than conflicting action. +# +# This workflow runs `scripts/dependency-refresh.mjs` against `v2/main` once a +# month and files or updates ONE tracking issue listing every outdated package +# across the root install and each client — no PR is opened automatically. A +# maintainer reviews the issue, picks what to bump, and opens a normal PR +# against `v2/main`. +# +# `GITHUB_TOKEN` is sufficient: it only needs to read milestones (public) and +# create/edit an issue (`issues: write`). Board placement is intentionally NOT +# attempted here — that needs an org-project PAT this token cannot have — so a +# filed-but-unboarded issue is picked up by the next `/issue-triage` sweep, +# same as any other maintainer-filed issue. +name: Dependency Refresh + +on: + schedule: + - cron: "23 6 1 * *" # 06:23 UTC on the 1st of every month + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + npm-outdated: + runs-on: ubuntu-latest + steps: + - name: Checkout v2/main + uses: actions/checkout@v7 + with: + ref: v2/main + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies (root + all clients) + run: npm install + + - name: Run the npm-outdated sweep + run: node scripts/dependency-refresh.mjs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/scripts/dependency-refresh.mjs b/scripts/dependency-refresh.mjs new file mode 100644 index 000000000..6d6ad5a8b --- /dev/null +++ b/scripts/dependency-refresh.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node +// Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs. +// +// A Dependabot version-update PR carries no issue and no board card — the same +// carve-out from "every PR references an issue" that the security-update flow +// had (that half is handled separately by the alert-driven pipeline, also +// #2229). Turning npm version updates off in `.github/dependabot.yml` is #2235; +// this script is the replacement it switches over to, and lands first, so the +// two flows overlap until #2235 does. Once a month it runs +// `npm outdated` across the root install and every client under `clients/*` +// (each has its own package.json + lockfile — v2 is not a workspace), and +// files or updates ONE tracking issue listing everything behind. A maintainer +// picks what to bump and opens a normal PR against `v2/main`; there is no +// auto-generated PR here at all. +// +// Idempotent by design: the issue body starts with a fixed HTML marker +// (ISSUE_MARKER below), which is how a second run in the same month finds and +// updates the existing open issue instead of filing a duplicate. +// +// `parseOutdated`, `buildIssueBody` and `buildClearedBody` are pure. `main()` +// shells out to `npm outdated` and `gh`, so it takes its spawn function as a +// parameter (defaulting to the real one) and `dependency-refresh.test.mjs` +// drives it with a fake — covering npm failure, create vs. edit, the milestone +// lookup and both no-op paths. `workflow_dispatch` is a production trigger, +// not a substitute for that (Copilot): the helper-only tests it replaced let a +// non-zero `npm outdated` exit report a clean sweep. + +import { spawnSync } from "node:child_process"; + +export const ISSUE_MARKER = ""; + +/** Installs to check, relative to the repo root, and their npm-outdated label. */ +export const INSTALLS = [ + { dir: ".", label: "root" }, + { dir: "clients/web", label: "clients/web" }, + { dir: "clients/cli", label: "clients/cli" }, + { dir: "clients/tui", label: "clients/tui" }, + { dir: "clients/launcher", label: "clients/launcher" }, +]; + +/** + * Normalize one install's `npm outdated --json` output. + * + * @param {string} json raw stdout from `npm outdated --json` (may be `""` or `"{}"`) + * @returns {Array<{name: string, current: string, wanted: string, latest: string}>} + */ +export function parseOutdated(json) { + const trimmed = json.trim(); + if (trimmed === "") return []; + const parsed = JSON.parse(trimmed); + return Object.entries(parsed) + .map(([name, info]) => ({ + name, + current: info.current ?? "(missing)", + wanted: info.wanted ?? info.current ?? "?", + latest: info.latest ?? "?", + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * @param {Array<{label: string, packages: ReturnType}>} installs + * @returns {string | null} the issue body, or `null` when nothing is outdated anywhere + */ +export function buildIssueBody(installs) { + const withPackages = installs.filter((i) => i.packages.length > 0); + if (withPackages.length === 0) return null; + + const sections = withPackages.map(({ label, packages }) => { + const rows = packages + .map( + (p) => `| \`${p.name}\` | ${p.current} | ${p.wanted} | ${p.latest} |`, + ) + .join("\n"); + return `### \`${label}\`\n\n| Package | Current | Wanted | Latest |\n| --- | --- | --- | --- |\n${rows}`; + }); + + return [ + ISSUE_MARKER, + "Routine dependency refresh — `npm outdated` run against `v2/main` on a monthly schedule, replacing Dependabot version-update PRs (#2229).", + "", + "This is a tracking issue, not a diff: pick what's worth bumping (`wanted` is the safe default; `latest` may cross a major and needs its own judgment call, especially for anything root-declared per [Dependency placement](https://github.com/modelcontextprotocol/inspector/blob/v2/main/AGENTS.md#dependency-placement)) and open a normal PR against `v2/main`.", + "", + ...sections, + "", + "A second run of this sweep before this issue closes updates this body in place rather than filing a duplicate.", + ].join("\n"); +} + +/** + * The body a still-open tracking issue is rewritten to once every install is + * current again. Without it the issue keeps its last package table forever and + * reads as live work that no longer exists (Copilot). + * + * The sweep rewrites rather than closes: it deliberately takes no board + * actions (see the workflow header), and closing an issue whose card a + * maintainer has already moved would make the board claim work shipped that + * this script cannot verify shipped. A maintainer closes it. + * + * @param {string} isoDate the sweep date, as `YYYY-MM-DD` + * @returns {string} + */ +export function buildClearedBody(isoDate) { + return [ + ISSUE_MARKER, + `Every install is up to date as of ${isoDate} — nothing is outdated at the root or in any client.`, + "", + "This issue was filed by an earlier run of the monthly sweep (#2229) and its package table is gone because the packages it listed are no longer behind. Either they were bumped or their ranges caught up; nothing here is outstanding.", + "", + "Safe to close. A later sweep that finds something outdated will refile this body with a fresh table rather than open a duplicate.", + ].join("\n"); +} + +function runOutdated(dir, spawn) { + const result = spawn("npm", ["outdated", "--json"], { + cwd: dir, + encoding: "utf8", + }); + if (result.error) throw result.error; + // `npm outdated` exits 0 when everything is current and 1 when it finds + // something outdated — both are successful runs. Every other status is a + // real failure (a registry or config error exits 2), and it MUST throw + // rather than fall through: a failed run also prints nothing to stdout, so + // accepting it parses to an empty package list and reports a clean no-op. + // With five installs swept in a loop, that turns a total outage into a + // silent "nothing to do" (Copilot). + if (result.status !== 0 && result.status !== 1) { + throw new Error( + `npm outdated failed in ${dir} (exit ${result.status}): ${(result.stderr ?? "").trim()}`, + ); + } + return result.stdout ?? ""; +} + +function findExistingIssue(repo, spawn) { + const result = spawn( + "gh", + [ + "issue", + "list", + "--repo", + repo, + "--state", + "open", + "--search", + ISSUE_MARKER, + "--json", + "number,body", + "--limit", + "10", + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + throw new Error(`gh issue list failed: ${result.stderr}`); + } + const issues = JSON.parse(result.stdout || "[]"); + return issues.find((i) => i.body?.startsWith(ISSUE_MARKER)) ?? null; +} + +function currentMilestone(repo, spawn) { + const result = spawn( + "gh", + [ + "api", + `repos/${repo}/milestones`, + "--jq", + 'map(select(.state=="open")) | sort_by(.due_on) | .[0].title // empty', + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + throw new Error(`milestone lookup failed: ${result.stderr}`); + } + return result.stdout.trim() || null; +} + +function editIssue(repo, number, body, spawn) { + const edit = spawn( + "gh", + ["issue", "edit", String(number), "--repo", repo, "--body", body], + { encoding: "utf8" }, + ); + if (edit.status !== 0) + throw new Error(`gh issue edit failed: ${edit.stderr}`); +} + +export function main(repo = process.env.GITHUB_REPOSITORY, spawn = spawnSync) { + if (!repo) throw new Error("repo not specified (GITHUB_REPOSITORY unset)"); + + const installs = INSTALLS.map(({ dir, label }) => ({ + label, + packages: parseOutdated(runOutdated(dir, spawn)), + })); + + // Look the existing issue up BEFORE branching on `body`: the nothing- + // outdated case still has to reach an open issue to clear it. + const existing = findExistingIssue(repo, spawn); + const body = buildIssueBody(installs); + + if (body === null) { + if (!existing) { + console.log( + "dependency-refresh: nothing outdated in any install — no-op", + ); + return; + } + editIssue( + repo, + existing.number, + buildClearedBody(new Date().toISOString().slice(0, 10)), + spawn, + ); + console.log( + `dependency-refresh: nothing outdated — cleared stale list on #${existing.number}`, + ); + return; + } + + if (existing) { + editIssue(repo, existing.number, body, spawn); + console.log(`dependency-refresh: updated existing #${existing.number}`); + return; + } + + const milestone = currentMilestone(repo, spawn); + const args = [ + "issue", + "create", + "--repo", + repo, + "--title", + "chore(deps): monthly dependency refresh", + "--label", + "v2", + "--label", + "chore", + "--label", + "dependabot", + "--body", + body, + ]; + if (milestone) args.push("--milestone", milestone); + + const create = spawn("gh", args, { encoding: "utf8" }); + if (create.status !== 0) + throw new Error(`gh issue create failed: ${create.stderr}`); + if (!milestone) { + // Unmilestoned means unapproved, so triage sweeps it into `Incoming` — NOT + // `Todo`, which asserts a maintainer signed off (Copilot). + console.log( + "dependency-refresh: no open milestone — issue filed unmilestoned, will be swept into Incoming at next triage", + ); + } + console.log(`dependency-refresh: filed ${create.stdout.trim()}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/dependency-refresh.test.mjs b/scripts/dependency-refresh.test.mjs new file mode 100644 index 000000000..f180b863c --- /dev/null +++ b/scripts/dependency-refresh.test.mjs @@ -0,0 +1,268 @@ +// Tests for dependency-refresh.mjs (#2229) — both the pure parsing/formatting +// helpers and `main()`'s orchestration, the latter driven through the injected +// spawn function so no `npm` or `gh` process is ever started. +// +// `main()` is covered rather than left to `workflow_dispatch` because a +// production trigger is not a test (Copilot): the helper-only suite this +// replaced passed while a non-zero `npm outdated` exit reported a clean sweep. +// Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildClearedBody, + buildIssueBody, + main, + parseOutdated, + INSTALLS, + ISSUE_MARKER, +} from "./dependency-refresh.mjs"; + +/** + * A fake `spawnSync` that answers by command shape and records every call. + * + * @param {object} opts + * @param {Record} [opts.outdated] parsed `npm outdated` payload, per install dir + * @param {number} [opts.outdatedStatus] exit status for every `npm outdated` + * @param {Array<{number:number,body:string}>} [opts.existing] what `gh issue list` returns + * @param {string|null} [opts.milestone] what the milestone lookup returns + */ +function fakeSpawn({ + outdated = {}, + outdatedStatus, + existing = [], + milestone = "v2.6.0", +} = {}) { + const calls = []; + const fn = (cmd, args, opts) => { + calls.push({ cmd, args, cwd: opts?.cwd }); + if (cmd === "npm") { + const payload = outdated[opts.cwd] ?? {}; + const found = Object.keys(payload).length > 0; + return { + // Real `npm outdated` exits 1 precisely when it found something. + status: outdatedStatus ?? (found ? 1 : 0), + stdout: found ? JSON.stringify(payload) : "", + stderr: outdatedStatus ? "ENOTFOUND registry.npmjs.org" : "", + }; + } + if (args[0] === "issue" && args[1] === "list") + return { status: 0, stdout: JSON.stringify(existing), stderr: "" }; + if (args[0] === "api") + return { + status: 0, + stdout: milestone ? `${milestone}\n` : "", + stderr: "", + }; + if (args[0] === "issue" && args[1] === "create") + return { + status: 0, + stdout: "https://github.com/o/r/issues/9\n", + stderr: "", + }; + if (args[0] === "issue" && args[1] === "edit") + return { status: 0, stdout: "", stderr: "" }; + throw new Error(`unexpected spawn: ${cmd} ${args.join(" ")}`); + }; + fn.calls = calls; + return fn; +} + +const ghCall = (spawn, verb) => + spawn.calls.find((c) => c.cmd === "gh" && c.args[1] === verb); + +/** Silence main()'s progress logging; returns the captured lines. */ +function captureLog(run) { + const lines = []; + const original = console.log; + console.log = (...a) => lines.push(a.join(" ")); + try { + run(); + } finally { + console.log = original; + } + return lines; +} + +test("parseOutdated returns [] for empty npm-outdated output", () => { + assert.deepEqual(parseOutdated(""), []); + assert.deepEqual(parseOutdated("{}"), []); +}); + +test("parseOutdated normalizes and sorts entries by name", () => { + const json = JSON.stringify({ + zod: { current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" }, + ajv: { current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" }, + }); + assert.deepEqual(parseOutdated(json), [ + { name: "ajv", current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" }, + { name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" }, + ]); +}); + +test("parseOutdated falls back when a field is missing", () => { + const json = JSON.stringify({ pkg: { current: "1.0.0" } }); + assert.deepEqual(parseOutdated(json), [ + { name: "pkg", current: "1.0.0", wanted: "1.0.0", latest: "?" }, + ]); +}); + +test("buildIssueBody returns null when every install is up to date", () => { + assert.equal( + buildIssueBody([ + { label: "root", packages: [] }, + { label: "clients/web", packages: [] }, + ]), + null, + ); +}); + +test("buildIssueBody starts with the idempotency marker and skips empty installs", () => { + const body = buildIssueBody([ + { label: "root", packages: [] }, + { + label: "clients/web", + packages: [ + { name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" }, + ], + }, + ]); + assert.ok(body.startsWith(ISSUE_MARKER)); + assert.ok(body.includes("### `clients/web`")); + assert.ok(!body.includes("### `root`")); + assert.ok(body.includes("| `zod` | 3.0.0 | 3.1.0 | 4.0.0 |")); +}); + +test("buildClearedBody keeps the marker so the next sweep still finds the issue", () => { + const body = buildClearedBody("2026-09-03"); + assert.ok(body.startsWith(ISSUE_MARKER)); + assert.ok(body.includes("2026-09-03")); + assert.ok(body.includes("Safe to close")); +}); + +test("main throws when npm outdated exits with an undocumented status", () => { + // The regression that motivated covering main(): exit 2 used to fall through + // to empty stdout and report a clean sweep across all five installs. + const spawn = fakeSpawn({ outdatedStatus: 2 }); + assert.throws( + () => captureLog(() => main("o/r", spawn)), + /npm outdated failed in \.\s*\(exit 2\).*ENOTFOUND/s, + ); + assert.equal(ghCall(spawn, "create"), undefined); +}); + +test("main sweeps every install and files one milestoned issue", () => { + const spawn = fakeSpawn({ + outdated: { + "clients/web": { + zod: { current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" }, + }, + }, + }); + const log = captureLog(() => main("o/r", spawn)); + + assert.deepEqual( + spawn.calls.filter((c) => c.cmd === "npm").map((c) => c.cwd), + INSTALLS.map((i) => i.dir), + ); + const create = ghCall(spawn, "create"); + assert.ok(create, "expected an issue to be created"); + assert.deepEqual(create.args.slice(-2), ["--milestone", "v2.6.0"]); + assert.ok(create.args[create.args.indexOf("--body") + 1].includes("`zod`")); + assert.ok(log.some((l) => l.includes("filed"))); +}); + +test("main edits the existing issue instead of filing a duplicate", () => { + const spawn = fakeSpawn({ + outdated: { + ".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } }, + }, + existing: [{ number: 77, body: `${ISSUE_MARKER}\nstale` }], + }); + const log = captureLog(() => main("o/r", spawn)); + + assert.equal(ghCall(spawn, "create"), undefined); + const edit = ghCall(spawn, "edit"); + assert.equal(edit.args[2], "77"); + assert.ok(edit.args[edit.args.length - 1].includes("`ajv`")); + assert.ok(log.some((l) => l.includes("updated existing #77"))); +}); + +test("main clears a still-open issue once everything is current again", () => { + const spawn = fakeSpawn({ + existing: [{ number: 77, body: `${ISSUE_MARKER}\n| \`zod\` | 3.0.0 |` }], + }); + const log = captureLog(() => main("o/r", spawn)); + + const edit = ghCall(spawn, "edit"); + assert.ok(edit, "an open issue must be cleared, not left with a stale table"); + const body = edit.args[edit.args.length - 1]; + assert.ok(body.startsWith(ISSUE_MARKER)); + assert.ok(!body.includes("`zod`")); + assert.ok(log.some((l) => l.includes("cleared stale list on #77"))); +}); + +test("main is a true no-op when nothing is outdated and no issue is open", () => { + const spawn = fakeSpawn(); + const log = captureLog(() => main("o/r", spawn)); + + assert.equal(ghCall(spawn, "edit"), undefined); + assert.equal(ghCall(spawn, "create"), undefined); + assert.ok(log.some((l) => l.includes("no-op"))); +}); + +test("main says Incoming, not Todo, when it files without a milestone", () => { + const spawn = fakeSpawn({ + outdated: { + ".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } }, + }, + milestone: null, + }); + const log = captureLog(() => main("o/r", spawn)); + + const create = ghCall(spawn, "create"); + assert.ok(!create.args.includes("--milestone")); + // Unmilestoned is unapproved; triage parks it in Incoming. + assert.ok(log.some((l) => l.includes("Incoming"))); + assert.ok(!log.some((l) => l.includes("Todo"))); +}); + +test("main refuses to run without a repo", () => { + // `main`'s default reads process.env.GITHUB_REPOSITORY, which GitHub Actions + // sets on every run — so this has to clear the variable rather than assume + // the ambient environment lacks it. Relying on the ambient value passed + // locally and failed in CI, which is the one place the default is always + // populated. + const saved = process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_REPOSITORY; + try { + assert.throws( + () => main(undefined, fakeSpawn()), + /GITHUB_REPOSITORY unset/, + ); + } finally { + if (saved !== undefined) process.env.GITHUB_REPOSITORY = saved; + } +}); + +test("main falls back to GITHUB_REPOSITORY when no repo is passed", () => { + // The other half of the default: with the variable set, `main(undefined, …)` + // must use it rather than throw. Together the two tests pin the default's + // behavior in both environments instead of inheriting whichever one happens + // to be running. + const saved = process.env.GITHUB_REPOSITORY; + process.env.GITHUB_REPOSITORY = "env/repo"; + try { + const spawn = fakeSpawn({ + outdated: { + ".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } }, + }, + }); + captureLog(() => main(undefined, spawn)); + const create = ghCall(spawn, "create"); + assert.equal(create.args[create.args.indexOf("--repo") + 1], "env/repo"); + } finally { + if (saved === undefined) delete process.env.GITHUB_REPOSITORY; + else process.env.GITHUB_REPOSITORY = saved; + } +});