From 2504c5cac2318db95a9aac43b95a6ad7edb0fa8e Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Tue, 13 Oct 2026 08:41:58 +0300 Subject: [PATCH 1/2] Refuse a change that is both active and archived Pull request 883 put cli-is-visible-on-the-homepage back among the active changes after the base archived it, and the merge gate passed it, so the board drew a shipped change as unfinished. The gate now refuses such a change, naming it and its archived folder, and the one existing duplicate is removed. --- ...hange-is-never-both-active-and-archived.md | 6 ++ .../.openspec.yaml | 4 + .../design.md | 58 ++++++++++++++ .../proposal.md | 44 +++++++++++ .../specs/ci-cli/spec.md | 23 ++++++ .../tasks.md | 49 ++++++++++++ .../cli-is-visible-on-the-homepage/design.md | 50 ------------ .../proposal.md | 35 --------- .../specs/release-quality/spec.md | 41 ---------- .../cli-is-visible-on-the-homepage/tasks.md | 34 -------- packages/cli/src/main.ts | 3 + .../cli/src/openspec-validate.gate.test.ts | 46 +++++++++++ packages/cli/src/openspec-validate.ts | 18 ++++- packages/core/src/index.ts | 1 + packages/core/src/resurrected-changes.test.ts | 78 +++++++++++++++++++ packages/core/src/resurrected-changes.ts | 41 ++++++++++ 16 files changed, 370 insertions(+), 161 deletions(-) create mode 100644 .changeset/a-change-is-never-both-active-and-archived.md create mode 100644 openspec/changes/a-change-is-never-both-active-and-archived/.openspec.yaml create mode 100644 openspec/changes/a-change-is-never-both-active-and-archived/design.md create mode 100644 openspec/changes/a-change-is-never-both-active-and-archived/proposal.md create mode 100644 openspec/changes/a-change-is-never-both-active-and-archived/specs/ci-cli/spec.md create mode 100644 openspec/changes/a-change-is-never-both-active-and-archived/tasks.md delete mode 100644 openspec/changes/cli-is-visible-on-the-homepage/design.md delete mode 100644 openspec/changes/cli-is-visible-on-the-homepage/proposal.md delete mode 100644 openspec/changes/cli-is-visible-on-the-homepage/specs/release-quality/spec.md delete mode 100644 openspec/changes/cli-is-visible-on-the-homepage/tasks.md create mode 100644 packages/core/src/resurrected-changes.test.ts create mode 100644 packages/core/src/resurrected-changes.ts diff --git a/.changeset/a-change-is-never-both-active-and-archived.md b/.changeset/a-change-is-never-both-active-and-archived.md new file mode 100644 index 00000000..a82f0009 --- /dev/null +++ b/.changeset/a-change-is-never-both-active-and-archived.md @@ -0,0 +1,6 @@ +--- +"@openspec-ui/core": patch +"@openspec-ui/cli": patch +--- + +The merge gate refuses a change the pull request puts back among the active ones after its base archived it, so the board cannot show a shipped change as unfinished. diff --git a/openspec/changes/a-change-is-never-both-active-and-archived/.openspec.yaml b/openspec/changes/a-change-is-never-both-active-and-archived/.openspec.yaml new file mode 100644 index 00000000..78a7fa02 --- /dev/null +++ b/openspec/changes/a-change-is-never-both-active-and-archived/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-10-13 +follows: + - a-change-is-archived-with-nothing-open diff --git a/openspec/changes/a-change-is-never-both-active-and-archived/design.md b/openspec/changes/a-change-is-never-both-active-and-archived/design.md new file mode 100644 index 00000000..424e8637 --- /dev/null +++ b/openspec/changes/a-change-is-never-both-active-and-archived/design.md @@ -0,0 +1,58 @@ +# Design + +## Context + +The merge gate (`validate` with `--base `) already reads the base's +tree to compare people, histories and the archive. The duplicate of +`cli-is-visible-on-the-homepage` shows the missing case: nothing read the +base's *archived* names against the working tree's *active* names. ADR 0035 +and ADR 0036 make archiving a product action; the gate is the one moment a +pull request can undo it. + +## Goals / Non-Goals + +**Goals:** + +- A pull request that puts a change back after the base archived it is + refused, naming the change and where it was archived. +- The archive's own pull request keeps passing. +- The one existing duplicate is removed from `main`. + +**Non-Goals:** + +- No Pipeline change. The gate is where a pull request is judged; the board + only draws what the gate let through. + +## Decisions + +1. **In the gate, with the base.** The rule reads two listings the way + `readArchivedSince` reads the archive: the base's active names and + archived names, and the working tree's active names. Without `--base` + there is nothing to compare, and the rule says nothing. Rejected: a + Pipeline-time check — it would redraw cards but still let the bad pull + request land. +2. **Core computes; the CLI reports.** `readResurrectedChanges` returns the + change and its archived folder, and the CLI prints one `FAIL` line per + change. The `ok` flag folds them in, so the exit code is the contract it + already is. +3. **The archive is exempt.** A change the base still has active is not + named, even if it also appears in the base's archive under an older date: + archiving is exactly the pull request that has the active copy in the + commit before it moves it, and refusing it would jam the product's own + archive. Rejected: refusing both directions — it cannot tell the archive + apart from a resurrection. +4. **The date prefix is stripped before comparing.** An archive folder is + `-`; the active folder is ``. The same strip + `readArchivedSince` already relies on keeps the two keyed by one name. +5. **The stray copy goes with the rule.** The rule and its one existing + instance are the same defect: landing the rule while leaving the copy + would leave the board lying for one more cycle. + +## Risks / Trade-offs + +- A change archived under an older date and legitimately re-opened later + (a new proposal under the same name) is refused. The repository has no + such case; a change that must live again is given a new name, as its + history already does. Documented, not a defect. +- The comparison needs the base's tree, which the gate already fetches at + depth one; no new fetch. diff --git a/openspec/changes/a-change-is-never-both-active-and-archived/proposal.md b/openspec/changes/a-change-is-never-both-active-and-archived/proposal.md new file mode 100644 index 00000000..466330d3 --- /dev/null +++ b/openspec/changes/a-change-is-never-both-active-and-archived/proposal.md @@ -0,0 +1,44 @@ +# Proposal + +## Why + +On 2026-10-10 `cli-is-visible-on-the-homepage` was archived: pull request +#881 renamed its folder into `openspec/changes/archive/2026-10-10-…`. Eight +minutes later pull request #883 ("add 2 more changes") added the same change +back among the active ones, every task open, and the merge gate passed it. +The Pipeline then drew a change as unfinished that had already shipped. + +The merge gate already compares the people and the histories against the +base, and holds each archived change to the open-item rule; it had no rule +for the reverse — a change put back after its base archived it. A change +being both active and archived makes the board lie, and nothing mechanical +refused the one pull request that could do it. + +## What Changes + +- The merge gate, with `--base `, refuses every active change whose + name the base has archived, naming the change and its archived folder. +- The rule does not fire for the change a pull request is archiving: the + archive's own pull request holds the active copy in the commit before it + moves it, and the base still has it active. +- The one existing duplicate, `cli-is-visible-on-the-homepage`, is removed: + its archived copy is the source of truth. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `ci-cli`: the merge gate refuses a change that is both active and + archived. + +## Impact + +`packages/core` (`resurrected-changes.ts`), `packages/cli` +(`openspec-validate.ts`, `main.ts`), the merge-gate tests, and +`openspec/specs/ci-cli/spec.md`. No command or event of the protocol +changes; `--base` gains one more comparison in the report it already +prints. A patch for `@openspec-ui/core` and `@openspec-ui/cli`. diff --git a/openspec/changes/a-change-is-never-both-active-and-archived/specs/ci-cli/spec.md b/openspec/changes/a-change-is-never-both-active-and-archived/specs/ci-cli/spec.md new file mode 100644 index 00000000..15937b72 --- /dev/null +++ b/openspec/changes/a-change-is-never-both-active-and-archived/specs/ci-cli/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: The merge gate refuses a change that is both active and archived + +With `--base `, `validate` SHALL fail on every change the working tree +has active whose name the base has archived, naming the change and the +base's archived folder for it. A change the base still has active SHALL NOT +be named: the archive's own pull request holds the active copy in the commit +before it moves it. + +#### Scenario: A change put back after its base archived it + +- **WHEN** `validate --base origin/main` runs on a branch that has + `cli-is-visible-on-the-homepage` active and `origin/main` has + `2026-10-10-cli-is-visible-on-the-homepage` archived +- **THEN** the report fails, naming the change and that it is archived as + `2026-10-10-cli-is-visible-on-the-homepage` + +#### Scenario: The archive's own pull request + +- **WHEN** `validate --base origin/main` runs on the branch that archives + `landing`, and `origin/main` still has `landing` active +- **THEN** the change is not named for being both active and archived diff --git a/openspec/changes/a-change-is-never-both-active-and-archived/tasks.md b/openspec/changes/a-change-is-never-both-active-and-archived/tasks.md new file mode 100644 index 00000000..aa42069f --- /dev/null +++ b/openspec/changes/a-change-is-never-both-active-and-archived/tasks.md @@ -0,0 +1,49 @@ +From a defect found on 2026-10-10: pull request #883 put +`cli-is-visible-on-the-homepage` back among the active changes after the +base archived it, and the merge gate passed it. + +## 1. Core + +- [x] 1.1 `packages/core/src/resurrected-changes.ts`: `readResurrectedChanges` + returns each active change whose name the base has archived, with its + archived folder; it exempts a change the base still has active, strips the + date prefix, and throws where the base cannot be read. Exported from + `packages/core/src/index.ts`. +- [x] 1.2 `packages/core/src/resurrected-changes.test.ts`: names a change put + back, exempts the archive's own change, ignores a new change and a bare + older archive, reads a name containing a date, and throws on an unreadable + base. Verified to fail with the base-active exemption removed. + +## 2. The merge gate + +- [x] 2.1 `packages/cli/src/openspec-validate.ts`: `runValidateAll` reads + `readResurrectedChanges` when a base is given, folds the findings into + `resurrectedChanges` and into `ok`, and reuses `archiveCheckFailed` where + the base cannot be read. +- [x] 2.2 `packages/cli/src/main.ts`: the text report prints one `FAIL` line + per resurrected change, naming it and its archived folder. +- [x] 2.3 `packages/cli/src/openspec-validate.gate.test.ts`: the gate fails a + change put back, passes the archive's own change, and says nothing without + a base. Verified to fail when `ok` ignores the findings. + +## 3. The existing duplicate + +- [x] 3.1 `openspec/changes/cli-is-visible-on-the-homepage/` removed: the + active copy with every task open, already archived as + `2026-10-10-cli-is-visible-on-the-homepage`. + +## 4. Documents + +- [x] 4.1 `openspec/specs/ci-cli/spec.md` gains the requirement, carried by + this change's delta. +- [x] 4.2 A changeset: core, cli - patch. + +## 5. Checks + +- [x] 5.1 `npm run typecheck && npm run lint`, and the affected core and CLI + tests, with two workers. +- [x] 5.2 `openspec validate a-change-is-never-both-active-and-archived + --strict`. +- [x] 5.3 On the real #883 head tree and its base listings: the check names + `cli-is-visible-on-the-homepage`, archived as + `2026-10-10-cli-is-visible-on-the-homepage`. diff --git a/openspec/changes/cli-is-visible-on-the-homepage/design.md b/openspec/changes/cli-is-visible-on-the-homepage/design.md deleted file mode 100644 index 5be54e0b..00000000 --- a/openspec/changes/cli-is-visible-on-the-homepage/design.md +++ /dev/null @@ -1,50 +0,0 @@ -# Design - -## Context - -`MANIFEST_PRODUCTS` already includes `ci-cli`, but with `public: false`. -The homepage renders both sections from `manifest.public_products`. Its -reference schema is `OpenSpec-UI-Homepage/app/schemas/manifest.py`. - -## Goals - -Expose the CLI's package version, notes and npm destination to the site, -without altering the other four product identities or npm publishing. - -## Non-Goals - -GitHub CLI releases, download artifacts, a second CLI button, npm links for -private packages, implementation during proposal creation, and site deployment. - -## Decisions - -1. Preserve `ci-cli`; set `public: true` and a concise English summary. - Rejected: rename it to `cli`, which would split recorded release history. -2. Extend `ProductLinks` with optional `npm?: string`. Populate it only for - `ci-cli` with the package page URL above. The consumer adds the matching - optional field before production rollout. Keep `schema_version` at 1. - Rejected: use `marketplace` for npm, which confuses two distinct destinations; - rejected: version 2, which would make old consumers reject an additive change. -3. Keep the existing version fingerprint and webhook. The CLI minor changeset - ensures that the merged version commit changes the fingerprint and publishes - the visibility/link update. Do not claim that editing metadata alone triggers - publication. If rollout occurs before a version bump, it remains outstanding. - Rejected: widen fingerprint semantics for this one addition. -4. Versions still mean the version carried by the repository, not confirmed npm - registry availability. The npm button links to the package page, not an invented - version-specific artifact. npm publishing remains separately dispatched. - -## Risks / Trade-offs - -- An old homepage accepts but ignores `links.npm`; deploy its companion first. -- Until a version bump publishes a manifest and the site syncs, CLI stays hidden. -- A package version can precede npm publication; the card must not claim otherwise. -- All five products remain in the manifest; only Shared UI remains non-public. - -## Compatibility and Verification - -No core commands/events or server/webui/extension protocols change, so their -contract-test updates are not needed. Test public flags, stable identities, -CLI-only npm links, schema version, and serialization in the existing CLI suite. -Then validate a generated manifest with the homepage schema and verify the -signed-webhook/scheduled-sync rollout without dispatching an npm publication. diff --git a/openspec/changes/cli-is-visible-on-the-homepage/proposal.md b/openspec/changes/cli-is-visible-on-the-homepage/proposal.md deleted file mode 100644 index 64451c73..00000000 --- a/openspec/changes/cli-is-visible-on-the-homepage/proposal.md +++ /dev/null @@ -1,35 +0,0 @@ -# Proposal - -## Why - -ADR-0009 makes `@openspec-ui/cli` an installable npm product, but the release -manifest marks `ci-cli` as non-public. The official homepage therefore omits -its version from both Current versions and Current releases. - -## What Changes - -- Mark the existing `ci-cli` product public, preserving its identity and version source. -- Add a CLI summary and optional `links.npm`, pointing to - `https://www.npmjs.com/package/@openspec-ui/cli`; no other product gets an npm link. -- Retain manifest schema version 1: the new link is optional and additive. -- Deliver the updated manifest through the existing release path and signed webhook. -- Coordinate with `OpenSpec-UI-Homepage` change `cli-is-visible-on-the-homepage`, - which renders the fourth tile/card and its npm button in the existing style. - -## Capabilities - -### New Capabilities - -None. - -### Modified Capabilities - -- `release-quality`: the published manifest exposes CLI as public and supplies its npm destination. - -## Impact - -`packages/cli/src/release-manifest.ts`, its existing tests, and CLI documentation. -A minor changeset for `@openspec-ui/cli` is required at implementation time. -No GitHub CLI release, tarball, new CI publishing job, npm authentication fix, -or core command/event change is included. No architecture invariant changes; -ADR-0009 and ADR-0048 remain in force. This entry is planning only. diff --git a/openspec/changes/cli-is-visible-on-the-homepage/specs/release-quality/spec.md b/openspec/changes/cli-is-visible-on-the-homepage/specs/release-quality/spec.md deleted file mode 100644 index 1b6b0a71..00000000 --- a/openspec/changes/cli-is-visible-on-the-homepage/specs/release-quality/spec.md +++ /dev/null @@ -1,41 +0,0 @@ -# Release Quality Delta - -## ADDED Requirements - -### Requirement: The manifest exposes CLI as a public npm product - -The manifest SHALL preserve the stable `ci-cli` identity and derive its version -from `packages/cli/package.json`. It SHALL mark CLI public, include a summary, -and include `links.npm` equal to `https://www.npmjs.com/package/@openspec-ui/cli`. -No other product SHALL gain an npm link in this change. All five product entries -SHALL remain present; Shared UI SHALL remain non-public. - -#### Scenario: The CLI manifest is generated - -- **WHEN** `write manifest` builds a complete manifest -- **THEN** CLI is public with its own package version, summary and npm link -- **AND** all five stable product ids remain present - -#### Scenario: Existing products retain their destinations - -- **WHEN** the updated manifest is generated -- **THEN** extension, standalone, core and Shared UI have no new npm link -- **AND** existing release artifacts and links remain unchanged - -### Requirement: The CLI link is additive and delivered through the release path - -`links.npm` SHALL be optional and the manifest SHALL retain schema version 1. -The CLI minor version bump SHALL trigger the existing fingerprint-based manifest -publication and notification path. This change SHALL NOT create a GitHub CLI -release or change separately dispatched npm publishing. - -#### Scenario: An older consumer receives the npm link - -- **WHEN** a schema-1 consumer that ignores unknown fields reads the new manifest -- **THEN** it accepts the manifest without requiring a schema-version upgrade - -#### Scenario: The CLI version bump lands - -- **WHEN** the change's CLI version bump lands on main -- **THEN** the version fingerprint changes and the existing release path publishes - CLI visibility and its npm destination for the homepage to consume diff --git a/openspec/changes/cli-is-visible-on-the-homepage/tasks.md b/openspec/changes/cli-is-visible-on-the-homepage/tasks.md deleted file mode 100644 index 7f3a2b66..00000000 --- a/openspec/changes/cli-is-visible-on-the-homepage/tasks.md +++ /dev/null @@ -1,34 +0,0 @@ -# Tasks - -## 1. Manifest Producer - -- [ ] 1.1 In `packages/cli/src/release-manifest.ts`, set only `ci-cli.public` - to true and add its summary; preserve all five ids and package version sources. -- [ ] 1.2 In `packages/cli/src/release-manifest.ts`, add optional `ProductLinks.npm` - and populate only the CLI destination `https://www.npmjs.com/package/@openspec-ui/cli`. - Keep schema version 1 and do not invent a GitHub release or artifact. -- [ ] 1.3 In `packages/cli/src/release-manifest.test.ts`, assert four public products, - only Shared UI private, CLI summary/npm URL, other products without npm links, - all five stable ids, schema 1, and unchanged attachment behavior. -- [ ] 1.4 In `packages/cli/src/release-manifest.test.ts`, exercise `write manifest` - serialization so `public`, version and `links.npm` reach the emitted JSON. -- [ ] 1.5 In `packages/cli/README.md`, document CLI public manifest metadata and - the distinction between the repository version and separately dispatched npm publication. -- [ ] 1.6 Add `.changeset/cli-is-visible-on-the-homepage.md` proposing a minor - `@openspec-ui/cli` bump. Do not edit package versions directly. - -## 2. Verification and Rollout - -- [ ] 2.1 Run the focused `packages/cli/src/release-manifest.test.ts` suite using - the pinned runtime; record counts and command output here. -- [ ] 2.2 Run root `npm run typecheck`, `npm run lint` and `npm run test` unpiped - with the pinned runtime; record results and package counts here. -- [ ] 2.3 Validate this change with `openspec validate cli-is-visible-on-the-homepage --strict`. -- [ ] 2.4 **Delegated to copilot-cli.** Generate `releases.json` using the exact - `write manifest` CI invocation and validate it with the companion homepage's - `app/schemas/manifest.py`; record both commands and evidence for `ci-cli.public` - and `ci-cli.links.npm`. Do not modify the homepage's production snapshot manually. -- [ ] 2.5 **Human-only.** After companion deployment and the CLI version bump, - confirm that `.github/workflows/quality.yml` publishes the new manifest and the - site's sync consumes it. Record run id, manifest commit and observed CLI version. - A metadata-only merge with an unchanged fingerprint does not satisfy this item. diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 75864712..c4fa712f 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -497,6 +497,9 @@ function formatText(result: ValidateAllResult): string { for (const problem of result.historyProblems ?? []) { lines.push(`FAIL ${problem.file}: ${problem.problem}`); } + for (const one of result.resurrectedChanges ?? []) { + lines.push(`FAIL ${one.changeName} is active, and the base archived it as ${one.archivedAs}`); + } lines.push(result.ok ? "\nAll changes valid." : "\nOne or more changes failed validation."); return lines.join("\n"); } diff --git a/packages/cli/src/openspec-validate.gate.test.ts b/packages/cli/src/openspec-validate.gate.test.ts index e5389e85..3abff178 100644 --- a/packages/cli/src/openspec-validate.gate.test.ts +++ b/packages/cli/src/openspec-validate.gate.test.ts @@ -190,3 +190,49 @@ describe("the people", () => { }]); }); }); + +// a-change-is-never-both-active-and-archived. Pull request 883 put two plans +// back among the active changes after the base had archived them, and the +// gate passed it. +describe("a change that is active and archived", () => { + const gitWith = (active: string[], archived: string[]) => ({ + listTreeNames: async (_ref: string, tree: string) => (tree.endsWith("/archive") ? archived : tree.startsWith("openspec/changes") ? ["archive", ...active] : []), + listFilesUnder: async () => [], + showFile: async () => undefined, + }); + + it("fails a change the base archived and this pull request has active again, and names both", async () => { + const root = await workspace({ "the-change": CLOSED, "put-back": CLOSED }); + + const result = await runValidateAll(root, { + change: "the-change", + archivedSince: "origin/main", + git: gitWith(["the-change"], ["2026-10-10-put-back"]), + ...seams, + }); + + expect(result.ok).toBe(false); + expect(result.resurrectedChanges).toEqual([{ changeName: "put-back", archivedAs: "2026-10-10-put-back" }]); + }); + + it("passes a change the base still has active, which this pull request archives", async () => { + const root = await workspace({ "landing": CLOSED }); + + const result = await runValidateAll(root, { + change: "landing", + archivedSince: "origin/main", + git: gitWith(["landing"], ["2026-10-10-landing"]), + ...seams, + }); + + expect(result.resurrectedChanges).toBeUndefined(); + }); + + it("says nothing where no base was given", async () => { + const root = await workspace({ "put-back": CLOSED }); + + const result = await runValidateAll(root, { change: "put-back", ...seams }); + + expect(result.resurrectedChanges).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/openspec-validate.ts b/packages/cli/src/openspec-validate.ts index c3a86501..adadcabc 100644 --- a/packages/cli/src/openspec-validate.ts +++ b/packages/cli/src/openspec-validate.ts @@ -14,8 +14,10 @@ import { readArchivedSince, readPeople, readPeopleAt, + readResurrectedChanges, type HistoryProblem, type PeopleProblem, + type ResurrectedChange, readTaskChecklist, validateChange, type GitWrapper, @@ -59,6 +61,9 @@ export interface ValidateAllResult { * or changed, and a file this pull request adds that does not check out * or breaks a hand-over rule (a-change-keeps-its-history, ADR 0037). */ historyProblems?: HistoryProblem[]; + /** Changes this pull request has active although the base already archived + * them, where a base was given (a-change-is-never-both-active-and-archived). */ + resurrectedChanges?: ResurrectedChange[]; } /** The two calls that spawn the `openspec` CLI. Seams, because the job @@ -182,11 +187,21 @@ export async function runValidateAll( historyProblems = [{ file: "openspec/changes", problem: `the histories could not be checked: ${error instanceof Error ? error.message : String(error)}` }]; } + const resurrected: ResurrectedChange[] = []; + if (options.archivedSince !== undefined) { + try { + resurrected.push(...await readResurrectedChanges(cwd, options.archivedSince, options.git ?? createGitWrapper({ cwd }))); + } catch (error) { + archiveCheckFailed ??= error instanceof Error ? error.message : String(error); + } + } + const ok = results.every((result) => result.valid) && (archived === undefined || archived.length === 0) && archiveCheckFailed === undefined && peopleProblems.length === 0 - && historyProblems.length === 0; + && historyProblems.length === 0 + && resurrected.length === 0; return { ok, results, @@ -194,5 +209,6 @@ export async function runValidateAll( ...(archiveCheckFailed !== undefined ? { archiveCheckFailed } : {}), ...(peopleProblems.length > 0 ? { peopleProblems } : {}), ...(historyProblems.length > 0 ? { historyProblems } : {}), + ...(resurrected.length > 0 ? { resurrectedChanges: resurrected } : {}), }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a6e123a3..1893f9df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from "./change-state.js"; export * from "./change-standing.js"; export * from "./deferred-items.js"; export * from "./archived-since.js"; +export * from "./resurrected-changes.js"; export * from "./branch-rebase.js"; export { readsArchivesAsFiles, removeTree } from "./plain-fs.js"; export { sweepWorkspace, describeWorkspaceSweep, createArchiveFollower, sweepsAgainSoon, type ArchiveFollower, type WorkspaceSweep } from "./workspace-sweep.js"; diff --git a/packages/core/src/resurrected-changes.test.ts b/packages/core/src/resurrected-changes.test.ts new file mode 100644 index 00000000..d1ec85bc --- /dev/null +++ b/packages/core/src/resurrected-changes.test.ts @@ -0,0 +1,78 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readResurrectedChanges } from "./resurrected-changes.js"; + +// a-change-is-never-both-active-and-archived. The base is a listing, so no git +// is started: only the two directories the base lists are supplied. + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }).catch(() => undefined))); +}); + +async function checkout(active: string[]): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "resurrected-")); + roots.push(root); + await mkdir(path.join(root, "openspec", "changes", "archive"), { recursive: true }); + for (const name of active) await mkdir(path.join(root, "openspec", "changes", name), { recursive: true }); + return root; +} + +function base(active: string[], archived: string[]) { + return { + listTreeNames: async (_ref: string, directory: string) => (directory.endsWith("/archive") ? archived : ["archive", ...active]), + }; +} + +describe("readResurrectedChanges", () => { + it("names a change the base archived and this tree has active again", async () => { + const root = await checkout(["cli-is-visible", "other-work"]); + + const found = await readResurrectedChanges(root, "origin/main", base(["something-else"], ["2026-10-10-cli-is-visible"])); + + expect(found).toEqual([{ changeName: "cli-is-visible", archivedAs: "2026-10-10-cli-is-visible" }]); + }); + + it("does not name a change the base still has active: archiving it is what the pull request is for", async () => { + // The base has "landing" active; this tree still holds it active while also carrying its archive, + // as a pull request does in the commit before it removes the active copy. + const root = await checkout(["landing"]); + + const found = await readResurrectedChanges(root, "origin/main", base(["landing"], ["2026-10-10-landing"])); + + expect(found).toEqual([]); + }); + + it("does not name a new change, or one only an older archive resembles", async () => { + const root = await checkout(["brand-new", "cli"]); + + const found = await readResurrectedChanges(root, "origin/main", base([], ["2026-10-10-cli-is-visible"])); + + expect(found).toEqual([]); + }); + + it("reads the change's own name, not a date inside it", async () => { + const root = await checkout(["2026-extra"]); + + const found = await readResurrectedChanges(root, "origin/main", base([], ["2026-10-10-2026-extra"])); + + expect(found).toEqual([{ changeName: "2026-extra", archivedAs: "2026-10-10-2026-extra" }]); + }); + + it("names nothing where this checkout has no changes directory", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "resurrected-empty-")); + roots.push(root); + + expect(await readResurrectedChanges(root, "origin/main", base([], ["2026-10-10-x"]))).toEqual([]); + }); + + it("fails where the base cannot be read, rather than finding nothing", async () => { + const root = await checkout(["x"]); + const broken = { listTreeNames: async () => { throw new Error("bad ref"); } }; + + await expect(readResurrectedChanges(root, "origin/nope", broken)).rejects.toThrow("bad ref"); + }); +}); diff --git a/packages/core/src/resurrected-changes.ts b/packages/core/src/resurrected-changes.ts new file mode 100644 index 00000000..bc308a51 --- /dev/null +++ b/packages/core/src/resurrected-changes.ts @@ -0,0 +1,41 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import type { GitWrapper } from "./git.js"; + +const CHANGES = "openspec/changes"; +const ARCHIVE = `${CHANGES}/archive`; +const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/u; + +/** A change this pull request puts back among the active ones although the base already archived it. */ +export interface ResurrectedChange { + changeName: string; + /** The base's archive directory, `-`. */ + archivedAs: string; +} + +/** The active changes here that the base does not have active but does have archived. + * A change the base still has active is not named: archiving it is what such a pull request is for. + * Throws where `ref` cannot be read: a comparison that could not be made found nothing out. */ +export async function readResurrectedChanges( + workspaceRoot: string, + ref: string, + git: Pick, +): Promise { + const activeOnBase = new Set((await git.listTreeNames(ref, CHANGES)).filter((name) => name !== "archive")); + const archivedOnBase = new Map(); + for (const name of await git.listTreeNames(ref, ARCHIVE)) archivedOnBase.set(name.replace(DATE_PREFIX, ""), name); + + let here: string[]; + try { + here = (await readdir(path.join(workspaceRoot, CHANGES), { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== "archive") + .map((entry) => entry.name); + } catch { + return []; + } + + return here + .filter((name) => !activeOnBase.has(name) && archivedOnBase.has(name)) + .sort() + .map((name) => ({ changeName: name, archivedAs: archivedOnBase.get(name) as string })); +} From 1ba16963dd0607be21570295e52a48f6be7072ed Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Tue, 13 Oct 2026 08:56:42 +0300 Subject: [PATCH 2/2] State the time budget the resurrected-changes test's temporary files cost --- packages/core/src/resurrected-changes.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/resurrected-changes.test.ts b/packages/core/src/resurrected-changes.test.ts index d1ec85bc..49149052 100644 --- a/packages/core/src/resurrected-changes.test.ts +++ b/packages/core/src/resurrected-changes.test.ts @@ -1,12 +1,16 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { readResurrectedChanges } from "./resurrected-changes.js"; // a-change-is-never-both-active-and-archived. The base is a listing, so no git // is started: only the two directories the base lists are supplied. +// every-varying-check-has-a-budget: temporary files only, no process. +// Measured 2026-10-13 at under 1 s for the file on this machine. +vi.setConfig({ testTimeout: 15_000 }); + const roots: string[] = []; afterEach(async () => {