From 96f6cacb206c65bee30066f6a1f4e9b855a0d783 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:44:00 +1000 Subject: [PATCH 01/19] chore: add changeset for stores beta and config JSON parsing (#1267) * Add changeset for stores beta and config JSON parsing * Remove leaked tool-wrapper lines from changeset --- .changeset/stores-beta-config-parse.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/stores-beta-config-parse.md diff --git a/.changeset/stores-beta-config-parse.md b/.changeset/stores-beta-config-parse.md new file mode 100644 index 0000000000..94d53cd7e8 --- /dev/null +++ b/.changeset/stores-beta-config-parse.md @@ -0,0 +1,11 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features + +- **Stores (very early beta)** — Introduces stores as a simpler way to organize specs and changes, replacing the workspace and initiative model. This feature is in very early beta — expect rough edges and breaking changes in upcoming releases. + +### Bug Fixes + +- **Config parsing** — Configuration values wrapped in JSON containers are now parsed correctly. From 546224e00db26bd1be69874be465d5d6f5e4a851 Mon Sep 17 00:00:00 2001 From: "openspec-release-bot[bot]" <254190582+openspec-release-bot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:02:43 +1000 Subject: [PATCH 02/19] Version Packages (#1248) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/escape-yaml-carriage-return.md | 7 ------- .changeset/stores-beta-config-parse.md | 11 ----------- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) delete mode 100644 .changeset/escape-yaml-carriage-return.md delete mode 100644 .changeset/stores-beta-config-parse.md diff --git a/.changeset/escape-yaml-carriage-return.md b/.changeset/escape-yaml-carriage-return.md deleted file mode 100644 index 23594df892..0000000000 --- a/.changeset/escape-yaml-carriage-return.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@fission-ai/openspec": patch ---- - -fix(adapters): escape carriage returns in generated YAML frontmatter - -`escapeYamlValue` flagged `\r` as a character requiring quoting but never escaped it, leaving a literal carriage return inside the double-quoted scalar where YAML line folding/normalization could silently corrupt the value (realistic with CRLF-authored command descriptions). Carriage returns are now escaped as `\r`. The helper — previously duplicated verbatim across five adapters (bob, claude, cursor, pi, windsurf) — is extracted into a shared `command-generation/yaml.ts` module so the behavior stays consistent and is fixed in one place. diff --git a/.changeset/stores-beta-config-parse.md b/.changeset/stores-beta-config-parse.md deleted file mode 100644 index 94d53cd7e8..0000000000 --- a/.changeset/stores-beta-config-parse.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@fission-ai/openspec": minor ---- - -### New Features - -- **Stores (very early beta)** — Introduces stores as a simpler way to organize specs and changes, replacing the workspace and initiative model. This feature is in very early beta — expect rough edges and breaking changes in upcoming releases. - -### Bug Fixes - -- **Config parsing** — Configuration values wrapped in JSON containers are now parsed correctly. diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d858446b..64d822913f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # @fission-ai/openspec +## 1.5.0 + +### Minor Changes + +- [#1267](https://github.com/Fission-AI/OpenSpec/pull/1267) [`96f6cac`](https://github.com/Fission-AI/OpenSpec/commit/96f6cacb206c65bee30066f6a1f4e9b855a0d783) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features + + - **Stores (very early beta)** — Introduces stores as a simpler way to organize specs and changes, replacing the workspace and initiative model. This feature is in very early beta — expect rough edges and breaking changes in upcoming releases. + + ### Bug Fixes + + - **Config parsing** — Configuration values wrapped in JSON containers are now parsed correctly. + +### Patch Changes + +- [#1240](https://github.com/Fission-AI/OpenSpec/pull/1240) [`cbf386b`](https://github.com/Fission-AI/OpenSpec/commit/cbf386bd6888f103f8ff7d59b3eab98ce5b57998) Thanks [@zied-jlassi](https://github.com/zied-jlassi)! - fix(adapters): escape carriage returns in generated YAML frontmatter + + `escapeYamlValue` flagged `\r` as a character requiring quoting but never escaped it, leaving a literal carriage return inside the double-quoted scalar where YAML line folding/normalization could silently corrupt the value (realistic with CRLF-authored command descriptions). Carriage returns are now escaped as `\r`. The helper — previously duplicated verbatim across five adapters (bob, claude, cursor, pi, windsurf) — is extracted into a shared `command-generation/yaml.ts` module so the behavior stays consistent and is fixed in one place. + ## 1.4.1 ### Patch Changes diff --git a/package.json b/package.json index f1b61ebb86..a4420a24f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fission-ai/openspec", - "version": "1.4.1", + "version": "1.5.0", "description": "AI-native system for spec-driven development", "keywords": [ "openspec", From a3253051ea1934fd0d76620addb855dfce801742 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 3 Jul 2026 03:00:44 -0500 Subject: [PATCH 03/19] fix(resolution): converge validate, view, and archive onto canonical resolution (#1182, #1202, #1156) (#1280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(openspec): propose resolution/validation parity bug bundle (#1182, #1202, #1156) Planning artifacts only (proposal/design/spec deltas/tasks) for a focused bug-fix bundle. Three read/validate paths silently diverge from the canonical logic a sibling command already gets right: - #1182 validate ignores workspace planning homes that status/instructions resolve - #1202 view counts only changes//tasks.md, ignoring the schema tasks glob - #1156 the SHALL/MUST body-keyword hint fires for deltas but not main specs Fix converges each divergent path onto the canonical one; parity is asserted by test. No new surface, no behavior change to the already-correct paths. Validates --strict. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): bulletproof the parity bundle after adversarial source review Hardened all three bugs after tracing each path to source with parallel verification agents. Material corrections: - #1182: reframed from "workspace planning home resolution" (planning homes are repo-only; the feature is now 'stores', and validate already accepts --store) to the real, reproducible-at-HEAD mechanism: validate's proposal.md membership gate (getActiveChangeIds) vs status/instructions' directory-existence rule (validateChangeExists). Pulled nested specs// delta discovery and bulk --all into scope; noted show.ts sibling. - #1202: widened from view-only to the shared helper's real blast radius — also the archive incomplete-task gate (silently archives unfinished glob-tasks changes: data safety) and a 2nd hardcoded copy in change.ts. Pinned apply.tracks as the source, change-dir scope containment, and the no-schema fallback. Added cli-archive delta for the gate. - #1156: the main-spec parser discards the requirement header before Zod runs, so the hint can't be "lifted" — fix needs header recovery (reuse requirement-blocks) + Zod de-dup, and the main-spec message can't be byte-identical to the delta's (no ADDED prefix). Pinned the actionable sentence + single-emission + regression scenarios across all main-spec surfaces. 4 deltas (cli-validate x2, cli-view, cli-archive). Validates --strict. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): deep-harden the parity bundle with empirical reproduction Round 2 of bulletproofing: 3 parallel agents reproduced every bug against the built (pre-fix) CLI and traced fix sites. This pass corrected two substantive errors in my own prior spec and closed several gaps. #1202 (two corrections to the prior draft): - apply.tracks is a FILENAME that selects the tracked artifact, NOT a glob; the glob is that artifact's `generates`. status resolves via resolveArtifactOutputs(changeDir, artifact.generates). Fixed all wording. - "view/archive counts equal status" is FALSE: status checks file EXISTENCE, not checkboxes (proven: status calls a 3/5 change isComplete:true). Deleted the two count-parity scenarios; reframed as resolution-mechanism parity (same files). - Added schema-resolution-failure fallback (resolveSchema throws; helper must catch or view/list/archive crash). Added projectRoot param + 6-site wiring. - Empirically PROVEN data-safety bug: archive moved a 3/5 unfinished change into changes/archive/. #1182: - Found a THIRD getActiveChangeIds site (interactive selector, validate.ts:97). - Proven: --all with a lone proposal-less change exits 0 silently. Added exit-code scenarios. Trimmed over-scope: getSpecIds spec-side is NOT a bug; no store-specific scenario needed; noun-form scoped out. #1156: - Refine-relaxation regression resolved: deltas don't use the Zod refine (validate imperatively), so REMOVE it (not relax) once applySpecRules owns both header-only and no-keyword cases. Added RENAMED (out-of-scope), lowercase, and the new no-body-line-valid-today scenarios; pinned exact message + prefix. Still 4 deltas; validates --strict; empirical evidence section added to design. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: converge validate/view/archive onto canonical resolution (#1182, #1202, #1156) Implements the resolution/validation parity bug bundle planned in openspec/changes/fix-validate-view-resolution-parity. Each fix points a divergent read/validate path at the canonical implementation a sibling command already gets right, with parity tests guarding against re-forking. #1182 — validate resolves changes like status. validate now resolves a change by directory existence (shared getAvailableChanges) instead of requiring proposal.md, at all three sites (targeted, bulk, interactive selector). A scaffolded/still-authoring change is validated rather than reported Unknown item; a resolved-but-invalid change exits non-zero. show.ts and the deprecated noun-form change validate are scoped out. #1182b — validateChangeDeltaSpecs recurses the nested multi-area layout (specs///spec.md) via a new findDeltaSpecFiles walker, so a resolved multi-area change validates its deltas instead of reporting "No delta sections found". #1202 — getTaskProgressForChange resolves task progress through the tracked-tasks artifact's generates glob (the same resolveArtifactOutputs status uses), aggregating checkboxes across every matched tasks.md scoped to the change dir, with a never-throw fallback to a single top-level tasks.md. Updates all four callers (view/list/archive x2) for the new projectRoot arg and folds the second copy in change.ts onto the helper. Fixes view's Draft misclassification and the archive incomplete-task gate that let an unfinished glob-tasks change archive (data safety). #1156 — the SHALL/MUST body-keyword hint applies to main specs. applySpecRules recovers the requirement header via extractRequirementsSection and emits the targeted hint (header-only) or generic message (no keyword), exactly once; the Zod refine is removed (deltas never used it). The actionable sentence is byte-identical to the change-delta path. Adds parity/regression tests (Decision 7): validate<->status resolution incl. exit code, view/archive resolve the same files as status, and the main-spec<->delta actionable-sentence parity. Full suite green (1791 passed; only the pre-existing, environment-specific zsh-installer failures remain). Change validates --strict; all 36 repo specs pass --specs --strict with no new false positives. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../fix-validate-view-resolution-parity.md | 9 + .../.openspec.yaml | 2 + .../design.md | 93 ++++++++++ .../proposal.md | 56 ++++++ .../specs/cli-archive/spec.md | 32 ++++ .../specs/cli-validate/spec.md | 113 ++++++++++++ .../specs/cli-view/spec.md | 58 ++++++ .../tasks.md | 46 +++++ src/commands/change.ts | 53 +----- src/commands/validate.ts | 21 ++- src/core/archive.ts | 4 +- src/core/list.ts | 2 +- src/core/schemas/base.schema.ts | 12 +- src/core/validation/validator.ts | 73 ++++++-- src/core/view.ts | 2 +- src/utils/task-progress.ts | 76 +++++++- test/commands/validate.test.ts | 53 ++++++ test/core/archive.test.ts | 44 +++++ test/core/validation.test.ts | 145 ++++++++++++++- test/core/view.test.ts | 49 +++++ test/utils/task-progress.test.ts | 168 ++++++++++++++++++ 21 files changed, 1033 insertions(+), 78 deletions(-) create mode 100644 .changeset/fix-validate-view-resolution-parity.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml create mode 100644 openspec/changes/fix-validate-view-resolution-parity/design.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/proposal.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/tasks.md create mode 100644 test/utils/task-progress.test.ts diff --git a/.changeset/fix-validate-view-resolution-parity.md b/.changeset/fix-validate-view-resolution-parity.md new file mode 100644 index 0000000000..974055a1fe --- /dev/null +++ b/.changeset/fix-validate-view-resolution-parity.md @@ -0,0 +1,9 @@ +--- +"@fission-ai/openspec": patch +--- + +### Bug Fixes + +- **`validate` resolves changes like `status`** — `openspec validate ` (and `--all`/`--changes` and the interactive selector) now resolves a change by directory existence, matching `status`/`instructions`, instead of requiring `proposal.md`. A scaffolded or still-authoring change is validated rather than reported as `Unknown item`, and a resolved-but-invalid change now exits non-zero. Delta discovery also recurses the nested `specs///spec.md` layout. (#1182) +- **Task progress reads nested/glob `tasks.md`** — `openspec view`, `list`, and the `archive` incomplete-task gate now resolve task progress through the tracked-tasks artifact's `generates` glob (the same file-resolution `status` uses), so a change whose tasks live in nested `tasks.md` files is classified correctly and can no longer archive while unfinished. (#1202) +- **SHALL/MUST body-keyword hint applies to main specs** — A main-spec requirement whose normative keyword sits only in the `### Requirement:` header now receives the same targeted "move it to the body line" remediation as a change delta, emitted exactly once. (#1156) diff --git a/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml b/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/fix-validate-view-resolution-parity/design.md b/openspec/changes/fix-validate-view-resolution-parity/design.md new file mode 100644 index 0000000000..93ef6618b1 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/design.md @@ -0,0 +1,93 @@ +# Design + +## Context + +This is a bug-fix bundle, not a feature. The three issues are grouped because they share one structural defect: a read/validate command forks its own resolution or validation logic instead of reusing the canonical implementation a sibling command already gets right. Fixing them together lets the implementation converge the divergent paths onto shared helpers in one pass, and lets one set of *parity tests* guard all three against future drift. + +The unifying invariant this change establishes: + +> A command that reports on or validates a change MUST resolve files the same way `openspec status` does, and MUST produce the same requirement-quality messages the change-delta validator does. Divergence is a bug, and parity is asserted by test. + +Every claim below was verified against source at the base commit `546224e` **and reproduced empirically against the built (pre-fix) CLI**. The reproductions are summarized under "Empirical evidence." The framings that changed under this review are flagged inline; two of them (the #1202 `apply.tracks` mechanism and the "agrees with status" count claim) were corrections to an earlier draft of this very proposal. + +## Root causes (verified) + +| # | Symptom | Canonical path (correct) | Divergent path (bug) | Anchor | +|---|---------|--------------------------|----------------------|--------| +| #1182 | `validate ` → `Unknown item`; `--all` → "No items found" | `status`/`instructions` resolve by **directory existence** (`validateChangeExists`) | `validate` resolves via `getActiveChangeIds`, which **requires `proposal.md`** | `src/commands/validate.ts:97,120,238`; `src/utils/item-discovery.ts:11-16`; `src/commands/workflow/shared.ts:168-170`; scaffolder omits proposal.md `src/utils/change-utils.ts:121-210` | +| #1182b | resolved nested-layout change → "No delta sections found" | spec-driven specs glob is `specs/**/*.md` | `validateChangeDeltaSpecs` discovers deltas one level deep only | `src/core/validation/validator.ts:115-138,265` | +| #1202 | `view` shows a tasked change as `Draft`; `archive` archives an unfinished change | `status` tests the tasks **artifact's `generates` glob** via `resolveArtifactOutputs` | `getTaskProgressForChange` hardcodes `changes//tasks.md` | `src/utils/task-progress.ts:28`; callers `src/core/view.ts:100`, `src/core/list.ts:112`, `src/core/archive.ts:342,540`; 2nd copy `src/commands/change.ts:111,164`; helper `src/core/artifact-graph/outputs.ts:17`; tracks type `src/core/artifact-graph/types.ts:18` | +| #1156 | Main-spec SHALL-in-header-only → generic error; delta → targeted hint | Delta validator runs `containsShallOrMust` + `buildMissingShallOrMustMessage` | Main-spec requirement validation falls through to generic `REQUIREMENT_NO_SHALL`; header is discarded before validation | `src/core/validation/validator.ts:167-189,443-463`; `src/core/schemas/base.schema.ts:11-14`; `src/core/parsers/markdown-parser.ts:220-226` | + +## Empirical evidence (built pre-fix CLI, fresh `init`'d projects) + +- **#1182:** `new change foo` writes `changes/foo/.openspec.yaml` only. `status --change foo` resolves (exit 0); `validate foo` → `Unknown item 'foo'` (exit 1); `validate --all` with foo as the sole change → `No items found to validate` (**exit 0**, a silent CI failure). Writing `proposal.md` flips `validate` to resolve — confirming the exact lever. A valid two-level `specs///spec.md` change → `No deltas found` (#1182b); one-level control validates clean. +- **#1202:** project-local schema with tasks `generates: "**/tasks.md"`; change `foo` = `backend/tasks.md` (2/2) + `frontend/tasks.md` (1/3) = 3/5, no top-level file. `status` → `4/4 artifacts complete, isComplete:true` (file existence, not checkboxes); `view` → **Draft**; `list` → `No tasks`; `list --json` → `totalTasks:0`. `archive foo --skip-specs --no-validate --yes` **moved the unfinished change into `changes/archive/`** — the incomplete-task gate was wholly bypassed. Baselines (default schema top-level `tasks.md`; bare project) classify correctly and are preserved by the fix. +- **#1156:** main spec, SHALL in header only → generic `Requirement must contain SHALL or MUST keyword`. The same mistake as an ADDED/MODIFIED delta → the targeted hint. RENAMED delta → no error (no body). No-keyword-anywhere main spec → generic error. Lowercase `shall` → error on both paths. **Header-only with no body line at all → reported VALID today** (parser keeps `text` = header, which contains SHALL). + +## Decisions + +### Decision 1 — Converge, don't re-implement + +Each fix points the divergent path at the *existing* canonical implementation rather than writing a second copy. A second copy is what created every one of these bugs. + +### Decision 2 — #1182: the lever is the membership gate, not "workspace homes" + +The original framing (validate doesn't understand workspace planning homes) is wrong at HEAD: planning homes are repo-only (`PlanningHomeKind = 'repo'`), the workspace feature is now **stores**, and `validate` already accepts `--store` and resolves the store root through the same `resolveRootForCommand` as `status`. The actual, reproducible divergence is that `validate` gates change membership on `proposal.md` (`getActiveChangeIds`) at **three** sites — targeted (validate.ts:120), bulk (validate.ts:238), and the interactive "pick one" selector (validate.ts:97) — while `status`/`instructions` gate on directory existence (`validateChangeExists`). Since `createChange` writes `.openspec.yaml` but not `proposal.md`, any scaffolded or still-authoring change resolves everywhere except `validate`. + +The fix: `validate` resolves a change by directory existence within the already-resolved root, at all three sites. This is store-correct for free (the store root is resolved identically by all three commands), so the reported store/workspace symptom is covered transitively, and no store-specific scenario is needed. `getChangeDir`/`resolveCurrentPlanningHomeSync` are **not** the lever (the former is a pure path join with no membership decision). + +Two boundaries confirmed empirically and held out of scope: (a) the **spec** side is correct — `getSpecIds` requires `spec.md`, and `spec show` agrees, so a spec dir without `spec.md` is correctly "not found"; no spec-side scenario is added. (b) The deprecated noun-form `openspec change validate ` already resolves a passed name by directory existence (change.ts:215) but is cwd-based (cannot reach a `--store` root) and its JSON mode does not set a non-zero exit on invalid — pre-existing noun-form defects, explicitly not addressed here. + +### Decision 3 — #1182: nested delta discovery is in scope + +Resolution success is not validation success. `validateChangeDeltaSpecs` discovers deltas exactly one directory deep (`changeDir/specs//spec.md`), but the multi-area layout that motivates stores/workspaces is `changeDir/specs///spec.md`. Without recursing, a resolved multi-area change reports "No delta sections found" (reproduced). So delta discovery is extended to the nested layout in this change; otherwise the #1182 fix does not actually let the reported change validate. + +### Decision 4 — #1202: resolve via the tracked artifact's `generates` glob, and parity is resolution-only + +The fix lands in the shared helper `getTaskProgressForChange`, correcting all four call sites at once; the spec pins two consumers explicitly (`cli-view` — the filed Draft symptom; `cli-archive` — the incomplete-task gate, a data-safety regression that lets an unfinished change archive). `openspec list` is corrected by the same helper; the independent second copy in `openspec change list` (`change.ts:111,164`, its own `countTasks`) is folded onto the shared helper by a task — not left as an orphan. + +Two corrections to an earlier draft, both load-bearing: + +- **`apply.tracks` is a filename that *selects* the artifact; it is not the glob.** `apply.tracks` is typed `string | null` and is consumed elsewhere as a literal path (`path.join(changeDir, tracks)` + `existsSync`), so `apply.tracks: "**/tasks.md"` cannot match nested files. The glob `status` actually uses is the tracked artifact's **`generates`**, resolved by `resolveArtifactOutputs(changeDir, artifact.generates)`. So the fix identifies the tracked-tasks artifact (the artifact whose `generates` equals `apply.tracks`, falling back to artifact id `tasks` when no `apply` block is present), then counts checkboxes across `resolveArtifactOutputs(changeDir, thatArtifact.generates)`. `resolveArtifactOutputs` roots `fast-glob` at the change directory (so a sibling `changes/archive/` or another change's `tasks.md` cannot match) and de-dups via a `Set` (so no double counting). +- **`status` checks file *existence*, not checkbox completion.** Empirically `status` calls a 3/5 change `4/4 complete, isComplete:true`. So the parity established here is **resolution-mechanism parity** (`view`/`archive` resolve the same set of files `status` resolves), not count parity — `view`/`archive` additionally count checkboxes. Any "view/archive task counts equal status" claim is false and is removed from the spec. + +The signature gains `projectRoot` (needed to resolve project-local schemas via `resolveSchema`); all four call sites plus the two `change.ts` sites can derive it. `resolveSchema` **throws** on an unresolvable/misnamed schema, whereas the current helper never throws — so the helper MUST catch and fall back to single-file `tasks.md`, or `view`/`list`/`archive` would crash on a project whose config names a deleted schema. This fallback is specified and tested. + +### Decision 5 — #1156: recover the header, remove the refine, pin an exact (not byte-identical) message + +The targeted delta hint works because the delta parser keeps the requirement header (`RequirementBlock.name`) separate from the body. The **main-spec parser overwrites the header with the first body line** (`markdown-parser.ts:220-226`) before validation, so the Zod refine that emits `REQUIREMENT_NO_SHALL` never sees the header and cannot detect "keyword in header only." + +The fix: + +1. **Recover the header.** Reuse the header-preserving parser `src/core/parsers/requirement-blocks.ts` (`extractRequirementsSection`, which yields header+body pairs and is the same source the delta path trusts) and run the existing `containsShallOrMust` + `buildMissingShallOrMustMessage` detection in the imperative main-spec rules (`applySpecRules`, validator.ts:290-329), which already loops requirements and has the raw content. +2. **Remove the Zod refine, don't merely relax it.** Change deltas do **not** use the refine — they validate imperatively in `validateChangeDeltaSpecs` (proven: a no-keyword delta emits the imperative `must contain SHALL or MUST` base string, not the Zod `REQUIREMENT_NO_SHALL` string). So the refine is exercised only on the main-spec path. Once the imperative rule in `applySpecRules` owns **both** sub-cases — keyword-in-header-only → targeted hint, and keyword-nowhere → generic message — the `.refine` on `RequirementSchema` (base.schema.ts:11-14) is **removed entirely**. Keeping a conditional refine "for the no-keyword case only" risks double-emission on the header-only case, which the "exactly one issue" scenario forbids. +3. **Message.** The actionable sentence is byte-identical to the delta path; the prefix differs (main specs have no `ADDED`/`MODIFIED`). The main-spec message is: `Requirement "" must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.` Generalize `buildMissingShallOrMustMessage` to accept the prefix so the actionable sentence lives in one place and cannot drift between paths. Lowercase is rejected via the shared `\b(SHALL|MUST)\b` regex (converging the main-spec path off the case-sensitive Zod `.includes`). + +RENAMED requirements carry no body and are not subject to the hint (the `'ADDED' | 'MODIFIED'` action set is correct); a scenario pins this so it is not mistaken for a gap. + +### Decision 6 — Additive coverage, with one intended behavior change called out + +The fixes are additive coverage: changes that already have `proposal.md`, single-file `tasks.md` projects, projects with no resolvable schema, and delta-spec validation produce byte-identical output before and after. One main-spec case is an **intended** behavior change, not an unchanged case: a requirement with the keyword in the header and **no body line at all** is reported valid today and becomes a body-keyword hint under header recovery (the delta path already errors on this case). This is called out explicitly so it is not discovered as an accidental regression; every other previously-passing case is unchanged. + +### Decision 7 — Parity is the test strategy + +Tests assert *agreement*, not just fixed outputs in isolation: + +- a change that `status --change ` resolves (including a proposal-less and a store change) is also resolved by `validate `, included by `validate --all`, and listed by the interactive selector; a resolved-but-invalid change exits non-zero; +- for a schema whose tracked-tasks `generates` is `**/tasks.md`, `view`, `list`, and the `archive` gate resolve the **same set of files** `status` resolves (and additionally count checkboxes consistently with each other); +- a requirement with SHALL/MUST in the header only yields the same actionable sentence whether it appears in `openspec/specs/**` or a change delta, emitted exactly once. + +Parity assertions fail loudly if a future refactor re-forks any path. + +### Decision 8 — Scope boundary against sibling proposals + +- #1112 (delta header absent from base passing `validate`, aborting at `archive`) is an *authoring* false-positive resolved by the deterministic `sync --check` gate in the sync/unarchive proposal — out of scope here. +- Artifact *completeness* gaps (a half-written or skipped artifact reported as done, #1084/#1260) belong to the artifact-graph/update-workflow proposal — out of scope here. #1202 is narrower: *where* task counts are read from, not whether the tasks are complete. + +## Risks and mitigations + +- **Risk:** relaxing the change membership gate changes ambiguity behavior when a name exists as both a change directory and a spec. **Mitigation:** preserve the existing ambiguity/`--type` semantics; only swap the change-membership predicate (proposal.md → directory existence) at all three sites, keeping `getSpecIds` as the spec predicate. Covered by an ambiguity scenario. +- **Risk:** the task-progress signature change breaks the other call sites, or crashes on an unresolvable schema. **Mitigation:** update all six sites (four helper callers + two `change.ts` copies) in the same change; catch `resolveSchema` failure and fall back to single-file `tasks.md`; assert `view`/`archive` resolve the same files as `status`. +- **Risk:** the glob over-matches or the archive gate regresses. **Mitigation:** reuse `resolveArtifactOutputs` (rooted at the change dir, de-duped); add scope-containment and archive-gate scenarios. +- **Risk:** removing the Zod refine drops the no-keyword error on the main-spec path. **Mitigation:** the imperative `applySpecRules` rule must own the no-keyword case before the refine is removed; assert the no-keyword regression and single emission. Delta validation is untouched (it never used the refine). diff --git a/openspec/changes/fix-validate-view-resolution-parity/proposal.md b/openspec/changes/fix-validate-view-resolution-parity/proposal.md new file mode 100644 index 0000000000..d42af8dbb3 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/proposal.md @@ -0,0 +1,56 @@ +## Why + +Three commands silently give a wrong or incomplete answer about the spec source of truth, because sibling read/validate paths reimplement narrower logic than the canonical path each should share. + +- `openspec validate ` rejects a change as `Unknown item` whenever `proposal.md` is absent (a scaffolded or still-authoring change, in a repo or a store), though `status`/`instructions` resolve it by directory existence — so spec checks are skipped for the changes most likely to be malformed (#1182). +- `openspec view` labels a fully-tasked change `Draft` when its tasks live in nested/glob `tasks.md` files, contradicting `status`; the same blind spot lets `archive` silently archive an unfinished change (#1202). +- `openspec validate` gives the targeted "move SHALL/MUST onto the body line" hint for deltas, but only the generic message for the same mistake in a main spec (#1156). + +Each is deterministic and fixed by converging a divergent path onto the canonical one. + +## Background: one root cause, three commands + +OpenSpec sells one promise — the specs are the source of truth and the CLI tells you the truth about them. These three bugs break that promise the same way: a command that *reads* or *validates* state quietly forks its own resolution logic instead of reusing the canonical implementation a sibling command already gets right. The fork is invisible until the two paths disagree, and then the tool reports a confident falsehood (`Unknown item`, `Draft`, a clean archive of an unfinished change, a worse error message) with no signal that anything diverged. + +This proposal was hardened by tracing each path to source (anchors in `design.md`). Two framings changed during that review and are called out so reviewers can check them: + +- **#1182 is a membership-gate bug, not a "home" bug.** Planning homes are repo-only today (`PlanningHomeKind = 'repo'`); the "managed workspace planning home" from the 1.4.1 issue is the feature since renamed **stores**, and `validate` already accepts `--store`. The real divergence is narrower and reproducible at HEAD: `status`/`instructions` resolve a change by **directory existence** (`validateChangeExists`), while `validate` resolves it through `getActiveChangeIds`, which **requires `proposal.md`**. `createChange` does not write `proposal.md`, so a scaffolded change — including a store change still being authored — resolves everywhere except `validate`. Sharing the canonical resolution covers the reported store/workspace symptom transitively, because the store root is already resolved identically by all three commands. +- **#1202 is wider than `view`.** The buggy helper `getTaskProgressForChange` is consumed by `view`, `list`, and the `archive` incomplete-task gate. The `archive` case is a correctness/data-safety risk, not a cosmetic mislabel: under a glob-tasks schema it reads zero tasks, finds nothing incomplete, and archives a change whose work is not done. A second, independent hardcoded copy lives in `openspec change list`. + +## What Changes + +- **`validate` shares the canonical change-resolution rule (#1182).** `openspec validate ` resolves a change by directory existence — the same rule `status`/`instructions` use — instead of requiring `proposal.md`. This applies to targeted `validate `, bulk `validate --all`/`--changes`, **and** the interactive "pick one" selector, within both the repo root and a `--store`-selected root. Spec/change ambiguity handling and `--type` overrides are preserved. Delta discovery is extended to the nested `specs///spec.md` layout so a resolved multi-area change actually validates its deltas instead of reporting "no deltas found." +- **`view`/`archive`/`list` resolve tasks through the tracked-tasks artifact glob (#1202).** Task progress for a change is resolved through the tracked-tasks artifact's `generates` glob — the same file-resolution `status` uses — counting every matching `tasks.md` scoped to the change directory, with the single-file `tasks.md` and no-resolvable-schema cases preserved as today. (The tracked artifact is selected via `apply.tracks`, which is a filename, not a glob; the glob is that artifact's `generates`.) As a result `view`'s Draft/Active/Completed classification stops being blind to nested files, and `archive`'s incomplete-task gate no longer passes an unfinished glob-tasks change. The second hardcoded copy in `openspec change list` is folded onto the same shared resolution. Because `status` checks task-file *existence* (not checkboxes), the guarantee is that these commands resolve the *same files* `status` resolves — not that they reproduce a count `status` does not compute. +- **The SHALL/MUST body-keyword hint applies to main specs (#1156).** A main-spec requirement whose normative keyword sits only in the `### Requirement:` header receives the same targeted "move it to the body line" remediation as a change delta, instead of the generic message — emitted exactly once (no duplicate generic error), across every main-spec surface (`validate `, `--all`, JSON, `spec validate`, and rebuilt-spec validation). + +### What this deliberately does *not* change + +- The canonical paths (`status`, `instructions`, the delta-spec validator) are not changed in behavior — the divergent paths are moved onto them. +- No new command, flag, schema field, or output format. Existing JSON shapes are preserved; only the values they carry become correct. +- Resolution for changes that already have `proposal.md`, single-file `tasks.md` projects, projects with no resolvable schema, and delta-spec validation are byte-for-byte unchanged — these fixes only add coverage where a path was previously blind. +- It does not address the #1112 authoring false-positive (a delta MODIFIED/REMOVED header absent from the base spec passing `validate`, aborting at `archive`); that is handled by the deterministic `sync --check` gate in the separate sync/unarchive proposal. The overlap is intentionally avoided. +- It does not change artifact *completeness* semantics (whether a half-written artifact counts as done, #1084/#1260); #1202 here is strictly about *where* task counts are read from, not whether the tasks are complete. + +## Capabilities + +### Modified Capabilities + +- `cli-validate`: resolves a change by directory existence (matching `status`/`instructions`) for targeted, bulk, and interactive-selector validation in repo and store roots; discovers deltas under nested `specs/**` layouts; and emits the targeted SHALL/MUST body-keyword hint for main specs, once, across all surfaces. +- `cli-view`: resolves task progress through the tracked-tasks artifact's `generates` glob (the same file-resolution `status` uses), so Draft/Active/Completed classification stops being blind to nested `tasks.md` files. +- `cli-archive`: the incomplete-task gate reads task progress through the same tracked-tasks resolution, so a glob-tasks change with unfinished work cannot pass the gate. + +## Impact + +- **Affected specs:** `cli-validate` (2 added requirements), `cli-view` (1 added requirement), `cli-archive` (1 added requirement). +- **Affected code (implementation follow-up, not in this planning PR):** + - `src/commands/validate.ts` — replace the `getActiveChangeIds` membership gate with directory-existence resolution mirroring `validateChangeExists` (`src/commands/workflow/shared.ts:168-170`) at all three sites: targeted (line 120), bulk (line 238), interactive selector (line 97). Reconcile with `getSpecIds` for the change/spec ambiguity path (leave `getSpecIds` unchanged — it is correct). Sibling `src/commands/show.ts:81,115,121` shares the gate and should be folded in or explicitly scoped out; the deprecated noun-form `change validate` is out of scope. + - `src/core/validation/validator.ts` — extend delta discovery (`validateChangeDeltaSpecs`, lines 115-138) to recurse the nested `specs///spec.md` layout. + - `src/utils/task-progress.ts` — `getTaskProgressForChange` gains a `projectRoot` param, identifies the tracked-tasks artifact (artifact whose `generates` equals the schema `apply.tracks`, fallback id `tasks`), counts checkboxes across `resolveArtifactOutputs(changeDir, artifact.generates)` (`src/core/artifact-graph/outputs.ts:17`, de-duped, change-rooted). `apply.tracks` selects the artifact; the glob is its `generates`. Catch `resolveSchema` failure → fall back to single-file `tasks.md` (never throw). Update all four call sites (`src/core/view.ts:100`, `src/core/list.ts:112`, `src/core/archive.ts:342`, `:540`) for the new arg; fold the second copy in `src/commands/change.ts:111,164` onto the helper. + - `src/core/validation/validator.ts` + `src/core/parsers/requirement-blocks.ts` — recover the requirement header (lost at `markdown-parser.ts:220-226`) via `extractRequirementsSection` so the main-spec rule in `applySpecRules` can detect "keyword in header only" and emit the targeted hint via a prefix-generalized `buildMissingShallOrMustMessage` (lines 443-463); **remove** the Zod refine (`src/core/schemas/base.schema.ts:11-14`) once the imperative rule owns both the header-only and no-keyword cases (deltas validate imperatively and never used the refine, so removal cannot regress them). +- **Risk:** low-to-moderate. Each fix points a command at logic that already exists for the canonical path; the larger surface is the task-progress signature change (six sites incl. schema-failure fallback) and the validator header recovery. Regression risk is bounded by parity tests asserting `validate`/`view`/`archive`/the main-spec validator agree with their canonical counterparts, plus explicit no-regression scenarios for the unchanged cases. + +## Issues addressed + +- [#1182](https://github.com/Fission-AI/OpenSpec/issues/1182) — `openspec validate` cannot resolve a change that `status`/`instructions` resolve (reported for a managed workspace/store home; root cause is the `proposal.md` membership gate). +- [#1202](https://github.com/Fission-AI/OpenSpec/issues/1202) — `openspec view` does not detect nested/glob `tasks.md`, classifying complete changes as `Draft` (and the same helper silently weakens the `archive` incomplete-task gate). +- [#1156](https://github.com/Fission-AI/OpenSpec/issues/1156) — the 1.4.0 SHALL/MUST body-keyword hint applies to change deltas but not main specs. diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md new file mode 100644 index 0000000000..f6bb55dfa2 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Archive incomplete-task gate SHALL use the tracked-tasks artifact glob + +`openspec archive`'s incomplete-task gate — the check that prevents archiving a change whose tasks are not all complete — SHALL read task progress through the change's tracked-tasks artifact glob, the same file-resolution `openspec status` and `openspec view` use, rather than a fixed `changes//tasks.md` path. The tracked-tasks artifact SHALL be identified as the artifact whose `generates` equals the schema's `apply.tracks` value, falling back to the artifact with id `tasks` when no `apply` block is present; checkbox counts SHALL be aggregated across every file matched by that artifact's `generates` glob, scoped to the change directory. When the schema cannot be resolved or no tracked-tasks artifact is found, the gate SHALL fall back to a single top-level `tasks.md` exactly as today and SHALL NOT crash. This closes the data-safety gap where a change whose tasks live in nested/glob `tasks.md` files is read as having zero tasks, no incomplete work, and is allowed to archive while unfinished. + +#### Scenario: Glob-tasks change with unfinished work cannot archive + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **AND** a change with `backend/tasks.md` containing unchecked tasks and no top-level `tasks.md` +- **WHEN** running `openspec archive` on that change +- **THEN** the incomplete-task gate SHALL detect the unfinished tasks and block (or require explicit override of) the archive +- **AND** SHALL NOT treat the change as having zero tasks + +#### Scenario: Archive gate resolves the same tracked files as view + +- **GIVEN** any change with a tracked-tasks glob +- **WHEN** the `archive` incomplete-task gate and `openspec view` each compute task progress for that change +- **THEN** they SHALL resolve the same set of `tasks.md` files and count the same checkboxes + +#### Scenario: Unresolvable schema falls back without error + +- **GIVEN** a change whose configured schema cannot be resolved +- **WHEN** running `openspec archive` on that change +- **THEN** the incomplete-task gate SHALL fall back to a single top-level `tasks.md` +- **AND** SHALL NOT crash + +#### Scenario: Single top-level tasks file archiving is unchanged + +- **GIVEN** a change with a single top-level `changes//tasks.md`, or a project with no resolvable schema +- **WHEN** running `openspec archive` +- **THEN** the incomplete-task gate SHALL behave exactly as today diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md new file mode 100644 index 0000000000..84c85fa6ef --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Validate SHALL resolve changes by directory existence, matching status + +`openspec validate` SHALL resolve whether a named item is a change using the same rule `openspec status` and `openspec instructions` use — directory existence within the resolved root — rather than requiring a `proposal.md` to be present. This SHALL apply to targeted validation (`openspec validate `), bulk validation (`openspec validate --all` / `--changes`), and the interactive "pick one" selector shown when no item is given in a TTY — within both the repository root and a `--store`-selected root. A resolved change with a nested multi-area spec layout SHALL have its deltas discovered and validated. Spec/change ambiguity handling and `--type` overrides SHALL remain unchanged. The spec-resolution side (a spec is resolved by the presence of its `spec.md`) is correct today and SHALL be left unchanged. + +#### Scenario: Scaffolded change without proposal.md + +- **GIVEN** a change directory created by `openspec new change ` that has not yet had `proposal.md` written +- **WHEN** executing `openspec validate ` +- **THEN** validate resolves the change and validates it +- **AND** it SHALL NOT print `Unknown item ''` + +#### Scenario: Targeted-resolution parity with status + +- **GIVEN** any change that `openspec status --change ` resolves, including a change in a `--store`-selected root +- **WHEN** executing `openspec validate ` (passing the same `--store` when applicable) +- **THEN** validate SHALL resolve the same change that status resolved, and SHALL NOT report it as unknown + +#### Scenario: Bulk validation includes a sole proposal-less change + +- **GIVEN** a repository whose only active change lacks `proposal.md` and is listed by `openspec status` +- **WHEN** executing `openspec validate --all` (or `--changes`) +- **THEN** validate SHALL validate that change, and SHALL NOT print "No items found to validate" +- **AND** the exit status SHALL reflect the change's validity + +#### Scenario: Interactive selector lists proposal-less changes + +- **GIVEN** a TTY and a change directory without `proposal.md` that `openspec status` lists +- **WHEN** executing `openspec validate` with no item name +- **THEN** the interactive "pick one" selector SHALL include that change + +#### Scenario: Resolved-but-invalid change exits non-zero + +- **GIVEN** a change that resolves by directory existence but fails validation +- **WHEN** executing `openspec validate ` or `openspec validate --all` +- **THEN** validate SHALL exit with a non-zero status +- **AND** SHALL NOT exit 0 while reporting the change as having issues + +#### Scenario: Nested multi-area delta discovery + +- **GIVEN** a resolved change whose deltas live at `specs///spec.md` (nested deeper than one directory) +- **WHEN** validating that change +- **THEN** validate SHALL discover and validate those delta specs +- **AND** SHALL NOT report "No delta sections found" for a change that does contain deltas + +#### Scenario: Change/spec ambiguity is preserved + +- **GIVEN** a name that exists both as a change directory and as a spec +- **WHEN** executing `openspec validate ` +- **THEN** validate SHALL print the ambiguity error and respect `--type change` / `--type spec`, exactly as before + +#### Scenario: Changes with proposal.md are unaffected + +- **GIVEN** a change that already contains `proposal.md` +- **WHEN** validating it targeted or in bulk +- **THEN** resolution and validation behavior SHALL be byte-for-byte unchanged from today + +### Requirement: SHALL/MUST body-keyword hint SHALL apply to main specs + +When a requirement places the normative keyword (SHALL or MUST) only in its `### Requirement:` header and omits it from the requirement body line, `openspec validate` SHALL emit the same targeted remediation guidance for main specs under `openspec/specs/**` as it already does for change delta specs, instead of the generic "must contain SHALL or MUST" message. The targeted message SHALL be emitted exactly once for such a requirement, the generic `REQUIREMENT_NO_SHALL` message SHALL no longer be emitted on the main-spec path, and the behavior SHALL be uniform across every main-spec validation surface (`openspec validate `, `--all`, JSON output, `openspec spec validate`, and rebuilt-spec validation via `validateSpecContent`). The main-spec message's actionable sentence SHALL be byte-identical to the change-delta message; only the leading prefix differs (main specs have no `ADDED`/`MODIFIED` action). + +#### Scenario: Main spec with the keyword in the header only + +- **GIVEN** a main spec requirement whose header contains SHALL or MUST but whose body line omits it +- **WHEN** running `openspec validate` over that spec +- **THEN** the error message SHALL contain the actionable sentence: "must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the \"### Requirement: ...\" header." +- **AND** SHALL NOT be the generic "Requirement must contain SHALL or MUST keyword" message + +#### Scenario: Actionable-sentence parity with change deltas + +- **GIVEN** the identical header-only-keyword mistake authored once in a main spec and once in a change delta +- **WHEN** validating each +- **THEN** the actionable remediation sentence SHALL be byte-identical between the two (the change-delta `ADDED`/`MODIFIED` prefix is not required for the main-spec message) + +#### Scenario: Exactly one issue is emitted + +- **GIVEN** a main spec requirement with the keyword in the header only +- **WHEN** validating it +- **THEN** validate SHALL emit exactly one issue for the missing body keyword +- **AND** SHALL NOT emit both the generic message and the targeted message for the same requirement + +#### Scenario: Requirement missing the keyword entirely still errors + +- **GIVEN** a main spec requirement that contains no SHALL or MUST in either the header or the body +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL report that the requirement must contain SHALL or MUST, as it does today + +#### Scenario: Keyword present in the body is not flagged + +- **GIVEN** a main spec requirement whose body line contains SHALL or MUST (whether or not the header also does) +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL NOT raise a missing-keyword error for that requirement + +#### Scenario: Lowercase keyword does not satisfy the body requirement + +- **GIVEN** a main spec requirement whose only "shall"/"must" is lowercase +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL report a missing-keyword error, matching the change-delta behavior for the same lowercase mistake + +#### Scenario: Header keyword with no body line emits the hint + +- **GIVEN** a main spec requirement whose header contains SHALL or MUST and that has no body line before its first scenario +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL emit the body-keyword hint (the keyword is only in the header) +- **AND** this case, which is reported valid today, becomes a deliberate, additive validation improvement + +#### Scenario: Renamed requirements are not subject to the body-keyword hint + +- **GIVEN** a change delta `## RENAMED Requirements` whose TO header contains SHALL or MUST +- **WHEN** validating that change +- **THEN** validate SHALL NOT emit the body-keyword hint for the renamed pair +- **AND** RENAMED validation behavior SHALL be byte-for-byte unchanged diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md new file mode 100644 index 0000000000..de664bf8b9 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Task progress SHALL be resolved through the tracked-tasks artifact glob + +`openspec view` SHALL determine a change's task progress by resolving its tracked-tasks artifact and counting checkboxes across that artifact's output glob (`generates`) — the same file-resolution `openspec status` uses to detect the tasks artifact — rather than assuming a fixed `changes//tasks.md` path. The tracked-tasks artifact SHALL be identified as the artifact whose `generates` equals the schema's `apply.tracks` value, falling back to the artifact with id `tasks` when no `apply` block is present. (`apply.tracks` is a filename that selects the artifact; the glob is that artifact's `generates`.) Resolution SHALL be scoped to the change directory, SHALL aggregate completed and total checkbox counts across every matching file, and SHALL NOT double-count. When the schema cannot be resolved, no tracked-tasks artifact is found, or the glob matches no file, `view` SHALL fall back to counting a single top-level `tasks.md` exactly as today, and SHALL NOT raise an error. + +Note on scope: `openspec status` detects whether the tasks artifact *file exists*; it does not count checkboxes (a change whose nested `tasks.md` files exist is reported by `status` as having the tasks artifact complete even when boxes are unchecked). The parity established here is therefore **resolution-mechanism parity** — `view` resolves the same set of `tasks.md` files `status` resolves — and `view` additionally counts checkboxes within them. The fix removes `view`'s blindness to nested files; it does not make `view` agree with a task count `status` does not produce. + +#### Scenario: Nested tasks files under a glob schema + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **AND** a change with `backend/tasks.md` and `frontend/tasks.md` and no top-level `tasks.md` +- **WHEN** running `openspec view` +- **THEN** the change SHALL show aggregated task progress summed across both files +- **AND** SHALL NOT be classified as a Draft change solely because no top-level `tasks.md` exists + +#### Scenario: Tracked-tasks files resolve the same as status + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **WHEN** running `openspec view` and `openspec status --change ` +- **THEN** both SHALL resolve the same set of `tasks.md` files for the change — `status` to detect the tasks artifact, `view` to count checkboxes within them + +#### Scenario: Files exist but tasks unchecked are not Completed + +- **GIVEN** a glob-tasks change whose matched `tasks.md` files contain unchecked boxes +- **WHEN** running `openspec view` +- **THEN** the change SHALL be classified Active (not Completed), even though `status` reports the tasks artifact as present + +#### Scenario: Tracked-tasks artifact identified by apply.tracks, not a fixed id + +- **GIVEN** a custom schema whose tracked-tasks artifact is not named `tasks` but is selected by `apply.tracks` +- **WHEN** running `openspec view` +- **THEN** task progress SHALL be resolved from that artifact's `generates` glob + +#### Scenario: Resolution stays scoped to the change directory + +- **WHEN** resolving a change's `tasks.md` files +- **THEN** matching SHALL be rooted at `changes//` only +- **AND** SHALL NOT count `tasks.md` files belonging to another change or under `changes/archive/` + +#### Scenario: Unresolvable schema falls back without error + +- **GIVEN** a change whose configured schema cannot be resolved (for example, the config names a missing schema) +- **WHEN** running `openspec view` +- **THEN** task progress SHALL fall back to counting a single top-level `tasks.md` +- **AND** `view` SHALL NOT crash + +#### Scenario: Single top-level tasks file is unchanged + +- **GIVEN** a change with exactly one top-level `changes//tasks.md`, or a project with no resolvable schema +- **WHEN** running `openspec view` +- **THEN** task progress SHALL be counted from that single file exactly as before + +#### Scenario: A change with no tasks anywhere stays Draft + +- **GIVEN** a change with no `tasks.md` matching the tracked-tasks glob +- **WHEN** running `openspec view` +- **THEN** the change SHALL report zero tasks and be classified as Draft, as today diff --git a/openspec/changes/fix-validate-view-resolution-parity/tasks.md b/openspec/changes/fix-validate-view-resolution-parity/tasks.md new file mode 100644 index 0000000000..ff105ed4b1 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/tasks.md @@ -0,0 +1,46 @@ +# Tasks + +## 1. #1182 — validate resolves changes like status (membership gate) + +- [x] 1.1 Reproduce at HEAD: `openspec new change X` (creates dir + `.openspec.yaml`, no `proposal.md`); confirm `status --change X` resolves it (exit 0) but `validate X` prints `Unknown item` and `validate --all` (X alone) prints "No items found" and exits 0. +- [x] 1.2 In `src/commands/validate.ts`, replace the `getActiveChangeIds` membership gate for change resolution with directory-existence resolution mirroring `validateChangeExists` (`src/commands/workflow/shared.ts:168-170`); keep `getSpecIds` as the spec predicate. Apply at all THREE sites: targeted (line 120), bulk `--all`/`--changes` (line 238), and the interactive "pick one" selector (line 97). _Converged onto the canonical `getAvailableChanges` lister via a private `listChangeIds` helper (sorted to preserve prior ordering)._ +- [x] 1.3 Confirm correctness within a `--store`-selected root (resolution already shares `resolveRootForCommand`); add a store-root resolution test. No store-specific scenario beyond parity is required. _Store-correct for free: `validate` resolves the store root through the same `resolveRootForCommand` as `status`, and the change predicate now matches; no dedicated store fixture added, per the design note._ +- [x] 1.4 Preserve change/spec ambiguity and `--type` override behavior; reconcile the directory-existence change predicate with the spec predicate. Leave the spec-resolution side (`getSpecIds`) unchanged — it is correct. _`getSpecIds` untouched; ambiguity test still green._ +- [x] 1.5 Sibling `src/commands/show.ts:81,115,121` shares the `getActiveChangeIds` gate — fold it onto the same resolution or record an explicit out-of-scope note. Add a one-line scope note that the deprecated noun-form `change validate` already resolves by directory existence but is cwd-based and its JSON mode does not set a non-zero exit (pre-existing, out of scope). _DECISION: `show.ts` scoped OUT. `ChangeCommand.show` hard-requires `proposal.md` (throws "not found at .../proposal.md"), so folding it in would only convert "Unknown item" into a different downstream proposal-read error in a path no `cli-*` spec scenario covers. The deprecated noun-form `change validate` is likewise out of scope (cwd-based; JSON mode does not set a non-zero exit)._ +- [x] 1.6 Tests: proposal-less change resolves (targeted + bulk + interactive selector); store change resolves; ambiguity/`--type` unchanged; changes with `proposal.md` byte-identical; a resolved-but-invalid change exits non-zero (regression guard for the `--all` exit-0 observation). _Added to `test/commands/validate.test.ts`: scaffolded resolves (targeted), sole proposal-less change in `--all`, resolved-but-invalid exits non-zero. Interactive selector uses the same `listChangeIds`._ + +## 2. #1182b — nested multi-area delta discovery + +- [x] 2.1 Reproduce: a resolved change with deltas at `specs///spec.md` reports "No delta sections found"; one-level `specs//spec.md` is the control. +- [x] 2.2 Extend delta discovery in `src/core/validation/validator.ts` `validateChangeDeltaSpecs` (lines 115-138) to recurse the nested `specs/**` layout (the spec-driven specs glob is `specs/**/*.md`). _Added a recursive `findDeltaSpecFiles` walker collecting every `spec.md`; `entryPath` is now the POSIX relative path from `specs/`._ +- [x] 2.3 Tests: nested-layout change discovers and validates its deltas; single-level layout unchanged. _Added to `test/core/validation.test.ts`._ + +## 3. #1202 — task progress through the tracked-tasks artifact glob (view + archive + list) + +- [x] 3.1 Reproduce: project-local schema with tasks artifact `generates: "**/tasks.md"`; a change with `backend/tasks.md` + `frontend/tasks.md` (some unchecked); confirm `status` reports the tasks artifact present while `view` shows `Draft`, `list` shows "No tasks", and `archive` would let it archive unfinished. +- [x] 3.2 In `src/utils/task-progress.ts`, change `getTaskProgressForChange` to: identify the tracked-tasks artifact (the artifact whose `generates` equals the schema `apply.tracks` value, falling back to artifact id `tasks` when no `apply` block), then count checkboxes across `resolveArtifactOutputs(changeDir, artifact.generates)` (`src/core/artifact-graph/outputs.ts:17`, returns a de-duped, change-rooted path list). NOTE: `apply.tracks` is a filename that selects the artifact, NOT a glob — the glob is the artifact's `generates`. +- [x] 3.3 Add a required `projectRoot` parameter (needed for `resolveSchema` / project-local schemas); resolve schema → tracked artifact → `generates` inside the helper. +- [x] 3.4 Catch `resolveSchema` failure (it throws on an unresolvable/misnamed schema) and fall back to a single top-level `tasks.md`; preserve the no-schema / no-tracked-artifact / zero-match fallback and the swallowed-missing-file behavior. The helper MUST NOT throw. +- [x] 3.5 Update all four call sites for the new `projectRoot` argument: `src/core/view.ts:100` (`path.dirname(openspecDir)`), `src/core/list.ts:112` (`targetPath`), `src/core/archive.ts:342` and `:540` (`path.resolve(changesDir,'..','..')`). +- [x] 3.6 Fold the independent second copy in `src/commands/change.ts:111,164` (its own `countTasks`, JSON list + long list) onto the shared helper passing `process.cwd()`; drop the now-orphan `countTasks` and unused `TASK_PATTERN`/`COMPLETED_TASK_PATTERN` consts. +- [x] 3.7 Tests: nested-glob change aggregates and is not `Draft`; files-exist-but-unchecked is Active not Completed; `apply.tracks`-selected artifact resolves; resolution scoped to the change dir (archive/ and sibling changes excluded); no double-count; unresolvable-schema falls back without crashing; single-file and no-schema unchanged; zero-match stays Draft; `view`/`list`/`archive` resolve the same files as `status`. _`test/utils/task-progress.test.ts` (unit) + `test/core/view.test.ts` (Active classification) + `test/core/archive.test.ts` (gate)._ + +## 4. #1202 — archive incomplete-task gate (data safety) + +- [x] 4.1 Confirm `src/core/archive.ts:342,540` feed the incomplete-task gate (`archive.ts:348-353`). +- [x] 4.2 With the shared-helper fix in place, verify the gate sees nested/glob tasks (the empirical repro archived a 3/5 change — this must now block). _Verified end-to-end against the built CLI: `archive` now reports "2 incomplete task(s)" and exits non-zero for a 3/5 glob-tasks change._ +- [x] 4.3 Tests: a glob-tasks change with unchecked tasks is blocked (or requires explicit override); the gate resolves the same files as `view`; unresolvable-schema falls back without crash; single-file behavior unchanged. _Added to `test/core/archive.test.ts`; helper-level fallback/parity covered in `test/utils/task-progress.test.ts`._ + +## 5. #1156 — SHALL/MUST hint on main specs (header recovery + remove refine) + +- [x] 5.1 Reproduce: a main spec requirement with SHALL/MUST in the header only emits the generic message while the equivalent ADDED/MODIFIED delta emits the targeted hint; a RENAMED delta emits no hint; a header-only-no-body main spec is valid today. +- [x] 5.2 Recover the requirement header for main specs (lost at `src/core/parsers/markdown-parser.ts:220-226`) by reusing `src/core/parsers/requirement-blocks.ts` (`extractRequirementsSection`, header+body pairs). +- [x] 5.3 In `src/core/validation/validator.ts` `applySpecRules` (lines 290-329), run `containsShallOrMust` + `buildMissingShallOrMustMessage` on the recovered header/body so the imperative rule owns BOTH the header-only case (targeted hint) and the no-keyword-anywhere case (generic message). +- [x] 5.4 REMOVE the Zod refine from `RequirementSchema` (`src/core/schemas/base.schema.ts:11-14`) entirely (not merely relax it) — deltas never used it (they validate imperatively in `validateChangeDeltaSpecs`), so removal cannot regress the delta path, and it prevents double-emission on the main-spec path. +- [x] 5.5 Generalize `buildMissingShallOrMustMessage` to accept a prefix; main-spec prefix = `Requirement ""`, so the actionable sentence stays in one place and is byte-identical across paths. Converge lowercase handling onto the shared `\b(SHALL|MUST)\b` regex. Keep the delta-path message string unchanged. _Delta call sites now pass `ADDED ""` / `MODIFIED ""` prefixes, producing byte-identical strings._ +- [x] 5.6 Tests (assert across `validate `, `--all`, `--json`, `spec validate`, and `validateSpecContent`): header-only main spec → actionable sentence byte-identical to delta; exactly one issue; no-keyword-anywhere still errors; body-keyword not flagged; lowercase `shall` errors; header-only-no-body emits the hint (intended change); RENAMED emits no hint and is byte-for-byte unchanged. _Added a `main-spec SHALL/MUST body-keyword hint (#1156)` describe in `test/core/validation.test.ts` driving `validateSpecContent` (the shared surface for `validate`/`--all`/`--json`/`spec validate`/rebuilt-spec validation); the obsolete schema-refine unit test was updated to reflect the moved enforcement. End-to-end cases A–D verified against the built CLI._ + +## 6. Parity guard and verification + +- [x] 6.1 Add the cross-command parity assertions from design Decision 7 as regression tests (validate↔status resolution incl. exit code; view/list/archive resolve the same files as status; main-spec↔delta actionable sentence). +- [x] 6.2 Run `openspec validate fix-validate-view-resolution-parity --strict` and the full test suite; confirm no behavior change on the canonical paths and the documented unchanged cases (the header-only-no-body main-spec case is the one intended exception, per design Decision 6). _Change validates `--strict` (exit 0); all 36 repo specs pass `--specs --strict` (no #1156 false positives); full suite 1791 passed with only the pre-existing, environment-specific zsh-installer failures unchanged._ diff --git a/src/commands/change.ts b/src/commands/change.ts index eae9fffd48..561fb3d6ab 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -7,11 +7,10 @@ import { Change } from '../core/schemas/index.js'; import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; +import { getTaskProgressForChange } from '../utils/task-progress.js'; // Constants for better maintainability const ARCHIVE_DIR = 'archive'; -const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; -const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; export class ChangeCommand { private converter: JsonConverter; @@ -108,25 +107,17 @@ export class ChangeCommand { const changeDetails = await Promise.all( changes.map(async (changeName) => { const proposalPath = path.join(changesPath, changeName, 'proposal.md'); - const tasksPath = path.join(changesPath, changeName, 'tasks.md'); - + try { const content = await fs.readFile(proposalPath, 'utf-8'); const changeDir = path.join(changesPath, changeName); const parser = new ChangeParser(content, changeDir); const change = await parser.parseChangeWithDeltas(changeName); - - let taskStatus = { total: 0, completed: 0 }; - try { - const tasksContent = await fs.readFile(tasksPath, 'utf-8'); - taskStatus = this.countTasks(tasksContent); - } catch (error) { - // Tasks file may not exist, which is okay - if (process.env.DEBUG) { - console.error(`Failed to read tasks file at ${tasksPath}:`, error); - } - } - + + // Resolve task progress through the shared tracked-tasks helper so + // this deprecated noun-form list cannot re-fork the resolution (#1202). + const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + return { id: changeName, title: this.extractTitle(content, changeName), @@ -161,20 +152,11 @@ export class ChangeCommand { // Long format: id: title and minimal counts for (const changeName of sorted) { const proposalPath = path.join(changesPath, changeName, 'proposal.md'); - const tasksPath = path.join(changesPath, changeName, 'tasks.md'); try { const content = await fs.readFile(proposalPath, 'utf-8'); const title = this.extractTitle(content, changeName); - let taskStatusText = ''; - try { - const tasksContent = await fs.readFile(tasksPath, 'utf-8'); - const { total, completed } = this.countTasks(tasksContent); - taskStatusText = ` [tasks ${completed}/${total}]`; - } catch (error) { - if (process.env.DEBUG) { - console.error(`Failed to read tasks file at ${tasksPath}:`, error); - } - } + const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : ''; const changeDir = path.join(changesPath, changeName); const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir); const change = await parser.parseChangeWithDeltas(changeName); @@ -269,23 +251,6 @@ export class ChangeCommand { return match ? match[1].trim() : changeName; } - private countTasks(content: string): { total: number; completed: number } { - const lines = content.split('\n'); - let total = 0; - let completed = 0; - - for (const line of lines) { - if (line.match(TASK_PATTERN)) { - total++; - if (line.match(COMPLETED_TASK_PATTERN)) { - completed++; - } - } - } - - return { total, completed }; - } - private printNextSteps(): void { const bullets: string[] = []; bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 4690f6f6b5..708c66024e 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -9,7 +9,8 @@ import { isStoreSelectedRoot, } from '../core/root-selection.js'; import { isInteractive, resolveNoInteractive } from '../utils/interactive.js'; -import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; +import { getSpecIds } from '../utils/item-discovery.js'; +import { getAvailableChanges } from './workflow/shared.js'; import { nearestMatches } from '../utils/match.js'; type ItemType = 'change' | 'spec'; @@ -77,6 +78,18 @@ export class ValidateCommand { return undefined; } + /** + * Resolve change IDs by directory existence within the resolved root — the + * same rule `openspec status`/`instructions` use (`getAvailableChanges`) — + * rather than requiring `proposal.md`. This lets `validate` resolve a + * scaffolded or still-authoring change that the sibling commands already + * resolve (#1182). Sorted to preserve the prior `getActiveChangeIds` ordering. + */ + private async listChangeIds(root: ResolvedOpenSpecRoot): Promise { + const ids = await getAvailableChanges(root.path, root.changesDir); + return ids.sort(); + } + private async runInteractiveSelector(root: ResolvedOpenSpecRoot, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise { const { select } = await import('@inquirer/prompts'); const choice = await select({ @@ -94,7 +107,7 @@ export class ValidateCommand { if (choice === 'specs') return this.runBulkValidation(root, { changes: false, specs: true }, opts); // one - const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); + const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]); const items: { name: string; value: { type: ItemType; id: string } }[] = []; items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } }))); items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } }))); @@ -117,7 +130,7 @@ export class ValidateCommand { } private async validateDirectItem(root: ResolvedOpenSpecRoot, itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { - const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); + const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); @@ -235,7 +248,7 @@ export class ValidateCommand { private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ - scope.changes ? getActiveChangeIds(root.path) : Promise.resolve([]), + scope.changes ? this.listChangeIds(root) : Promise.resolve([]), scope.specs ? getSpecIds(root.path) : Promise.resolve([]), ]); diff --git a/src/core/archive.ts b/src/core/archive.ts index 24a336b709..a39d0756a4 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -339,7 +339,7 @@ export class ArchiveCommand { } // Show progress and check for incomplete tasks - const progress = await getTaskProgressForChange(changesDir, changeName); + const progress = await getTaskProgressForChange(changesDir, changeName, path.resolve(changesDir, '..', '..')); if (!json) { const status = formatTaskStatus(progress); console.log(`Task status: ${status}`); @@ -537,7 +537,7 @@ export class ArchiveCommand { try { const progressList: Array<{ id: string; status: string }> = []; for (const id of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, id); + const progress = await getTaskProgressForChange(changesDir, id, path.resolve(changesDir, '..', '..')); const status = formatTaskStatus(progress); progressList.push({ id, status }); } diff --git a/src/core/list.ts b/src/core/list.ts index 28e4c2fc27..8e4d0a9ed7 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -109,7 +109,7 @@ export class ListCommand { const changes: ChangeInfo[] = []; for (const changeDir of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, changeDir); + const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); const changePath = path.join(changesDir, changeDir); const lastModified = await getLastModified(changePath); changes.push({ diff --git a/src/core/schemas/base.schema.ts b/src/core/schemas/base.schema.ts index 548ef35e56..aa08cea1e9 100644 --- a/src/core/schemas/base.schema.ts +++ b/src/core/schemas/base.schema.ts @@ -6,12 +6,14 @@ export const ScenarioSchema = z.object({ }); export const RequirementSchema = z.object({ + // SHALL/MUST body-keyword enforcement lives in the imperative validator + // (Validator.applySpecRules), not here: the parser collapses the requirement + // header into `text`, so a Zod refine on `text` cannot tell "keyword in header + // only" from "keyword in body" and emits a misleading generic error. The + // validator recovers the header and emits the targeted hint for both the + // main-spec and change-delta paths (#1156). text: z.string() - .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY) - .refine( - (text) => text.includes('SHALL') || text.includes('MUST'), - VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL - ), + .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY), scenarios: z.array(ScenarioSchema) .min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS), }); diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 47071ed477..4f896fb3a4 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -10,7 +10,7 @@ import { MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js'; -import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js'; +import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js'; import { findMainSpecStructureIssues } from '../parsers/spec-structure.js'; import { FileSystemUtils } from '../../utils/file-system.js'; @@ -120,11 +120,12 @@ export class Validator { const emptySectionSpecs: Array<{ path: string; sections: string[] }> = []; try { - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const specName = entry.name; - const specFile = path.join(specsDir, specName, 'spec.md'); + // Discover delta specs at any depth so the nested multi-area layout + // (specs///spec.md) is validated, not just the + // one-level specs//spec.md layout (#1182b). The spec-driven + // specs glob is specs/**/*.md; delta files are always named spec.md. + const specFiles = await this.findDeltaSpecFiles(specsDir); + for (const specFile of specFiles) { let content: string | undefined; try { content = await fs.readFile(specFile, 'utf-8'); @@ -133,7 +134,7 @@ export class Validator { } const plan = parseDeltaSpec(content); - const entryPath = `${specName}/spec.md`; + const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile)); const sectionNames: string[] = []; if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements'); if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements'); @@ -165,7 +166,7 @@ export class Validator { if (!requirementText) { issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` }); } else if (!this.containsShallOrMust(requirementText)) { - issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('ADDED', block.name) }); + issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) }); } const scenarioCount = this.countScenarios(block.raw); if (scenarioCount < 1) { @@ -186,7 +187,7 @@ export class Validator { if (!requirementText) { issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` }); } else if (!this.containsShallOrMust(requirementText)) { - issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('MODIFIED', block.name) }); + issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) }); } const scenarioCount = this.countScenarios(block.raw); if (scenarioCount < 1) { @@ -273,6 +274,34 @@ export class Validator { return this.createReport(issues); } + /** + * Recursively collect every delta `spec.md` under a change's specs directory, + * so both the one-level (specs//spec.md) and nested multi-area + * (specs///spec.md) layouts are discovered (#1182b). + * Returns absolute paths, sorted for deterministic issue ordering. + */ + private async findDeltaSpecFiles(specsDir: string): Promise { + const results: string[] = []; + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile() && entry.name === 'spec.md') { + results.push(full); + } + } + }; + await walk(specsDir); + return results.sort(); + } + private convertZodErrors(error: ZodError): ValidationIssue[] { return error.issues.map(err => { let message = err.message; @@ -315,7 +344,7 @@ export class Validator { message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG, }); } - + if (req.scenarios.length === 0) { issues.push({ level: 'WARNING', @@ -324,7 +353,25 @@ export class Validator { }); } }); - + + // SHALL/MUST body-keyword enforcement for main specs (#1156). The main-spec + // parser collapses the requirement header into `text`, so we recover the + // header+body pairs here (the same source the delta path trusts) and reuse + // the delta detection: a body that omits the keyword errors, with the + // targeted "move it to the body line" hint when the keyword is in the header + // only and the generic message otherwise. Emitted exactly once per + // requirement (the Zod refine that used to emit a generic error is removed). + extractRequirementsSection(content).bodyBlocks.forEach((block, index) => { + const requirementText = this.extractRequirementText(block.raw); + if (!requirementText || !this.containsShallOrMust(requirementText)) { + issues.push({ + level: 'ERROR', + path: `requirements[${index}]`, + message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name), + }); + } + }); + return issues; } @@ -454,8 +501,8 @@ export class Validator { * on the requirement body line (the line right after the header), so we point * the author at that exact fix when the keyword is found in the header only. */ - private buildMissingShallOrMustMessage(action: 'ADDED' | 'MODIFIED', blockName: string): string { - const base = `${action} "${blockName}" must contain SHALL or MUST`; + private buildMissingShallOrMustMessage(prefix: string, blockName: string): string { + const base = `${prefix} must contain SHALL or MUST`; if (this.containsShallOrMust(blockName)) { return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`; } diff --git a/src/core/view.ts b/src/core/view.ts index e67c352688..343775bb5d 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -97,7 +97,7 @@ export class ViewCommand { for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'archive') { - const progress = await getTaskProgressForChange(changesDir, entry.name); + const progress = await getTaskProgressForChange(changesDir, entry.name, path.dirname(openspecDir)); if (progress.total === 0) { // No tasks defined yet - still in planning/draft phase diff --git a/src/utils/task-progress.ts b/src/utils/task-progress.ts index a14b866f08..e45c274162 100644 --- a/src/utils/task-progress.ts +++ b/src/utils/task-progress.ts @@ -1,5 +1,8 @@ import { promises as fs } from 'fs'; import path from 'path'; +import type { Artifact, SchemaYaml } from '../core/artifact-graph/index.js'; +import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/index.js'; +import { resolveSchemaForChange } from './change-metadata.js'; const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; @@ -24,8 +27,38 @@ export function countTasksFromContent(content: string): TaskProgress { return { total, completed }; } -export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise { - const tasksPath = path.join(changesDir, changeName, 'tasks.md'); +/** + * Identifies the change's tracked-tasks artifact: the artifact whose `generates` + * equals the schema's `apply.tracks` value, falling back to the artifact with id + * `tasks` when no `apply` block declares what it tracks. (`apply.tracks` is a + * filename that *selects* the artifact; the glob is that artifact's `generates`.) + */ +function findTrackedTasksArtifact(schema: SchemaYaml): Artifact | undefined { + const tracks = schema.apply?.tracks; + if (tracks != null) { + return schema.artifacts.find((a) => a.generates === tracks); + } + return schema.artifacts.find((a) => a.id === 'tasks'); +} + +/** + * Resolves the tracked-tasks artifact's output glob for a change, or undefined + * when the schema cannot be resolved or no tracked-tasks artifact exists. + * `resolveSchema` throws on an unresolvable/misnamed schema; we swallow that so + * the caller falls back to a single top-level `tasks.md` and never crashes. + */ +function resolveTrackedTasksGlob(changeDir: string, projectRoot: string): string | undefined { + try { + const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); + return findTrackedTasksArtifact(schema)?.generates; + } catch { + return undefined; + } +} + +async function countSingleTopLevelTasksFile(changeDir: string): Promise { + const tasksPath = path.join(changeDir, 'tasks.md'); try { const content = await fs.readFile(tasksPath, 'utf-8'); return countTasksFromContent(content); @@ -34,6 +67,45 @@ export async function getTaskProgressForChange(changesDir: string, changeName: s } } +/** + * Computes a change's task progress by resolving its tracked-tasks artifact and + * counting checkboxes across every file matched by that artifact's `generates` + * glob — the same file-resolution `openspec status` uses to detect the tasks + * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested + * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly + * as before) when the schema is unresolvable, no tracked-tasks artifact is found, + * or the glob matches no file. Never throws. + */ +export async function getTaskProgressForChange( + changesDir: string, + changeName: string, + projectRoot: string +): Promise { + const changeDir = path.join(changesDir, changeName); + + const generates = resolveTrackedTasksGlob(changeDir, projectRoot); + if (generates) { + const files = resolveArtifactOutputs(changeDir, generates); + if (files.length > 0) { + let total = 0; + let completed = 0; + for (const file of files) { + try { + const content = await fs.readFile(file, 'utf-8'); + const progress = countTasksFromContent(content); + total += progress.total; + completed += progress.completed; + } catch { + // Swallow files that vanish between glob and read, as before. + } + } + return { total, completed }; + } + } + + return countSingleTopLevelTasksFile(changeDir); +} + export function formatTaskStatus(progress: TaskProgress): string { if (progress.total === 0) return 'No tasks'; if (progress.completed === progress.total) return '✓ Complete'; diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index b94f72d351..65e9ce80e2 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -131,6 +131,59 @@ describe('top-level validate command', () => { expect(result.exitCode).toBe(0); }); + // #1182 — validate resolves a change by directory existence (matching + // status/instructions), not by requiring proposal.md. + const validDelta = [ + '## ADDED Requirements', + '### Requirement: Scaffolded change SHALL validate without a proposal', + 'The change SHALL validate by directory existence without a proposal file.', + '', + '#### Scenario: Validate scaffolded change', + '- **GIVEN** a change directory with no proposal.md', + '- **WHEN** openspec validate runs', + '- **THEN** the change resolves and its deltas are validated', + ].join('\n'); + + it('resolves and validates a scaffolded change without proposal.md (#1182)', async () => { + const changeDir = path.join(changesDir, 'scaffolded'); + const deltaDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + await fs.writeFile(path.join(deltaDir, 'spec.md'), validDelta, 'utf-8'); + + const result = await runCLI(['validate', 'scaffolded'], { cwd: testDir }); + expect(result.stderr).not.toContain('Unknown item'); + expect(result.exitCode).toBe(0); + }); + + it('a resolved-but-invalid proposal-less change exits non-zero, not "Unknown item" (#1182)', async () => { + // Resolves by directory existence, then fails validation (no deltas). + const changeDir = path.join(changesDir, 'scaffolded-empty'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + + const result = await runCLI(['validate', 'scaffolded-empty'], { cwd: testDir }); + expect(result.stderr).not.toContain('Unknown item'); + expect(result.exitCode).toBe(1); + }); + + it('includes a sole proposal-less change in --all (not "No items found") (#1182)', async () => { + const isoRoot = path.join(projectRoot, 'test-validate-iso-tmp'); + const isoChanges = path.join(isoRoot, 'openspec', 'changes'); + const deltaDir = path.join(isoChanges, 'only', 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + try { + await fs.writeFile(path.join(isoChanges, 'only', '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + await fs.writeFile(path.join(deltaDir, 'spec.md'), validDelta, 'utf-8'); + + const result = await runCLI(['validate', '--all'], { cwd: isoRoot }); + expect(result.stdout + result.stderr).not.toContain('No items found to validate'); + expect(result.exitCode).toBe(0); + } finally { + await fs.rm(isoRoot, { recursive: true, force: true }); + } + }); + it('respects --no-interactive flag passed via CLI', async () => { // This test ensures Commander.js --no-interactive flag is correctly parsed // and passed to the validate command. The flag sets options.interactive = false diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 977508929c..ddd0658bec 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -105,6 +105,50 @@ describe('ArchiveCommand', () => { ); }); + it('detects incomplete tasks in nested glob tasks.md files (#1202 data-safety gate)', async () => { + // Before the fix the gate read a fixed changes//tasks.md, saw zero + // tasks for a glob-tasks change, and let an unfinished change archive. + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: glob-tasks', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n') + ); + + const changeName = 'glob-incomplete-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true }); + await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n'); + await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n'); + await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n'); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true }); + + // The gate now sees 5 tasks / 2 incomplete across the nested files. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('2 incomplete task(s) found') + ); + }); + it('should update specs when archiving (delta-based ADDED) and include change name in skeleton', async () => { const changeName = 'spec-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); diff --git a/test/core/validation.test.ts b/test/core/validation.test.ts index 72ebc2aba6..d7104aa42d 100644 --- a/test/core/validation.test.ts +++ b/test/core/validation.test.ts @@ -49,7 +49,11 @@ describe('Validation Schemas', () => { expect(result.success).toBe(true); }); - it('should reject requirement without SHALL or MUST', () => { + it('no longer enforces SHALL or MUST at the schema level (moved to the validator)', () => { + // SHALL/MUST body-keyword enforcement moved out of the Zod refine and into + // Validator.applySpecRules so it can recover the requirement header and + // emit the targeted body-keyword hint (#1156). The schema therefore accepts + // a body without the keyword; the validator (exercised below) reports it. const requirement = { text: 'The system provides user authentication', scenarios: [ @@ -58,12 +62,9 @@ describe('Validation Schemas', () => { }, ], }; - + const result = RequirementSchema.safeParse(requirement); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toBe('Requirement must contain SHALL or MUST keyword'); - } + expect(result.success).toBe(true); }); it('should reject requirement without scenarios', () => { @@ -676,5 +677,137 @@ The system MUST support mixed case delta headers. expect(report.summary.warnings).toBe(0); expect(report.summary.info).toBe(0); }); + + // #1182b — delta discovery recurses the nested multi-area layout. + it('discovers and validates deltas in a nested specs// layout (#1182b)', async () => { + const changeDir = path.join(testDir, 'test-change-nested'); + const nestedDir = path.join(changeDir, 'specs', 'area-one', 'cap-a'); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile( + path.join(nestedDir, 'spec.md'), + `## ADDED Requirements\n\n### Requirement: Nested capability\nThe system SHALL support nested multi-area delta layouts.\n\n#### Scenario: Nested delta is discovered\n- **WHEN** validating a change with nested specs\n- **THEN** the delta is found and validated` + ); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.issues.some(i => i.message.includes('No delta sections found'))).toBe(false); + expect(report.issues.some(i => i.message.includes('No deltas found'))).toBe(false); + expect(report.valid).toBe(true); + }); + + it('still validates a single-level layout unchanged (#1182b control)', async () => { + const changeDir = path.join(testDir, 'test-change-onelevel'); + const oneLevelDir = path.join(changeDir, 'specs', 'cap-a'); + await fs.mkdir(oneLevelDir, { recursive: true }); + await fs.writeFile( + path.join(oneLevelDir, 'spec.md'), + `## ADDED Requirements\n\n### Requirement: One level capability\nThe system SHALL support a one-level layout.\n\n#### Scenario: One level delta\n- **WHEN** validating\n- **THEN** the delta is found` + ); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + }); + + // #1156 — the SHALL/MUST body-keyword hint applies to main specs too, with the + // actionable sentence byte-identical to the change-delta path, emitted once. + describe('main-spec SHALL/MUST body-keyword hint (#1156)', () => { + const ACTIONABLE_SENTENCE = + 'must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.'; + + const buildSpec = (requirementBlock: string): string => + [ + '# Demo Spec', + '', + '## Purpose', + 'A purpose long enough to satisfy the validator length threshold for tests.', + '', + '## Requirements', + '', + requirementBlock, + ].join('\n'); + + const shallIssues = (issues: { message: string }[]) => + issues.filter(i => i.message.includes('SHALL or MUST')); + + it('emits the targeted hint when the keyword is in the header only (with a body line)', async () => { + const content = buildSpec( + '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); // exactly one, no duplicate generic + expect(issues[0].message).toContain('not only in the header'); + expect(issues[0].message).toContain(ACTIONABLE_SENTENCE); + }); + + it('uses an actionable sentence byte-identical to the change-delta message', async () => { + const block = + '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'; + + const specReport = await new Validator().validateSpecContent('demo', buildSpec(block)); + const specMsg = shallIssues(specReport.issues)[0].message; + + const changeDir = path.join(testDir, 'change-parity-sentence'); + const deltaDir = path.join(changeDir, 'specs', 'cap'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), `## ADDED Requirements\n\n${block}`); + const deltaReport = await new Validator().validateChangeDeltaSpecs(changeDir); + const deltaMsg = shallIssues(deltaReport.issues)[0].message; + + // Same actionable sentence; only the leading prefix differs. + expect(specMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true); + expect(deltaMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true); + expect(specMsg.startsWith('Requirement "The system SHALL log"')).toBe(true); + expect(deltaMsg.startsWith('ADDED "The system SHALL log"')).toBe(true); + }); + + it('keeps a generic missing-keyword error when neither header nor body has the keyword', async () => { + const content = buildSpec( + '### Requirement: Logging\nThe system will log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); + expect(issues[0].message).not.toContain('not only in the header'); + }); + + it('does not flag a requirement whose body line contains the keyword', async () => { + const content = buildSpec( + '### Requirement: Logging\nThe system SHALL log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + expect(shallIssues(report.issues)).toHaveLength(0); + }); + + it('rejects a lowercase shall/must in the body (matching the delta path)', async () => { + const content = buildSpec( + '### Requirement: Logging\nthe system shall log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + expect(shallIssues(report.issues)).toHaveLength(1); + }); + + it('emits the hint for a header-only requirement with no body line (intended additive change)', async () => { + const content = buildSpec( + '### Requirement: The system MUST be available\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('not only in the header'); + }); + + it('does not subject RENAMED requirements to the hint (byte-for-byte unchanged)', async () => { + const changeDir = path.join(testDir, 'change-renamed'); + const deltaDir = path.join(changeDir, 'specs', 'cap'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + '## RENAMED Requirements\n\n- FROM: `### Requirement: Old name`\n- TO: `### Requirement: The system SHALL do the new thing`\n' + ); + const report = await new Validator().validateChangeDeltaSpecs(changeDir); + expect(report.issues.some(i => i.message.includes('not only in the header'))).toBe(false); + }); }); }); diff --git a/test/core/view.test.ts b/test/core/view.test.ts index b8b56df1e5..653bb8624e 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -125,5 +125,54 @@ describe('ViewCommand', () => { 'gamma-change' ]); }); + + it('classifies a nested glob-tasks change as Active, not Draft (#1202)', async () => { + const openspecDir = path.join(tempDir, 'openspec'); + const changesDir = path.join(openspecDir, 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + + // Project-local schema whose tasks artifact resolves a nested glob. + const schemaDir = path.join(openspecDir, 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: glob-tasks', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n') + ); + + const changeDir = path.join(changesDir, 'nested-change'); + await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true }); + await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n'); + await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n'); + await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n'); + + await new ViewCommand().execute(tempDir); + const output = logOutput.map(stripAnsi).join('\n'); + + // Active section lists the change with aggregated 3/5 progress; not Draft. + const activeLines = logOutput.map(stripAnsi).filter(line => line.includes('◉')); + expect(activeLines.some(line => line.includes('nested-change'))).toBe(true); + const draftLines = logOutput.map(stripAnsi).filter(line => line.includes('○')); + expect(draftLines.some(line => line.includes('nested-change'))).toBe(false); + expect(output).toContain('60%'); + }); }); diff --git a/test/utils/task-progress.test.ts b/test/utils/task-progress.test.ts new file mode 100644 index 0000000000..33f89794a9 --- /dev/null +++ b/test/utils/task-progress.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { getTaskProgressForChange } from '../../src/utils/task-progress.js'; +import { resolveArtifactOutputs } from '../../src/core/artifact-graph/index.js'; + +/** + * #1202 — task progress is resolved through the tracked-tasks artifact's + * `generates` glob (the same file-resolution `openspec status` uses), not a + * fixed `changes//tasks.md` path. + */ +describe('getTaskProgressForChange (#1202 tracked-tasks resolution)', () => { + let projectRoot: string; + let changesDir: string; + + const GLOB_SCHEMA = [ + 'name: glob-tasks', + 'version: 1', + 'description: tasks artifact uses a nested glob', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n'); + + beforeEach(async () => { + projectRoot = path.join(os.tmpdir(), `openspec-taskprogress-${Date.now()}-${Math.round(performance.now())}`); + changesDir = path.join(projectRoot, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + async function writeGlobSchema(): Promise { + const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile(path.join(schemaDir, 'schema.yaml'), GLOB_SCHEMA, 'utf-8'); + } + + async function writeChange(name: string, files: Record, schema = 'glob-tasks'): Promise { + const changeDir = path.join(changesDir, name); + await fs.mkdir(changeDir, { recursive: true }); + if (schema) { + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), `schema: ${schema}\n`, 'utf-8'); + } + for (const [rel, content] of Object.entries(files)) { + const full = path.join(changeDir, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, content, 'utf-8'); + } + return changeDir; + } + + it('aggregates checkboxes across nested tasks.md files matched by the glob', async () => { + await writeGlobSchema(); + await writeChange('globchange', { + 'backend/tasks.md': '- [x] 1.1 a\n- [x] 1.2 b\n', + 'frontend/tasks.md': '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n', + }); + + const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot); + expect(progress).toEqual({ total: 5, completed: 3 }); + }); + + it('resolves the same set of files status resolves (resolution-mechanism parity)', async () => { + await writeGlobSchema(); + const changeDir = await writeChange('globchange', { + 'backend/tasks.md': '- [x] a\n- [x] b\n', + 'frontend/tasks.md': '- [x] a\n- [ ] b\n- [ ] c\n', + }); + + // `status` detects the tasks artifact via resolveArtifactOutputs(changeDir, generates). + const statusFiles = resolveArtifactOutputs(changeDir, '**/tasks.md'); + expect(statusFiles).toHaveLength(2); + + // The helper's aggregate equals the checkbox sum over exactly those files. + let total = 0; + let completed = 0; + for (const file of statusFiles) { + const content = await fs.readFile(file, 'utf-8'); + total += (content.match(/^[-*]\s+\[[\sx]\]/gim) ?? []).length; + completed += (content.match(/^[-*]\s+\[x\]/gim) ?? []).length; + } + const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot); + expect(progress).toEqual({ total, completed }); + }); + + it('scopes resolution to the change dir (excludes archive/ and sibling changes)', async () => { + await writeGlobSchema(); + await writeChange('target', { 'backend/tasks.md': '- [x] a\n- [ ] b\n' }); + // Decoys that must NOT be counted. + await fs.mkdir(path.join(changesDir, 'archive', 'old'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'archive', 'old', 'tasks.md'), '- [x] x\n- [x] y\n', 'utf-8'); + await writeChange('sibling', { 'backend/tasks.md': '- [x] s1\n- [x] s2\n' }); + + const progress = await getTaskProgressForChange(changesDir, 'target', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('identifies the tracked artifact by apply.tracks even when it is not named "tasks"', async () => { + const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'custom-track'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: custom-track', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: checklist', + ' generates: "work/*.md"', + ' description: Work checklist', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [checklist]', + ' tracks: "work/*.md"', + '', + ].join('\n'), + 'utf-8' + ); + await writeChange('customchange', { 'work/a.md': '- [x] a\n- [ ] b\n' }, 'custom-track'); + + const progress = await getTaskProgressForChange(changesDir, 'customchange', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('falls back to a single top-level tasks.md when the schema cannot be resolved (no crash)', async () => { + await writeChange('badschema', { 'tasks.md': '- [x] a\n- [ ] b\n' }, 'does-not-exist'); + + const progress = await getTaskProgressForChange(changesDir, 'badschema', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('counts a single top-level tasks.md unchanged under the default schema', async () => { + // No project-local schema, no .openspec.yaml -> default spec-driven (tracks tasks.md). + await writeChange('plain', { 'tasks.md': '- [x] a\n- [x] b\n- [ ] c\n' }, ''); + + const progress = await getTaskProgressForChange(changesDir, 'plain', projectRoot); + expect(progress).toEqual({ total: 3, completed: 2 }); + }); + + it('reports zero tasks when no file matches the tracked glob', async () => { + await writeGlobSchema(); + await writeChange('notasks', {}); // schema set, but no tasks.md anywhere + + const progress = await getTaskProgressForChange(changesDir, 'notasks', projectRoot); + expect(progress).toEqual({ total: 0, completed: 0 }); + }); +}); From 65a7233f36ad022e99cc23115279768b8ca24fb6 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 3 Jul 2026 09:21:42 -0500 Subject: [PATCH 04/19] docs: add cloudflare documentation deployment website (#1285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(website): add Fumadocs documentation site for Cloudflare Pages Add a self-contained marketing + documentation site under website/, built with Fumadocs (Next.js) and configured as a static export so it deploys directly to Cloudflare Pages with no server runtime. What's included: - A marketing landing page (hero, the two-folder model, the four core ideas, the explore→propose→apply→archive loop, and the "why"). - 13 documentation pages rewritten for clarity and delight: introduction, installation, getting started, how commands work, core concepts, the workflow, explore first, existing projects, editing a change, customization, FAQ, and a reference section (slash commands, CLI, supported tools). - Static client-side search (Orama), per-page Open Graph images, and llms.txt / llms-full.txt routes — fitting for an AI-native tool. - website/README.md with one-table Cloudflare Pages deploy settings (root: website, build: npm run build, output: out). Content is faithful to the docs/ overhaul from #1237, restated in a simpler, friendlier voice. Verified with a clean `next build` (48 static pages, no warnings). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(website): sharpen the sell, add a Stores guide Completes the documentation work begun in #1237 by tightening the Fumadocs site toward the quality bar of the stores user-guide: - Intro now opens problem-first ("the requirements lived only in chat"), adds an honest "How it compares" table (Spec Kit / Kiro / nothing), and frames the tradeoff in a "When the ceremony isn't worth it" callout. - New Stores guide (beta) distilled from docs/stores-beta/user-guide.md: the problem, the annotated shape, a five-minute walkthrough with real command output, a role-based story, the root-resolution order, and an honest-limitations section. Linked from Existing Projects. Verified with a clean `next build` (51 static pages, no warnings). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(website): make the value tangible — landing sections + Examples page Continue the #1237 docs completion with a stronger product story: - Landing page now reads like a real product site: - "Works with the tools you already use" strip (15 named assistants + more) - "What a change actually looks like" — three real artifacts (proposal.md, a spec delta, tasks.md) so the workflow is concrete - "The honest middle" comparison block (Spec Kit / Kiro / no specs) - Robust hero gradient via color-mix instead of v3 theme() syntax - New Examples & Recipes page: seven copy-pasteable, narrated walkthroughs (small feature, bug fix, explore-first, parallel changes, no-behavior refactor with --skip-specs, step-by-step, onboard). Linked from the intro and getting-started. Verified: clean `next build` (54 static pages, no warnings); Tailwind opacity/color-mix utilities confirmed in the generated CSS; all internal links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(website): add favicon, sitemap, and robots for a complete public site - Branded SVG favicon (app/icon.svg) in the OpenSpec indigo. - Static sitemap.xml covering the home page and every doc, built from the content source and NEXT_PUBLIC_SITE_URL. - robots.txt allowing all and pointing at the sitemap. All three are emitted by the static export. Clean `next build`, 57 pages. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: lead with stores as "why teams adopt OpenSpec"; complete docs coverage Final pass completing the #1237 documentation work. Reposition stores (beta) as the team adoption story, consistently: - README.md gains a prominent "Why teams adopt OpenSpec" section right after the demo (cross-repo features, shared requirements, plan before code), leading with stores. - Landing page gains a matching "Why teams adopt OpenSpec" section. - Docs intro gains a teams card + callout pointing at stores. - Stores page expanded with full References and Worksets technical examples (the cross-team requirements story, workset create/open). Incorporate the remaining source-doc knowledge so the site is complete: - New pages: Glossary, Troubleshooting, Multi-Language, and an Agents & Automation reference (the machine-readable --json surfaces and workflow primitives that make OpenSpec AI-native). - The Workflow page now covers ff-vs-continue, a three-dimension verify example, and the update-vs-start-fresh decision guide. - Nav restructured with a Help section; reference section gains Agents. Build hardening: `build` now runs `fumadocs-mdx && next build` so the content source is always regenerated. Clean build: 69 static pages, no warnings; all internal links verified. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(website): fix docs GitHub source links + address review nits - page.tsx: prefix ViewOptionsPopover githubUrl with website/ so the "view/edit source" links resolve to website/content/docs/... instead of 404-ing on every deployed docs page (Alfred blocker). - installation.mdx: note that `yarn global add` is Classic Yarn only and point Yarn Berry users at `yarn dlx` / npm / pnpm. - index.mdx: label the comparison table's first column ("Option"). - (home)/page.tsx: use the shared docsRoute constant for all /docs links instead of hardcoded paths. Verified with `npm run build` in website/ — 69 static pages, and the built getting-started page links to blob/main/website/content/docs/... Co-Authored-By: Claude Opus 4.8 (1M context) * docs(website): mirror docs/*.md into the site + auto-deploy on a cadence Make the repository's docs/*.md the single source of truth for the docs site instead of maintaining a parallel set of hand-written MDX pages that silently drift. - scripts/sync-docs.mjs mirrors ../docs into content/docs/ on every build: derives title/description, injects Fumadocs frontmatter (+ githubSource), rewrites internal *.md links to /docs routes, and emits meta.json. Pages are written as .md so /{braces} in the docs stay literal and never break the MDX build. - docs.sync.config.mjs is the one manifest deciding which docs publish and their slug/section/icon. content/docs/ is now generated + git-ignored; the curated .mdx pages are removed. The marketing landing page stays hand-authored. - build/dev/types:check run sync:docs first, so the site is always current. - .github/workflows/deploy-docs.yml rebuilds and deploys to Cloudflare Pages via Wrangler on push to docs/**|website/**, daily on a schedule, on demand, and as a build-only check on PRs. Needs CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID secrets and the DOCS_SITE_URL variable. - source.config.ts carries githubSource so "edit this page" opens the real docs/*.md; website/README.md documents the pipeline. Verified: clean build, 23 pages generated, 78 static pages, no warnings; all internal doc links resolve; MDX-hazard docs (cli, customization) build. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(website): fall back to default site URL when NEXT_PUBLIC_SITE_URL is empty The deploy workflow passes NEXT_PUBLIC_SITE_URL from the DOCS_SITE_URL repo variable, which resolves to an empty string when unset. `?? fallback` does not catch '' (only null/undefined), so `metadataBase: new URL('')` crashed `next build` with ERR_INVALID_URL while collecting page data. Use `||` so an empty value also falls back. Verified: `NEXT_PUBLIC_SITE_URL='' npm run build` now generates all 78 static pages. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: add reviewing, writing-specs, and team-workflow guides Fill the biggest gaps a new user hits, in the plain-language voice of the stores user guide: - reviewing-changes.md: the two-minute human review of an AI-drafted plan before /opsx:apply — what to open, in what order, and the red flags per artifact — plus the /opsx:verify pass after code. - writing-specs.md: what a strong requirement and scenario are made of, choosing ADDED/MODIFIED/REMOVED, and right-sizing a change. - team-workflow.md: how a change maps onto a branch and a pull request, reviewing spec deltas in a PR, when to archive, and parallel changes — framed as convention, since OpenSpec never touches git. Wire them into the docs map (README), the site nav (docs.sync.config.mjs), and light "next steps" cross-links from getting-started, editing-changes, and workflows. Verified: site builds clean, 26 pages, all internal links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(website): add one-time deploy setup checklist + landing-page note Spell out the three maintainer steps that activate auto-deploy (create the openspec-docs Pages project, add CLOUDFLARE_API_TOKEN/ACCOUNT_ID secrets, merge to main), and note that the pipeline mirrors docs on build regardless. Also flag that openspec.dev is a separate Astro landing page and whether to keep/port this Fumadocs landing page is a maintainer decision. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(website): address review feedback on docs-site PR Maintainer review (TabishB) + Alfred blocker: - deploy-docs.yml: guard the Cloudflare deploy on `github.ref == refs/heads/main`. A `workflow_dispatch` on a feature branch previously passed the guard and, since wrangler hardcodes `--branch=main` (a production deploy), would overwrite the live docs site. Non-main dispatches are now build-only. Also resolves Alfred's deploy-path blocker. - package.json: drop the direct `cnfast` dependency and delete the dead `lib/cn.ts` (nothing imports it; a class-merge helper isn't used). - package.json: declare `zod` (^4.4.3) — it was a phantom dep only resolving via fumadocs-mdx's hoisted copy. Refresh the lockfile. - docs page: omit the on-page . The frontmatter description is derived from the first body paragraph, so it rendered the intro twice on every page. Kept in generateMetadata for SEO/OG. - team-workflow.md: `openspec store create` does an initial commit, so scope "never commits" to the user's project and reframe the store clause as "never clones or syncs on its own." - README.md: bump stale "20+ AI assistants" to "30+" to match the site. Verified: npm run types:check + npm run build pass, 26 docs synced, intro paragraph now renders once per page. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(website): use pnpm to match the rest of the repo Per maintainer review (TabishB): the root repo is pnpm (ci.yml runs `pnpm install --frozen-lockfile` against a v9 `pnpm-lock.yaml`), but `website/` had introduced npm + a `package-lock.json`. Standardize on one package manager: - Replace website/package-lock.json with website/pnpm-lock.yaml (lockfileVersion 9.0, generated with pnpm v9 to match root). - deploy-docs.yml: add pnpm/action-setup@v4 (version 9, before setup-node, as in ci.yml), switch setup-node to `cache: pnpm` / `cache-dependency-path: website/pnpm-lock.yaml`, and `npm ci` → `pnpm install --frozen-lockfile`, `npm run build` → `pnpm run build`. - package.json scripts + README: `npm run ...` → `pnpm run ...`. website/ stays a standalone package (no pnpm-workspace.yaml), as before. Verified: `pnpm install --frozen-lockfile`, `pnpm run build`, and `pnpm run types:check` all pass — 26 docs synced, 87/87 static pages. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: temporarily disable docs deploy --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Co-authored-by: TabishB --- .github/workflows/deploy-docs.yml | 78 + README.md | 14 +- docs/README.md | 7 + docs/editing-changes.md | 1 + docs/getting-started.md | 2 + docs/reviewing-changes.md | 143 + docs/team-workflow.md | 74 + docs/workflows.md | 3 + docs/writing-specs.md | 101 + website/.gitignore | 29 + website/README.md | 159 + website/app/(home)/layout.tsx | 6 + website/app/(home)/page.tsx | 635 +++ website/app/api/search/route.ts | 9 + website/app/docs/[[...slug]]/page.tsx | 69 + website/app/docs/layout.tsx | 11 + website/app/global.css | 21 + website/app/icon.svg | 5 + website/app/layout.tsx | 42 + website/app/llms-full.txt/route.ts | 10 + .../app/llms.mdx/docs/[[...slug]]/route.ts | 23 + website/app/llms.txt/route.ts | 8 + website/app/og/docs/[...slug]/route.tsx | 28 + website/app/robots.ts | 16 + website/app/sitemap.ts | 24 + website/components/mdx.tsx | 24 + website/components/provider.tsx | 8 + website/components/search.tsx | 48 + website/docs.sync.config.mjs | 76 + website/lib/layout.shared.tsx | 32 + website/lib/shared.ts | 27 + website/lib/source.ts | 44 + website/next.config.mjs | 17 + website/package.json | 35 + website/pnpm-lock.yaml | 4609 +++++++++++++++++ website/postcss.config.mjs | 7 + website/scripts/sync-docs.mjs | 185 + website/source.config.ts | 27 + website/tsconfig.json | 35 + 39 files changed, 6691 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 docs/reviewing-changes.md create mode 100644 docs/team-workflow.md create mode 100644 docs/writing-specs.md create mode 100644 website/.gitignore create mode 100644 website/README.md create mode 100644 website/app/(home)/layout.tsx create mode 100644 website/app/(home)/page.tsx create mode 100644 website/app/api/search/route.ts create mode 100644 website/app/docs/[[...slug]]/page.tsx create mode 100644 website/app/docs/layout.tsx create mode 100644 website/app/global.css create mode 100644 website/app/icon.svg create mode 100644 website/app/layout.tsx create mode 100644 website/app/llms-full.txt/route.ts create mode 100644 website/app/llms.mdx/docs/[[...slug]]/route.ts create mode 100644 website/app/llms.txt/route.ts create mode 100644 website/app/og/docs/[...slug]/route.tsx create mode 100644 website/app/robots.ts create mode 100644 website/app/sitemap.ts create mode 100644 website/components/mdx.tsx create mode 100644 website/components/provider.tsx create mode 100644 website/components/search.tsx create mode 100644 website/docs.sync.config.mjs create mode 100644 website/lib/layout.shared.tsx create mode 100644 website/lib/shared.ts create mode 100644 website/lib/source.ts create mode 100644 website/next.config.mjs create mode 100644 website/package.json create mode 100644 website/pnpm-lock.yaml create mode 100644 website/postcss.config.mjs create mode 100644 website/scripts/sync-docs.mjs create mode 100644 website/source.config.ts create mode 100644 website/tsconfig.json diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000000..9fbe0e8a43 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,78 @@ +name: Docs site + +# The documentation site (website/) mirrors docs/*.md via scripts/sync-docs.mjs, +# which runs as the first step of `pnpm run build`. This workflow rebuilds that +# mirror and deploys the static export to Cloudflare Pages: +# - on every push to main that touches docs/ or website/ (deploy immediately), +# - on a daily schedule (re-mirror the latest docs even if nothing pushed), +# - manually via the Actions tab, +# - and as a build-only check on pull requests. +# +# Deploys require two repository secrets: CLOUDFLARE_API_TOKEN and +# CLOUDFLARE_ACCOUNT_ID. Set the site's public URL via the DOCS_SITE_URL +# repository variable (used for OG/sitemap absolute URLs). + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'website/**' + - '.github/workflows/deploy-docs.yml' + pull_request: + paths: + - 'docs/**' + - 'website/**' + - '.github/workflows/deploy-docs.yml' + schedule: + # Daily at 06:00 UTC — picks up any docs changes merged since the last run. + - cron: '0 6 * * *' + workflow_dispatch: + +# Never run two deploys at once; let an in-flight deploy finish. +concurrency: + group: deploy-docs + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: website/pnpm-lock.yaml + + - name: Install dependencies + working-directory: website + run: pnpm install --frozen-lockfile + + - name: Build site (mirrors docs/*.md, then next build) + working-directory: website + env: + NEXT_PUBLIC_SITE_URL: ${{ vars.DOCS_SITE_URL }} + run: pnpm run build + + # Temporarily disabled until Cloudflare setup is ready. + # - name: Deploy to Cloudflare Pages + # # Only deploy from main on the canonical repo. This keeps PRs build-only, + # # keeps forks (no secrets) build-only, and because the wrangler command + # # below hardcodes `--branch=main` (a *production* deploy) prevents a + # # `workflow_dispatch` on a feature branch from overwriting the live site. + # if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'Fission-AI/OpenSpec' }} + # uses: cloudflare/wrangler-action@v3 + # with: + # apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + # accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # workingDirectory: website + # command: pages deploy out --project-name=openspec-docs --branch=main diff --git a/README.md b/README.md index 8876a8b3e1..7501ea94ab 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,18 @@ AI: Archived to openspec/changes/archive/2025-01-23-add-dark-mode/ +## Why teams adopt OpenSpec + +Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the hard part moves: a feature spans the API server, the web app, and a shared library; requirements are owned by one team and consumed by others; planning starts before any code exists. + +**[Stores](docs/stores-beta/user-guide.md)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo. + +- **Cross-repo features** — one change, one plan, even when the code lands in three repos. +- **Shared requirements** — a platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No drifting wiki. +- **Plan before code** — capture the plan in the store now; the code repos catch up later. + +> Stores are in **beta**. Start with the [Stores User Guide](docs/stores-beta/user-guide.md). + ## Quick Start **Requires Node.js 20.19.0 or higher.** @@ -150,7 +162,7 @@ AI coding assistants are powerful but unpredictable when requirements live only - **Agree before you build** — human and AI align on specs before code gets written - **Stay organized** — each change gets its own folder with proposal, specs, design, and tasks - **Work fluidly** — update any artifact anytime, no rigid phase gates -- **Use your tools** — works with 20+ AI assistants via slash commands +- **Use your tools** — works with 30+ AI assistants via slash commands ### How we compare diff --git a/docs/README.md b/docs/README.md index 627d76e31d..2e3df67b1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,10 @@ That second one matters more than it looks. OpenSpec has two halves: a command l **I learn by example.** The [Examples & Recipes](examples.md) page walks through real changes start to finish: a small feature, a bug fix, a refactor, an exploration. +**The AI just drafted a plan — now what?** Read it. [Reviewing a Change](reviewing-changes.md) shows the two-minute pass that catches a wrong turn while it's still cheap, and [Writing Good Specs](writing-specs.md) covers what a plan worth approving is made of. + +**I work on a team.** [OpenSpec on a Team](team-workflow.md) shows how a change maps onto a branch and a pull request, and how teammates review a plan before the code. + **I'm coming from the old workflow.** The [Migration Guide](migration-guide.md) explains what changed and why, and promises your existing work is safe. **I want to bend it to my team's process.** [Customization](customization.md) covers project config, custom schemas, and shared context. @@ -49,6 +53,9 @@ That second one matters more than it looks. OpenSpec has two halves: a command l |-----|-------------------| | [Workflows](workflows.md) | Common patterns and when to reach for each command | | [Examples & Recipes](examples.md) | Full walkthroughs of real changes, copy-pasteable | +| [Writing Good Specs](writing-specs.md) | What a strong requirement and scenario look like, and how to right-size a change | +| [Reviewing a Change](reviewing-changes.md) | The two-minute pass on a drafted plan before any code is written | +| [OpenSpec on a Team](team-workflow.md) | How changes fit branches, pull requests, and review | | [Using OpenSpec in an Existing Project](existing-projects.md) | Adopting OpenSpec on a large brownfield codebase | | [Editing & Iterating on a Change](editing-changes.md) | Update artifacts, go back, reconcile manual edits | | [Commands](commands.md) | Reference for every `/opsx:*` slash command | diff --git a/docs/editing-changes.md b/docs/editing-changes.md index dedeeb5785..e2fc830bdf 100644 --- a/docs/editing-changes.md +++ b/docs/editing-changes.md @@ -85,6 +85,7 @@ There's a full flowchart and worked examples in [Workflows: When to Update vs St ## Where to go next - [Workflows](workflows.md) - patterns, plus the update-vs-new decision guide +- [Reviewing a Change](reviewing-changes.md) - the two-minute pass on a plan before you build it - [Explore First](explore.md) - the place to step back to when an idea needs rethinking - [Commands](commands.md) - `/opsx:continue`, `/opsx:apply`, and `/opsx:verify` in detail - [Concepts: Artifacts](concepts.md#artifacts) - what each artifact is for diff --git a/docs/getting-started.md b/docs/getting-started.md index 7bb46c4e80..caf6bf846b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -275,6 +275,8 @@ openspec view ## Next Steps - [Explore First](explore.md) - Use `/opsx:explore` to think through an idea before you commit +- [Reviewing a Change](reviewing-changes.md) - What to check in the plan the AI drafts, before any code +- [Writing Good Specs](writing-specs.md) - What a strong requirement and scenario look like - [Using OpenSpec in an Existing Project](existing-projects.md) - Start on a large brownfield codebase - [Editing & Iterating on a Change](editing-changes.md) - Update artifacts, go back, reconcile manual edits - [Core Concepts at a Glance](overview.md) - The whole mental model on one page diff --git a/docs/reviewing-changes.md b/docs/reviewing-changes.md new file mode 100644 index 0000000000..c99f6bca37 --- /dev/null +++ b/docs/reviewing-changes.md @@ -0,0 +1,143 @@ +# Reviewing a Change + +OpenSpec's whole promise is that you and your AI **agree on what to build before any code is written.** That agreement only means something if you actually read what the AI drafted. This page is about the two minutes where you do that — what to open, in what order, and what to look for. + +The bet is simple: catching a wrong turn in a one-paragraph plan is nearly free. Catching the same wrong turn in 300 lines of code is not. Review is where you collect on that bet. + +## The two moments you review + +There are exactly two: + +``` +/opsx:propose ──► REVIEW THE PLAN ──► /opsx:apply ──► REVIEW THE CODE ──► /opsx:archive + (before any code) (/opsx:verify) +``` + +1. **After `/opsx:propose`** (or `/opsx:ff`), before `/opsx:apply` — read the plan while it's still just words. +2. **After building**, with `/opsx:verify` — check that the code actually did what the plan said. + +The first review is the one that saves you the most, and the one people skip. This page spends most of its time there. + +## Read it in this order + +A change is a folder of plain Markdown in `openspec/changes//`. Read the files in the order that lets you quit earliest if something's wrong: + +``` +openspec/changes/add-dark-mode/ +├── proposal.md 1. the intent and scope ← if this is wrong, stop here +├── specs/…/spec.md 2. the requirements ← the heart of the review +├── design.md (only for bigger changes) — the technical approach +└── tasks.md 3. the plan of work +``` + +You don't need to read every line. You need to answer three questions, one per file. + +## The proposal: is this the right problem? + +Open `proposal.md` first. It captures the "why" and "what" — the intent, the scope, the approach in a paragraph or two. + +**What good looks like:** one clear intent, a scope you recognize, and a reason this is worth doing now. + +**Red flags:** + +- It solves a slightly *different* problem than the one you asked for. +- The scope has grown — you asked for a theme toggle and the proposal also touches auth "while we're in there." +- It's vague. "Improve the settings page" is not a scope; "add a dark-mode toggle that respects the OS preference" is. + +**The question to answer:** *Does this match what I actually asked for, and is anything sneaking in?* If the answer is no, stop — don't read further, fix the proposal (see [Pushing back](#pushing-back-is-cheap)). + +## The spec deltas: is "done" defined correctly? + +This is the heart of the review. The delta specs under `specs/` say what will be *true* when the change ships — as requirements and the scenarios that prove them: + +```markdown +## ADDED Requirements + +### Requirement: Dark Mode Toggle +The system SHALL let a user switch between light and dark themes. + +#### Scenario: Respects the OS preference on first load +- GIVEN a user who has never set a theme +- WHEN they open the app on a device set to dark mode +- THEN the app renders in dark mode +``` + +**What a good requirement looks like:** one clear `SHALL`/`MUST` statement you could hand to a tester, and at least one scenario whose GIVEN/WHEN/THEN actually exercises that statement. + +**Red flags:** + +- **A vague requirement.** "The system SHALL be fast" can't be built or tested. What's fast? +- **A requirement with no scenario**, or a scenario that doesn't test the requirement it sits under. +- **The most valuable catch of all: what's missing.** The AI faithfully writes down what you *said*. Your job is to notice what you *forgot* to say. If you cared most about the OS-preference case and no scenario mentions it, that's the review paying for itself. + +Read the deltas asking *would I be happy if the system did exactly — and only — this?* Nothing here is about code yet, so it stays cheap to change. + +## The tasks: is the plan of work sane? + +Open `tasks.md` last. It's the implementation checklist the AI will work through. + +**What good looks like:** ordered steps, each traceable to a requirement, nothing mysterious. + +**Red flags:** + +- A task with no matching requirement (where did that come from?). +- One giant "implement the feature" task that hides all the real decisions. +- A task that touches something outside the scope you just approved. + +You're not estimating or micromanaging here — you're checking that the plan matches the requirements you already accepted. + +## Pushing back is cheap + +If any of the three questions came back wrong, say so. There are no phases and nothing is locked — you fix it and move on. Two ways, exactly as in [Editing a change](editing-changes.md): + +- **Edit the file yourself.** It's plain Markdown; change the scope line, tighten a requirement, delete a task. +- **Tell the AI what's wrong** and let it revise: *"drop the auth changes — out of scope,"* *"add a scenario for when the user has already picked a theme,"* *"split task 3 into schema and UI."* + +Then re-read the part you changed. Re-draft until it's a plan you'd sign your name to. That back-and-forth *is* the product working. + +## After the code: verify + +Once the work is built, `/opsx:verify` is your second review. It re-reads the artifacts and the code and reports mismatches across three dimensions: + +| Dimension | What it checks | +|-----------|----------------| +| **Completeness** | Every task done, every requirement implemented, scenarios covered | +| **Correctness** | The implementation matches the spec's intent, edge cases handled | +| **Coherence** | Design decisions actually show up in the code | + +``` +You: /opsx:verify + +AI: Verifying add-dark-mode... + + COMPLETENESS + ✓ All 8 tasks in tasks.md are checked + ✓ All requirements in specs have corresponding code + ⚠ Scenario "Respects the OS preference on first load" has no test coverage +``` + +It flags issues as CRITICAL, WARNING, or SUGGESTION, and it does **not** block archiving — it surfaces the gaps and leaves the call to you. This is the difference between "did the AI write code" and "did it build what we agreed." + +`/opsx:verify` is in the expanded profile. If you don't have it, turn it on with `openspec config profile` (then `openspec update`), or just re-read the change and the diff yourself. + +## Right-size the review + +Not every change earns the full pass. A one-file typo fix deserves a twenty-second skim. A change that touches auth, payments, or data you can't recover deserves every question above. The point was never ceremony — it's spending your attention where a mistake would be expensive, and skimming where it wouldn't. + +## The two-minute checklist + +- [ ] The proposal's intent matches what I asked for. +- [ ] Nothing extra has crept into the scope. +- [ ] Every requirement is specific enough to test. +- [ ] Every requirement has a scenario that actually exercises it. +- [ ] The case I care about most is covered. +- [ ] Tasks map to requirements; nothing is mysterious or out of scope. +- [ ] I'd be comfortable if the AI built exactly this and nothing more. + +If all seven pass, run `/opsx:apply` with confidence. If any fail, that's not a setback — it's the two minutes doing its job. + +## Where to go next + +- [Writing Good Specs](writing-specs.md) — the flip side: how to draft requirements and scenarios worth approving. +- [Editing & Iterating on a Change](editing-changes.md) — the mechanics of changing a plan after you've started. +- [Workflows](workflows.md) — where review fits in the larger loop. diff --git a/docs/team-workflow.md b/docs/team-workflow.md new file mode 100644 index 0000000000..76c83817b8 --- /dev/null +++ b/docs/team-workflow.md @@ -0,0 +1,74 @@ +# OpenSpec on a Team + +Everything in the other guides works the same whether you're solo or on a team of twenty. What changes on a team is the questions around the edges: where do the specs live, how do teammates review a plan, and how does any of this fit the pull-request flow we already have? + +The short answer: a change is just files, and OpenSpec never touches git. So it fits your existing workflow instead of replacing it. This page spells out the conventions that work well. + +## One rule: OpenSpec doesn't touch git + +OpenSpec reads and writes plain Markdown under `openspec/`. It never commits, branches, pushes, or pulls in your project — and it never clones or syncs a [store](stores-beta/user-guide.md) on its own. That means: + +- **You commit `openspec/` like any source.** Specs, active changes, and the archive are part of your project's history. (Yes, commit the whole folder — see the [FAQ](faq.md#should-i-commit-the-openspec-folder-to-git).) +- **A change is a folder you version like code.** `openspec/changes/add-dark-mode/` is just files on a branch. +- **Everything below is convention, not enforcement.** OpenSpec won't make you do it this way; it just fits cleanly. + +## The everyday loop + +The workflow that works well maps a change onto a branch and a pull request: + +``` +git switch -c add-dark-mode start a branch, as usual + │ +/opsx:propose add-dark-mode draft the plan (proposal + specs + tasks) + │ +REVIEW THE PLAN you read it before any code — see Reviewing a Change + │ +/opsx:apply build it; artifacts + code change together + │ +git commit && open a PR the PR contains the spec delta AND the code + │ +teammate reviews, merges + │ +/opsx:archive fold the delta into specs/, move the change to archive/ +``` + +The plan and the code live side by side in the same branch, so your teammates review both together, and six months later the archived spec still explains why the code looks the way it does. + +## Reviewing specs in a pull request + +This is where a team feels the payoff. When a PR includes the change's delta spec, the reviewer gets something a raw diff never gives them: **a plain-language statement of what this change is supposed to do**, before they read a single line of code. + +A good review order for the reviewer: + +1. **Read `proposal.md`** — is this the right problem and scope? +2. **Read the delta under `specs/`** — is "done" defined correctly? (This is the [Reviewing a Change](reviewing-changes.md) two-minute pass, now happening in the PR.) +3. **Then read the code diff** — does it deliver exactly those requirements? + +A reviewer who disagrees with the *approach* can say so against the proposal, cheaply, instead of relitigating it across 300 lines of code. Put the delta spec near the top of the PR description, or point reviewers at the change folder, so they start there. + +## When to archive + +Archiving folds a change's deltas into your main `openspec/specs/` and moves the change folder to `openspec/changes/archive/YYYY-MM-DD-/`. Because `specs/` is the **shared source of truth**, the timing matters on a team. Two workable conventions: + +- **Archive after the PR merges (recommended).** The branch carries the active change; once it's merged to your main branch, archive there (often a tiny follow-up commit or a scheduled cleanup). This keeps the shared `specs/` moving forward only with work that actually shipped. +- **Archive inside the PR.** Simpler for small teams: the same PR that adds the code also syncs and archives. The tradeoff is that your `specs/` diff and your code diff land together, which can make the PR noisier. + +Pick one and be consistent. Either way, `/opsx:archive` checks that tasks are complete and offers to sync first, so nothing merges half-finished by accident. + +## Two people, parallel changes + +Because changes are separate folders, they don't collide: + +- **Different changes, different people — no problem.** `add-dark-mode` and `rate-limit-login` are different folders on different branches; they never touch each other until they both archive. +- **One change, one owner.** Two people editing the same change folder conflict exactly like two people editing the same file. Keep a change to a single author, or split it into two changes (another reason to [right-size](writing-specs.md#right-size-the-change)). +- **The one place conflicts show up is `specs/`.** If two changes both modify the *same* requirement, archiving the second one will conflict in `openspec/specs/…/spec.md` — resolve it like any merge conflict, keeping the requirement that reflects reality. This is rare, and it's a feature: it's git telling you two changes disagreed about how the system should behave. + +## When planning outgrows one repo + +Everything above assumes the plan lives in the code repo's own `openspec/` folder, which is the right default. When your planning genuinely spans several repos or teams — one feature touching three services, or requirements one team owns and others consume — that's what the beta **stores** feature is for: planning gets its own repo that any code repo can point at. Start with the [Stores User Guide](stores-beta/user-guide.md). + +## Where to go next + +- [Reviewing a Change](reviewing-changes.md) — the review pass, now inside your PR. +- [Writing Good Specs](writing-specs.md) — including how to right-size a change so it fits one branch. +- [Stores User Guide](stores-beta/user-guide.md) — planning that spans repos and teams. diff --git a/docs/workflows.md b/docs/workflows.md index e333ca6efc..82f1e1efa2 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -474,6 +474,9 @@ For full command details and options, see [Commands](commands.md). ## Next Steps +- [Writing Good Specs](writing-specs.md) - What a strong requirement and scenario look like, and how to right-size a change +- [Reviewing a Change](reviewing-changes.md) - The two-minute pass on a drafted plan before any code +- [OpenSpec on a Team](team-workflow.md) - How changes fit branches and pull requests - [Commands](commands.md) - Full command reference with options - [Concepts](concepts.md) - Deep dive into specs, artifacts, and schemas - [Customization](customization.md) - Create custom workflows diff --git a/docs/writing-specs.md b/docs/writing-specs.md new file mode 100644 index 0000000000..9e21e6cd80 --- /dev/null +++ b/docs/writing-specs.md @@ -0,0 +1,101 @@ +# Writing Good Specs + +You rarely write a spec from a blank page. You describe a change in plain language, `/opsx:propose` drafts the requirements and scenarios, and then you make them good. This page is about that last part — what "good" looks like, and how to steer the AI toward it. + +It's the companion to [Reviewing a Change](reviewing-changes.md): reviewing is catching the weak spots in a draft, writing is knowing what a strong one is made of. + +## A spec is behavior, not code + +A spec says what your system *does*, in terms anyone could check — not how it's built. It's made of **requirements** (statements of behavior) and **scenarios** (concrete examples that prove them). + +```markdown +### Requirement: Session Timeout +The system SHALL expire a session after 30 minutes of inactivity. + +#### Scenario: Idle timeout +- GIVEN an authenticated session +- WHEN 30 minutes pass with no activity +- THEN the session is invalidated and the user must re-authenticate +``` + +Keep the *how* — the queue, the library, the table schema — in `design.md` or the code. When behavior and implementation get mixed into one requirement, the requirement stops being testable and starts going stale the moment the code changes. + +## What makes a good requirement + +A good requirement is one behavior, stated so plainly you could hand it to someone else to test. + +- **One statement, one `SHALL`/`MUST`.** If a requirement has three "and also" clauses, it's really three requirements. Split them. +- **Observable.** Someone outside the code should be able to tell whether it holds. "The system SHALL show an error banner when the upload exceeds 10 MB" is observable. "The system SHALL handle large uploads gracefully" is not. +- **The right strength.** OpenSpec uses the RFC 2119 keywords, and they mean different things: + + | Keyword | Meaning | + |---------|---------| + | `MUST` / `SHALL` | A hard requirement. Non-negotiable. | + | `SHOULD` | A strong recommendation, with room for a justified exception. | + | `MAY` | Genuinely optional. | + + Reach for `MUST`/`SHALL` by default. Use `SHOULD` only when you truly mean "unless there's a good reason not to." + +The test for a requirement: *could a tester who's never seen the code tell whether it passed?* If not, it needs sharpening. + +## What makes a good scenario + +Scenarios are where a requirement earns its keep. Each one is a concrete GIVEN / WHEN / THEN that could become an automated test. + +- **It exercises its requirement.** A scenario that just restates the requirement in other words tests nothing. Make it a specific situation with a specific outcome. +- **Cover the cases that matter, not just the happy path.** The valid login is easy. The empty input, the expired token, the second click, the thing that goes wrong — those are where bugs live, and where a scenario is worth the most. +- **Name the case in the title.** "Scenario: Rejects an expired token" tells a reviewer what's covered at a glance; "Scenario: Test 2" doesn't. + +A useful habit: before approving, ask *what's the one case I'd be upset to see broken?* — and make sure a scenario names it. + +## Pick the right kind of delta + +A change describes its edits to the specs with three section types. Using the right one keeps your archived specs honest: + +- **`## ADDED Requirements`** — brand-new behavior that didn't exist before. +- **`## MODIFIED Requirements`** — behavior that already existed and is changing. Include the full new version; a short note on what changed helps a reviewer. +- **`## REMOVED Requirements`** — behavior going away, with a line on why. + +On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is deleted. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there. + +## Right-size the change + +The single most common authoring mistake isn't a badly worded requirement — it's a change that's trying to be three changes. + +**A good change has one intent you can say in a sentence.** "Add a dark-mode toggle." "Rate-limit the login endpoint." "Migrate sessions off cookies." If describing the change needs a lot of "and also," that's the signal to split it. + +Signs a change is too big: + +- The proposal's scope reads like a list of unrelated features. +- Reviewing it would take an afternoon, so nobody will. +- Two people couldn't work on it without colliding. +- Half the tasks could ship on their own. + +Smaller changes are easier to review, easier to build in one focused session, and easier to reason about six months later when the archive is all that's left. You can always run several changes in parallel — see [Editing & iterating](editing-changes.md) and [Workflows](workflows.md). + +The opposite also happens: a one-line typo fix doesn't need three requirements and a design doc. Match the ceremony to the stakes. + +## How to steer the AI toward a good draft + +Because `/opsx:propose` does the first draft, the quality of what you get back tracks the quality of what you give it. You don't have to write requirements by hand — you have to aim the AI well: + +- **State the intent and the boundary.** *"Add a dark-mode toggle that follows the OS setting on first load — don't touch the existing theme API."* The out-of-scope half matters as much as the in-scope half. +- **Name the cases you care about.** *"Make sure there's a scenario for a user who already picked a theme manually."* The AI covers what you point at. +- **Then edit.** It's plain Markdown. Tighten a vague `SHALL`, delete a scenario that tests nothing, add the case it missed — or ask the AI to: *"the timeout requirement is vague, pin it to 30 minutes."* + +Draft, sharpen, repeat. A few rounds of that produces a spec you'd trust, which is the whole point. + +## A quick checklist + +- [ ] Each requirement is one observable behavior with a `SHALL`/`MUST`. +- [ ] No implementation details are baked into the requirements. +- [ ] Every requirement has at least one scenario that actually exercises it. +- [ ] The important edge and error cases have scenarios, not just the happy path. +- [ ] Deltas use ADDED / MODIFIED / REMOVED correctly against the current spec. +- [ ] The whole change has one intent you can state in a sentence. + +## Where to go next + +- [Reviewing a Change](reviewing-changes.md) — the two-minute pass that catches what slipped through. +- [Concepts](concepts.md) — the deeper model behind specs, changes, and deltas. +- [Examples & Recipes](examples.md) — real changes from start to finish. diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000000..a37b1c4815 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,29 @@ +# deps +/node_modules + +# generated content +.source + +# docs pages are generated from ../docs by scripts/sync-docs.mjs (npm run build) +/content/docs + +# test & build +/coverage +/.next/ +/out/ +/build +*.tsbuildinfo + +# misc +.DS_Store +*.pem +/.pnp +.pnp.js +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# others +.env*.local +.vercel +next-env.d.ts \ No newline at end of file diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..5f2b55a2ca --- /dev/null +++ b/website/README.md @@ -0,0 +1,159 @@ +# OpenSpec documentation site + +The marketing and documentation site for [OpenSpec](https://github.com/Fission-AI/OpenSpec), built with [Fumadocs](https://fumadocs.dev) and [Next.js](https://nextjs.org). It is configured as a **static export**, so it deploys to Cloudflare Pages (or any static host) with no server. + +> **The doc pages are generated, not authored here.** The repository's `docs/*.md` files are the single source of truth. `scripts/sync-docs.mjs` mirrors them into `content/docs/` (as `.md`) on every build, so the site stays current automatically — locally and in CI. Edit `../docs`, not `content/docs/`. Only the marketing landing page (`app/(home)/page.tsx`) is hand-authored. See [Keeping docs in sync](#keeping-docs-in-sync). + +## Quick start + +```bash +cd website +pnpm install +pnpm run dev # http://localhost:3000 +``` + +| Script | What it does | +|--------|--------------| +| `pnpm run sync:docs` | Mirror `../docs/*.md` into `content/docs/` | +| `pnpm run dev` | Sync docs, then start the dev server with hot reload | +| `pnpm run build` | Sync docs, then produce the static site in `out/` | +| `pnpm run start` | Serve the built `out/` directory locally | +| `pnpm run types:check` | Sync docs, generate types, and run `tsc --noEmit` | + +`sync:docs` runs automatically inside `dev`, `build`, and `types:check`, so you rarely call it directly. + +## Deploy to Cloudflare Pages + +This site is a pure static export — `pnpm run build` writes plain HTML, CSS, JS, a +prebuilt search index, and `llms.txt` into `out/`. Point Cloudflare Pages at this +directory and use these settings: + +| Setting | Value | +|---------|-------| +| Root directory | `website` | +| Build command | `pnpm run build` | +| Build output directory | `out` | +| Node version | `20.19.0` or higher (set `NODE_VERSION` if needed) | + +Set one environment variable so social/Open Graph image URLs resolve to your real +domain: + +| Variable | Example | +|----------|---------| +| `NEXT_PUBLIC_SITE_URL` | `https://your-docs-domain.com` | + +That's it. No Workers, adapters, or server runtime are required. (If you later +want server-side rendering on Cloudflare Workers instead, swap `output: 'export'` +in `next.config.mjs` for the `@opennextjs/cloudflare` adapter — but the static +path above is the simplest and is what this site is tuned for.) + +### Deploy with Wrangler (optional) + +```bash +pnpm run build +npx wrangler pages deploy out --project-name openspec-docs +``` + +## Keeping docs in sync + +The doc pages are a **mechanical mirror** of the repository's `docs/*.md`. There +is nothing to hand-edit under `content/docs/` — those files are generated and +git-ignored. + +**To change a page's content:** edit the corresponding file in `../docs`. The +next `pnpm run build`/`pnpm run dev` regenerates the site from it. + +**To add, remove, reorder, or re-slug a page, or change its sidebar section or +icon:** edit `docs.sync.config.mjs`. That manifest is the single place that +decides which docs are published and how they appear. `scripts/sync-docs.mjs` +then: + +- derives each page's title from its leading `# H1` and a description from its + first paragraph, and injects Fumadocs frontmatter (including `githubSource`, so + the "edit this page" link opens the real `docs/*.md`); +- rewrites internal `*.md` links to their on-site `/docs/...` routes; +- writes each page as `.md` (Fumadocs parses `.md` as plain Markdown, so + `` and `{braces}` in the docs are treated literally and never + break the build); +- regenerates `content/docs/meta.json` and `content/docs/reference/meta.json`. + +Because the docs are the source, the site cannot drift from them: every build +re-mirrors, and CI redeploys on a schedule (see below). + +## Automated deploys + +`.github/workflows/deploy-docs.yml` rebuilds the mirror and deploys the static +export to Cloudflare Pages via Wrangler: + +- on every push to `main` that touches `docs/**` or `website/**`, +- daily on a schedule (so docs merged elsewhere still go live), +- manually via the Actions tab, +- and as a build-only check on pull requests (never deploys). + +Once the site changes, that's it — a `docs/*.md` edit merged to `main` re-mirrors +and redeploys with no manual step. + +### One-time deploy setup (maintainer) + +The workflow is ready, but auto-deploy stays dormant until these three are done. +Until then, docs still mirror correctly on build — they just don't reach +Cloudflare on their own. + +1. **Create the Cloudflare Pages project** named `openspec-docs`, with its + production branch set to `main`. Once, via the dashboard or: + + ```bash + npx wrangler pages project create openspec-docs --production-branch main + ``` + + (Non-interactive CI can't create it on the fly, so this must exist first.) + +2. **Add two repository secrets** (Settings → Secrets and variables → Actions): + + | Secret | Where to get it | + |--------|-----------------| + | `CLOUDFLARE_API_TOKEN` | Cloudflare dashboard → My Profile → API Tokens → "Edit Cloudflare Pages" template | + | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare dashboard → Workers & Pages → Account ID | + + Optional: set a repository **variable** `DOCS_SITE_URL` to the site's public URL + (used for Open Graph / sitemap absolute links). Without it, the build falls + back to `https://openspec.dev`, so this is not required. + +3. **Merge this to `main`.** GitHub Actions only runs the `push`-to-`main` and + scheduled triggers from workflows on the default branch, so the automation + activates when the PR merges. + +To smoke-test before merging: run the workflow by hand from the **Actions** tab +(**workflow_dispatch**) once the project and secrets exist. + +### Landing page — a maintainer decision + +The current [openspec.dev](https://openspec.dev) landing page is a separate Astro +site. This site ships its own Fumadocs landing page at `app/(home)/page.tsx` +(the only hand-authored page here; everything under `/docs` is mirrored). Whether +to keep this landing page, port the Astro one into it, or point Pages only at +`/docs` is a maintainer call — nothing else in this pipeline depends on it. + +## Project structure + +```text +website/ +├── app/ # Next.js App Router +│ ├── (home)/page.tsx # the marketing landing page +│ ├── docs/ # docs layout + catch-all page +│ ├── api/search/ # static search index route +│ ├── llms.txt / llms-full.txt / llms.mdx/ # machine-readable docs for AI +│ └── og/ # generated Open Graph images per page +├── content/docs/ # ← GENERATED from ../docs (git-ignored, do not edit) +├── docs.sync.config.mjs # which docs publish + their slug/section/icon +├── scripts/sync-docs.mjs # mirrors ../docs/*.md -> content/docs/ +├── lib/ +│ ├── shared.ts # site name, URLs, GitHub/Discord links +│ ├── source.ts # Fumadocs content source + sidebar icons +│ └── layout.shared.tsx # shared nav/header options +├── components/ # MDX components, search dialog, root provider +├── next.config.mjs # static export config +└── source.config.ts # Fumadocs MDX collection config +``` + +Built with [Fumadocs](https://fumadocs.dev). diff --git a/website/app/(home)/layout.tsx b/website/app/(home)/layout.tsx new file mode 100644 index 0000000000..77379fac3f --- /dev/null +++ b/website/app/(home)/layout.tsx @@ -0,0 +1,6 @@ +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/'>) { + return {children}; +} diff --git a/website/app/(home)/page.tsx b/website/app/(home)/page.tsx new file mode 100644 index 0000000000..727d5c209e --- /dev/null +++ b/website/app/(home)/page.tsx @@ -0,0 +1,635 @@ +import Link from 'next/link'; +import { + ArrowRight, + Boxes, + Check, + Clock, + Compass, + FileText, + GitBranch, + Hammer, + Archive, + Layers, + ListChecks, + Share2, + Sparkles, +} from 'lucide-react'; +import { docsRoute, links } from '@/lib/shared'; + +export default function HomePage() { + return ( +
+ + + + + + + + + + + +
+ ); +} + +function Hero() { + return ( +
+
+
+ + + The lightweight spec layer for AI coding + +

+ Agree first. +
+ Then build confidently. +

+

+ OpenSpec is a tiny agreement layer between you and your AI. You write + down what a change should do, the AI drafts the details, you both look + at the same plan, and only then does code get written. No more + discovering halfway through that it built the wrong thing. +

+
+ + Get started + + + Star on GitHub + +
+ +
+
+ ); +} + +function Terminal() { + return ( +
+
+
+ + + + + your-project — AI chat + +
+
+          
+            /opsx:propose add-dark-mode
+            {'\n'}
+            
+              {'  '}✓ proposal.md — why we are doing this, what changes{'\n'}
+              {'  '}✓ specs/ — requirements and scenarios{'\n'}
+              {'  '}✓ design.md — technical approach{'\n'}
+              {'  '}✓ tasks.md — implementation checklist{'\n'}
+            
+            {'\n'}
+            /opsx:apply
+            {'\n'}
+            
+              {'  '}✓ working through tasks, checking each one off…{'\n'}
+            
+            {'\n'}
+            /opsx:archive
+            {'\n'}
+            
+              {'  '}✓ specs updated · change filed away · ready for the next one
+            
+          
+        
+
+
+ ); +} + +const PHILOSOPHY = [ + ['fluid', 'not rigid'], + ['iterative', 'not waterfall'], + ['easy', 'not complex'], + ['brownfield', 'not just greenfield'], +]; + +function Philosophy() { + return ( +
+
+ {PHILOSOPHY.map(([a, b]) => ( +
+
{a}
+
{b}
+
+ ))} +
+
+ ); +} + +function TwoFolders() { + return ( +
+
+

+ The whole idea, in two folders +

+

+ OpenSpec lives in one openspec/{' '} + directory in your repo. Two folders inside it carry the entire mental + model. +

+
+
+
+
+ +
+

+ specs/ — what is true +

+

+ The source of truth. Plain-language requirements and scenarios that + describe how your system behaves right now, organized by + domain. This is the agreed-upon answer to “what does this + software do?” +

+
+
+
+ +
+

+ changes/ — what you are proposing +

+

+ One folder per change. Each holds a proposal, a design, a task list, + and a small spec delta. When the work is done, you archive it and the + delta folds into the truth. The cycle closes. +

+
+
+
+ ); +} + +const IDEAS = [ + { + icon: FileText, + title: 'Specs are the truth', + body: 'Requirements and scenarios describe how your system behaves today. One agreed-upon answer, in your repo, readable by humans and AI alike.', + }, + { + icon: GitBranch, + title: 'A change is one unit of work', + body: 'One feature, one folder. Proposal, design, tasks, and spec edits all live together. Easy to review, easy to reason about.', + }, + { + icon: Layers, + title: 'Deltas, not rewrites', + body: 'You describe what is changing — ADDED, MODIFIED, REMOVED — not the whole world. That is the trick that makes OpenSpec great at brownfield code.', + }, + { + icon: Compass, + title: 'Enablers, not gates', + body: 'Artifacts build on each other in a natural order, but nothing locks. Learn something mid-build? Edit the plan and keep going.', + }, +]; + +function FiveIdeas() { + return ( +
+
+
+

+ Learn four ideas, and the rest is detail +

+

+ Everything in OpenSpec is built from a handful of simple concepts. +

+
+
+ {IDEAS.map(({ icon: Icon, title, body }) => ( +
+ +

{title}

+

{body}

+
+ ))} +
+
+
+ ); +} + +const STEPS = [ + { + icon: Compass, + cmd: '/opsx:explore', + label: 'optional', + body: 'A no-stakes thinking partner. It reads your code, weighs options, and turns a fuzzy idea into a concrete plan.', + }, + { + icon: FileText, + cmd: '/opsx:propose', + body: 'The AI drafts the proposal, spec deltas, design, and a task list. You read it and adjust before any code is written.', + }, + { + icon: Hammer, + cmd: '/opsx:apply', + body: 'The AI builds it, working through the tasks and checking each one off as it goes.', + }, + { + icon: Archive, + cmd: '/opsx:archive', + body: 'Spec deltas merge into the truth and the change is filed away with a date stamp. Ready for the next one.', + }, +]; + +function TheLoop() { + return ( +
+
+

The loop you run

+

+ Two terminal commands to set up. After that, you live in your AI chat. +

+
+
    + {STEPS.map(({ icon: Icon, cmd, label, body }, i) => ( +
  1. +
    + + + {label ?? `step ${i + 1}`} + +
    + + {cmd} + +

    {body}

    +
  2. + ))} +
+
+ ); +} + +function Why() { + return ( +
+
+
+

+ Why bother with the extra step? +

+

+ OpenSpec adds one small step — a short plan before building. Here is + what you get for it. +

+
+
+ {[ + [ + 'Catch wrong turns early', + 'Fixing a misunderstanding in a one-paragraph proposal is free. Fixing it after 400 lines of code is not.', + ], + [ + 'The plan lives with the code', + 'Six months later, the spec tells you and the next AI session why the system works the way it does.', + ], + [ + 'Changes are reviewable', + 'A change folder is a tidy package: read the proposal, skim the deltas, check the tasks. No chat archaeology.', + ], + [ + 'It fits existing codebases', + 'Deltas mean you can specify a change to a 50,000-line app without first documenting the whole thing.', + ], + ].map(([title, body]) => ( +
+ +
+
{title}
+

{body}

+
+
+ ))} +
+
+
+ ); +} + +const TEAM_SCENARIOS = [ + { + icon: Share2, + title: 'Cross-repo features', + body: 'One change, one plan — even when the code lands in the API server, the web app, and a shared library. No more "whose openspec/ folder does this live in?"', + }, + { + icon: Boxes, + title: 'Shared requirements', + body: 'A platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No more drifting wiki.', + }, + { + icon: Clock, + title: 'Plan before code', + body: 'Capture the plan in the store now, while it is just an idea. The code repos catch up later — the thinking is already recorded and reviewed.', + }, +]; + +function Teams() { + return ( +
+
+
+

+ For teams +

+

+ Why teams adopt OpenSpec +

+

+ Solo, OpenSpec keeps you and your AI honest on one repo. On a team, + the hard part moves: work spans repos, requirements cross team lines, + and planning starts before code exists. OpenSpec{' '} + + stores + {' '} + put planning in a repo of its own — one source of truth your whole + team and every coding agent can read, shared by{' '} + git push like anything else. +

+
+
+ {TEAM_SCENARIOS.map(({ icon: Icon, title, body }) => ( +
+
+ +
+

{title}

+

{body}

+
+ ))} +
+
+ + Explore stores + + + Beta + +
+
+
+ ); +} + +const TOOLS = [ + 'Claude Code', + 'Cursor', + 'Codex', + 'Windsurf', + 'Gemini CLI', + 'GitHub Copilot', + 'Cline', + 'RooCode', + 'Kilo Code', + 'Amazon Q', + 'OpenCode', + 'Qwen Code', + 'Kiro', + 'Continue', + 'Factory Droid', +]; + +function ToolStrip() { + return ( +
+

+ Works with the tools you already use +

+
+ {TOOLS.map((t) => ( + + {t} + + ))} + + + 15 more + +
+
+ ); +} + +const ARTIFACTS = [ + { + icon: FileText, + file: 'proposal.md', + caption: 'The why and what', + code: `# Proposal: Add Dark Mode + +## Intent +Reduce eye strain at night and +match the user's system theme. + +## Scope +- Theme toggle in settings +- System-preference detection +- Persist the choice`, + }, + { + icon: Layers, + file: 'specs/ui/spec.md', + caption: 'The delta — what changes', + code: `# Delta for UI + +## ADDED Requirements + +### Requirement: Theme Selection +The system SHALL let users choose +light or dark. + +#### Scenario: Manual toggle +- WHEN the toggle is clicked +- THEN the theme switches at once`, + }, + { + icon: ListChecks, + file: 'tasks.md', + caption: 'The checklist', + code: `# Tasks + +## 1. Theme Infrastructure +- [ ] 1.1 ThemeContext + state +- [ ] 1.2 CSS custom properties +- [ ] 1.3 localStorage persistence + +## 2. UI +- [ ] 2.1 ThemeToggle component`, + }, +]; + +function Anatomy() { + return ( +
+
+

+ What a change actually looks like +

+

+ Plain Markdown files your AI drafts and you review. No new formats to + learn, nothing you cannot read at a glance. +

+
+
+ {ARTIFACTS.map(({ icon: Icon, file, caption, code }) => ( +
+
+ + {file} +
+
+              {code}
+            
+
+ {caption} +
+
+ ))} +
+
+ ); +} + +const ROWS = [ + { + name: 'Spec Kit', + by: 'GitHub', + good: 'Thorough and structured', + catch: 'Rigid phase gates, lots of Markdown, Python setup', + us: false, + }, + { + name: 'Kiro', + by: 'AWS', + good: 'Powerful and integrated', + catch: 'Locked into their IDE and a limited set of models', + us: false, + }, + { + name: 'No specs', + by: 'the default', + good: 'Zero overhead', + catch: 'Vague prompts, unpredictable results, no record of why', + us: false, + }, + { + name: 'OpenSpec', + by: '', + good: 'Lightweight, fluid, lives in your repo', + catch: 'Adds one small step — worth it whenever agreement matters', + us: true, + }, +]; + +function Comparison() { + return ( +
+
+

The honest middle

+

+ Heavier tools exist. So does doing nothing. OpenSpec aims for the + spot where the value clearly beats the cost. +

+
+
+ {ROWS.map((r) => ( +
+
+ {r.us && } + {r.name} + {r.by && ( + + {r.by} + + )} +
+
+ {r.good}.{' '} + {r.catch}. +
+
+ ))} +
+
+ ); +} + +function FinalCta() { + return ( +
+

+ Ship your first change in five minutes +

+

+ Works with 30+ AI assistants — Claude Code, Cursor, Codex, Windsurf, + Gemini CLI, and more. +

+
+ $ + npm install -g @fission-ai/openspec@latest +
+
+ + Read the getting-started guide + +
+
+ ); +} diff --git a/website/app/api/search/route.ts b/website/app/api/search/route.ts new file mode 100644 index 0000000000..aaaff7ffd1 --- /dev/null +++ b/website/app/api/search/route.ts @@ -0,0 +1,9 @@ +import { source } from '@/lib/source'; +import { createFromSource } from 'fumadocs-core/search/server'; + +export const revalidate = false; + +export const { staticGET: GET } = createFromSource(source, { + // https://docs.orama.com/docs/orama-js/supported-languages + language: 'english', +}); diff --git a/website/app/docs/[[...slug]]/page.tsx b/website/app/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000000..1d2d034421 --- /dev/null +++ b/website/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,69 @@ +import { getPageImage, getPageMarkdownUrl, source } from '@/lib/source'; +import { + DocsBody, + DocsPage, + DocsTitle, + MarkdownCopyButton, + ViewOptionsPopover, +} from 'fumadocs-ui/layouts/docs/page'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/components/mdx'; +import type { Metadata } from 'next'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { gitConfig } from '@/lib/shared'; + +export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + const MDX = page.data.body; + const markdownUrl = getPageMarkdownUrl(page).url; + + return ( + + {page.data.title} + {/* + The frontmatter `description` is derived from the page's first paragraph + (see scripts/sync-docs.mjs), so rendering it here as a subtitle would + just duplicate the opening paragraph of the body below. We keep it in + `generateMetadata` for SEO/OG, but omit the on-page . + */} +
+ + +
+ + + +
+ ); +} + +export async function generateStaticParams() { + return source.generateParams(); +} + +export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + images: getPageImage(page).url, + }, + }; +} diff --git a/website/app/docs/layout.tsx b/website/app/docs/layout.tsx new file mode 100644 index 0000000000..a373143bf4 --- /dev/null +++ b/website/app/docs/layout.tsx @@ -0,0 +1,11 @@ +import { source } from '@/lib/source'; +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/docs'>) { + return ( + + {children} + + ); +} diff --git a/website/app/global.css b/website/app/global.css new file mode 100644 index 0000000000..f9eb064351 --- /dev/null +++ b/website/app/global.css @@ -0,0 +1,21 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; + +/* OpenSpec brand accent — a confident indigo that reads well on light and dark. */ +:root { + --color-fd-primary: #4f46e5; +} + +.dark { + --color-fd-primary: #818cf8; +} + +html { + scrollbar-gutter: stable; +} + +html > body[data-scroll-locked] { + margin-right: 0px !important; + --removed-body-scroll-bar-size: 0px !important; +} diff --git a/website/app/icon.svg b/website/app/icon.svg new file mode 100644 index 0000000000..710c67a392 --- /dev/null +++ b/website/app/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website/app/layout.tsx b/website/app/layout.tsx new file mode 100644 index 0000000000..243b59f38c --- /dev/null +++ b/website/app/layout.tsx @@ -0,0 +1,42 @@ +import { Inter } from 'next/font/google'; +import type { Metadata } from 'next'; +import { Provider } from '@/components/provider'; +import { appName, siteUrl } from '@/lib/shared'; +import './global.css'; + +const inter = Inter({ + subsets: ['latin'], +}); + +const description = + 'OpenSpec is a lightweight agreement layer between you and your AI. Agree on what to build before any code is written. Works with 30+ AI coding assistants.'; + +export const metadata: Metadata = { + metadataBase: new URL(siteUrl), + title: { + default: `${appName} — Agree first, then build confidently`, + template: `%s — ${appName}`, + }, + description, + openGraph: { + title: `${appName} — Agree first, then build confidently`, + description, + siteName: appName, + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: appName, + description, + }, +}; + +export default function Layout({ children }: LayoutProps<'/'>) { + return ( + + + {children} + + + ); +} diff --git a/website/app/llms-full.txt/route.ts b/website/app/llms-full.txt/route.ts new file mode 100644 index 0000000000..d494d2cbb6 --- /dev/null +++ b/website/app/llms-full.txt/route.ts @@ -0,0 +1,10 @@ +import { getLLMText, source } from '@/lib/source'; + +export const revalidate = false; + +export async function GET() { + const scan = source.getPages().map(getLLMText); + const scanned = await Promise.all(scan); + + return new Response(scanned.join('\n\n')); +} diff --git a/website/app/llms.mdx/docs/[[...slug]]/route.ts b/website/app/llms.mdx/docs/[[...slug]]/route.ts new file mode 100644 index 0000000000..012e877cda --- /dev/null +++ b/website/app/llms.mdx/docs/[[...slug]]/route.ts @@ -0,0 +1,23 @@ +import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { + const { slug } = await params; + // remove the appended "content.md" + const page = source.getPage(slug?.slice(0, -1)); + if (!page) notFound(); + + return new Response(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + slug: getPageMarkdownUrl(page).segments, + })); +} diff --git a/website/app/llms.txt/route.ts b/website/app/llms.txt/route.ts new file mode 100644 index 0000000000..fc80cb652c --- /dev/null +++ b/website/app/llms.txt/route.ts @@ -0,0 +1,8 @@ +import { source } from '@/lib/source'; +import { llms } from 'fumadocs-core/source'; + +export const revalidate = false; + +export function GET() { + return new Response(llms(source).index()); +} diff --git a/website/app/og/docs/[...slug]/route.tsx b/website/app/og/docs/[...slug]/route.tsx new file mode 100644 index 0000000000..877166d34f --- /dev/null +++ b/website/app/og/docs/[...slug]/route.tsx @@ -0,0 +1,28 @@ +import { getPageImage, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { ImageResponse } from 'next/og'; +import { generate as DefaultImage } from 'fumadocs-ui/og'; +import { appName } from '@/lib/shared'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + + return new ImageResponse( + , + { + width: 1200, + height: 630, + }, + ); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + lang: page.locale, + slug: getPageImage(page).segments, + })); +} diff --git a/website/app/robots.ts b/website/app/robots.ts new file mode 100644 index 0000000000..b0a31af94f --- /dev/null +++ b/website/app/robots.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from 'next'; +import { siteUrl } from '@/lib/shared'; + +// Static robots.txt, emitted by the static export. +export const revalidate = false; + +export default function robots(): MetadataRoute.Robots { + const base = siteUrl.replace(/\/$/, ''); + return { + rules: { + userAgent: '*', + allow: '/', + }, + sitemap: `${base}/sitemap.xml`, + }; +} diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts new file mode 100644 index 0000000000..5ed32ce8b7 --- /dev/null +++ b/website/app/sitemap.ts @@ -0,0 +1,24 @@ +import type { MetadataRoute } from 'next'; +import { source } from '@/lib/source'; +import { siteUrl } from '@/lib/shared'; + +// Static sitemap, emitted as sitemap.xml by the static export. +export const revalidate = false; + +export default function sitemap(): MetadataRoute.Sitemap { + const base = siteUrl.replace(/\/$/, ''); + const docs = source.getPages().map((page) => ({ + url: `${base}${page.url}`, + changeFrequency: 'weekly' as const, + priority: 0.7, + })); + + return [ + { + url: `${base}/`, + changeFrequency: 'weekly', + priority: 1, + }, + ...docs, + ]; +} diff --git a/website/components/mdx.tsx b/website/components/mdx.tsx new file mode 100644 index 0000000000..a407f51f42 --- /dev/null +++ b/website/components/mdx.tsx @@ -0,0 +1,24 @@ +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; +import type { MDXComponents } from 'mdx/types'; + +export function getMDXComponents(components?: MDXComponents) { + return { + ...defaultMdxComponents, + Tab, + Tabs, + Step, + Steps, + Accordion, + Accordions, + ...components, + } satisfies MDXComponents; +} + +export const useMDXComponents = getMDXComponents; + +declare global { + type MDXProvidedComponents = ReturnType; +} diff --git a/website/components/provider.tsx b/website/components/provider.tsx new file mode 100644 index 0000000000..522282b2de --- /dev/null +++ b/website/components/provider.tsx @@ -0,0 +1,8 @@ +'use client'; +import SearchDialog from '@/components/search'; +import { RootProvider } from 'fumadocs-ui/provider/next'; +import { type ReactNode } from 'react'; + +export function Provider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/website/components/search.tsx b/website/components/search.tsx new file mode 100644 index 0000000000..19037982a3 --- /dev/null +++ b/website/components/search.tsx @@ -0,0 +1,48 @@ +'use client'; +import { + SearchDialog, + SearchDialogClose, + SearchDialogContent, + SearchDialogHeader, + SearchDialogIcon, + SearchDialogInput, + SearchDialogList, + SearchDialogOverlay, + type SharedProps, +} from 'fumadocs-ui/components/dialog/search'; +import { useDocsSearch } from 'fumadocs-core/search/client'; +import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static'; +import { create } from '@orama/orama'; +import { useI18n } from 'fumadocs-ui/contexts/i18n'; + +function initOrama() { + return create({ + schema: { _: 'string' }, + // https://docs.orama.com/docs/orama-js/supported-languages + language: 'english', + }); +} + +export default function DefaultSearchDialog(props: SharedProps) { + const { locale } = useI18n(); // (optional) for i18n + const { search, setSearch, query } = useDocsSearch({ + client: oramaStaticClient({ + initOrama, + locale, + }), + }); + + return ( + + + + + + + + + + + + ); +} diff --git a/website/docs.sync.config.mjs b/website/docs.sync.config.mjs new file mode 100644 index 0000000000..0c7c220104 --- /dev/null +++ b/website/docs.sync.config.mjs @@ -0,0 +1,76 @@ +// Single source of truth for the documentation site's content. +// +// The pages under `content/docs/` are NOT authored by hand. They are generated +// from the repository's `docs/*.md` files by `scripts/sync-docs.mjs` (which runs +// as the first step of `npm run build` / `npm run dev`). Edit the docs in +// `../docs`, and the site mirrors them automatically — locally and in CI. +// +// This manifest is the only place that decides which docs are published, their +// slug/URL, their sidebar section and order, and their sidebar icon. +// +// `source` is a path relative to the repo root's `docs/` directory. +// `slug` is the page path under `/docs/` (may contain a folder, e.g. reference/cli). +// `icon` is any lucide-react icon name (unknown names simply render no icon). + +export const docsDir = '../docs'; + +/** Ordered sections; each becomes a labeled group in the sidebar. */ +export const sections = [ + { + label: 'Start here', + pages: [ + { source: 'README.md', slug: 'index', icon: 'Sparkles' }, + { source: 'installation.md', slug: 'installation', icon: 'Download' }, + { source: 'getting-started.md', slug: 'getting-started', icon: 'Rocket' }, + { source: 'how-commands-work.md', slug: 'how-commands-work', icon: 'Terminal' }, + ], + }, + { + label: 'Understand it', + pages: [ + { source: 'overview.md', slug: 'overview', icon: 'Map' }, + { source: 'concepts.md', slug: 'core-concepts', icon: 'Boxes' }, + { source: 'workflows.md', slug: 'the-workflow', icon: 'Workflow' }, + { source: 'opsx.md', slug: 'opsx', icon: 'GitBranch' }, + { source: 'explore.md', slug: 'explore', icon: 'Compass' }, + ], + }, + { + label: 'Guides', + pages: [ + { source: 'examples.md', slug: 'examples', icon: 'ListChecks' }, + { source: 'writing-specs.md', slug: 'writing-specs', icon: 'PenLine' }, + { source: 'reviewing-changes.md', slug: 'reviewing-changes', icon: 'SearchCheck' }, + { source: 'existing-projects.md', slug: 'existing-projects', icon: 'FolderGit2' }, + { source: 'editing-changes.md', slug: 'editing-changes', icon: 'Pencil' }, + { source: 'customization.md', slug: 'customization', icon: 'Settings2' }, + { source: 'multi-language.md', slug: 'multi-language', icon: 'Languages' }, + { source: 'team-workflow.md', slug: 'team-workflow', icon: 'GitPullRequest' }, + { source: 'stores-beta/user-guide.md', slug: 'stores', icon: 'Store' }, + ], + }, + { + // Rendered as a collapsible folder (its own meta.json) rather than a label. + label: 'Reference', + folder: 'reference', + icon: 'BookMarked', + pages: [ + { source: 'commands.md', slug: 'reference/slash-commands', icon: 'SquareSlash' }, + { source: 'cli.md', slug: 'reference/cli', icon: 'SquareTerminal' }, + { source: 'supported-tools.md', slug: 'reference/supported-tools', icon: 'Wrench' }, + { source: 'agent-contract.md', slug: 'reference/agents', icon: 'Bot' }, + ], + }, + { + label: 'Help', + pages: [ + { source: 'faq.md', slug: 'faq', icon: 'CircleHelp' }, + { source: 'troubleshooting.md', slug: 'troubleshooting', icon: 'LifeBuoy' }, + { source: 'glossary.md', slug: 'glossary', icon: 'BookA' }, + { source: 'migration-guide.md', slug: 'migration-guide', icon: 'ArrowLeftRight' }, + ], + }, +]; + +/** Flat list of every published page, in sidebar order. */ +export const pages = sections.flatMap((section) => section.pages); diff --git a/website/lib/layout.shared.tsx b/website/lib/layout.shared.tsx new file mode 100644 index 0000000000..0ec454ff9b --- /dev/null +++ b/website/lib/layout.shared.tsx @@ -0,0 +1,32 @@ +import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; +import { appName, links } from './shared'; + +/** + * Shared layout options for both the home (marketing) layout and the docs + * layout. Keeping nav links in one place means the header stays consistent + * everywhere. + */ +export function baseOptions(): BaseLayoutProps { + return { + nav: { + title: ( + + OpenSpec + + ), + }, + links: [ + { + text: 'Documentation', + url: '/docs', + active: 'nested-url', + }, + { + text: 'Discord', + url: links.discord, + external: true, + }, + ], + githubUrl: links.github, + }; +} diff --git a/website/lib/shared.ts b/website/lib/shared.ts new file mode 100644 index 0000000000..06eb54098c --- /dev/null +++ b/website/lib/shared.ts @@ -0,0 +1,27 @@ +export const appName = 'OpenSpec'; + +// Absolute base URL of the deployed site, used to resolve Open Graph / social +// image URLs. Set NEXT_PUBLIC_SITE_URL in your deploy environment (e.g. on +// Cloudflare Pages) to your real domain. The fallback covers local builds and +// CI runs where the variable is unset or empty (an empty string would otherwise +// crash `new URL()` at build time). +export const siteUrl = + process.env.NEXT_PUBLIC_SITE_URL || 'https://openspec.dev'; + +export const docsRoute = '/docs'; +export const docsImageRoute = '/og/docs'; +export const docsContentRoute = '/llms.mdx/docs'; + +// OpenSpec source repository, used for "edit this page" and GitHub links. +export const gitConfig = { + user: 'Fission-AI', + repo: 'OpenSpec', + branch: 'main', +}; + +export const links = { + github: `https://github.com/${gitConfig.user}/${gitConfig.repo}`, + discord: 'https://discord.gg/YctCnvvshC', + npm: 'https://www.npmjs.com/package/@fission-ai/openspec', + x: 'https://x.com/0xTab', +}; diff --git a/website/lib/source.ts b/website/lib/source.ts new file mode 100644 index 0000000000..a480d10a99 --- /dev/null +++ b/website/lib/source.ts @@ -0,0 +1,44 @@ +import { docs } from 'collections/server'; +import { loader } from 'fumadocs-core/source'; +import { icons } from 'lucide-react'; +import { createElement } from 'react'; +import { docsContentRoute, docsImageRoute, docsRoute } from './shared'; + +// See https://fumadocs.dev/docs/headless/source-api for more info +export const source = loader({ + baseUrl: docsRoute, + source: docs.toFumadocsSource(), + // Render a lucide icon in the sidebar when a page sets `icon:` in frontmatter. + icon(icon) { + if (icon && icon in icons) { + return createElement(icons[icon as keyof typeof icons]); + } + }, + plugins: [], +}); + +export function getPageImage(page: (typeof source)['$inferPage']) { + const segments = [...page.slugs, 'image.png']; + + return { + segments, + url: `${docsImageRoute}/${segments.join('/')}`, + }; +} + +export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) { + const segments = [...page.slugs, 'content.md']; + + return { + segments, + url: `${docsContentRoute}/${segments.join('/')}`, + }; +} + +export async function getLLMText(page: (typeof source)['$inferPage']) { + const processed = await page.data.getText('processed'); + + return `# ${page.data.title} (${page.url}) + +${processed}`; +} diff --git a/website/next.config.mjs b/website/next.config.mjs new file mode 100644 index 0000000000..d56c03567c --- /dev/null +++ b/website/next.config.mjs @@ -0,0 +1,17 @@ +import { createMDX } from 'fumadocs-mdx/next'; + +const withMDX = createMDX(); + +/** @type {import('next').NextConfig} */ +const config = { + // Static HTML export — the `out/` directory deploys directly to Cloudflare Pages. + output: 'export', + reactStrictMode: true, + // This site has its own lockfile and lives inside the OpenSpec monorepo, so + // pin the workspace root to silence Next's multi-lockfile inference warning. + turbopack: { + root: import.meta.dirname, + }, +}; + +export default withMDX(config); diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000000..9b050ee89a --- /dev/null +++ b/website/package.json @@ -0,0 +1,35 @@ +{ + "name": "@fission-ai/openspec-website", + "version": "0.0.0", + "private": true, + "description": "Documentation site for OpenSpec, built with Fumadocs and deployable to Cloudflare Pages.", + "scripts": { + "sync:docs": "node scripts/sync-docs.mjs", + "build": "pnpm run sync:docs && fumadocs-mdx && next build", + "dev": "pnpm run sync:docs && next dev", + "start": "serve out", + "types:check": "pnpm run sync:docs && fumadocs-mdx && next typegen && tsc --noEmit" + }, + "dependencies": { + "@orama/orama": "^3.1.18", + "fumadocs-core": "^16.10.7", + "fumadocs-mdx": "^15.0.13", + "fumadocs-ui": "^16.10.7", + "lucide-react": "^1.22.0", + "next": "16.2.9", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.1", + "@types/mdx": "^2.0.14", + "@types/node": "^26.0.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "postcss": "^8.5.15", + "serve": "^14.2.6", + "tailwindcss": "^4.3.1", + "typescript": "^6.0.3" + } +} diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml new file mode 100644 index 0000000000..45558dd554 --- /dev/null +++ b/website/pnpm-lock.yaml @@ -0,0 +1,4609 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@orama/orama': + specifier: ^3.1.18 + version: 3.1.18 + fumadocs-core: + specifier: ^16.10.7 + version: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-mdx: + specifier: ^15.0.13 + version: 15.0.13(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + fumadocs-ui: + specifier: ^16.10.7 + version: 16.10.7(@tailwindcss/oxide@4.3.2)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) + lucide-react: + specifier: ^1.22.0 + version: 1.23.0(react@19.2.7) + next: + specifier: 16.2.9 + version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.3.1 + version: 4.3.2 + '@types/mdx': + specifier: ^2.0.14 + version: 2.0.14 + '@types/node': + specifier: ^26.0.0 + version: 26.1.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + postcss: + specifier: ^8.5.15 + version: 8.5.16 + serve: + specifier: ^14.2.6 + version: 14.2.6 + tailwindcss: + specifier: ^4.3.1 + version: 4.3.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fuma-translate/react@1.0.2': + resolution: {integrity: sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw==} + peerDependencies: + '@types/react': '*' + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@fumadocs/tailwind@0.0.5': + resolution: {integrity: sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ==} + peerDependencies: + '@tailwindcss/oxide': ^4.0.0 + tailwindcss: ^4.0.0 + peerDependenciesMeta: + '@tailwindcss/oxide': + optional: true + tailwindcss: + optional: true + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@next/env@16.2.9': + resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + + '@next/swc-darwin-arm64@16.2.9': + resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.9': + resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.9': + resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.9': + resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.9': + resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.9': + resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.9': + resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.9': + resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@orama/orama@3.1.18': + resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} + engines: {node: '>= 20.0.0'} + + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} + + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-accordion@1.2.15': + resolution: {integrity: sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.11': + resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.15': + resolution: {integrity: sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.11': + resolution: {integrity: sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.18': + resolution: {integrity: sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.14': + resolution: {integrity: sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.11': + resolution: {integrity: sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-navigation-menu@1.2.17': + resolution: {integrity: sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.18': + resolution: {integrity: sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.2': + resolution: {integrity: sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.13': + resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.7': + resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.14': + resolution: {integrity: sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.13': + resolution: {integrity: sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.16': + resolution: {integrity: sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.7': + resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + + '@shikijs/core@4.3.0': + resolution: {integrity: sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.3.0': + resolution: {integrity: sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.3.0': + resolution: {integrity: sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==} + engines: {node: '>=20'} + + '@shikijs/langs@4.3.0': + resolution: {integrity: sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.3.0': + resolution: {integrity: sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==} + engines: {node: '>=20'} + + '@shikijs/themes@4.3.0': + resolution: {integrity: sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==} + engines: {node: '>=20'} + + '@shikijs/types@4.3.0': + resolution: {integrity: sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.2': + resolution: {integrity: sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@26.1.0': + resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.2': + resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + + '@zeit/schemas@2.36.0': + resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + engines: {node: '>=6.0.0'} + hasBin: true + + boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + caniuse-lite@1.0.30001800: + resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cnfast@0.0.8: + resolution: {integrity: sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==} + hasBin: true + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-value-to-estree@3.5.0: + resolution: {integrity: sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fumadocs-core@16.10.7: + resolution: {integrity: sha512-lR1hDOtJ8ubsLKYH2VMkp+iVZTDdOiMh4StFaWtuTvy8Wfnngz0YSHeFijmm2K+4lg2DLXLMuCEHQ6my54g2Eg==} + peerDependencies: + '@mdx-js/mdx': '*' + '@mixedbread/sdk': 0.x.x + '@orama/core': 1.x.x + '@oramacloud/client': 2.x.x + '@tanstack/react-router': 1.x.x + '@types/estree-jsx': '*' + '@types/hast': '*' + '@types/mdast': '*' + '@types/react': '*' + algoliasearch: 5.x.x + flexsearch: '*' + lucide-react: '*' + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + react-router: 7.x.x || 8.x.x + waku: '*' + zod: 4.x.x + peerDependenciesMeta: + '@mdx-js/mdx': + optional: true + '@mixedbread/sdk': + optional: true + '@orama/core': + optional: true + '@oramacloud/client': + optional: true + '@tanstack/react-router': + optional: true + '@types/estree-jsx': + optional: true + '@types/hast': + optional: true + '@types/mdast': + optional: true + '@types/react': + optional: true + algoliasearch: + optional: true + flexsearch: + optional: true + lucide-react: + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + react-router: + optional: true + waku: + optional: true + zod: + optional: true + + fumadocs-mdx@15.0.13: + resolution: {integrity: sha512-VsGhCiLriXXMzm3WbgrVP7t6LvOthwh1BC+IGSI1ZW63UcSo1jE4aAiuUrTIF0jv1EGQkJG8cPsy0cOnf4sejA==} + hasBin: true + peerDependencies: + '@types/mdast': '*' + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: ^16.7.0 + mdast-util-directive: '*' + next: ^15.3.0 || ^16.0.0 + react: ^19.2.0 + rolldown: '*' + vite: 7.x.x || 8.x.x + peerDependenciesMeta: + '@types/mdast': + optional: true + '@types/mdx': + optional: true + '@types/react': + optional: true + mdast-util-directive: + optional: true + next: + optional: true + react: + optional: true + rolldown: + optional: true + vite: + optional: true + + fumadocs-ui@16.10.7: + resolution: {integrity: sha512-zE93/DKW5bhedXRKHYg3rhE/juYi+kXx1xl3ey1dArWbCiPx6lq6/4RLswYXS0lQp1W1f8NsbY1TeA8wSQuEvw==} + peerDependencies: + '@takumi-rs/image-response': '*' + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: 16.10.7 + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@takumi-rs/image-response': + optional: true + '@types/mdx': + optional: true + '@types/react': + optional: true + next: + optional: true + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-yaml@5.2.1: + resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lucide-react@1.23.0: + resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.42.2: + resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.2.9: + resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + + registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + serve-handler@6.1.7: + resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} + + serve@14.2.6: + resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==} + engines: {node: '>= 14'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@4.3.0: + resolution: {integrity: sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==} + engines: {node: '>=20'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@fuma-translate/react@1.0.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + '@fumadocs/tailwind@0.0.5(@tailwindcss/oxide@4.3.2)(tailwindcss@4.3.2)': + optionalDependencies: + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.14 + acorn: 8.17.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.17.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@next/env@16.2.9': {} + + '@next/swc-darwin-arm64@16.2.9': + optional: true + + '@next/swc-darwin-x64@16.2.9': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.9': + optional: true + + '@next/swc-linux-arm64-musl@16.2.9': + optional: true + + '@next/swc-linux-x64-gnu@16.2.9': + optional: true + + '@next/swc-linux-x64-musl@16.2.9': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.9': + optional: true + + '@next/swc-win32-x64-msvc@16.2.9': + optional: true + + '@orama/orama@3.1.18': {} + + '@radix-ui/number@1.1.2': {} + + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-accordion@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collapsible@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-navigation-menu@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popover@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-scroll-area@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tabs@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.2': {} + + '@shikijs/core@4.3.0': + dependencies: + '@shikijs/primitive': 4.3.0 + '@shikijs/types': 4.3.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.3.0': + dependencies: + '@shikijs/types': 4.3.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.3.0': + dependencies: + '@shikijs/types': 4.3.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.3.0': + dependencies: + '@shikijs/types': 4.3.0 + + '@shikijs/primitive@4.3.0': + dependencies: + '@shikijs/types': 4.3.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.3.0': + dependencies: + '@shikijs/types': 4.3.0 + + '@shikijs/types@4.3.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/postcss@4.3.2': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + postcss: 8.5.16 + tailwindcss: 4.3.2 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + + '@types/ms@2.1.0': {} + + '@types/node@26.1.0': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.2': {} + + '@zeit/schemas@2.36.0': {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + arch@2.2.0: {} + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + astring@1.9.0: {} + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.10.40: {} + + boxen@7.0.0: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + bytes@3.0.0: {} + + bytes@3.1.2: {} + + camelcase@7.0.1: {} + + caniuse-lite@1.0.30001800: {} + + ccount@2.0.1: {} + + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-boxes@3.0.0: {} + + client-only@0.0.1: {} + + clipboardy@3.0.0: + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + + clsx@2.1.1: {} + + cnfast@0.0.8: {} + + collapse-white-space@2.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + compute-scroll-into-view@3.1.1: {} + + concat-map@0.0.1: {} + + content-disposition@0.5.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-extend@0.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@6.0.1: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.17.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-string-regexp@5.0.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-value-to-estree@3.5.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): + dependencies: + '@orama/orama': 3.1.18 + estree-util-value-to-estree: 3.5.0 + github-slugger: 2.0.0 + hast-util-to-estree: 3.1.3 + hast-util-to-jsx-runtime: 2.3.6 + js-yaml: 5.2.1 + mdast-util-mdx: 3.0.0 + mdast-util-to-markdown: 2.1.2 + remark: 15.0.1 + remark-gfm: 4.0.1 + remark-rehype: 11.1.2 + scroll-into-view-if-needed: 3.1.0 + shiki: 4.3.0 + tinyglobby: 0.2.17 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + optionalDependencies: + '@mdx-js/mdx': 3.1.1 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.17 + lucide-react: 1.23.0(react@19.2.7) + next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + fumadocs-mdx@15.0.13(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + dependencies: + '@mdx-js/mdx': 3.1.1 + '@standard-schema/spec': 1.1.0 + chokidar: 5.0.0 + esbuild: 0.28.1 + estree-util-value-to-estree: 3.5.0 + fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + js-yaml: 5.2.1 + mdast-util-mdx: 3.0.0 + picocolors: 1.1.1 + picomatch: 4.0.4 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + zod: 4.4.3 + optionalDependencies: + '@types/mdast': 4.0.4 + '@types/mdx': 2.0.14 + '@types/react': 19.2.17 + next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + transitivePeerDependencies: + - supports-color + + fumadocs-ui@16.10.7(@tailwindcss/oxide@4.3.2)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2): + dependencies: + '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.2)(tailwindcss@4.3.2) + '@radix-ui/react-accordion': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-tabs': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + class-variance-authority: 0.7.1 + cnfast: 0.0.8 + fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.23.0(react@19.2.7))(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + lucide-react: 1.23.0(react@19.2.7) + motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next-themes: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + rehype-raw: 7.0.0 + scroll-into-view-if-needed: 3.1.0 + shiki: 4.3.0 + unist-util-visit: 5.1.0 + optionalDependencies: + '@types/mdx': 2.0.14 + '@types/react': 19.2.17 + next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@tailwindcss/oxide' + - '@types/react-dom' + - tailwindcss + + get-nonce@1.0.1: {} + + get-stream@6.0.1: {} + + github-slugger@2.0.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.2 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-void-elements@3.0.0: {} + + human-signals@2.1.0: {} + + ini@1.3.8: {} + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@2.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + is-port-reachable@4.0.0: {} + + is-stream@2.0.1: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-yaml@5.2.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + longest-streak@3.1.0: {} + + lucide-react@1.23.0(react@19.2.7): + dependencies: + react: 19.2.7 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.2 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + merge-stream@2.0.0: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mime-db@1.33.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + + mimic-fn@2.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimist@1.2.8: {} + + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + negotiator@0.6.4: {} + + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@next/env': 16.2.9 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.40 + caniuse-lite: 1.0.30001800 + postcss: 8.4.31 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(react@19.2.7) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.9 + '@next/swc-darwin-x64': 16.2.9 + '@next/swc-linux-arm64-gnu': 16.2.9 + '@next/swc-linux-arm64-musl': 16.2.9 + '@next/swc-linux-x64-gnu': 16.2.9 + '@next/swc-linux-x64-musl': 16.2.9 + '@next/swc-win32-arm64-msvc': 16.2.9 + '@next/swc-win32-x64-msvc': 16.2.9 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + on-headers@1.1.0: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-is-inside@1.0.2: {} + + path-key@3.1.1: {} + + path-to-regexp@3.3.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.2.0: {} + + range-parser@1.2.0: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react@19.2.7: {} + + readdirp@5.0.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + registry-auth-token@3.3.2: + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + + registry-url@3.1.0: + dependencies: + rc: 1.2.8 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-from-string@2.0.2: {} + + safe-buffer@5.2.1: {} + + scheduler@0.27.0: {} + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + semver@7.8.5: + optional: true + + serve-handler@6.1.7: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.5 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + + serve@14.2.6: + dependencies: + '@zeit/schemas': 2.36.0 + ajv: 8.18.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.8.1 + is-port-reachable: 4.0.0 + serve-handler: 6.1.7 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@4.3.0: + dependencies: + '@shikijs/core': 4.3.0 + '@shikijs/engine-javascript': 4.3.0 + '@shikijs/engine-oniguruma': 4.3.0 + '@shikijs/langs': 4.3.0 + '@shikijs/themes': 4.3.0 + '@shikijs/types': 4.3.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + signal-exit@3.0.7: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + styled-jsx@5.1.6(react@19.2.7): + dependencies: + client-only: 0.0.1 + react: 19.2.7 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.3.2: {} + + tapable@2.3.3: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + type-fest@2.19.0: {} + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-check@1.5.4: + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + vary@1.1.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + web-namespaces@2.0.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/website/postcss.config.mjs b/website/postcss.config.mjs new file mode 100644 index 0000000000..297374d80b --- /dev/null +++ b/website/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/website/scripts/sync-docs.mjs b/website/scripts/sync-docs.mjs new file mode 100644 index 0000000000..2db71402cb --- /dev/null +++ b/website/scripts/sync-docs.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// Generate the Fumadocs content set (`content/docs/**`) from the repository's +// canonical Markdown in `../docs`. This is the mechanical mirror: docs/*.md is +// the single source of truth, and the site is a faithful, always-current view +// of it. Runs as the first step of `build`/`dev`, and on a cadence in CI. +// +// For each published doc (see docs.sync.config.mjs) it: +// - derives the page title from the leading `# H1` (and strips that H1), +// - derives a short description from the first paragraph, +// - injects Fumadocs frontmatter (title / description / icon / githubSource), +// - rewrites internal `*.md` links to their `/docs/...` routes, +// - writes the result as a `.md` file (Fumadocs parses `.md` as plain +// Markdown, so `` and `{braces}` in the docs stay literal), +// - and emits `meta.json` sidebar ordering for the root and the reference folder. +// +// Generated files live under content/docs/ and are git-ignored — never edit +// them by hand; edit ../docs instead. + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, posix, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { docsDir, pages, sections } from '../docs.sync.config.mjs'; + +const websiteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const docsRoot = resolve(websiteRoot, docsDir); +const outRoot = join(websiteRoot, 'content', 'docs'); +const gitBranch = 'main'; +const gitBlobBase = 'https://github.com/Fission-AI/OpenSpec/blob'; + +// Map every source path (relative to docs/, normalized) -> its /docs route, +// so cross-doc `.md` links resolve to on-site pages. +const routeBySource = new Map(); +for (const page of pages) { + const normalized = posix.normalize(page.source); + routeBySource.set(normalized, page.slug === 'index' ? '/docs' : `/docs/${page.slug}`); +} + +function yamlQuote(value) { + return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +// Pull the first `# Heading` out of the body; return { title, rest }. +function extractTitle(markdown, fallback) { + const lines = markdown.split('\n'); + for (let i = 0; i < lines.length; i++) { + const match = /^#\s+(.+?)\s*$/.exec(lines[i]); + if (match) { + lines.splice(0, i + 1); + return { title: match[1].trim(), rest: lines.join('\n').replace(/^\n+/, '') }; + } + if (lines[i].trim() !== '') break; // content before any H1 — leave as-is + } + return { title: fallback, rest: markdown }; +} + +// First real paragraph, flattened to a one-line meta description. +function extractDescription(markdown) { + const lines = markdown.split('\n'); + const buffer = []; + for (const line of lines) { + const trimmed = line.trim(); + if (buffer.length === 0) { + if (trimmed === '') continue; + // Skip non-paragraph openers (headings, quotes, lists, tables, fences). + if (/^(#|>|[-*+]\s|\d+\.\s|\||```|:::)/.test(trimmed)) return ''; + buffer.push(trimmed); + } else { + if (trimmed === '') break; + buffer.push(trimmed); + } + } + let text = buffer.join(' '); + text = text + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> text + .replace(/[*_`]/g, '') // emphasis / code ticks + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 200) { + text = text.slice(0, 200).replace(/\s+\S*$/, '') + '…'; + } + return text; +} + +// Rewrite internal Markdown links that point at other docs. +// `sourceRel` is the current doc's path relative to docs/ (for resolving ../). +function rewriteLinks(markdown, sourceRel) { + const sourceDir = posix.dirname(sourceRel); + return markdown.replace(/\]\(([^)]+)\)/g, (whole, target) => { + // Leave external, anchor-only, and non-.md links untouched. + if (/^(https?:|mailto:|#|\/)/.test(target)) return whole; + const [rawPath, hash] = target.split('#'); + if (!/\.md$/i.test(rawPath)) return whole; + const resolved = posix.normalize(posix.join(sourceDir, rawPath)).replace(/^\.\//, ''); + const route = routeBySource.get(resolved); + const suffix = hash ? `#${hash}` : ''; + if (route) return `](${route}${suffix})`; + // A link we don't publish (e.g. the repo-root README) — fall back to the + // source on GitHub, normalizing any `../` that escapes the docs/ folder. + const repoPath = posix.normalize(`docs/${resolved}`); + return `](${gitBlobBase}/${gitBranch}/${repoPath}${suffix})`; + }); +} + +function buildFrontmatter({ title, description, icon, source }) { + const fm = [`title: ${yamlQuote(title)}`]; + if (description) fm.push(`description: ${yamlQuote(description)}`); + if (icon) fm.push(`icon: ${icon}`); + fm.push(`githubSource: ${yamlQuote(`docs/${source}`)}`); + return `---\n${fm.join('\n')}\n---\n`; +} + +function generatePage(page) { + const srcPath = join(docsRoot, page.source); + if (!existsSync(srcPath)) { + throw new Error(`Missing source doc: docs/${page.source} (referenced by slug "${page.slug}")`); + } + const raw = readFileSync(srcPath, 'utf8'); + const fallbackTitle = page.slug.split('/').pop().replace(/-/g, ' '); + const { title, rest } = extractTitle(raw, fallbackTitle); + const description = extractDescription(rest); + const body = rewriteLinks(rest, posix.normalize(page.source)); + + const frontmatter = buildFrontmatter({ + title, + description, + icon: page.icon, + source: posix.normalize(page.source), + }); + + const outPath = join(outRoot, `${page.slug}.md`); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${frontmatter}\n${body.replace(/\s*$/, '')}\n`, 'utf8'); + return outPath; +} + +// meta.json for the docs root: labeled section separators + page slugs, with +// the reference folder inserted as a single entry. +function writeRootMeta() { + const items = []; + for (const section of sections) { + items.push(`---${section.label}---`); + if (section.folder) { + items.push(section.folder); + } else { + for (const page of section.pages) items.push(page.slug); + } + } + const meta = { title: 'Documentation', root: true, pages: items }; + writeFileSync(join(outRoot, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8'); +} + +// meta.json for each folder section (e.g. reference/). +function writeFolderMetas() { + for (const section of sections) { + if (!section.folder) continue; + const meta = { + title: section.label, + ...(section.icon ? { icon: section.icon } : {}), + pages: section.pages.map((page) => page.slug.split('/').pop()), + }; + const dir = join(outRoot, section.folder); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8'); + } +} + +function main() { + // Start clean so removed/renamed docs don't leave stale pages behind. + rmSync(outRoot, { recursive: true, force: true }); + mkdirSync(outRoot, { recursive: true }); + + let count = 0; + for (const page of pages) { + generatePage(page); + count++; + } + writeRootMeta(); + writeFolderMetas(); + + const rel = relative(process.cwd(), outRoot); + console.log(`sync-docs: generated ${count} pages from ${docsDir} into ${rel}/`); +} + +main(); diff --git a/website/source.config.ts b/website/source.config.ts new file mode 100644 index 0000000000..628513c667 --- /dev/null +++ b/website/source.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; +import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; +import { z } from 'zod'; + +// You can customize Zod schemas for frontmatter and `meta.json` here +// see https://fumadocs.dev/docs/mdx/collections +export const docs = defineDocs({ + dir: 'content/docs', + docs: { + // `githubSource` is injected by scripts/sync-docs.mjs and points at the + // canonical `docs/*.md` this page was generated from, so the "edit this + // page" link opens the real source rather than the generated mirror. + schema: pageSchema.extend({ githubSource: z.string().optional() }), + postprocess: { + includeProcessedMarkdown: true, + }, + }, + meta: { + schema: metaSchema, + }, +}); + +export default defineConfig({ + mdxOptions: { + // MDX options + }, +}); diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000000..e6be490b0f --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "paths": { + "@/*": ["./*"], + "collections/*": ["./.source/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} From 5956a8e872f41a8f690922b5c9b6927970252b2a Mon Sep 17 00:00:00 2001 From: Danilo Date: Mon, 6 Jul 2026 10:27:46 -0300 Subject: [PATCH 05/19] Fix `archive` exit code on validation failure (#1311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix archive exit code on validation failure In human (non-JSON) mode, openspec archive returned exit code 0 when validation failed and nothing was archived. The three blocking paths in ArchiveCommand.run() printed an error message but returned null silently, leaving process.exitCode at 0. Scripts and CI could not distinguish a blocked archive from a successful one. The --json path was already correct (it throws ArchiveBlockedError, caught by printJsonFailure which sets exitCode = 1). This was an asymmetry between the two modes for the same failure. Set process.exitCode = 1 at the three human-mode abort points before returning null: - delta-spec validation failure - spec rebuild failure - rebuilt-spec validation failure Legitimate user cancellations (selecting no change, declining a confirmation prompt) remain exit 0 by design. Aligns archive with the same exit-code guarantee already approved for apply instructions in #1250. References #498. * Add regression test for rebuilt-spec validation exit code Cover the third archive blocking path (spot 3): buildUpdatedSpec succeeds but Validator.validateSpecContent rejects the rebuilt content. Spy on validateSpecContent (same pattern as the existing --no-validate test) to force the rebuilt spec invalid while the rest of the flow runs for real, since this branch is otherwise defensive and nearly unreachable — spot 1 already enforces the same SHALL/MUST/scenario rules on the delta. Asserts process.exitCode === 1, the failure is logged, the main spec is left unchanged, and no archive is created. --- .changeset/fix-archive-exit-code.md | 7 ++ src/core/archive.ts | 3 + test/core/archive.test.ts | 173 ++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 .changeset/fix-archive-exit-code.md diff --git a/.changeset/fix-archive-exit-code.md b/.changeset/fix-archive-exit-code.md new file mode 100644 index 0000000000..26c7865750 --- /dev/null +++ b/.changeset/fix-archive-exit-code.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": patch +--- + +### Bug Fixes + +- **`archive` exits non-zero when blocked in human mode** — `openspec archive -y` (and any non-`--json` invocation) no longer returns exit code 0 when validation fails and nothing is archived. The three blocking paths in human mode — delta-spec validation failure, spec rebuild failure, and rebuilt-spec validation failure — now set `process.exitCode = 1`, matching the existing `--json` behavior. Previously the command printed "Validation failed" (or "Aborted. No files were changed.") and exited 0, letting scripts and CI believe the archive succeeded. Aligns `archive` with the same exit-code guarantee already approved for `apply` instructions (#1250). diff --git a/src/core/archive.ts b/src/core/archive.ts index a39d0756a4..3e9bf80025 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -306,6 +306,7 @@ export class ArchiveCommand { } console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.')); console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.')); + process.exitCode = 1; return null; } } else if (json) { @@ -428,6 +429,7 @@ export class ArchiveCommand { } console.log(String(err.message || err)); console.log('Aborted. No files were changed.'); + process.exitCode = 1; return null; } @@ -451,6 +453,7 @@ export class ArchiveCommand { else if (issue.level === 'WARNING') console.log(chalk.yellow(` ⚠ ${issue.message}`)); } console.log('Aborted. No files were changed.'); + process.exitCode = 1; return null; } } diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index ddd0658bec..d0d586862e 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -15,6 +15,7 @@ describe('ArchiveCommand', () => { let tempDir: string; let archiveCommand: ArchiveCommand; const originalConsoleLog = console.log; + const originalExitCode = process.exitCode; const originalXdgDataHome = process.env.XDG_DATA_HOME; beforeEach(async () => { @@ -38,6 +39,10 @@ describe('ArchiveCommand', () => { // Suppress console.log during tests console.log = vi.fn(); + // Isolate process.exitCode so a failing run can't leak into the next + // test or skew the vitest process exit status. + process.exitCode = undefined; + archiveCommand = new ArchiveCommand(); }); @@ -45,6 +50,9 @@ describe('ArchiveCommand', () => { // Restore console.log console.log = originalConsoleLog; + // Restore process.exitCode (clear anything a test set) + process.exitCode = originalExitCode; + if (originalXdgDataHome === undefined) { delete process.env.XDG_DATA_HOME; } else { @@ -826,6 +834,171 @@ E1 updated`); }); }); + describe('exit code on blocked archive (human mode)', () => { + // Regression for the silent-exit-0 bug: when archive is blocked in + // human mode it must set a non-zero exit code so scripts/CI can detect + // the failure, mirroring the JSON-mode behavior. + it('sets exit code 1 when delta spec validation fails', async () => { + const changeName = 'exit-delta-fail'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'bad-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Delta spec missing required SHALL/MUST keyword -> validation error + const specContent = `# Bad Capability - Changes + +## ADDED Requirements + +### Requirement: Logging Feature + +The system will log all events. + +#### Scenario: Event recorded +- **WHEN** an event occurs +- **THEN** it is captured`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Validation failed') + ); + + // Change must NOT have been archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('sets exit code 1 when spec rebuild fails (MODIFIED on new spec)', async () => { + const changeName = 'exit-rebuild-fail'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'new-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // MODIFIED on a non-existent target spec aborts the rebuild + const specContent = `# New Capability - Changes + +## ADDED Requirements + +### Requirement: New Feature +New feature description. + +## MODIFIED Requirements + +### Requirement: Existing Feature +Modified content.`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('sets exit code 1 when rebuilt spec fails validateSpecContent', async () => { + // Spot 3 is defensive: spot 1 (validateChangeDeltaSpecs) already + // enforces SHALL/MUST/scenario rules on the delta, and buildUpdatedSpec + // pre-validates target structure, so a real delta almost never reaches + // this branch. Spy on validateSpecContent (the existing --no-validate + // test uses the same spy pattern) to force the rebuilt spec invalid + // while buildUpdatedSpec runs for real — exercising the exit-code fix. + const changeName = 'exit-rebuilt-validate-fail'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'rebuilt-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Existing main spec so MODIFIED targets a real spec and buildUpdatedSpec + // succeeds (does not throw). + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'rebuilt-capability'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainContent = `# rebuilt-capability Specification + +## Purpose +Rebuilt capability purpose. + +## Requirements + +### Requirement: Existing Feature +The system SHALL do the thing. + +#### Scenario: works +- **WHEN** x +- **THEN** y`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + + // Valid MODIFIED delta (passes spot 1 delta validation). + const deltaContent = `# Rebuilt Capability - Changes + +## MODIFIED Requirements + +### Requirement: Existing Feature +The system SHALL do the thing differently. + +#### Scenario: works +- **WHEN** x +- **THEN** z`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + const specContentSpy = vi + .spyOn(Validator.prototype, 'validateSpecContent') + .mockResolvedValue({ + valid: false, + issues: [ + { level: 'ERROR', path: 'requirements[0]', message: 'mocked rebuilt-spec failure' }, + ], + summary: { errors: 1, warnings: 0, info: 0 }, + }); + + try { + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + // buildUpdatedSpec ran for real and the spy made its output "invalid" + expect(specContentSpy).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Validation errors in rebuilt spec for rebuilt-capability') + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + // Main spec must be unchanged (no writes happened) + const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(still).toBe(mainContent); + + // Change must NOT have been archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + } finally { + specContentSpy.mockRestore(); + } + }); + + it('leaves exit code 0 on successful archive (no leak from prior test)', async () => { + const changeName = 'exit-ok'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBeUndefined(); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(true); + }); + }); + describe('error handling', () => { it('should throw error when openspec directory does not exist', async () => { // Remove openspec directory From a70daccf0ec034f23bc7df5c2c397c120ec31999 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 7 Jul 2026 11:13:36 -0500 Subject: [PATCH 06/19] feat(skills): propose /opsx:update planning-artifact update skill (#1278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(openspec): propose add-update-workflow — graph-driven /opsx:update + cohesive audit Dogfooded OpenSpec proposal for the missing first-class "update" action: a /opsx:update workflow that propagates an edit to one artifact across its downstream dependents (targeted mode) or audits a whole change for stale/ incoherent artifacts (audit mode) — driven by the schema's artifact graph, never hardcoded filenames, editing planning artifacts only (never code). - artifact-graph: expose reverse-dependency queries (getDependents/getDownstream) + a requires-edge mtime staleness signal (the engine already builds the dependents map at graph.ts:98 and discards it). - cli-artifact-workflow: surface requires/dependents/stale on `openspec status --json` and add a `--impact ` downstream-revisit-order selector. - opsx-update-skill: the user-facing /opsx:update command (targeted + audit). Supersedes the proposal-only stub add-artifact-regeneration-support. Addresses the cluster #1188/#705/#673/#247 (closes), #694/#684/#618 (answers), and is graph-driven to avoid the #777/#666 hardcoded-artifact-pattern bug class. Validates clean under `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): make add-update-workflow deterministic & grounded (Tabish review) Reframe per the steer "more deterministic and grounded in reality": - Deterministic spine: the CLI computes the impact set (which downstream artifacts to revisit, in build order, with paths) as a pure function of schema edges + filesystem. The agent only rewrites prose. Grounded in real APIs already present: getUnlockedArtifacts (direct dependents), getBuildOrder (order), resolveArtifactOutputs (paths); reverse map built at graph.ts:82-87. - Replace fragile mtime staleness with a newline-normalized SHA-256 content digest (reproducible cross-platform). Drift = upstream digest vs recorded baseline; no baseline => "unknown", never a false positive. mtime and pure-git rejected with rationale; digest ledger is a separable, optional layer. - Explicit determinism boundary decision (CLI decides files/order/drift; agent rewrites). Skill MUST source the file list/order from `openspec status --impact`, never compute it. - Corrected all code citations to verified lines (graph.ts:82-87, instruction-loader.ts:366/429, status.ts); noted #1277's coverage helpers are not in this branch's base (coordinate, don't reuse). - Specs updated: artifact-graph Content Digest requirement; cli status digest + deterministic impact ordering; skill determinism + baseline-aware audit. tasks add digest/determinism/cross-platform tests + optional ledger section. Still validates clean under `openspec validate add-update-workflow --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): harden add-update-workflow determinism; drop direct name refs - Digest ledger tracks DIRECT upstream digests; document that transitive drift emerges hop-by-hop as downstream is reconciled (no transitive bookkeeping). - Ground audit's no-baseline structural facts on signals available in this branch (missing/empty output, blocked/incomplete); capability-coverage is an add-on only when #1277's validateChangeCapabilityCoverage is present. - Add the "update revises only existing downstream; defer not-yet-created ones to /opsx:continue" rule across proposal/design/specs/tasks; impact entries now carry existence/status. - Note artifact-level (not file-level) granularity and that getDownstream terminates by the schema's acyclic guarantee. - Remove direct personal references from the docs. Validates clean under `openspec validate add-update-workflow --strict`; 10 deltas. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): full issue/PR/discussion coverage + command-family design After a comprehensive sweep of open issues, PRs, and discussions, grounded the proposal in the complete adjacent landscape and answered the open design questions the cluster raises: - #783 (Cross-artifact quality review before apply) is now a primary Closes: it IS audit mode. Answer its open "new skill vs. extend validate" question via the determinism split — deterministic checks (drift/completeness/coverage) are CLI/validate-shaped; the semantic cross-artifact review is the skill. Added a skill spec scenario for the #783 patterns (scope contradiction, spec gap, duplication). - Discussion #1206 ("refine proposal now?") + prior-art PR #372: official answer is /opsx:update. - New design Decision 8 (command family): delineate /opsx:update from /opsx:clarify (#702, within-artifact), /opsx:review (#1251, plan-vs-code), and verify; /opsx:update consolidates update+regen+refine into one action, addressing skill-sprawl (#1263, #783). - Reuse, don't reinvent: audit's empty/incomplete check reuses #1098's artifactOutputComplete (same outputs.ts the digest helper lives in); capability coverage reuses #1277's validateChangeCapabilityCoverage. - New open questions: surface deterministic coherence in `validate` for a CI gate (#783-B, #829); naming reconciliation with #783's /opsx:refine. - Confirmed add-update-command* branches are the `openspec update` tool-file refresh (not artifact update) — no collision. Validates clean under --strict; 10 deltas; all relative links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): resolve open questions to committed decisions; drift in scope Per review steer, every open question is now a committed happy-path decision so build-out has no dangling forks, and the deterministic drift baseline is pulled into scope (it is what makes audit-mode drift deterministic vs. agent-guessed): - Digest ledger IN SCOPE (design Decision 3): per-artifact DIRECT upstream digests in ChangeMetadataSchema, written by a deterministic `openspec status --record`; pre-existing changes (no baseline) degrade to drift `unknown` + structural checks. Generating-flow auto-recording stays optional (graceful). - cli-artifact-workflow spec: folded drift into the digest requirement (record baseline / drift vs baseline / unknown-without-baseline) — stays at 10 deltas. - opsx-update-skill spec: skill records baseline via `--record` after each confirmed edit, so audits clear once reconciled. - Replaced "## Open Questions" with "## Decisions resolved": ledger in scope; targeted entry baseline-aware; apply stays standalone (points to update on drift); cross-change (#247), continue/ff de-hardcoding (#777), and validate CI-gate (#783-B/#829) are named follow-ups, not deferrals of the core feature; /opsx:update kept as the umbrella name. - Migration Plan + Capabilities + Impact + tasks updated; status JSON gains `drift`, CLI gains `--record`. Re-synced with upstream main (0 behind). Validates clean under --strict; 10 deltas; all links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): harden add-update-workflow — close cross-OS, read-only, edge gaps Stress-tested every claim against live source and fixed the soft spots: - Cross-OS digest determinism (real bug): resolveArtifactOutputs (outputs.ts:34) sorts ABSOLUTE paths via .sort(), which differs by OS — so a multi-file glob artifact (specs/**/*.md) would hash differently on Windows vs POSIX. Digest now specified to order files by change-relative forward-slash path and hash relpath+content. Added spec scenarios (cross-platform glob stability; rename changes digest) and a cross-OS test task. - Read-only status invariant: moved baseline recording OFF `openspec status` (a read command silently mutating the drift reference is a footgun) to a dedicated `openspec reconcile` write verb. Updated spec, skill, design, impact, capabilities, tasks; reconciled the "no new verb" claims. - Edge case: missing upstream at record time is stored as an explicit `absent` marker so later creating it registers as drift (spec scenario added). - Edge case: coherent change yields no edits (clean-path scenario). - Grounding fixes: continue-change hardcoded block is duplicated (skill 103-112 + command 225-234) — both must be fixed in the #777 follow-up; verified no content-hash util exists. - Fixed two stale claims the layered edits left: the Impact digest bullet (concatenation→relative-path) and the naming-boundary line. Validates clean under --strict; 10 deltas (4+3+3), 44 scenarios; all links resolve; re-synced with upstream main (0 behind); issue/PR/discussion sweep re-run, no new items. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): pin data contracts + digest forward-compat; delineate #880 Grounded the surface so an implementer builds it without guessing, and added proportionate forward-compatibility: - New design "Data contracts" section with exact shapes: extended ArtifactStatus (requires/dependents/digest/drift/driftFrom — additive to the real interface at instruction-loader.ts:120), the --impact response, and the `.openspec.yaml` baselines ledger. All additive; nothing existing changes type. - Digest scheme tag (`sha256-relpath-v1:`) + forward-compat: drift compares only same-scheme digests; an unrecognized/older scheme reports `unknown` rather than silently mis-comparing — re-reconcile restores it. Added a cli spec scenario and tasks for it. - Grounded the ledger write: there is no central change-metadata writer today (change-metadata/index.ts only re-exports schema), so reconcile does a safe read-modify-write of .openspec.yaml mirroring the store's parse/serialize/writeStoreMetadataState pattern (foundation.ts). - Coverage: re-swept; folded #880 (/opsx:validate code-vs-living-specs) into the plan-vs-code delineation alongside #1251/#1073. Main unchanged (546224e); all citations still valid. Validates clean under --strict; 10 deltas; links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): simplify add-update-workflow to a thin /opsx:update skill Rework per @TabishB review (PR #1278): the proposal over-built. Drop the deterministic-spine machinery and lean on the existing status command. - Cut the reverse-dependency graph API (getDependents/getDownstream), SHA-256 content digests, the .openspec.yaml baseline ledger, the `openspec reconcile` write op, the drift report, and `status --impact`. Removes the artifact-graph and cli-artifact-workflow spec deltas. - Reframe propagation as bidirectional coherence (editing design can require revising proposal), not downstream-only. - Center the feature on one thin skill over the existing `openspec status` / `openspec list`; design now sketches the actual minimal skill instruction body ("written by hand"). - v1 adds no new CLI/graph/schema code: just update-change.ts + wiring. Validates clean: `openspec validate add-update-workflow --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(update-workflow): pin the status path contract to existingOutputPaths Address @alfred-openspec's review: the skill's write target was described loosely as "resolved paths." Make it precise across proposal/design/spec/tasks: - `openspec status --json` already returns everything the skill needs, in the top-level `artifactPaths` map — `resolvedOutputPath` and `existingOutputPaths` per artifact. No new CLI field is required. - The skill edits `existingOutputPaths` (the concrete, glob-expanded files) and never writes to `resolvedOutputPath`, which for a glob artifact like `specs/**/*.md` remains the glob pattern rather than a real file. - Add spec scenarios for editing a glob artifact's concrete files and for deferring a brand-new file under a glob artifact to `/opsx:continue`. - Tighten the cross-platform scenario and add a template test (3.4) asserting the write target is `existingOutputPaths`, not a glob `resolvedOutputPath`. Validates clean under `openspec validate add-update-workflow --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(update-workflow): address review — default profile, next-step guidance, change-scoped naming - Register /opsx:update in the default core profile, not expanded-only (maintainer call on the PR) - Add next-step guidance: after updating, recommend /opsx:continue, /opsx:apply (esp. when the change was already implemented), or /opsx:archive — guidance only, never acted on - Pin naming scope: skill openspec-update-change, change proposals only; generalizing update to other graph types is an explicit non-goal Co-Authored-By: Claude Fable 5 * feat(skills): implement the /opsx:update skill (openspec-update-change) Implements the approved add-update-workflow change: one thin skill over the existing status/list commands, in the default core profile. - new update-change.ts template (skill + command), registered across init, profiles, skill-generation, tool-detection, profile-sync-drift - update joins CORE_WORKFLOWS and ALL_WORKFLOWS - docs: opsx.md command row + usage note, commands.md reference section, supported-tools.md skill list - retire the superseded add-artifact-regeneration-support stub - template tests pin the guardrails (schema-driven ids, planning-only, existingOutputPaths write contract, next-step guidance); parity hashes regenerated; profile/init/update/config tests cover the new core set - tasks.md checked off; validate --strict passes Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/commands.md | 45 +++++ docs/opsx.md | 7 + docs/supported-tools.md | 1 + .../proposal.md | 136 -------------- .../add-update-workflow/.openspec.yaml | 2 + .../changes/add-update-workflow/design.md | 115 ++++++++++++ .../changes/add-update-workflow/proposal.md | 66 +++++++ .../specs/opsx-update-skill/spec.md | 138 ++++++++++++++ openspec/changes/add-update-workflow/tasks.md | 30 +++ src/core/init.ts | 1 + src/core/profile-sync-drift.ts | 1 + src/core/profiles.ts | 3 +- src/core/shared/skill-generation.ts | 4 + src/core/shared/tool-detection.ts | 2 + src/core/templates/skill-templates.ts | 1 + src/core/templates/workflows/update-change.ts | 175 ++++++++++++++++++ test/commands/config-profile.test.ts | 31 ++-- test/commands/config.test.ts | 2 +- test/core/init.test.ts | 6 +- test/core/profiles.test.ts | 12 +- test/core/shared/skill-generation.test.ts | 14 +- test/core/shared/tool-detection.test.ts | 3 +- .../templates/skill-templates-parity.test.ts | 8 + test/core/templates/update-change.test.ts | 88 +++++++++ test/core/update.test.ts | 7 +- test/utils/command-references.test.ts | 1 + 26 files changed, 730 insertions(+), 169 deletions(-) delete mode 100644 openspec/changes/add-artifact-regeneration-support/proposal.md create mode 100644 openspec/changes/add-update-workflow/.openspec.yaml create mode 100644 openspec/changes/add-update-workflow/design.md create mode 100644 openspec/changes/add-update-workflow/proposal.md create mode 100644 openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md create mode 100644 openspec/changes/add-update-workflow/tasks.md create mode 100644 src/core/templates/workflows/update-change.ts create mode 100644 test/core/templates/update-change.test.ts diff --git a/docs/commands.md b/docs/commands.md index 5d52c056c9..57ede52aa2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -13,6 +13,7 @@ For workflow patterns and when to use each command, see [Workflows](workflows.md | `/opsx:propose` | Create a change and generate planning artifacts in one step | | `/opsx:explore` | Think through ideas before committing to a change | | `/opsx:apply` | Implement tasks from the change | +| `/opsx:update` | Revise a change's planning artifacts and keep them coherent | | `/opsx:sync` | Merge delta specs into main specs | | `/opsx:archive` | Archive a completed change | @@ -317,6 +318,50 @@ AI: Implementing add-dark-mode... --- +### `/opsx:update` + +Revise a change's existing planning artifacts and keep them coherent with one another. Planning artifacts only - it never edits code. + +**Syntax:** +``` +/opsx:update [change-name] +``` + +**Arguments:** +| Argument | Required | Description | +|----------|----------|-------------| +| `change-name` | No | Which change to update (inferred from context if not provided) | + +**What it does:** +- Reads the change's artifacts via `openspec status --change --json` +- Applies your requested revision, or reviews the artifacts for contradictions if you didn't name one +- Reconciles the other existing artifacts in any direction (a design edit may ripple back to the proposal) +- Confirms every edit with you before writing, one artifact at a time +- Ends by recommending the next step: `/opsx:continue` (artifacts missing), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done) + +**Example:** +``` +You: /opsx:update add-dark-mode - we're storing the theme in a cookie now, not localStorage + +AI: Reading add-dark-mode artifacts... + + The design references localStorage in two places; tasks 1.3 covers + localStorage persistence; the proposal doesn't mention storage. + + Proposed revisions: + 1. design.md - swap localStorage decision for cookie storage + 2. tasks.md - reword task 1.3 to cookie persistence + + Apply revision 1? (design.md) +``` + +**Tips:** +- It won't create missing artifacts - that's `/opsx:continue` +- If the change was already implemented, follow up with `/opsx:apply` so the code matches the revised plan +- If your revision changes the *intent* of the change, start fresh with a new change instead (see [When to Update vs. Start Fresh](opsx.md#when-to-update-vs-start-fresh)) + +--- + ### `/opsx:verify` Validate that implementation matches your change artifacts. Checks completeness, correctness, and coherence. diff --git a/docs/opsx.md b/docs/opsx.md index bebe0a51dd..e396890add 100644 --- a/docs/opsx.md +++ b/docs/opsx.md @@ -163,6 +163,7 @@ rules: | `/opsx:continue` | Create the next artifact (expanded workflow) | | `/opsx:ff` | Fast-forward planning artifacts (expanded workflow) | | `/opsx:apply` | Implement tasks, updating artifacts as needed | +| `/opsx:update` | Revise a change's planning artifacts and keep them coherent | | `/opsx:verify` | Validate implementation against artifacts (expanded workflow) | | `/opsx:sync` | Sync delta specs to main (default workflow, optional) | | `/opsx:archive` | Archive when done | @@ -208,6 +209,12 @@ Creates all planning artifacts at once. Use when you have a clear picture of wha ``` Works through tasks, checking them off as you go. If you're juggling multiple changes, you can run `/opsx:apply `; otherwise it should infer from the conversation and prompt you to choose if it can't tell. +### Updating a change +``` +/opsx:update add-dark-mode - we're storing the theme in a cookie now +``` +Revises the change's existing planning artifacts and keeps them coherent - in any direction (a design edit may ripple back to the proposal). Planning artifacts only: it never edits code, and it never creates missing artifacts (that's `/opsx:continue`). Every edit is confirmed with you first. If the change was already implemented, it recommends `/opsx:apply` so the code catches up with the revised plan. If your revision changes the change's *intent*, start fresh instead - see [When to Update vs. Start Fresh](#when-to-update-vs-start-fresh). + ### Finish up ``` /opsx:archive # Move to archive when done (prompts to sync specs if needed) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb42..85b3ce25a7 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -96,6 +96,7 @@ When selected by profile/workflow config, OpenSpec generates these skills: - `openspec-new-change` - `openspec-continue-change` - `openspec-apply-change` +- `openspec-update-change` - `openspec-ff-change` - `openspec-sync-specs` - `openspec-archive-change` diff --git a/openspec/changes/add-artifact-regeneration-support/proposal.md b/openspec/changes/add-artifact-regeneration-support/proposal.md deleted file mode 100644 index d855cdc971..0000000000 --- a/openspec/changes/add-artifact-regeneration-support/proposal.md +++ /dev/null @@ -1,136 +0,0 @@ -# Add Artifact Regeneration Support - -## Problem - -Currently, there is **no way to regenerate artifacts** in the OPSX workflow: - -- `/opsx:apply` just reads whatever's on disk -- `/opsx:continue` only creates the NEXT artifact - won't touch existing ones - -If you edit `design.md` after `tasks.md` exists, your only options are: -1. Delete tasks.md manually, then run `/opsx:continue` -2. Edit tasks.md manually - -The documentation claims you can "update artifacts mid-flight and continue" but there's no mechanism that actually supports this. - -## Proposed Solution - -Two parts: - -### Part 1: Staleness Detection -Add artifact staleness detection to `/opsx:apply`: - -1. **Track modification times**: When generating an artifact, record the mtime of its dependencies -2. **Detect staleness**: When `/opsx:apply` runs, check if upstream artifacts (design.md, specs) have been modified since tasks.md was generated -3. **Prompt user**: If stale, ask: "Design was modified after tasks were generated. Would you like to regenerate tasks with `/opsx:continue`?" - -## User Experience - -### Vision: Seamless Mid-Flight Correction - -This is the workflow we want to enable (currently documented but not supported): - -``` -You: /opsx:apply - -AI: Working through tasks... - ✓ Task 1.1: Created caching layer - ✓ Task 1.2: Added cache invalidation - - Working on 1.3: Implement TTL... - I noticed the design assumes Redis, but your project uses - in-memory caching. Should I update the design? - -You: Yes, update it to use the existing cache module. - -AI: Updated design.md to use CacheManager from src/cache/ - Updated tasks.md with revised implementation steps - Continuing implementation... - ✓ Task 1.3: Implemented TTL using CacheManager - ... -``` - -**No restart needed.** Just update the artifact and continue. - -### Staleness Warning UX - -When user manually edits an upstream artifact: - -``` -$ /opsx:apply - -⚠️ Detected changes to upstream artifacts: - - design.md modified 5 minutes ago (after tasks.md was generated) - -Options: -1. Regenerate tasks (recommended) -2. Continue anyway with current tasks -3. Cancel - -> -``` - -### Part 2: Regeneration Capability - -Add a way to regenerate specific artifacts: - -```bash -# Option A: Flag on continue -/opsx:continue --regenerate tasks - -# Option B: Separate command -/opsx:regenerate tasks - -# Option C: Interactive prompt when staleness detected -/opsx:apply -# "Design changed. Regenerate tasks? [y/N]" -``` - -## Technical Approach - -### Option A: Metadata File -Store `.openspec-meta.json` in change directory: -```json -{ - "tasks.md": { - "generated_at": "2025-01-24T10:00:00Z", - "dependencies": { - "design.md": "2025-01-24T09:55:00Z", - "specs/feature/spec.md": "2025-01-24T09:50:00Z" - } - } -} -``` - -### Option B: Frontmatter -Add YAML frontmatter to generated artifacts: -```markdown ---- -generated_at: 2025-01-24T10:00:00Z -depends_on: - - design.md@2025-01-24T09:55:00Z ---- -# Tasks -... -``` - -### Option C: Git-based -Use git to detect if upstream files changed since downstream was last modified. No extra metadata needed but requires git. - -## Non-Goals - -- Automatic regeneration (user should always choose) -- Blocking apply entirely (just warn) -- Tracking code file changes (only artifact dependencies) - -## Dependencies - -- Should be implemented after `fix-midflight-update-docs` so docs are accurate first -- Could be combined with that change if desired - -## Success Criteria - -- User is warned when applying with stale artifacts -- Clear path to regenerate if needed -- No false positives (only warn when genuinely stale) -- Documentation claims become actually true diff --git a/openspec/changes/add-update-workflow/.openspec.yaml b/openspec/changes/add-update-workflow/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/add-update-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md new file mode 100644 index 0000000000..9ab208b735 --- /dev/null +++ b/openspec/changes/add-update-workflow/design.md @@ -0,0 +1,115 @@ +# Design: `/opsx:update` — a thin update skill + +## Context + +OPSX models a change as a small DAG of planning artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them, and `openspec status --change --json` already reports, per artifact: its `status` (`done`/`ready`/`blocked`), its `outputPath`, and — via the top-level `artifactPaths` map — its `resolvedOutputPath` and `existingOutputPaths`, plus the change's `schemaName` and `isComplete`. The two path fields differ in a way that matters for a write operation: `existingOutputPaths` is the concrete files that exist on disk (for a glob artifact such as `specs/**/*.md`, the glob already expanded to real files); `resolvedOutputPath` is the change-dir-joined declared path, which for a glob artifact is still the glob (`.../specs/**/*.md`) and is therefore **not** a write target. `/opsx:update` edits the files in `existingOutputPaths`. `openspec list --json` lists changes by recency. + +That is everything an update skill needs. The artifacts are a handful of markdown files on disk; the agent can read them. So `/opsx:update` is built as a thin skill over the **existing** CLI, in the same shape as `continue-change.ts` (select change → `openspec status --json` → act). + +This proposal began larger — a reverse-dependency graph API, content digests, a baseline ledger, a `reconcile` write op, a `status --impact` selector. Review feedback ([PR #1278](https://github.com/Fission-AI/OpenSpec/pull/1278)) was that this over-builds: coding agents tend to over-complicate skills, and the feature should work off the existing `status` command with as little new code as possible. This design follows that steer. + +## Goals / Non-Goals + +**Goals** +- A `/opsx:update` action that revises a change's existing planning artifacts and keeps them coherent with one another. +- Drive it from the artifact set and paths the CLI already reports — zero hardcoded artifact names — so custom schemas work. +- Edit planning artifacts only; never touch code. Confirm every edit with the user. +- Add as little code as possible: one skill template, no changes to the graph engine, the `status` command, or the metadata schema. + +**Non-Goals** +- A new top-level `openspec update*` CLI verb (name is taken; see Naming). +- Automatic, unattended regeneration (the user always confirms). +- Content digests, a drift/staleness signal, a baseline ledger, a `reconcile` op, or a `status --impact` selector (see "Why not the heavier machinery"). +- Regenerating *code* from updated artifacts — that is `/opsx:apply`'s job; `/opsx:update` stops at the plan and hands off. +- Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — a later proposal; this change is intra-change. +- Updating anything other than a change's planning artifacts. v1 is specific to change proposals; generalizing "update" to other graph types is deferred until such a graph exists (see Naming). + +## The skill, written by hand + +Working backwards from "what is the minimal instruction set," here is the skill body in sketch form. It is short on purpose — few tokens, few commands: + +``` +Revise a change's planning artifacts and keep them coherent. Never edit code. + +1. Resolve the change. + - If named, use it. Else infer from context; if unclear, run `openspec list --json` + and ask the user to choose (most-recently-modified first). Never auto-select. + +2. Get the artifacts. + - Run `openspec status --change "" --json`. + - Read `artifacts[]` (ids + status) and the `artifactPaths` map. These come from the + active schema — do not assume the artifact ids or paths. + - The files to edit are `artifactPaths..existingOutputPaths` (already glob-expanded + for artifacts like `specs/**/*.md`). Do not write to `resolvedOutputPath`: for a glob + artifact it is still the glob pattern, not a real file. + +3. Understand the request. + - If the user named a change ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent," treat it as a coherence review. + +4. Read and reconcile. + - Read the artifact(s) the request touches and the other existing artifacts in the change. + - Apply the requested edit. Then check every other existing artifact against it — in any + direction (an edit to design may require revising the proposal, not only the tasks) — + and note what is now inconsistent, missing, or contradictory. + - Do not invent artifacts that don't exist yet; point the user to `/opsx:continue` to create them. + +5. Confirm and apply, one artifact at a time. + - Show each proposed revision and why. Write only after the user confirms. + - When a substantial rewrite is needed, `openspec instructions --change "" --json` + gives that artifact's rules/template to follow. + +6. Point to the next step (guidance only — never act on it). + - Artifacts still missing → suggest `/opsx:continue`. Change already implemented (tasks + checked off / applied) → the code may no longer match the revised plan; suggest + `/opsx:apply` to carry the delta. Fully done and implemented → suggest `/opsx:archive`. + +Guardrails: +- Planning artifacts only. If the plan now implies code changes, stop and point to `/opsx:apply`. +- Use artifact ids/paths from `openspec status`; never branch on literal proposal/specs/design/tasks names. +- If the request changes the change's *intent* rather than refining it, recommend `/opsx:new` + (the "Update vs. Start Fresh" heuristic, docs/opsx.md). +``` + +The `spec-driven` artifact names may appear once, as a worked *example* of how to apply step 4, exactly as `continue-change.ts` does today — but the control flow reads ids from the CLI, so the skill never branches on those names. A template test asserts there is no name-based branching (the anti-[#777](https://github.com/Fission-AI/OpenSpec/issues/777) guard). + +## Decisions + +### 1. Bidirectional coherence, not downstream propagation +The artifact graph has a build *order*, but "what needs updating after an edit" is not strictly downstream. If `design` changes, the `proposal` it elaborates may need to change too; if `tasks` reveal a missing capability, the `specs` may need a new requirement. The skill therefore reads the change's artifacts and reconciles them in whatever direction the edit demands. Build order is still useful as a default *reading* order and for presenting fixes, but it is not a constraint on which artifacts may be revised. This is why the design does not add a one-directional `getDownstream` / `--impact` primitive: it would encode the wrong model. + +### 2. Lean on the existing `status` command +`openspec status --change --json` already returns the artifact set, per-artifact status, and, in the `artifactPaths` map, the on-disk paths. The skill writes to `artifactPaths..existingOutputPaths` — the concrete files, glob-expanded — and deliberately not to `resolvedOutputPath`, which for a glob artifact is the pattern itself and not a file. That is everything the skill needs to know what exists and where it lives; no new CLI field is required. Picking the change reuses `openspec list --json`, exactly like `/opsx:continue`. No new CLI surface is introduced. + +### 3. Why not the heavier machinery (digests, ledger, reconcile, impact) +The first draft proposed SHA-256 content digests, a per-change baseline ledger in `.openspec.yaml`, an `openspec reconcile` write op, a derived drift signal, and a `status --impact` selector — so the CLI could tell the agent *which* artifacts are stale without the agent reading them. + +Rejected for v1, because the cost outweighs the need: +- The artifacts are a few markdown files. An agent that is going to *rewrite* them must read them anyway, so computing staleness for it saves little and adds a stateful subsystem (a ledger that `status` must not mutate, a separate write verb, scheme-versioning for forward-compat, cross-platform digest canonicalization, and the round-trip tests for all of it). +- A digest/ledger only earns its keep when something must judge staleness *without* reading content — e.g. unattended drift detection across many changes ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) cross-change, [#846](https://github.com/Fission-AI/OpenSpec/issues/846) tracking files). Those are out of scope here. When one of them becomes concrete, this machinery can be designed against that real need. + +So `/opsx:update` v1 has the agent read the change's artifacts and judge coherence directly. If, after using it, a deterministic signal proves necessary, the smallest first step is to expose the schema's `requires` edges on `status --json` (a single additive field, no new command) — and only then consider digests. + +### 4. Naming: `/opsx:update` skill, not `openspec update` CLI +`openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts](../../../src/cli/index.ts)). Overloading it would give one verb two unrelated meanings. The artifact-update action is therefore the **skill** `/opsx:update`, with no new `openspec` verb at all. Considered and rejected: `openspec regen --from ` ([#705](https://github.com/Fission-AI/OpenSpec/issues/705)) — a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. + +Review feedback flagged that "update" alone is generic — could it apply to any graph? The resolution: the skill is scoped to **change proposals only**, and the specific name carries that scope. The skill is `openspec-update-change`, following the `openspec--change` naming of its siblings (`openspec-continue-change`, `openspec-new-change`, …). The command is `/opsx:update` because every verb in the `/opsx:` family operates on a change (`continue`, `apply`, `archive` — none says `-change`); a change-scoped meaning is what the namespace already promises. If a future graph type needs its own update action, it gets its own specific skill name then — nothing here blocks or breaks that. + +### 5. Guardrails (the part that makes it the requested command) +- **Planning artifacts only.** The skill's write targets are the artifact paths from `status`; if a revision implies code changes it stops and points to `/opsx:apply`. This directly answers [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint that the manual workaround edits code. +- **Schema-driven.** Ids and paths come from `status`; no branching on literal `proposal`/`specs`/`design`/`tasks`. Works for custom schemas ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). +- **Confirm each edit.** One artifact at a time, shown before writing. +- **Intent guard.** A revision that changes intent rather than refining it is redirected to `/opsx:new` (the "Update vs. Start Fresh" heuristic, [docs/opsx.md](../../../docs/opsx.md)). + +### 6. Next-step guidance, especially for already-implemented changes +A change can be revised after it was built — tasks checked off, `/opsx:apply` already run. The update itself behaves identically (planning artifacts only), but stopping silently would strand the user: the code and the revised plan now disagree. So the skill ends by reporting where the change stands (from the status JSON and the tasks checklist) and recommending the next command — `/opsx:continue` if artifacts are missing, `/opsx:apply` to carry a revised plan into code, `/opsx:archive` when everything is done. Guidance only: the skill never implements, mirroring the "All artifacts created! You can now implement this change with `/opsx:apply`" hand-off that `continue-change.ts` already uses. + +## Risks / Trade-offs + +- **No deterministic staleness signal.** With no digest/ledger, the skill relies on the agent reading the artifacts to spot incoherence. Trade-off accepted: an agent that rewrites prose must read it anyway, and a content-blind signal earns its cost only for use cases this change excludes (Decision 3). +- **Coherence quality depends on the agent.** Mitigated by confirming every edit and by keeping scope to one change's artifacts (a small, readable set). +- **Skill drifts back to hardcoding artifact names.** Mitigated by a template test asserting the control flow reads ids from `status` JSON and contains no name-based branching. + +## Migration Plan + +Additive and backward-compatible. One new skill template, installed with the default `core` profile (maintainer call on the PR: update is part of the default happy path, not expanded-only); one docs row. No existing command changes behavior; no schema or graph changes. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md new file mode 100644 index 0000000000..4bfc506bf8 --- /dev/null +++ b/openspec/changes/add-update-workflow/proposal.md @@ -0,0 +1,66 @@ +## Why + +OPSX names **four** first-class actions — "create, implement, **update**, archive — do any of them anytime" ([docs/opsx.md:52](../../../docs/opsx.md)). Three ship as commands. **`update` does not exist.** The only mechanism offered is *"edit the files manually"* — and when you edit one artifact, nothing helps you keep the rest of the change coherent. Worse, the manual workaround lets the agent edit **code** when the user only wanted to revise the **plan** ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)). + +This is the most-requested missing capability in the tracker. It is one gap with several faces, and the fix is small: a thin `/opsx:update` skill that revises a change's planning artifacts and keeps them coherent with each other, built on the **existing** `openspec status` / `openspec list` commands. No new graph engine, no digests, no ledger — just an agent that reads the change's artifacts and updates what needs updating, with the user's confirmation. + +## What Changes + +The whole feature is a single new workflow skill, `/opsx:update`. The skill is deliberately change-scoped — `openspec-update-change`, following the `openspec--change` naming of its siblings — and applies to change proposals only, not arbitrary artifact graphs (see design, Naming). Written by hand, its instruction set is short: + +1. **Understand the request** — what the user wants to revise (or, with no specific ask, "review this change for coherence"). +2. **Get the artifacts** — run `openspec status --change --json`. Its `artifactPaths` map reports, per artifact, which files exist and where: `existingOutputPaths` is the concrete file list to edit — already expanded for glob artifacts like `specs/**/*.md`. (`openspec list --json` to pick the change when it isn't given.) +3. **Read and revise** — read the relevant artifacts, make the requested edit, then check the change's **other** artifacts against it and propose any follow-on edits needed to keep the plan coherent. +4. **Confirm and apply** — show each proposed revision, write only after the user confirms. +5. **Point to the next step** — report where the change now stands and recommend what comes next: artifacts still missing → `/opsx:continue`; plan revised after the change was already implemented → `/opsx:apply` to carry the delta into code; everything done and implemented → `/opsx:archive`. Guidance only — the skill never acts on it. + +Two guardrails make it the command the cluster asked for: + +- **Planning artifacts only, never code.** If a revised plan implies code changes, it hands off to `/opsx:apply` ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)). +- **Schema-driven, not name-driven.** Artifact ids and paths come from `openspec status`, so the skill works for custom schemas, not just the default `proposal → specs → design → tasks` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). + +**Coherence is bidirectional.** Earlier framing treated update as strictly "downstream" propagation. That is wrong: in `proposal → specs → design → tasks`, editing `design` can require revising `proposal` too. The skill reads the change's artifacts and reconciles them in whatever direction the edit demands, rather than assuming a fixed flow. + +### Deliberately not built (yet) + +Per the steer to introduce as little code as possible, and only when there is a defined need, this change does **not** add: a reverse-dependency graph API, content digests / staleness signals, a `.openspec.yaml` baseline ledger, an `openspec reconcile` write op, a drift report, or a `status --impact` selector. The agent reads the change's artifacts directly — a handful of markdown files — which is enough to judge coherence. If a future, concrete need emerges (e.g. unattended drift detection across many changes), exposing the schema's `requires` edges on `openspec status --json` is a one-field additive follow-up. It is out of scope here. + +## Capabilities + +### New Capabilities + +- `opsx-update-skill`: A new `/opsx:update` workflow skill that revises a change's existing planning artifacts and keeps them coherent with one another. It reads the artifact set and paths from `openspec status`, reviews related artifacts in any direction (not only downstream), edits planning artifacts only and never code, and confirms each edit with the user. It ends with next-step guidance — recommending `/opsx:continue`, `/opsx:apply`, or `/opsx:archive` based on the change's state — without acting on it. + +## Impact + +- `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts`. Reads artifact ids and paths from `openspec status --json`; embeds no artifact-name patterns. +- Skill/command registration + [src/core/profiles.ts](../../../src/core/profiles.ts) — add `update` to `ALL_WORKFLOWS` **and to the default `core` profile** (`propose`, `explore`, `apply`, `sync`, `archive`), so `/opsx:update` is part of the default install rather than expanded-only (maintainer call on the PR). +- `docs/opsx.md` — add a `/opsx:update` row to the command table and a short "Updating a change" usage note. +- `openspec/changes/add-artifact-regeneration-support/` — the in-repo proposal-only stub for this gap is superseded; retire it or fold its notes into design. +- No changes to `src/core/artifact-graph/*`, `src/commands/workflow/status.ts`, or `ChangeMetadataSchema`. The skill uses `openspec status` / `openspec list` as they exist today. + +## Issues addressed + +Verified against `Fission-AI/OpenSpec` on 2026-06-30. + +Closes (the missing-update-action family): + +- [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) — "Add a command to update proposal, design and task" (and stop it editing code). Delivered as `/opsx:update`, planning-artifacts-only. +- [#705](https://github.com/Fission-AI/OpenSpec/issues/705) — "Rebuild downstream artifacts from a modified upstream." Delivered as the skill's read-and-reconcile pass over the change's artifacts. +- [#673](https://github.com/Fission-AI/OpenSpec/issues/673) — "clarify": update existing artifacts without auto-advancing the build frontier. `/opsx:update` revises in place and never creates the next artifact. +- [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "review and update all change proposals." Delivered as the within-a-change coherence review; cross-change audit is a separate, later proposal. + +Answers (questions whose honest answer today is "no command exists"): + +- [#694](https://github.com/Fission-AI/OpenSpec/issues/694), [#684](https://github.com/Fission-AI/OpenSpec/issues/684), [#618](https://github.com/Fission-AI/OpenSpec/issues/618) — "which command regenerates a document after the flow progressed / after apply?" → `/opsx:update`. +- Discussion [#1206](https://github.com/Fission-AI/OpenSpec/discussions/1206) — the official answer becomes `/opsx:update`. + +Supersedes: + +- `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by this skill. Its hardcoded-filename dependency tracking and metadata-file staleness mechanism are dropped in favor of letting the agent read the artifacts. + +Delineated from adjacent commands (distinct surfaces — coordinate, don't collide): + +- [#702](https://github.com/Fission-AI/OpenSpec/pull/702) `/opsx:clarify` — resolves ambiguity *within one artifact* via Q&A; a complementary upstream step. `/opsx:update` then reconciles the change's artifacts with each other. +- [#1251](https://github.com/Fission-AI/OpenSpec/pull/1251) `/opsx:review`, [#880](https://github.com/Fission-AI/OpenSpec/issues/880) — review the *implementation (code)* against the plan. `/opsx:update` is the mirror image: it keeps the *plan* coherent and never touches code. +- [#783](https://github.com/Fission-AI/OpenSpec/issues/783) — cross-artifact quality review. The skill's coherence pass is the lightweight form of this; a deterministic `validate`-side check is a separate proposal. diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md new file mode 100644 index 0000000000..a8074d2c8a --- /dev/null +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -0,0 +1,138 @@ +## ADDED Requirements + +### Requirement: Update Workflow Command + +The system SHALL provide a `/opsx:update` workflow skill that revises a change's existing planning artifacts in place. It SHALL NOT advance the build frontier (it does not create a not-yet-started artifact) and SHALL edit planning artifacts only, never implementation code. + +#### Scenario: Select the change to update + +- **WHEN** the user invokes `/opsx:update` without a change name +- **THEN** the skill infers the change from conversation context if possible +- **AND** if it cannot, it lists available changes (most-recently-modified first) via `openspec list --json` and asks the user to choose, never auto-selecting + +#### Scenario: Revise without advancing the frontier + +- **WHEN** the user asks `/opsx:update` to revise an existing artifact +- **THEN** the skill updates that artifact and reconciles the change's other existing artifacts with it +- **AND** it does NOT create any artifact that does not yet exist (that remains the job of `/opsx:continue`/`/opsx:propose`) + +#### Scenario: Missing artifacts are deferred to continue + +- **WHEN** keeping the change coherent would require an artifact that has not been created yet +- **THEN** the skill revises only the artifacts that currently exist +- **AND** it notes the not-yet-created artifacts and points the user to `/opsx:continue` to create them + +#### Scenario: Update stays within the plan + +- **WHEN** revising artifacts would imply changes to implementation code +- **THEN** the skill updates the planning artifacts only +- **AND** it directs the user to `/opsx:apply` to carry the revised plan into code, rather than editing code itself + +### Requirement: Schema-Driven Artifact Resolution + +The `/opsx:update` skill SHALL learn which artifacts exist and where they live by reading the change's status from the CLI, and SHALL NOT rely on hardcoded artifact names or assumed path separators. This makes the skill correct for custom schemas and on every platform, not only the default `spec-driven` schema. + +#### Scenario: Reads the artifact set from status + +- **WHEN** the skill needs to know which artifacts a change has and where they are +- **THEN** it runs `openspec status --change --json` and uses the reported artifact ids, statuses, and the `artifactPaths` map (`existingOutputPaths` for the files to edit) +- **AND** it does not assume the artifact ids or output paths + +#### Scenario: Does not branch on hardcoded artifact names + +- **WHEN** the skill decides which artifacts to read and revise +- **THEN** its control flow uses the ids reported by the CLI +- **AND** it does not branch on literal `proposal`/`specs`/`design`/`tasks` names + +#### Scenario: Works for a custom schema + +- **WHEN** the active change uses a custom schema whose artifact ids are not `proposal`/`specs`/`design`/`tasks` +- **THEN** the skill uses the artifact ids and paths reported by the CLI +- **AND** it works without any change to the skill + +#### Scenario: Resolve artifact paths cross-platform + +- **WHEN** the skill reads or writes an artifact on macOS, Linux, or Windows +- **THEN** it uses the `existingOutputPaths` provided by the CLI status output +- **AND** it does not assume forward-slash separators + +#### Scenario: Edit the concrete files of a glob artifact + +- **WHEN** an artifact's declared output path is a glob (for example `specs/**/*.md`) +- **THEN** the skill edits the concrete files reported in that artifact's `existingOutputPaths` +- **AND** it does not write to `resolvedOutputPath`, which for a glob artifact remains the glob pattern rather than a real file + +#### Scenario: A new file under a glob artifact is deferred to continue + +- **WHEN** keeping the change coherent would require a new file under a glob artifact that does not exist yet (for example a spec for a not-yet-captured capability) +- **THEN** the skill revises only the files already present in `existingOutputPaths` +- **AND** it points the user to `/opsx:continue`/`/opsx:propose` to create the new file rather than inventing a path from the glob + +### Requirement: Bidirectional Coherence Review + +The `/opsx:update` skill SHALL keep a change's existing planning artifacts coherent with one another after a revision, reviewing affected artifacts in any direction rather than assuming a fixed downstream flow. + +#### Scenario: Reconcile related artifacts after an edit + +- **WHEN** the user revises one artifact +- **THEN** the skill reviews the change's other existing artifacts against the revision +- **AND** it proposes follow-on edits to any artifact that is now inconsistent, whether that artifact is upstream or downstream of the edited one + +#### Scenario: Upstream artifact may be revised + +- **WHEN** an edit to a later artifact (for example design) contradicts an earlier one (for example the proposal) +- **THEN** the skill may propose revising the earlier artifact to restore coherence +- **AND** it does not treat propagation as downstream-only + +#### Scenario: Coherence review with no specific edit + +- **WHEN** the user invokes `/opsx:update` without a specific revision in mind ("make this change coherent") +- **THEN** the skill reads the change's existing artifacts and reviews them against each other for contradictions, gaps, and duplication +- **AND** it presents any findings for the user to confirm before editing + +#### Scenario: Coherent change yields no changes + +- **WHEN** the skill finds the change's artifacts already coherent +- **THEN** it reports the change as coherent and makes no edits + +### Requirement: Next-Step Guidance + +After applying confirmed revisions (or finding none needed), the `/opsx:update` skill SHALL report where the change stands and recommend the next command, without acting on the recommendation itself. + +#### Scenario: Updating an already-implemented change + +- **WHEN** the user updates a change whose implementation already happened (for example tasks are checked off or `/opsx:apply` was already run) +- **THEN** the skill still revises planning artifacts only +- **AND** it notes that the implementation may no longer match the revised plan and recommends `/opsx:apply` to carry the delta into code +- **AND** it does not implement anything itself + +#### Scenario: Next step when artifacts are incomplete + +- **WHEN** the update finishes and the change still has not-yet-created artifacts +- **THEN** the skill recommends `/opsx:continue` to create them + +#### Scenario: Next step when the change is fully done + +- **WHEN** the update finishes and the change's artifacts are complete and already implemented +- **THEN** the skill recommends `/opsx:archive` + +### Requirement: User-Confirmed Incremental Application + +The `/opsx:update` skill SHALL propose each artifact revision and apply it only after user confirmation. + +#### Scenario: Confirm before writing + +- **WHEN** the skill has a proposed revision for an artifact +- **THEN** it shows the user what it intends to change and why before writing +- **AND** it writes only after the user confirms + +#### Scenario: Rejected revision is not written + +- **WHEN** the user rejects a proposed revision for an artifact +- **THEN** the skill does not write that revision +- **AND** the artifact is left unchanged + +#### Scenario: Intent change is redirected to a new change + +- **WHEN** the requested revision changes the intent of the change rather than refining it (per the "Update vs. Start Fresh" heuristic) +- **THEN** the skill recommends starting a new change (`/opsx:new`) instead of mutating the existing proposal into different work diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md new file mode 100644 index 0000000000..c56308f0c7 --- /dev/null +++ b/openspec/changes/add-update-workflow/tasks.md @@ -0,0 +1,30 @@ +# Tasks: `/opsx:update` — a thin update skill + +> The whole feature is one new skill template over the existing `openspec status` / `openspec list` commands. No changes to the graph engine, the `status` command, or the metadata schema. + +## 1. The `/opsx:update` skill + +- [x] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. The skill name is `openspec-update-change` — change-scoped, per the `openspec--change` convention (see design, Naming). +- [x] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time → end with next-step guidance (`/opsx:continue` / `/opsx:apply` / `/opsx:archive` based on the change's state; see design Decision 6), never acting on it. Read artifact ids from the status JSON only, and write to `artifactPaths..existingOutputPaths` (never to a glob `resolvedOutputPath`). +- [x] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing files (`existingOutputPaths`) — defer not-yet-created artifacts, and new files under a glob artifact, to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). +- [x] 1.4 Register the skill/command and add `update` to `ALL_WORKFLOWS` **and the default `core` profile** in `src/core/profiles.ts` (maintainer call: default install, not expanded-only). + +## 2. Docs & supersede the stub + +- [x] 2.1 Add a `/opsx:update` row to the command table in `docs/opsx.md`, plus a short "Updating a change" usage note. +- [x] 2.2 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single update proposal. +- [x] 2.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. + +## 3. Tests + +- [x] 3.1 Template generation snapshot for the skill and command templates. +- [x] 3.2 Assert the template's control flow contains NO hardcoded artifact-name branching (the anti-#777 guard): artifact ids must be read from `openspec status` JSON. +- [x] 3.3 Assert the template instructs planning-artifacts-only with a hand-off to `/opsx:apply` for code, and never advances the build frontier. +- [x] 3.4 Assert the template instructs writing to `existingOutputPaths` (the glob-expanded concrete files) and not to a glob `resolvedOutputPath`. +- [x] 3.5 Assert the template ends with next-step guidance (`/opsx:continue`/`/opsx:apply`/`/opsx:archive`) and instructs the agent never to act on it. +- [x] 3.6 Assert `update` is included in the `core` profile's workflows (profiles test). + +## 4. End-to-end verification + +- [x] 4.1 `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. +- [x] 4.2 Manual walk-through: on a `spec-driven` change, edit `design`, run `/opsx:update`, confirm it proposes coherence edits to other existing artifacts (including upstream where warranted) and never touches code. diff --git a/src/core/init.ts b/src/core/init.ts index 7f5149dd46..fba6d80733 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -67,6 +67,7 @@ const WORKFLOW_TO_SKILL_DIR: Record = { 'new': 'openspec-new-change', 'continue': 'openspec-continue-change', 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', 'ff': 'openspec-ff-change', 'sync': 'openspec-sync-specs', 'archive': 'openspec-archive-change', diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9fa..488d16cfdc 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -16,6 +16,7 @@ export const WORKFLOW_TO_SKILL_DIR: Record = { 'new': 'openspec-new-change', 'continue': 'openspec-continue-change', 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', 'ff': 'openspec-ff-change', 'sync': 'openspec-sync-specs', 'archive': 'openspec-archive-change', diff --git a/src/core/profiles.ts b/src/core/profiles.ts index 29d4927468..acdc3ec953 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -11,7 +11,7 @@ import type { Profile } from './global-config.js'; * Core workflows included in the 'core' profile. * These provide the streamlined experience for new users. */ -export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'sync', 'archive'] as const; +export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] as const; /** * All available workflows in the system. @@ -22,6 +22,7 @@ export const ALL_WORKFLOWS = [ 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 898e7a25e8..2570a95a3e 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -9,6 +9,7 @@ import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, + getUpdateChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, @@ -20,6 +21,7 @@ import { getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, + getOpsxUpdateCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, @@ -59,6 +61,7 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change', workflowId: 'new' }, { template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change', workflowId: 'continue' }, { template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change', workflowId: 'apply' }, + { template: getUpdateChangeSkillTemplate(), dirName: 'openspec-update-change', workflowId: 'update' }, { template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change', workflowId: 'ff' }, { template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs', workflowId: 'sync' }, { template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change', workflowId: 'archive' }, @@ -85,6 +88,7 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxNewCommandTemplate(), id: 'new' }, { template: getOpsxContinueCommandTemplate(), id: 'continue' }, { template: getOpsxApplyCommandTemplate(), id: 'apply' }, + { template: getOpsxUpdateCommandTemplate(), id: 'update' }, { template: getOpsxFfCommandTemplate(), id: 'ff' }, { template: getOpsxSyncCommandTemplate(), id: 'sync' }, { template: getOpsxArchiveCommandTemplate(), id: 'archive' }, diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 72a0ebc8a3..30622209dc 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -16,6 +16,7 @@ export const SKILL_NAMES = [ 'openspec-new-change', 'openspec-continue-change', 'openspec-apply-change', + 'openspec-update-change', 'openspec-ff-change', 'openspec-sync-specs', 'openspec-archive-change', @@ -35,6 +36,7 @@ export const COMMAND_IDS = [ 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index ff687d900a..598fcc4465 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -10,6 +10,7 @@ export { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workfl export { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js'; export { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js'; export { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js'; +export { getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate } from './workflows/update-change.js'; export { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js'; export { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js'; export { getArchiveChangeSkillTemplate, getOpsxArchiveCommandTemplate } from './workflows/archive-change.js'; diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts new file mode 100644 index 0000000000..cf5475a6fe --- /dev/null +++ b/src/core/templates/workflows/update-change.ts @@ -0,0 +1,175 @@ +/** + * Skill Template Workflow Modules + * + * This file is generated by splitting the legacy monolithic + * templates file into workflow-focused modules. + */ +import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; + +export function getUpdateChangeSkillTemplate(): SkillTemplate { + return { + name: 'openspec-update-change', + description: "Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.", + instructions: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. + +${STORE_SELECTION_GUIDANCE} + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update. + + Present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from \`schema\` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from \`lastModified\` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update. + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Get the change's artifacts** + \`\`\`bash + openspec status --change "" --json + \`\`\` + Parse the JSON to understand current state. The response includes: + - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") + - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`isComplete\`: Boolean indicating if all artifacts are complete + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. + + The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged. + + The files to edit are \`artifactPaths..existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file. + +3. **Understand the request** + - If the user asked for a specific revision ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication. + +4. **Read and reconcile** + - Read the artifact(s) the request touches and the change's other existing artifacts. + - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Note everything that is now inconsistent, missing, or contradictory. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - If the change is already coherent, say so and make no edits. + +5. **Confirm and apply, one artifact at a time** + - Show each proposed revision and why. Write only after the user confirms. + - If the user rejects a revision, do not write it - leave that artifact unchanged. + - When a substantial rewrite is needed, get that artifact's rules and template first: + \`\`\`bash + openspec instructions --change "" --json + \`\`\` + +6. **Point to the next step (guidance only - NEVER act on it)** + - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. + - Everything done and implemented -> suggest \`/opsx:archive\`. + +**Output** + +After each invocation, show: +- Which artifacts were revised (and which proposed revisions were rejected) +- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- Where the change stands and the recommended next command + +**Guardrails** +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. +- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Confirm every edit with the user before writing. +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic).`, + license: 'MIT', + compatibility: 'Requires openspec CLI.', + metadata: { author: 'openspec', version: '1.0' }, + }; +} + +export function getOpsxUpdateCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Update', + description: "Update a change - revise existing planning artifacts and keep them coherent (Experimental)", + category: 'Workflow', + tags: ['workflow', 'artifacts', 'experimental'], + content: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. + +${STORE_SELECTION_GUIDANCE} + +**Input**: Optionally specify a change name after \`/opsx:update\` (e.g., \`/opsx:update add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update. + + Present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from \`schema\` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from \`lastModified\` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update. + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Get the change's artifacts** + \`\`\`bash + openspec status --change "" --json + \`\`\` + Parse the JSON to understand current state. The response includes: + - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") + - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`isComplete\`: Boolean indicating if all artifacts are complete + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. + + The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged. + + The files to edit are \`artifactPaths..existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file. + +3. **Understand the request** + - If the user asked for a specific revision ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication. + +4. **Read and reconcile** + - Read the artifact(s) the request touches and the change's other existing artifacts. + - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Note everything that is now inconsistent, missing, or contradictory. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - If the change is already coherent, say so and make no edits. + +5. **Confirm and apply, one artifact at a time** + - Show each proposed revision and why. Write only after the user confirms. + - If the user rejects a revision, do not write it - leave that artifact unchanged. + - When a substantial rewrite is needed, get that artifact's rules and template first: + \`\`\`bash + openspec instructions --change "" --json + \`\`\` + +6. **Point to the next step (guidance only - NEVER act on it)** + - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. + - Everything done and implemented -> suggest \`/opsx:archive\`. + +**Output** + +After each invocation, show: +- Which artifacts were revised (and which proposed revisions were rejected) +- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- Where the change stands and the recommended next command + +**Guardrails** +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. +- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Confirm every edit with the user before writing. +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic).` + }; +} diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index d1b60002ac..679e89a547 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -73,12 +73,12 @@ describe('deriveProfileFromWorkflowSelection', () => { it('returns custom when selection is a superset of core workflows', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'sync', 'archive', 'new'])).toBe('custom'); + expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'new'])).toBe('custom'); }); it('returns core when selection has exactly core workflows in different order', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'apply', 'explore', 'propose'])).toBe('core'); + expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); }); }); @@ -104,6 +104,7 @@ describe('config profile interactive flow', () => { 'openspec-propose', 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', ]; @@ -113,7 +114,7 @@ describe('config profile interactive flow', () => { fs.writeFileSync(skillPath, `name: ${dirName}\n`, 'utf-8'); } - const coreCommands = ['propose', 'explore', 'apply', 'sync', 'archive']; + const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; for (const commandId of coreCommands) { const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`); fs.mkdirSync(path.dirname(commandPath), { recursive: true }); @@ -168,7 +169,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('skills'); @@ -183,7 +184,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('keep'); await runConfigCommand(['profile']); @@ -211,7 +212,7 @@ describe('config profile interactive flow', () => { const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('workflows'); checkbox.mockResolvedValueOnce(['propose', 'explore']); @@ -255,7 +256,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('workflows'); checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'sync', 'archive']); @@ -281,7 +282,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); const configPath = getGlobalConfigPath(); const beforeContent = fs.readFileSync(configPath, 'utf-8'); @@ -301,7 +302,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupDriftedProjectArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -315,7 +316,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupSyncedCoreBothArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -329,7 +330,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupDriftedProjectArtifacts(tempDir); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('both'); @@ -345,7 +346,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupSyncedCoreBothArtifacts(tempDir); addExtraVerifyWorkflowArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -360,7 +361,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); select.mockResolvedValueOnce('delivery'); @@ -380,7 +381,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); select.mockResolvedValueOnce('delivery'); @@ -407,7 +408,7 @@ describe('config profile interactive flow', () => { const config = getGlobalConfig(); expect(config.profile).toBe('core'); expect(config.delivery).toBe('skills'); - expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); expect(select).not.toHaveBeenCalled(); expect(checkbox).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index d6ac830d3d..9d3541b686 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -250,7 +250,7 @@ describe('config profile command', () => { const result = getGlobalConfig(); expect(result.profile).toBe('core'); expect(result.delivery).toBe('skills'); // preserved - expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); }); it('custom workflow selection should set profile to custom', async () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed1..39ae092a0a 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -82,11 +82,12 @@ describe('InitCommand', () => { await initCommand.execute(testDir); - // Core profile: propose, explore, apply, sync, archive + // Core profile: propose, explore, apply, update, sync, archive const coreSkillNames = [ 'openspec-propose', 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', ]; @@ -121,11 +122,12 @@ describe('InitCommand', () => { await initCommand.execute(testDir); - // Core profile: propose, explore, apply, sync, archive + // Core profile: propose, explore, apply, update, sync, archive const coreCommandNames = [ 'opsx/propose.md', 'opsx/explore.md', 'opsx/apply.md', + 'opsx/update.md', 'opsx/sync.md', 'opsx/archive.md', ]; diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index b46901fa2e..b06456e016 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -9,7 +9,11 @@ import { describe('profiles', () => { describe('CORE_WORKFLOWS', () => { it('should contain the default core workflows', () => { - expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + }); + + it('should include update in the core profile (default install, not expanded-only)', () => { + expect(CORE_WORKFLOWS).toContain('update'); }); it('should be a subset of ALL_WORKFLOWS', () => { @@ -20,13 +24,13 @@ describe('profiles', () => { }); describe('ALL_WORKFLOWS', () => { - it('should contain all 11 workflows', () => { - expect(ALL_WORKFLOWS).toHaveLength(11); + it('should contain all 12 workflows', () => { + expect(ALL_WORKFLOWS).toHaveLength(12); }); it('should contain expected workflow IDs', () => { const expected = [ - 'propose', 'explore', 'new', 'continue', 'apply', + 'propose', 'explore', 'new', 'continue', 'apply', 'update', 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index 6c755f51d2..5f4bba9d55 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 11 skill templates', () => { + it('should return all 12 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique directory names', () => { @@ -28,6 +28,7 @@ describe('skill-generation', () => { expect(dirNames).toContain('openspec-new-change'); expect(dirNames).toContain('openspec-continue-change'); expect(dirNames).toContain('openspec-apply-change'); + expect(dirNames).toContain('openspec-update-change'); expect(dirNames).toContain('openspec-ff-change'); expect(dirNames).toContain('openspec-sync-specs'); expect(dirNames).toContain('openspec-archive-change'); @@ -88,9 +89,9 @@ describe('skill-generation', () => { }); describe('getCommandTemplates', () => { - it('should return all 11 command templates', () => { + it('should return all 12 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique IDs', () => { @@ -108,6 +109,7 @@ describe('skill-generation', () => { expect(ids).toContain('new'); expect(ids).toContain('continue'); expect(ids).toContain('apply'); + expect(ids).toContain('update'); expect(ids).toContain('ff'); expect(ids).toContain('sync'); expect(ids).toContain('archive'); @@ -142,9 +144,9 @@ describe('skill-generation', () => { }); describe('getCommandContents', () => { - it('should return all 11 command contents', () => { + it('should return all 12 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(11); + expect(contents).toHaveLength(12); }); it('should have valid content structure', () => { diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 5a66ff3cd5..eb3c04f97d 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -28,11 +28,12 @@ describe('tool-detection', () => { describe('SKILL_NAMES', () => { it('should contain all skill names matching COMMAND_IDS', () => { - expect(SKILL_NAMES).toHaveLength(11); + expect(SKILL_NAMES).toHaveLength(12); expect(SKILL_NAMES).toContain('openspec-explore'); expect(SKILL_NAMES).toContain('openspec-new-change'); expect(SKILL_NAMES).toContain('openspec-continue-change'); expect(SKILL_NAMES).toContain('openspec-apply-change'); + expect(SKILL_NAMES).toContain('openspec-update-change'); expect(SKILL_NAMES).toContain('openspec-ff-change'); expect(SKILL_NAMES).toContain('openspec-sync-specs'); expect(SKILL_NAMES).toContain('openspec-archive-change'); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index cc6ec7bc12..298d627c49 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -23,8 +23,10 @@ import { getOpsxSyncCommandTemplate, getOpsxProposeCommandTemplate, getOpsxProposeSkillTemplate, + getOpsxUpdateCommandTemplate, getOpsxVerifyCommandTemplate, getSyncSpecsSkillTemplate, + getUpdateChangeSkillTemplate, getVerifyChangeSkillTemplate, } from '../../../src/core/templates/skill-templates.js'; import { @@ -58,6 +60,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxProposeSkillTemplate: '8dfb5e9c719d5ba547aff0d3953c076dca6b33d7223be98cbffc396b8f1e0048', getOpsxProposeCommandTemplate: '7cd569beb32d99cdabd0b49615a8245160a8e152b6ea67a99fc4dd71e3f39f50', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', + getUpdateChangeSkillTemplate: 'fe2e8edaf973d42dc7fc7dfd846105c4c3cfec0437606e582ec644985cd4e81d', + getOpsxUpdateCommandTemplate: 'e55ac5774203a7d9037d2d588889c97c53f3f930da49497cc79e865375920da7', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { @@ -72,6 +76,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': '97d1eed5b900788706c28339e27c1d2d9c548626316253f43ebd00d8d52d02d6', 'openspec-onboard': 'd136b6ab7134d6bceeca73bc2f6037624506587e8df99059f77fe88874256ed1', 'openspec-propose': '5c350d80247722489374a49ec9853d5fda55a827f421fbb32b6b6a078fcb69ee', + 'openspec-update-change': 'c755a35c44245326780a3df1342df15103ed6a9de7af864581844a10de4f554d', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -88,6 +93,7 @@ const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ ['openspec-verify-change', getVerifyChangeSkillTemplate], ['openspec-onboard', getOnboardSkillTemplate], ['openspec-propose', getOpsxProposeSkillTemplate], + ['openspec-update-change', getUpdateChangeSkillTemplate], ]; function stableStringify(value: unknown): string { @@ -136,6 +142,8 @@ describe('skill templates split parity', () => { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate, getFeedbackSkillTemplate, + getUpdateChangeSkillTemplate, + getOpsxUpdateCommandTemplate, }; const actualHashes = Object.fromEntries( diff --git a/test/core/templates/update-change.test.ts b/test/core/templates/update-change.test.ts new file mode 100644 index 0000000000..d0f5202c52 --- /dev/null +++ b/test/core/templates/update-change.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { + getUpdateChangeSkillTemplate, + getOpsxUpdateCommandTemplate, +} from '../../../src/core/templates/skill-templates.js'; +import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; + +const skill = getUpdateChangeSkillTemplate(); +const command = getOpsxUpdateCommandTemplate(); + +// Both delivery surfaces must carry the same contract; every behavioral +// assertion below runs against each body. +const bodies: Array<[string, string]> = [ + ['skill', skill.instructions], + ['command', command.content], +]; + +describe('update-change templates', () => { + it('generates the expected skill and command shape (3.1)', () => { + expect(skill.name).toBe('openspec-update-change'); + expect(skill.description).toContain('Never edits code'); + expect(skill.license).toBe('MIT'); + expect(skill.compatibility).toBe('Requires openspec CLI.'); + expect(skill.metadata).toEqual({ author: 'openspec', version: '1.0' }); + + expect(command.name).toBe('OPSX: Update'); + expect(command.category).toBe('Workflow'); + expect(command.tags).toEqual(['workflow', 'artifacts', 'experimental']); + expect(command.content).toContain('/opsx:update add-auth'); + + for (const [label, body] of bodies) { + expect(body, label).toContain(STORE_SELECTION_GUIDANCE); + expect(body, label).toContain('openspec list --json'); + expect(body, label).toContain('openspec status --change "" --json'); + expect(body, label).toContain('openspec instructions --change "" --json'); + } + }); + + it('reads artifact ids from status JSON and never branches on hardcoded artifact names (3.2)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('do NOT assume them, and do NOT branch on hardcoded artifact names'); + expect(body, label).toContain('never branch on hardcoded artifact names'); + expect(body, label).toContain('Custom schemas must work unchanged'); + // No literal artifact filenames anywhere: no proposal.md/design.md/tasks.md + // branching, and no worked example that names them. The only .md literal + // allowed is the specs/**/*.md glob illustration. + expect(body.replace(/specs\/\*\*\/\*\.md/g, ''), label).not.toMatch(/\b[\w-]+\.md\b/); + } + }); + + it('edits planning artifacts only, hands code off to /opsx:apply, never advances the frontier (3.3)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('Never edit code'); + expect(body, label).toContain('NEVER edit implementation code'); + expect(body, label).toContain('stop and point to `/opsx:apply`'); + expect(body, label).toContain('Do not advance the build frontier'); + expect(body, label).toContain('Do NOT create artifacts that don\'t exist yet'); + } + }); + + it('writes to existingOutputPaths, never to a glob resolvedOutputPath (3.4)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('artifactPaths..existingOutputPaths'); + expect(body, label).toContain('Do NOT write to `resolvedOutputPath`'); + expect(body, label).toContain('still the glob pattern, not a real file'); + } + }); + + it('ends with next-step guidance and never acts on it (3.5)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('guidance only - NEVER act on it'); + expect(body, label).toContain('suggest `/opsx:continue`'); + expect(body, label).toContain('suggest `/opsx:apply`'); + expect(body, label).toContain('suggest `/opsx:archive`'); + expect(body, label).toContain('the code may no longer match the revised plan'); + } + }); + + it('confirms every edit and redirects intent changes to /opsx:new', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('Write only after the user confirms'); + expect(body, label).toContain('If the user rejects a revision, do not write it'); + expect(body, label).toContain('recommend starting fresh with `/opsx:new`'); + expect(body, label).toContain('Update vs. Start Fresh'); + } + }); +}); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7ed..dfdadfb58f 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -155,10 +155,11 @@ Old instructions content await updateCommand.execute(testDir); - // Verify core profile skill files were created/updated (propose, explore, apply, sync, archive) + // Verify core profile skill files were created/updated (propose, explore, apply, update, sync, archive) const coreSkillNames = [ 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', 'openspec-propose', @@ -233,8 +234,8 @@ Old instructions content await updateCommand.execute(testDir); - // Verify core profile commands were created (propose, explore, apply, sync, archive) - const coreCommandIds = ['explore', 'apply', 'sync', 'archive', 'propose']; + // Verify core profile commands were created (propose, explore, apply, update, sync, archive) + const coreCommandIds = ['explore', 'apply', 'update', 'sync', 'archive', 'propose']; const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); for (const cmdId of coreCommandIds) { const cmdFile = path.join(commandsDir, `${cmdId}.md`); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index c7ff2ed85b..dcd805f9f9 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -65,6 +65,7 @@ Finally /opsx-apply to implement`; 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', From 9a0dfb5cd136b423c9f13c0b29ec3ea69761b4e6 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 7 Jul 2026 11:13:47 -0500 Subject: [PATCH 07/19] refactor: unify requirement reader and surface #498 (#1281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(openspec): propose spec parser reading fidelity (fixes #361, #498, #312) The requirement-parsing layer silently misreads valid Markdown: - #361: requirement-body extraction returns only the first non-blank line, so a SHALL/MUST that wraps onto line 2 fails `validate --strict`. - #498: `validate` (delta-block parser) and `archive` (full-spec parser) recognize requirements by different rules, so a stray `###` header passes validate but becomes a phantom requirement that blocks archive. - #312 (residual): the requirement-body loop breaks on any `#` line without consulting the code-fence mask, truncating bodies that contain fenced code with `#` comments. Proposal: one shared, multi-line, fence-aware requirement-body extractor used by both the validator and the markdown parser; recognize only `### Requirement:`-prefixed level-3 headers; guarantee validate/archive parity. Adds regression + parity tests. #559 investigated and deferred (ambiguous root cause — see design.md). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): bulletproof parser-fidelity proposal with empirical evidence Hardened the proposal after reproducing every claim against main with the bundled CLI and correcting two inaccuracies: - #498 reframed: archive does NOT hard-fail. validate passes; archive emits NON-BLOCKING phantom "Proposal warnings in proposal.md" because validateChange/parseRequirements counts every level-3 header as a requirement, while the delta-block parser (validate) and specs-apply (rebuild) only recognize canonical `### Requirement:`. It is a consistency bug, not data loss. Verified the rebuilt spec is clean. - #312 reframed: the original repro is already fixed by codeFenceLineMask (requirement count verified correct). The residual is a regression hazard: the body loop is fence-unaware, harmless only while first-line-only, so the multi-line fix must be fence-aware from the start. Also: unify recognition on the canonical REQUIREMENT_HEADER_REGEX (/^###\s*Requirement:\s*(.+)$/i, case-insensitive); surfaced a third latent inconsistency (Zod substring includes('SHALL') vs delta word-boundary \b(SHALL|MUST)\b) and added a single-predicate requirement; verified zero non-Requirement level-3 headers in repo specs (CI-safe); added edge-case scenarios (multi-line spec+delta paths, fenced scenario-looking lines, REMOVED/RENAMED unaffected, display vs detection); replaced broken relative links with plain paths. Proposal passes `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): deepen parser-fidelity proposal — add #418, upgrade #312, tier the risk Second adversarial bulletproofing pass (reproduced everything against main): - Add #418 (metadata-before-description): live on the spec path (req.text = "**ID**: ...") but ALREADY fixed on the delta path. The asymmetry is direct evidence for unifying the two extractors. - Upgrade #312 from "regression hazard" to LIVE bug: a fenced code block before the prose line makes req.text = "```bash" on both paths today (distinct from the already-fixed section-count manifestation). - Tier the fixes by risk after auditing the existing test contract (markdown-parser.test.ts, 15 tests green on main): Tier 1 (false-negative fixes #361/#418/#312): only widens what is read; updates one test (:331, which asserts the first-line bug). Fence tests (:106/:139) preserved because skip-and-join keeps SHALL-first bodies. Tier 2 (recognition tightening #498): canonical ### Requirement: only; a deliberate behavior change that updates bare-header tests (:258/:310) and needs a migration note. Flagged for maintainer decision, with a conservative opt-in-lint alternative documented. - Surface the four-column extractor divergence table (capture / metadata / recognition / predicate) and an explicit "Behavior changes and test impact" section with exact test line refs. Proposal passes `openspec validate --strict`. Does not claim #1156 (PR #1280). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): third pass — reject recognition tightening, add fenced-scenario bug, #498→safe INFO Third deep pass found the prior Tier 2 (recognition tightening to `### Requirement:`) was the WRONG fix and over-scoped: - Bare `### ` headers are a SUPPORTED, tested requirement format: test/core/validation.test.ts asserts a bare-header spec is valid, and bare headers appear across json-converter/archive/spec tests and tmp-init fixtures. Tightening would break a large test surface and silently drop requirements from real specs. REJECTED, with evidence documented. - Replace the #498 fix with a SAFE INFO note in validate that surfaces non-`### Requirement:` headers in delta sections. INFO never fails validation (strict: valid = no errors && no warnings), so nothing newly fails. - New bug found and folded in: countScenarios is fence-unaware, so a `#### Scenario:` inside a fenced block is counted as real — a malformed delta passes validate while validate correctly fails. Same fence family. - Proved the archive WRITE path is independent of the reader: specs-apply rebuilds from raw `### Requirement:` blocks (extractRequirementsSection + RequirementBlock.raw), never parseSpec/req.text → Part A cannot change archived content. Net effect: recognition is unchanged, so the proposal now updates exactly ONE existing test (:331, the first-line assertion) instead of breaking bare-header tests. Consolidated to a single cli-validate delta (dropped cli-archive and openspec-conventions deltas). Dropped the no-space-header hypothesis (no divergence). Passes `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(parser): unify the requirement reader, fence/metadata/multi-line aware (#361, #418, #312); surface #498 The requirement reader was implemented twice — MarkdownParser.parseRequirements (validate /archive) and Validator.extractRequirementText/countScenarios (validate ) — and the two had drifted. Both now delegate to one shared, fence-/metadata-/multi-line-aware extraction in parsers/requirement-text.ts so they cannot diverge again. Part A — unify the reader: - Capture the full requirement body up to the first non-fenced `#### Scenario:`, skipping blank, `**metadata**:`, and fenced-code lines; run SHALL/MUST detection over the whole body. Fixes a wrapped keyword being dropped (#361), metadata before the description failing validate (#418), and a fenced block before the prose line becoming the requirement text (#312). - Count only non-fenced `#### ` headers, so a `#### Scenario:` inside a fenced example no longer counts as a real scenario in validate (parity with validate ). - One whole-word `\b(SHALL|MUST)\b` predicate (containsShallOrMust) shared by the validator and base.schema, replacing the substring/word-boundary split. - Extract buildCodeFenceMask into the shared module; MarkdownParser and ChangeParser import it (single fence implementation). Part B — surface #498 safely: - validate emits an INFO note when an ADDED/MODIFIED Requirements section contains a non-`### Requirement:` level-3 header (one the delta reader silently skips). INFO never changes the valid result, including under --strict, so nothing newly fails. Recognition is unchanged: bare `### ` headers remain a supported requirement format. Write path is unaffected: specs-apply rebuilds from raw `### Requirement:` blocks, never req.text, so archived content cannot change. Displayed text in JSON output and delta descriptions now reflects the full body. Tests: markdown-parser.test.ts:331 updated to expect the full body; regression tests added for #361/#418/#312, the fenced scenario, the #498 INFO note, a single-line guard, and CRLF. Changeset added (patch). tasks.md completed. Co-Authored-By: Claude Opus 4.8 (1M context) * test(parser): add cross-reader predicate + metadata-only guards (design edge cases) Exhaustive verification of the unified reader surfaced two design "edge cases for tests" not yet covered by committed unit tests: - Cross-reader predicate agreement: a SHALL substring inside a word ("MARSHALL") is rejected identically by validate and validate — proving the one shared whole-word predicate, and guarding against a regression to the old substring check. - Metadata-only body still fails validation (no requirement text) on the delta path. Behavior unchanged; tests only. Full end-to-end parity across all four spec requirements confirmed against the real Validator; no spurious INFO note fires on any existing repo change. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(parser): address review — metadata-only bodies, header-bounded extraction, reader-derived INFO - Skip **metadata**: lines only when other body text remains; a body written entirely as metadata (e.g. `**Constraint**: The system MUST ...`) is kept as the requirement text instead of being emptied (was a regression vs main). - Move the empty-body rule into the shared reader: both paths fall back to the header title, so the same block cannot pass one path and fail the other. - End body extraction at any non-fenced markdown header, restoring old-reader parity: a stray `### Background` divider's notes no longer satisfy the SHALL/MUST check. - Replace the standalone fence-aware INFO scanner with skipped-header collection inside parseDeltaSpec, so the note reflects exactly what the reader skipped (same section boundaries, no whole-file fence mask). - Special-case the nameless `### Requirement:` INFO message; document that the any-#### scenario match is deliberate spec-path parity; un-export REQUIREMENT_HEADER_REGEX; move the import up top. - Soften the changeset claim and list the known remaining divergences in design.md. Co-Authored-By: Claude Fable 5 * docs(openspec): record the no-space ###Requirement: divergence as a known leftover Jun's edge (reproduced): the delta/write reader's REQUIREMENT_HEADER_REGEX accepts `###Requirement:` with no space, but MarkdownParser.parseSections requires whitespace (per GFM) — so a no-space requirement validates as a change with zero INFO, syncs as-is, then fails validate . Pre-existing on main and out of scope here (tightening the shared regex would change write-path recognition); documented under known remaining divergences with the follow-up options, folded together with the bullet from the merge resolution. Corrects c63913b's 'no divergence' note. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: TabishB --- .changeset/spec-parser-reading-fidelity.md | 16 + .../fix-spec-parser-fidelity/.openspec.yaml | 2 + .../fix-spec-parser-fidelity/design.md | 77 ++++ .../fix-spec-parser-fidelity/proposal.md | 71 ++++ .../specs/cli-validate/spec.md | 69 ++++ .../changes/fix-spec-parser-fidelity/tasks.md | 43 ++ src/core/parsers/change-parser.ts | 3 +- src/core/parsers/markdown-parser.ts | 88 +--- src/core/parsers/requirement-blocks.ts | 75 +++- src/core/parsers/requirement-text.ts | 151 +++++++ src/core/schemas/base.schema.ts | 2 +- src/core/validation/validator.ts | 79 ++-- test/core/parsers/markdown-parser.test.ts | 166 +++++++- test/core/validation.test.ts | 389 ++++++++++++++++++ 14 files changed, 1106 insertions(+), 125 deletions(-) create mode 100644 .changeset/spec-parser-reading-fidelity.md create mode 100644 openspec/changes/fix-spec-parser-fidelity/.openspec.yaml create mode 100644 openspec/changes/fix-spec-parser-fidelity/design.md create mode 100644 openspec/changes/fix-spec-parser-fidelity/proposal.md create mode 100644 openspec/changes/fix-spec-parser-fidelity/specs/cli-validate/spec.md create mode 100644 openspec/changes/fix-spec-parser-fidelity/tasks.md create mode 100644 src/core/parsers/requirement-text.ts diff --git a/.changeset/spec-parser-reading-fidelity.md b/.changeset/spec-parser-reading-fidelity.md new file mode 100644 index 0000000000..a5d82f1f61 --- /dev/null +++ b/.changeset/spec-parser-reading-fidelity.md @@ -0,0 +1,16 @@ +--- +"@fission-ai/openspec": patch +--- + +### Bug Fixes + +- **Requirement reading fidelity** — The requirement reader used by `validate `, `validate `, and `archive` is now unified into one fence-, metadata-, and multi-line-aware extraction, closing the known divergences between the change-delta path and the main-spec path (the remaining ones are documented in the change's design doc): + - A `SHALL`/`MUST` keyword that wraps onto a later body line is detected instead of dropped (#361). + - Metadata lines (`**ID**:`, `**Priority**:`) before the description are skipped on the spec path, matching the change path (#418). A requirement written entirely as metadata (e.g. `**Constraint**: The system MUST ...`) keeps that line as its text instead of being emptied. + - A fenced code block before the prose line no longer becomes the requirement text (#312). + - A `#### Scenario:` inside a fenced example no longer counts as a real scenario in `validate `, matching `validate `. + - `SHALL`/`MUST` detection uses one whole-word predicate across all readers, and a requirement with no body text falls back to its header title on both paths. + + Displayed requirement text (e.g. in JSON output and delta descriptions) now reflects the full requirement body rather than only its first line. Archived spec content is unchanged — the archive rebuild reads raw `### Requirement:` blocks, not the parsed text. + +- **Surface non-canonical delta headers** — `validate ` now emits an INFO note when an `## ADDED`/`## MODIFIED Requirements` section contains a level-3 header that is not a canonical `### Requirement:` header (one the delta reader silently skips, such as a stray `### Documentation Requirements` divider). The note never changes the `valid` result, including under `--strict` (#498). diff --git a/openspec/changes/fix-spec-parser-fidelity/.openspec.yaml b/openspec/changes/fix-spec-parser-fidelity/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/fix-spec-parser-fidelity/design.md b/openspec/changes/fix-spec-parser-fidelity/design.md new file mode 100644 index 0000000000..f9909941aa --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/design.md @@ -0,0 +1,77 @@ +# Design: Spec parser reading fidelity + +## The requirement reader is implemented twice + +| | spec reader: `MarkdownParser.parseRequirements` → `req.text` | delta reader: `Validator.extractRequirementText` / `countScenarios` | +|---|---|---| +| Recognition | every level-3 child of the section | canonical `REQUIREMENT_HEADER_REGEX` `/^###\s*Requirement:\s*(.+)$/i` | +| Body capture | first non-empty line | first substantial line | +| Skip `**metadata**:` | no | yes | +| Fenced code in body | not skipped | not skipped | +| Fenced `#### Scenario:` | not counted (parseSections fence-masks it) | **counted** (`/^####\s+/gm` is fence-unaware) | +| `SHALL`/`MUST` | `text.includes('SHALL')` (substring) | `/\b(SHALL\|MUST)\b/` (word boundary) | +| Reached by | `validate `, `archive` | `validate ` | + +`ChangeParser extends MarkdownParser` and reuses `parseRequirements`, so there is no third reader. Every row where the two columns differ is a reproduced defect. + +## Reproductions (against `main`) + +- **#361** — `### Requirement: …` with `SHALL` on body line 2 → `validate ` `✗ must contain SHALL or MUST`; `validate ` `✗ requirements.0.text: …`. +- **#418** — metadata lines before a `MUST` description → `validate ` **valid**; `validate ` `✗`, `req.text` = `**ID**: REQ-FILE-001`. +- **#312** — fenced block (with `#` comments) before the prose line → both paths `✗`; `req.text` = `` ```bash ``. (Distinct from the already-fixed section-count manifestation.) +- **Fenced scenario** — requirement whose only `#### Scenario:` is inside a ` ```markdown ` block → `validate ` **valid** (counts the fenced scenario); `validate ` `✗ requirements.0.scenarios: must have at least one scenario`. The delta reader passes a malformed requirement. +- **#498** — stray `### Documentation Requirements` divider → `validate ` **valid**; `archive` prints non-blocking phantom `Proposal warnings in proposal.md`; `validate ` blocking `✗`. (Also: `show`/`view` count the divider as a requirement — `count=2` with `text='Documentation Notes'`.) + +## Approach + +### Part A — one shared, fence-aware extraction + +A single helper takes the requirement block's lines plus the fence mask and returns the full body: lines from after the header to the first markdown header found on a **non-fence-masked** line (usually `#### Scenario:`, but also a stray `###` divider the delta reader absorbed into the block — its notes must not feed the keyword check), skipping fence-masked lines and blank lines. `**metadata**:` lines are skipped only when other body text remains; a requirement written entirely as `**Constraint**: The system MUST ...` keeps that line as its body. When the body comes back empty, `MarkdownParser` still falls back to the header title for display and bare-header compatibility; validator body-keyword checks for canonical `### Requirement:` blocks use the body-only extraction so #1280's "keyword only in header" hint remains intact on both validation paths. A companion fence-aware scenario counter counts only non-fence-masked `####` headers (deliberately *any* `####`, since the spec path treats every level-4 child as a scenario). Both readers delegate to these. `SHALL`/`MUST` detection uses one predicate. + +Why the existing fence tests still pass: in `markdown-parser.test.ts:106`/`:139` the `SHALL` line is first and the fenced block follows, so skipping fenced lines leaves `text` exactly equal to the `SHALL` line — the asserted value. The breaking case (#312) is the inverse — fence *before* prose — which no test covers. + +### Part B — surface the #498 divergence (INFO, no recognition change) + +`parseDeltaSpec` records the non-canonical level-3 headers it skips *while parsing* the `## ADDED`/`## MODIFIED Requirements` sections, and `validateChangeDeltaSpecs` emits each as an INFO issue. Collecting during the parse (rather than with a separate scanner) guarantees the note describes the reader's real boundaries — a header the reader never saw (e.g. after a fenced `##` line ended the section early) gets no note, and a fenced `###` example line, which the body reader treats as content, is not reported. Under `--strict`, `valid = errors === 0 && warnings === 0` — **INFO is excluded**, so this never changes pass/fail; it only informs. This is the minimal change that makes `validate ` stop *silently* passing the #498 input. + +## Why recognition tightening is rejected + +The obvious #498 fix is to make `parseRequirements` recognize only `### Requirement:` headers. It is rejected because **bare `### ` headers are a supported, tested requirement format**, not a convention violation: + +- `test/core/validation.test.ts` builds a spec whose requirements are `### The system SHALL provide secure user authentication` (no `Requirement:` prefix) and asserts `report.valid === true`. +- Bare headers also appear as valid requirements in `test/core/converters/json-converter.test.ts`, `test/core/archive.test.ts`, `test/commands/spec.test.ts`, and `test/core/parsers/markdown-parser.test.ts` (`:258`, `:310`, and the fixtures at `:14`/`:22`/`:55`/`:85`). + +Tightening would reclassify all of these as non-requirements, breaking those tests and silently dropping requirements from any real spec that uses the bare style. The cost is not justified by #498, whose harm is a *confusing signal*, not data loss (the archive rebuild already filters to `### Requirement:` blocks, so rebuilt specs are correct regardless). Part B fixes the signal safely. If maintainers later decide to make `### Requirement:` mandatory, that belongs in its own change with a deprecation cycle and fixture migration. + +## Safety: write path is independent of the reader + +`src/core/specs-apply.ts` rebuilds specs during archive from `extractRequirementsSection` + `RequirementBlock.raw` (raw text split on the canonical header). It does not import or call `parseSpec`/`parseRequirements` and never reads `req.text`. Consequently Part A changes only what is *read/validated/displayed*; archived spec bytes are unchanged. (Note: this means `specs-apply` already uses the canonical `### Requirement:` rule — another reason recognition divergence is a reader-only concern.) + +## Read-only blast radius (no write path) + +Consumers of `parseSpec`/`req.text`: `view.ts`/`list.ts` (requirement **counts** — unchanged, since recognition is unchanged), `json-converter.ts` (JSON `text` — now the full body), `spec.ts` (display), `change-parser.ts:96` (delta descriptions `Add requirement: ${req.text}` — may span lines), and the `MAX_REQUIREMENT_TEXT_LENGTH` INFO (non-blocking). None affect archived content or pass/fail of valid specs. + +## Edge cases for tests + +- Single-line requirement unchanged (text and count byte-for-byte). +- Metadata-only body still flags missing `SHALL`/`MUST`. +- Fenced `#### Scenario:` / `#`-comment lines do not corrupt text or inflate scenario count. +- LF/CRLF/CR via `normalizeContent`; `~~~`/length-≥3/leading-whitespace fences via existing `buildCodeFenceMask`. +- INFO note appears for a stray delta header but does not change `valid` (including `--strict`). + +## Known remaining divergences + +Unification closes the reproduced defects; these divergences remain and are accepted: + +- **Empty scenarios** — a `#### Scenario:` header with no body counts on the delta path (`countScenarios` counts headers) but not on the spec path (`parseScenarios` keeps only scenarios with content), so `validate ` passes what `validate `/`archive` rejects. +- **Recognition** — bare `### ` headers are requirements on the spec path but skipped on the delta path. Deliberate (see "Why recognition tightening is rejected"); the Part B INFO note surfaces it instead of unifying it. +- **No-space `###Requirement:` headers** — `REQUIREMENT_HEADER_REGEX` (`\s*` after `###`) accepts them on the delta and write paths, but `MarkdownParser.parseSections` requires whitespace (matching GFM, which does not treat `###Requirement:` as a heading). So a no-space requirement validates as a change with zero INFO (the reader accepts it, so the skip note never fires), syncs into the main spec as-is, and the synced spec then fails `validate ` — the same shape as #498. Pre-existing (both regexes unchanged from `main`) and accepted here: the no-space form is a tested normalization case (`requirement-blocks.test.ts`), and tightening the shared regex would change write-path recognition. Closing it should be a separate compatibility change — deprecate no-space headers with an INFO/WARN first, or broaden the skipped-header collection to any `^###` line before tightening recognition. +- **Delta section/block splitting is not fence-aware** — `splitTopLevelSections` and `parseRequirementBlocksFromSection` treat a fenced `## ...` line as a section boundary and a fenced `### Requirement:` line as a new block, while the spec path fence-masks its sectioning. The skipped-header INFO is collected during the actual parse precisely so it reflects these boundaries instead of describing different ones. + +## Prior art + +`findMainSpecStructureIssues` (`spec-structure.ts`) already flags a `### Requirement:` header *outside* the `## Requirements` section and delta headers inside a main spec. The Part B INFO note is complementary: it flags non-`Requirement:` headers *inside* a delta Requirements section, which that function does not cover. + +## Out of scope: #559 + +Deferred — transcript shows an unqualified `changes//...` path (missing `openspec/` prefix), not a demonstrated folder-vs-title mismatch. diff --git a/openspec/changes/fix-spec-parser-fidelity/proposal.md b/openspec/changes/fix-spec-parser-fidelity/proposal.md new file mode 100644 index 0000000000..f56cce97f7 --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/proposal.md @@ -0,0 +1,71 @@ +## Why + +OpenSpec's promise is that the spec is the source of truth, and `validate`/`archive` are the gate that protects it. That gate is undermined by a fragmented requirement-parsing layer: the requirement **reader** is implemented twice — `MarkdownParser.parseRequirements` (used by `validate ` and `archive`) and `Validator.extractRequirementText` + `countScenarios` (used by `validate `) — and the two have drifted apart. Every defect below was reproduced against `main` with the bundled CLI; outputs are quoted in `design.md`. + +The two readers differ in ways that are each a reproduced bug: + +| | spec reader (`parseRequirements`) | delta reader (`extractRequirementText`/`countScenarios`) | +|---|---|---| +| Body capture | first line only | first line only | +| Skips `**metadata**:` lines | **no** | yes | +| Ignores fenced code in body | **no** | **no** | +| Counts fenced `#### Scenario:` | no (fence-masked) | **yes** | +| `SHALL`/`MUST` predicate | substring `includes('SHALL')` | word-boundary `\b(SHALL\|MUST)\b` | + +### Reproduced bugs + +- **#361 — wrapped keyword invisible.** Both readers capture only the first body line, so a `SHALL`/`MUST` on line 2 fails both `validate ` and `validate `. +- **#418 — metadata before description, spec path only.** A requirement that opens with `**ID**:`/`**Priority**:` lines passes `validate ` (delta reader skips metadata) but fails `validate ` (`req.text` = `**ID**: REQ-FILE-001`). +- **#312 — fenced block before prose corrupts text.** The original count-corruption is already fixed by `codeFenceLineMask`, but the body loop is still fence-unaware: a fenced code block before the `SHALL` line makes `req.text` = `` ```bash `` on both paths today. +- **Fenced scenario counted as real (discovered during hardening, no open issue).** `countScenarios` matches `^####` with a fence-unaware regex, so a requirement whose only `#### Scenario:` lives inside a fenced example passes `validate ` — while the same content correctly fails `validate `. A malformed delta slips through the gate. +- **#498 — validate and archive disagree.** `validate ` recognizes requirements only by the canonical `### Requirement:` header; `parseRequirements` treats every level-3 header as a requirement. A stray divider like `### Documentation Requirements` is silently ignored by `validate ` but flagged by `archive` (non-blocking phantom warning) and `validate ` (blocking error). The author gets no signal at validate time. + +## What Changes + +### Part A — unify the reader (fixes #361, #418, #312, fenced-scenario counting) + +One shared, fence-/metadata-/multi-line-aware extraction used by **both** readers, so they cannot drift again: + +- Requirement-body capture spans every line from after the `### Requirement:` header to the first `#### Scenario:` header found on a **non-fenced** line, skipping fence-masked lines and `**metadata**:` lines; `SHALL`/`MUST` detection runs over the full body. +- Scenario counting ignores fence-masked `####` lines, so fenced examples never count as real scenarios. +- One normative-keyword predicate (`\b(SHALL|MUST)\b`) replaces the substring/word-boundary split. + +Part A only corrects what is *detected*. It fixes false negatives (#361/#418/#312) and one false positive (fenced scenario), and does **not** change which headers count as requirements. + +### Part B — make the #498 divergence visible (safe, no recognition change) + +`validate ` emits an **INFO**-level note when an `## ADDED`/`## MODIFIED Requirements` section contains a level-3 header that is not a canonical `### Requirement:` header — i.e. one the delta reader will silently skip. This surfaces the stray-header problem at validate time instead of letting it appear only at archive, **without** changing recognition. INFO never fails validation (not even `--strict`), so no currently-passing change newly fails. + +### Rejected: tightening recognition to `### Requirement:` only + +The tempting #498 fix — make `parseRequirements` recognize only `### Requirement:` headers — is **rejected**. Bare `### ` headers (e.g. `### The system SHALL …`) are a **supported, widely-tested requirement format**: `test/core/validation.test.ts` asserts a bare-header spec is `valid`, and bare headers appear across `json-converter`, `archive`, and `spec` tests plus the `tmp-init` fixtures. Tightening would reclassify those as non-requirements and break a large swath of the suite (and likely real user specs). Surfacing the divergence (Part B) achieves consistency of *signal* without a breaking change to recognition. See `design.md` for the full analysis. + +Out of scope (investigated, deferred): #559 — its transcript shows an unqualified `changes/...` path, not a proven folder-vs-title mismatch. + +## Safety: the archive write path is unaffected + +`specs-apply` (the archive rebuild) reconstructs specs from raw `### Requirement:` blocks via `extractRequirementsSection` + `RequirementBlock.raw` — it never calls `parseSpec`/`parseRequirements` and never reads `req.text`. Therefore changing the reader (Part A) **cannot alter archived spec content**; it only changes what `validate`/`view`/`show` report. Verified by inspection of `src/core/specs-apply.ts`. + +## Existing-test impact + +All 15 tests in `test/core/parsers/markdown-parser.test.ts` pass on `main`. Because recognition is unchanged, this proposal updates **one** test: `should extract requirement text from first non-empty content line` (`:331`), which asserts `req.text` is only the first body line — the #361 bug itself; it is updated to expect the full body. The fence tests (`:106`, `:139`) are preserved (skip-and-join keeps `SHALL`-first bodies intact). Bare-header tests (`:258`, `:310`) and `validation.test.ts`/`json-converter.test.ts` are **not** affected, because recognition does not change. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `cli-validate`: requirement-text extraction becomes multi-line, fence-aware, and metadata-aware; scenario counting becomes fence-aware; one normative-keyword predicate; an INFO note surfaces non-`Requirement:` headers in delta sections. + +## Impact + +- `src/core/parsers/markdown-parser.ts` — shared multi-line/fence/metadata-aware body extraction. +- `src/core/validation/validator.ts` — `extractRequirementText` and `countScenarios` delegate to the shared, fence-aware helpers; INFO note for stray delta headers. +- `src/core/parsers/requirement-blocks.ts` — export the canonical `REQUIREMENT_HEADER_REGEX` for the INFO check. +- `src/core/schemas/base.schema.ts` — schema-level `SHALL`/`MUST` enforcement stays removed after #1280; the imperative validator uses the shared predicate. +- `test/core/parsers/markdown-parser.test.ts:331` updated; regression tests added. +- Read-only blast radius (display only, no write path): `view`/`list` requirement counts and `json-converter`/`spec` JSON `text` reflect the fuller body; `change-parser` delta descriptions built from `req.text` may span multiple lines; the `MAX_REQUIREMENT_TEXT_LENGTH` check is INFO (non-blocking). Requirement **counts** are unchanged (recognition unchanged). +- Fixes #361, #418, #312; surfaces #498. Related: #559 (deferred). Does not claim #1156 (PR #1280). Hardens the reader that #1112/#1246/#1277 rely on. diff --git a/openspec/changes/fix-spec-parser-fidelity/specs/cli-validate/spec.md b/openspec/changes/fix-spec-parser-fidelity/specs/cli-validate/spec.md new file mode 100644 index 0000000000..4791804218 --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/specs/cli-validate/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Requirement bodies SHALL be parsed in full for normative keywords +The validator SHALL detect `SHALL`/`MUST` across the entire requirement body, not only the first body line. Requirement-text extraction SHALL capture every body line from after the `### Requirement:` header up to the first Markdown header on a non-fenced line (a `#### Scenario:` header, or a stray `###` divider absorbed into a delta block), skipping blank lines and lines inside fenced code blocks. `**metadata**:` lines SHALL be skipped only when other body text remains; a body consisting solely of metadata lines SHALL be kept as the requirement text. Detection SHALL run over the full captured body. Canonical `### Requirement:` blocks with no body text SHALL NOT satisfy body-keyword validation from the header title alone; they SHALL receive the existing body-keyword hint when the keyword appears only in the header. The Markdown parser MAY still use the header title as display text for supported bare-header specs. The change-delta reader and the main-spec validator SHALL share this body extraction so they cannot diverge. + +#### Scenario: Normative keyword on the second wrapped line (change and spec) +- **GIVEN** a requirement whose text wraps across two lines with `SHALL` on the second line +- **WHEN** running `openspec validate --strict` for both a change delta and a main spec +- **THEN** both SHALL detect the keyword and SHALL NOT report a missing-`SHALL`/`MUST` error + +#### Scenario: Metadata fields precede the description +- **GIVEN** a requirement whose body begins with `**ID**:`/`**Priority**:` lines before a `MUST` description +- **WHEN** running `openspec validate --strict` +- **THEN** validation SHALL skip the metadata lines, detect `MUST`, and pass — matching `openspec validate ` + +#### Scenario: Requirement written entirely as a metadata line +- **GIVEN** a requirement whose whole body is `**Constraint**: The system MUST ...` +- **WHEN** running `openspec validate --strict` for both a change delta and a main spec +- **THEN** both SHALL keep that line as the requirement text and detect the `MUST` + +#### Scenario: Stray divider bounds the requirement body +- **GIVEN** a delta requirement followed by a stray `### Background` divider whose notes contain `MUST` +- **WHEN** running `openspec validate --strict` +- **THEN** the requirement body SHALL end at the divider and the `MUST` in the notes SHALL NOT satisfy the keyword check + +#### Scenario: Single-line requirement is unaffected +- **GIVEN** a requirement whose `SHALL` statement is on a single body line +- **WHEN** running `openspec validate --strict` +- **THEN** validation behavior, messages, and displayed text SHALL be unchanged from before this change + +### Requirement: Fenced code blocks SHALL NOT corrupt extraction or scenario counting +The validator and Markdown parser SHALL ignore lines inside fenced code blocks (` ``` ` or `~~~`) when extracting requirement body text, when locating the body-ending header boundary, and when counting scenarios. A fenced block before the prose line SHALL NOT make the fence marker the requirement text, and a `#### Scenario:` inside a fenced block SHALL NOT count as a real scenario. + +#### Scenario: Fenced block before the prose line +- **GIVEN** a requirement whose body opens with a fenced code block containing `#`-comment lines, followed by the `SHALL` prose line +- **WHEN** the spec or change is validated +- **THEN** the captured requirement text SHALL be the prose line (not the fence marker) and validation SHALL pass + +#### Scenario: Fenced scenario is not a real scenario +- **GIVEN** a requirement whose only `#### Scenario:` appears inside a fenced code example, with no real scenario +- **WHEN** running `openspec validate --strict` +- **THEN** validation SHALL report the requirement as missing a scenario — the same result as `openspec validate ` + +### Requirement: A single normative-keyword predicate SHALL be used across readers +All `SHALL`/`MUST` detection SHALL use one predicate that matches `SHALL` or `MUST` as whole words (delimited by word boundaries, so a substring inside a longer word such as `MARSHALL` does not match), so the change-delta reader and the schema-based reader accept and reject identical text. + +#### Scenario: Keyword detection agrees across readers +- **GIVEN** identical requirement body text validated once as a change delta and once as a main spec +- **WHEN** running `openspec validate` on each +- **THEN** both SHALL reach the same conclusion about whether the body contains a normative keyword + +### Requirement: Non-canonical headers in delta sections SHALL be surfaced without changing recognition +When an `## ADDED`/`## MODIFIED Requirements` section in a change delta contains a level-3 header that is not a canonical `### Requirement:` header, `openspec validate ` SHALL emit an INFO-level note identifying it, because the delta reader will otherwise skip it silently. The note SHALL be derived from the headers the delta reader actually skips while parsing, so it describes the reader's real section and fence boundaries. This note SHALL NOT change which headers are recognized as requirements, and SHALL NOT change the `valid` result — including under `--strict`. This behavior applies only to change deltas: bare `### ` headers in main specs are recognized requirements (see the scenario below) and SHALL NOT trigger such notes. + +#### Scenario: Stray divider header is reported, not silently skipped +- **GIVEN** a delta whose `## ADDED Requirements` section contains `### Documentation Requirements` followed by a valid `### Requirement: …` block +- **WHEN** running `openspec validate --strict` +- **THEN** validation SHALL emit an INFO note naming the stray `### Documentation Requirements` header +- **AND** the `valid` result SHALL be unchanged from current behavior (the INFO does not cause failure) + +#### Scenario: Nameless requirement header gets a dedicated hint +- **GIVEN** a delta whose `## ADDED Requirements` section contains a bare `### Requirement:` header with no name +- **WHEN** running `openspec validate ` +- **THEN** the INFO note SHALL say the header is missing a requirement name (not suggest `### Requirement: Requirement:`) + +#### Scenario: Bare requirement headers in main specs remain supported +- **GIVEN** a main spec whose requirements use bare `### ` headers without the `Requirement:` prefix +- **WHEN** running `openspec validate --strict` +- **THEN** those headers SHALL continue to be recognized as requirements exactly as before this change diff --git a/openspec/changes/fix-spec-parser-fidelity/tasks.md b/openspec/changes/fix-spec-parser-fidelity/tasks.md new file mode 100644 index 0000000000..0c9a3f28ba --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/tasks.md @@ -0,0 +1,43 @@ +## 1. Part A — shared, fence-aware extraction (#361, #418, #312, fenced-scenario) + +- [x] 1.1 Add a shared `extractRequirementBody(lines, fenceMask, startIndex)` helper in `src/core/parsers/` returning the full body: lines after the header up to the first `#### Scenario:` on a non-fence-masked line, skipping fence-masked and `**metadata**:` lines. +- [x] 1.2 Add a fence-aware scenario counter (count only non-fence-masked `####` headers). +- [x] 1.3 Rewrite `MarkdownParser.parseRequirements` to use the body helper (replacing first-line logic) and consult `codeFenceLineMask`. +- [x] 1.4 Rewrite `Validator.extractRequirementText` to delegate to the body helper, and `countScenarios` to the fence-aware counter. +- [x] 1.5 Run `SHALL`/`MUST` detection over the full body in both paths. + +## 2. Part A — single normative-keyword predicate + +- [x] 2.1 Use the shared `containsShallOrMust` (`/\b(SHALL|MUST)\b/`) for validator keyword checks; after the #1280 merge, schema-level keyword enforcement remains removed and owned by the imperative validator. + +## 3. Part B — surface the #498 divergence (INFO, no recognition change) + +- [x] 3.1 Record the non-canonical level-3 headers `parseDeltaSpec` skips while parsing ADDED/MODIFIED sections (`DeltaPlan.skippedHeaders`), so the note reflects the reader's real boundaries. +- [x] 3.2 In `validateChangeDeltaSpecs`, emit an INFO issue for each skipped header. Do **not** change recognition. Special-case a nameless `### Requirement:` header. +- [x] 3.3 Confirm INFO does not affect `valid` under `--strict` (`valid = errors === 0 && warnings === 0`). + +## 4. Update the one affected existing test + +- [x] 4.1 `markdown-parser.test.ts:331` (*first non-empty content line*) → assert `req.text` is the full joined body. Confirm `:106`/`:139` (fence) and `:258`/`:310` (bare-header) tests still pass unchanged. + +## 5. Regression tests + +- [x] 5.1 (#361) `SHALL` wrapped onto body line 2 passes `validate ` and `validate `. +- [x] 5.2 (#418) metadata lines before the prose pass `validate `; delta path stays green. +- [x] 5.3 (#312) fenced block before the prose line captures the real body and passes. +- [x] 5.4 (fenced scenario) a requirement whose only `#### Scenario:` is inside a fence FAILS `validate ` (parity with `validate `). +- [x] 5.5 (#498) a stray `### Documentation Requirements` divider in a delta yields an INFO note from `validate ` and does not change `valid` (including `--strict`). +- [x] 5.6 Guard: single-line requirements unchanged; bare-header specs still valid; LF/CRLF covered. + +## 6. Release + +- [x] 6.1 Add a changeset: Fixes #361, #418, #312; surfaces #498. Note the read-only display changes (fuller `req.text` in JSON/descriptions); no archived-content change. + +## 7. Review fixes (PR #1281) + +- [x] 7.1 Skip `**metadata**:` lines only when other body text remains; a metadata-only body (e.g. `**Constraint**: The system MUST ...`) is kept as the requirement text. +- [x] 7.2 Keep header-title fallback in the Markdown parser for display/bare-header compatibility, while validator checks use body-only extraction so canonical header-only requirements still receive the #1280 body-keyword hint. +- [x] 7.3 End the body at any non-fenced Markdown header, so a stray `###` divider's notes cannot satisfy the keyword check (old-reader parity). +- [x] 7.4 Replace the standalone INFO scanner with skipped-header collection inside `parseDeltaSpec` (notes match the reader's real boundaries). +- [x] 7.5 Special-case the nameless `### Requirement:` INFO message; document that the any-`####` scenario match is deliberate; un-export `REQUIREMENT_HEADER_REGEX`. +- [x] 7.6 Soften the changeset wording and document the known remaining divergences in `design.md`. diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index a2c364b70c..2473d16ace 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -1,4 +1,5 @@ import { MarkdownParser, Section } from './markdown-parser.js'; +import { buildCodeFenceMask } from './requirement-text.js'; import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js'; import path from 'path'; import { promises as fs } from 'fs'; @@ -179,7 +180,7 @@ export class ChangeParser extends MarkdownParser { private parseSectionsFromContent(content: string): Section[] { const normalizedContent = ChangeParser.normalizeContent(content); const lines = normalizedContent.split('\n'); - const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines); + const codeFenceLineMask = buildCodeFenceMask(lines); const sections: Section[] = []; const stack: Section[] = []; diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index abad78df22..8dca1ef64f 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -1,4 +1,5 @@ import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js'; +import { buildCodeFenceMask, extractRequirementText } from './requirement-text.js'; export interface Section { level: number; @@ -15,7 +16,7 @@ export class MarkdownParser { constructor(content: string) { const normalized = MarkdownParser.normalizeContent(content); this.lines = normalized.split('\n'); - this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines); + this.codeFenceLineMask = buildCodeFenceMask(this.lines); this.currentLine = 0; } @@ -23,54 +24,6 @@ export class MarkdownParser { return content.replace(/\r\n?/g, '\n'); } - protected static buildCodeFenceMask(lines: string[]): boolean[] { - const mask = new Array(lines.length).fill(false); - let activeFence: { marker: '`' | '~'; length: number } | null = null; - - for (let i = 0; i < lines.length; i++) { - const fence = MarkdownParser.getFenceMarker(lines[i]); - - if (!activeFence) { - if (fence) { - activeFence = fence; - mask[i] = true; - } - continue; - } - - mask[i] = true; - if (MarkdownParser.isClosingFence(lines[i], activeFence)) { - activeFence = null; - } - } - - return mask; - } - - private static getFenceMarker(line: string): { marker: '`' | '~'; length: number } | null { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); - if (!fenceMatch) { - return null; - } - - return { - marker: fenceMatch[1][0] as '`' | '~', - length: fenceMatch[1].length, - }; - } - - private static isClosingFence( - line: string, - activeFence: { marker: '`' | '~'; length: number } - ): boolean { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/); - return Boolean( - fenceMatch && - fenceMatch[1][0] === activeFence.marker && - fenceMatch[1].length >= activeFence.length - ); - } - parseSpec(name: string): Spec { const sections = this.parseSections(); const purpose = this.findSection(sections, 'Purpose')?.content || ''; @@ -197,43 +150,20 @@ export class MarkdownParser { protected parseRequirements(section: Section): Requirement[] { const requirements: Requirement[] = []; - + for (const child of section.children) { - // Extract requirement text from first non-empty content line, fall back to heading - let text = child.title; - - // Get content before any child sections (scenarios) - if (child.content.trim()) { - // Split content into lines and find content before any child headers - const lines = child.content.split('\n'); - const contentBeforeChildren: string[] = []; - - for (const line of lines) { - // Stop at child headers (scenarios start with ####) - if (line.trim().startsWith('#')) { - break; - } - contentBeforeChildren.push(line); - } - - // Find first non-empty line - const directContent = contentBeforeChildren.join('\n').trim(); - if (directContent) { - const firstLine = directContent.split('\n').find(l => l.trim()); - if (firstLine) { - text = firstLine.trim(); - } - } - } - + // Read the requirement text via the shared reader (multi-line, fence- and + // metadata-aware, with the shared header-title fallback for empty bodies). + const text = extractRequirementText(child.title, child.content.split('\n')); + const scenarios = this.parseScenarios(child); - + requirements.push({ text, scenarios, }); } - + return requirements; } diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index afc55f8914..adb8138aea 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -1,3 +1,5 @@ +import { buildCodeFenceMask } from './requirement-text.js'; + export interface RequirementBlock { headerLine: string; // e.g., '### Requirement: Something' name: string; // e.g., 'Something' @@ -16,6 +18,7 @@ export function normalizeRequirementName(name: string): string { return name.trim(); } +/** The canonical requirement header the delta reader recognizes. */ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i; /** @@ -96,11 +99,23 @@ export function extractRequirementsSection(content: string): RequirementsSection }; } +/** + * A level-3 header inside `## ADDED`/`## MODIFIED Requirements` that is not a + * canonical `### Requirement:` header, recorded at the moment the delta reader + * skips over it. Surfaced as an INFO note by `validate ` (#498). + */ +export interface SkippedHeader { + header: string; // header text without the leading ### + section: string; // the ## section title as written + line: number; // 1-based line number in the delta file +} + export interface DeltaPlan { added: RequirementBlock[]; modified: RequirementBlock[]; removed: string[]; // requirement names renamed: Array<{ from: string; to: string }>; + skippedHeaders: SkippedHeader[]; // non-canonical ### headers the reader skipped sectionPresence: { added: boolean; modified: boolean; @@ -123,15 +138,26 @@ export function parseDeltaSpec(content: string): DeltaPlan { const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements'); const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements'); const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements'); - const added = parseRequirementBlocksFromSection(addedLookup.body); - const modified = parseRequirementBlocksFromSection(modifiedLookup.body); + const skippedHeaders: SkippedHeader[] = []; + const added = parseRequirementBlocksFromSection(addedLookup.body, { + section: addedLookup.title, + bodyStartLine: addedLookup.bodyStartLine, + sink: skippedHeaders, + }); + const modified = parseRequirementBlocksFromSection(modifiedLookup.body, { + section: modifiedLookup.title, + bodyStartLine: modifiedLookup.bodyStartLine, + sink: skippedHeaders, + }); const removedNames = parseRemovedNames(removedLookup.body); const renamedPairs = parseRenamedPairs(renamedLookup.body); + skippedHeaders.sort((a, b) => a.line - b.line); return { added, modified, removed: removedNames, renamed: renamedPairs, + skippedHeaders, sectionPresence: { added: addedLookup.found, modified: modifiedLookup.found, @@ -141,9 +167,9 @@ export function parseDeltaSpec(content: string): DeltaPlan { }; } -function splitTopLevelSections(content: string): Record { +function splitTopLevelSections(content: string): Record { const lines = content.split('\n'); - const result: Record = {}; + const result: Record = {}; const indices: Array<{ title: string; index: number; level: number }> = []; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(##)\s+(.+)$/); @@ -156,27 +182,53 @@ function splitTopLevelSections(content: string): Record { const current = indices[i]; const next = indices[i + 1]; const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\n'); - result[current.title] = body; + // First body line, 1-based: the header is at 0-based current.index. + result[current.title] = { body, bodyStartLine: current.index + 2 }; } return result; } -function getSectionCaseInsensitive(sections: Record, desired: string): { body: string; found: boolean } { +function getSectionCaseInsensitive( + sections: Record, + desired: string +): { title: string; body: string; bodyStartLine: number; found: boolean } { const target = desired.toLowerCase(); - for (const [title, body] of Object.entries(sections)) { - if (title.toLowerCase() === target) return { body, found: true }; + for (const [title, { body, bodyStartLine }] of Object.entries(sections)) { + if (title.toLowerCase() === target) return { title, body, bodyStartLine, found: true }; } - return { body: '', found: false }; + return { title: desired, body: '', bodyStartLine: 0, found: false }; } -function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] { +function parseRequirementBlocksFromSection( + sectionBody: string, + skipped?: { section: string; bodyStartLine: number; sink: SkippedHeader[] } +): RequirementBlock[] { if (!sectionBody) return []; const lines = normalizeLineEndings(sectionBody).split('\n'); + // Record the non-canonical level-3 headers this reader skips, at the moment + // it skips them, so the INFO note describes the reader's real boundaries. + // Fence-masked lines are excluded: the body reader treats them as fenced + // content, not as headers. + const fenceMask = skipped ? buildCodeFenceMask(lines) : undefined; + const recordIfSkippedHeader = (index: number) => { + if (!skipped || fenceMask![index]) return; + const h3 = lines[index].match(/^###\s+(.+?)\s*$/); + if (h3 && !REQUIREMENT_HEADER_REGEX.test(lines[index])) { + skipped.sink.push({ + header: h3[1].trim(), + section: skipped.section, + line: skipped.bodyStartLine + index, + }); + } + }; const blocks: RequirementBlock[] = []; let i = 0; while (i < lines.length) { // Seek next requirement header - while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i])) i++; + while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i])) { + recordIfSkippedHeader(i); + i++; + } if (i >= lines.length) break; const headerLine = lines[i]; const m = headerLine.match(REQUIREMENT_HEADER_REGEX); @@ -185,6 +237,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc const buf: string[] = [headerLine]; i++; while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i]) && !/^##\s+/.test(lines[i])) { + recordIfSkippedHeader(i); buf.push(lines[i]); i++; } diff --git a/src/core/parsers/requirement-text.ts b/src/core/parsers/requirement-text.ts new file mode 100644 index 0000000000..8aa0e89567 --- /dev/null +++ b/src/core/parsers/requirement-text.ts @@ -0,0 +1,151 @@ +/** + * Shared, fence-aware requirement-reading helpers. + * + * The requirement reader used to be implemented twice — once for main specs + * (`MarkdownParser.parseRequirements`) and once for change deltas + * (`Validator.extractRequirementText` / `countScenarios`) — and the two drifted + * apart. These helpers are the single source of truth for requirement-body + * extraction, scenario counting, and `SHALL`/`MUST` detection in + * `validate `, `validate `, and `archive`. + */ + +/** + * Build a per-line mask marking lines that fall inside a fenced code block + * (``` ``` ``` or ``` ~~~ ```), including the fence lines themselves. Mirrors the + * fence rules markdown uses: a fence opens on the first ```` ```/~~~ ```` of + * length >= 3 and closes on a line of the same marker whose length is >= the + * opening length, with nothing but whitespace after it. + */ +export function buildCodeFenceMask(lines: string[]): boolean[] { + const mask = new Array(lines.length).fill(false); + let activeFence: { marker: '`' | '~'; length: number } | null = null; + + for (let i = 0; i < lines.length; i++) { + const fence = getFenceMarker(lines[i]); + + if (!activeFence) { + if (fence) { + activeFence = fence; + mask[i] = true; + } + continue; + } + + mask[i] = true; + if (isClosingFence(lines[i], activeFence)) { + activeFence = null; + } + } + + return mask; +} + +function getFenceMarker(line: string): { marker: '`' | '~'; length: number } | null { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (!fenceMatch) { + return null; + } + + return { + marker: fenceMatch[1][0] as '`' | '~', + length: fenceMatch[1].length, + }; +} + +function isClosingFence( + line: string, + activeFence: { marker: '`' | '~'; length: number } +): boolean { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/); + return Boolean( + fenceMatch && + fenceMatch[1][0] === activeFence.marker && + fenceMatch[1].length >= activeFence.length + ); +} + +/** Lines that look like `**ID**: ...` / `**Priority**: ...` metadata. */ +const METADATA_LINE = /^\*\*[^*]+\*\*:/; + +/** Any markdown header line — the boundary where a requirement body ends. */ +const HEADER_LINE = /^#{1,6}\s/; + +/** + * A level-4 header. Deliberately matches ANY `####` header, not only + * `#### Scenario:` — the spec path treats every level-4 child of a requirement + * as a scenario, so the delta counter must too (parity). Don't tighten this to + * `Scenario:` without changing both paths together. + */ +const SCENARIO_HEADER = /^####\s+/; + +/** + * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST` + * as whole words so the change-delta reader and the schema-based reader accept + * and reject identical text. + */ +export function containsShallOrMust(text: string): boolean { + return /\b(SHALL|MUST)\b/.test(text); +} + +/** + * Extract the full requirement body from the lines that follow a + * `### Requirement:` header (the lines may include scenarios and fenced code). + * + * Captures every body line from the start up to the first header found on a + * non-fenced line — usually the first `#### Scenario:`, but also a stray `###` + * divider the delta reader absorbed into the block — skipping blank lines and + * any line inside a fenced code block. `**metadata**:` lines are skipped only + * when other body text remains: a requirement written entirely as + * `**Constraint**: The system MUST ...` keeps that line as its body. Captured + * lines are trimmed and joined with newlines so a requirement whose text wraps + * across lines — or whose `SHALL`/`MUST` lands on a later line — is read in + * full. + */ +export function extractRequirementBody(bodyLines: string[]): string { + const mask = buildCodeFenceMask(bodyLines); + const captured: string[] = []; + const metadata: string[] = []; + + for (let i = 0; i < bodyLines.length; i++) { + if (mask[i]) continue; // inside a fenced code block + const line = bodyLines[i]; + if (HEADER_LINE.test(line)) break; // first scenario or stray divider + const trimmed = line.trim(); + if (trimmed.length === 0) continue; // blank + if (METADATA_LINE.test(trimmed)) { + metadata.push(trimmed); // **ID**: / **Priority**: ... + continue; + } + captured.push(trimmed); + } + + if (captured.length > 0) return captured.join('\n'); + return metadata.join('\n'); // metadata-only body: the metadata IS the body +} + +/** + * Parser/display fallback for a requirement block with no body text. This is + * what lets a bare `### The system SHALL ...` header remain readable on the + * spec path (the title is the requirement). Validator body-keyword checks for + * canonical `### Requirement:` blocks use `extractRequirementBody` directly so + * a keyword that appears only in the header still receives the #1156/#1280 + * body-keyword hint. + */ +export function extractRequirementText(headerTitle: string, bodyLines: string[]): string { + return extractRequirementBody(bodyLines) || headerTitle.trim(); +} + +/** + * Count the real scenarios in a requirement block: `#### ` headers on non-fenced + * lines. A `#### Scenario:` that lives inside a fenced example is not a real + * scenario and is not counted. + */ +export function countScenarios(bodyLines: string[]): number { + const mask = buildCodeFenceMask(bodyLines); + let count = 0; + for (let i = 0; i < bodyLines.length; i++) { + if (mask[i]) continue; + if (SCENARIO_HEADER.test(bodyLines[i])) count++; + } + return count; +} diff --git a/src/core/schemas/base.schema.ts b/src/core/schemas/base.schema.ts index aa08cea1e9..a6472ddb0a 100644 --- a/src/core/schemas/base.schema.ts +++ b/src/core/schemas/base.schema.ts @@ -19,4 +19,4 @@ export const RequirementSchema = z.object({ }); export type Scenario = z.infer; -export type Requirement = z.infer; \ No newline at end of file +export type Requirement = z.infer; diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 4f896fb3a4..511662f294 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -11,6 +11,11 @@ import { VALIDATION_MESSAGES } from './constants.js'; import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js'; +import { + extractRequirementBody as extractRequirementBodyShared, + containsShallOrMust as containsShallOrMustShared, + countScenarios as countScenariosShared, +} from '../parsers/requirement-text.js'; import { findMainSpecStructureIssues } from '../parsers/spec-structure.js'; import { FileSystemUtils } from '../../utils/file-system.js'; @@ -135,6 +140,25 @@ export class Validator { const plan = parseDeltaSpec(content); const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile)); + + // Surface (as INFO, never a failure) the non-canonical level-3 headers + // the delta reader skipped while parsing ADDED/MODIFIED sections — + // without this note a stray divider like "### Documentation + // Requirements" would pass validate while failing + // archive/validate . The list comes from the parse itself, so it + // reflects exactly what the reader skipped. + for (const stray of plan.skippedHeaders) { + const nameless = /^requirement:?$/i.test(stray.header); + issues.push({ + level: 'INFO', + path: entryPath, + line: stray.line, + message: nameless + ? `Header "### ${stray.header}" in ${stray.section} is missing a requirement name and is ignored by validation. Add a name, e.g. "### Requirement: ".` + : `Header "### ${stray.header}" in ${stray.section} is not a "### Requirement:" header and is ignored by validation. Use "### Requirement: ${stray.header}" if it should be validated as a requirement.`, + }); + } + const sectionNames: string[] = []; if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements'); if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements'); @@ -164,7 +188,13 @@ export class Validator { } const requirementText = this.extractRequirementText(block.raw); if (!requirementText) { - issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` }); + issues.push({ + level: 'ERROR', + path: entryPath, + message: this.containsShallOrMust(block.name) + ? this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) + : `ADDED "${block.name}" is missing requirement text`, + }); } else if (!this.containsShallOrMust(requirementText)) { issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) }); } @@ -185,7 +215,13 @@ export class Validator { } const requirementText = this.extractRequirementText(block.raw); if (!requirementText) { - issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` }); + issues.push({ + level: 'ERROR', + path: entryPath, + message: this.containsShallOrMust(block.name) + ? this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) + : `MODIFIED "${block.name}" is missing requirement text`, + }); } else if (!this.containsShallOrMust(requirementText)) { issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) }); } @@ -460,35 +496,17 @@ export class Validator { } private extractRequirementText(blockRaw: string): string | undefined { - const lines = blockRaw.split('\n'); - // Skip header line (index 0) - let i = 1; - - // Find the first substantial text line, skipping metadata and blank lines - for (; i < lines.length; i++) { - const line = lines[i]; - - // Stop at scenario headers - if (/^####\s+/.test(line)) break; - - const trimmed = line.trim(); - - // Skip blank lines - if (trimmed.length === 0) continue; - - // Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.) - if (/^\*\*[^*]+\*\*:/.test(trimmed)) continue; - - // Found first non-metadata, non-blank line - this is the requirement text - return trimmed; - } - - // No requirement text found - return undefined; + // Delegate to the shared, fence-/metadata-/multi-line-aware body reader. + // Validation intentionally does not use the parser/display header-title + // fallback for canonical `### Requirement:` blocks: #1280 requires a + // SHALL/MUST that appears only in the header to receive the body-keyword + // hint. Line 0 is the `### Requirement: ...` header. + const [, ...bodyLines] = blockRaw.split('\n'); + return extractRequirementBodyShared(bodyLines) || undefined; } private containsShallOrMust(text: string): boolean { - return /\b(SHALL|MUST)\b/.test(text); + return containsShallOrMustShared(text); } /** @@ -510,8 +528,9 @@ export class Validator { } private countScenarios(blockRaw: string): number { - const matches = blockRaw.match(/^####\s+/gm); - return matches ? matches.length : 0; + // Fence-aware count via the shared reader: a `#### Scenario:` inside a fenced + // example is not a real scenario. Drop the header line (index 0). + return countScenariosShared(blockRaw.split('\n').slice(1)); } private formatSectionList(sections: string[]): string { diff --git a/test/core/parsers/markdown-parser.test.ts b/test/core/parsers/markdown-parser.test.ts index 751ab98db0..7083fd95f2 100644 --- a/test/core/parsers/markdown-parser.test.ts +++ b/test/core/parsers/markdown-parser.test.ts @@ -328,7 +328,7 @@ Then result`; expect(spec.requirements[0].text).toBe('The system SHALL use heading text when no content'); }); - it('should extract requirement text from first non-empty content line', () => { + it('should extract the full requirement body, not only the first content line', () => { const content = `# Test Spec ## Purpose @@ -348,8 +348,168 @@ Then result`; const parser = new MarkdownParser(content); const spec = parser.parseSpec('test'); - - expect(spec.requirements[0].text).toBe('This is the actual requirement text.'); + + // Body spans both lines up to the first scenario (the #361 fix); the + // reader no longer drops everything after line one. + expect(spec.requirements[0].text).toBe( + 'This is the actual requirement text.\nThis is additional description.' + ); + }); + }); + + describe('requirement body reading fidelity', () => { + it('captures a normative keyword that wraps onto a later body line (#361)', () => { + const content = `# Test Spec + +## Purpose +Test overview for wrapped keyword handling. + +## Requirements + +### Requirement: Wrapped keyword +The system performs the described behavior and it +continues onto a second line where SHALL appears. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toContain('SHALL appears'); + expect(spec.requirements[0].text).toContain('The system performs the described behavior'); + }); + + it('skips **metadata**: lines before the description (#418)', () => { + const content = `# Test Spec + +## Purpose +Test overview for metadata-first requirements. + +## Requirements + +### Requirement: Metadata first +**ID**: REQ-FILE-001 +**Priority**: P1 (High) +The system MUST persist the uploaded file. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe('The system MUST persist the uploaded file.'); + }); + + it('keeps a metadata-only body as the requirement text', () => { + const content = `# Test Spec + +## Purpose +Test overview for metadata-only requirement bodies. + +## Requirements + +### Requirement: Constraint style +**Constraint**: The system MUST respond within the configured deadline. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + // Metadata lines are skipped only when other body text remains; when the + // whole body is metadata, the metadata IS the body. + expect(spec.requirements[0].text).toBe( + '**Constraint**: The system MUST respond within the configured deadline.' + ); + }); + + it('ignores a fenced code block that precedes the prose line (#312)', () => { + const content = `# Test Spec + +## Purpose +Test overview for fence-before-prose handling. + +## Requirements + +### Requirement: Fence first +\`\`\`bash +# this is a shell comment, not the requirement text +echo hello +\`\`\` +The system SHALL handle fenced examples before the prose line. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe( + 'The system SHALL handle fenced examples before the prose line.' + ); + expect(spec.requirements[0].scenarios).toHaveLength(1); + }); + + it('does not count a #### Scenario inside a fenced example as a real scenario', () => { + const content = `# Test Spec + +## Purpose +Test overview for fenced scenario handling. + +## Requirements + +### Requirement: Fenced scenario only +The system SHALL do something real. + +\`\`\`markdown +#### Scenario: not a real scenario +- **WHEN** a reader studies the example +- **THEN** it stays inside the fence +\`\`\``; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe('The system SHALL do something real.'); + expect(spec.requirements[0].scenarios).toHaveLength(0); + }); + + it('reads a wrapped body the same way under CRLF line endings', () => { + const content = [ + '# Test Spec', + '', + '## Purpose', + 'Test overview for CRLF body extraction.', + '', + '## Requirements', + '', + '### Requirement: Wrapped keyword', + 'The system performs the described behavior and it', + 'continues onto a second line where SHALL appears.', + '', + '#### Scenario: Test', + 'Given test', + 'When action', + 'Then result', + ].join('\r\n'); + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe( + 'The system performs the described behavior and it\ncontinues onto a second line where SHALL appears.' + ); }); }); }); diff --git a/test/core/validation.test.ts b/test/core/validation.test.ts index d7104aa42d..271d162ab4 100644 --- a/test/core/validation.test.ts +++ b/test/core/validation.test.ts @@ -810,4 +810,393 @@ The system MUST support mixed case delta headers. expect(report.issues.some(i => i.message.includes('not only in the header'))).toBe(false); }); }); + + describe('parser reading fidelity (#361, #418, #312, fenced scenario, #498)', () => { + async function writeChangeDelta(name: string, deltaSpec: string): Promise { + const changeDir = path.join(testDir, name); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec); + return changeDir; + } + + async function writeSpec(name: string, specContent: string): Promise { + const specPath = path.join(testDir, `${name}.md`); + await fs.writeFile(specPath, specContent); + return specPath; + } + + it('#361: a normative keyword on a wrapped body line passes both change and spec', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Wrapped keyword +The system performs the described behavior and it +continues onto a second line where SHALL appears in full. + +#### Scenario: Wrapped +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const changeDir = await writeChangeDelta('fidelity-361', delta); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(true); + expect(changeReport.summary.errors).toBe(0); + + const spec = `# Test Spec + +## Purpose +This spec exercises a normative keyword wrapped onto a second line. + +## Requirements + +### Requirement: Wrapped keyword +The system performs the described behavior and it +continues onto a second line where SHALL appears in full. + +#### Scenario: Wrapped +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const specPath = await writeSpec('fidelity-361-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(true); + expect(specReport.summary.errors).toBe(0); + }); + + it('#418: metadata before the description passes validate (matching )', async () => { + const spec = `# Test Spec + +## Purpose +This spec exercises metadata fields preceding the requirement description. + +## Requirements + +### Requirement: Metadata first +**ID**: REQ-FILE-001 +**Priority**: P1 (High) +The system MUST persist the uploaded file. + +#### Scenario: Persisted +**Given** an uploaded file +**When** the request completes +**Then** the file is stored`; + + const specPath = await writeSpec('fidelity-418-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(true); + expect(specReport.summary.errors).toBe(0); + }); + + it('#312: a fenced block before the prose line passes both change and spec', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Fence first +\`\`\`bash +# this is a shell comment, not the requirement text +echo hello +\`\`\` +The system SHALL handle fenced examples before the prose line. + +#### Scenario: Handled +**Given** a fenced example +**When** the requirement is read +**Then** the prose line is the requirement text`; + + const changeDir = await writeChangeDelta('fidelity-312', delta); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(true); + expect(changeReport.summary.errors).toBe(0); + }); + + it('fenced scenario: a #### Scenario inside a fence does not count (change matches spec)', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Fenced scenario only +The system SHALL do something real. + +\`\`\`markdown +#### Scenario: not a real scenario +- **WHEN** a reader studies the example +- **THEN** it stays inside the fence +\`\`\``; + + const changeDir = await writeChangeDelta('fidelity-fenced-scenario', delta); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // The only scenario is fenced, so the requirement has zero real scenarios + // and must fail — the same verdict validate already gives. + expect(changeReport.valid).toBe(false); + expect( + changeReport.issues.some(i => i.message.includes('must include at least one scenario')) + ).toBe(true); + }); + + it('#498: a stray ### divider yields an INFO note and does not change valid (even strict)', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Documentation Requirements + +### Requirement: Real requirement +The system SHALL do the real thing. + +#### Scenario: Works +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const changeDir = await writeChangeDelta('fidelity-498', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // INFO surfaces the stray header but never fails validation. + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + const info = report.issues.find( + i => i.level === 'INFO' && i.message.includes('Documentation Requirements') + ); + expect(info).toBeDefined(); + expect(report.summary.info).toBeGreaterThan(0); + }); + + it('guard: a single-line requirement is read byte-for-byte as before', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Single line +The system SHALL remain unchanged for single-line bodies. + +#### Scenario: Unchanged +**Given** a single-line requirement +**When** it is validated +**Then** nothing changes`; + + const changeDir = await writeChangeDelta('fidelity-single-line', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + expect(report.summary.info).toBe(0); + }); + + it('predicate agrees across readers: a SHALL substring inside a word is not a keyword', async () => { + // "MARSHALL" contains the substring SHALL but is not a whole-word normative + // keyword. Both readers must reject it identically (the shared predicate). + const body = `### Requirement: Marshalling +The MARSHALL coordinates parade logistics. + +#### Scenario: Coordinated +**Given** a parade +**When** it begins +**Then** logistics are coordinated`; + + const changeDir = await writeChangeDelta('fidelity-predicate', `# Test Spec\n\n## ADDED Requirements\n\n${body}`); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(false); + + const spec = `# Test Spec + +## Purpose +This spec checks that a SHALL substring inside a word is not treated as a keyword. + +## Requirements + +${body}`; + const specPath = await writeSpec('fidelity-predicate-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(false); + }); + + it('guard: a metadata-only body without a keyword still fails validation', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Metadata only +**ID**: REQ-META-001 +**Priority**: P1 (High) + +#### Scenario: Present +**Given** a metadata-only body +**When** it is validated +**Then** validation fails`; + + const changeDir = await writeChangeDelta('fidelity-metadata-only', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(false); + // The metadata IS the body when nothing else remains, so the failure is + // the missing keyword, not missing text. + expect( + report.issues.some(i => i.message.includes('must contain SHALL or MUST')) + ).toBe(true); + }); + + it('a requirement written entirely as **Constraint**: metadata keeps its MUST (change and spec)', async () => { + const body = `### Requirement: Constraint style +**Constraint**: The system MUST respond within the configured deadline. + +#### Scenario: Deadline honored +**Given** a configured deadline +**When** a request is handled +**Then** the response arrives in time`; + + const changeDir = await writeChangeDelta('fidelity-constraint-only', `# Test Spec\n\n## ADDED Requirements\n\n${body}`); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(true); + expect(changeReport.summary.errors).toBe(0); + + const spec = `# Test Spec + +## Purpose +This spec exercises a requirement whose whole body is a metadata-style line. + +## Requirements + +${body}`; + const specPath = await writeSpec('fidelity-constraint-only-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(true); + expect(specReport.summary.errors).toBe(0); + }); + + it('canonical empty bodies keep the body-keyword hint on both paths after #1280', async () => { + const body = `### Requirement: The tool MUST support header-only requirements + +#### Scenario: Header only +**Given** a requirement with no body text +**When** it is validated +**Then** both paths ask for the keyword in the body`; + + const changeDir = await writeChangeDelta('fidelity-empty-body', `# Test Spec\n\n## ADDED Requirements\n\n${body}`); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(false); + expect( + changeReport.issues.some(i => i.message.includes('not only in the header')) + ).toBe(true); + + const spec = `# Test Spec + +## Purpose +This spec exercises the shared body extraction without using the display fallback for validation. + +## Requirements + +${body}`; + const specPath = await writeSpec('fidelity-empty-body-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(false); + expect( + specReport.issues.some(i => i.message.includes('not only in the header')) + ).toBe(true); + }); + + it('a stray ### divider ends the requirement body: a MUST in its notes does not count', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Divider absorbed +The system performs the described behavior without a keyword. + +### Background +These notes explain that the system MUST NOT be read as requirement text. + +#### Scenario: Bounded +**Given** a stray divider +**When** the requirement is read +**Then** the body stops at the divider`; + + const changeDir = await writeChangeDelta('fidelity-divider-body', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // The body ends at "### Background", so the MUST in the notes is not + // seen and the requirement fails the keyword check (as it did on main) — + // and the skipped divider is surfaced as INFO. + expect(report.valid).toBe(false); + expect( + report.issues.some(i => i.level === 'ERROR' && i.message.includes('must contain SHALL or MUST')) + ).toBe(true); + expect( + report.issues.some(i => i.level === 'INFO' && i.message.includes('"### Background"')) + ).toBe(true); + }); + + it('a nameless "### Requirement:" header gets a dedicated INFO message', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: + +### Requirement: Real requirement +The system SHALL do the real thing. + +#### Scenario: Works +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const changeDir = await writeChangeDelta('fidelity-nameless', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + const info = report.issues.find( + i => i.level === 'INFO' && i.message.includes('missing a requirement name') + ); + expect(info).toBeDefined(); + expect(info!.message).not.toContain('Requirement: Requirement:'); + }); + + it('the skipped-header INFO reflects the reader: a fenced divider is not reported', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Fence with divider example +The system SHALL treat fenced headers as content. + +\`\`\`markdown +### Not A Real Divider +\`\`\` + +#### Scenario: Fenced +**Given** a fenced example containing a level-3 header +**When** the delta is validated +**Then** no INFO note is emitted for it`; + + const changeDir = await writeChangeDelta('fidelity-fenced-divider', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + expect(report.summary.info).toBe(0); + }); + + it('any #### header counts as a scenario on the delta path (deliberate spec-path parity)', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Notes as scenario +The system SHALL accept any level-4 child, matching the spec path. + +#### Notes +The spec path treats every level-4 child of a requirement as a scenario.`; + + const changeDir = await writeChangeDelta('fidelity-h4-parity', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // The spec path (parseScenarios) counts every level-4 child with content + // as a scenario, so the delta counter deliberately does the same. + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + }); }); From a5bfedafc8b3d914fe01d05eb36ad9ad3fbe35a2 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 7 Jul 2026 11:31:00 -0500 Subject: [PATCH 08/19] feat(skills): auto-approve the openspec CLI in generated skills and commands (#1300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): auto-approve the openspec CLI in generated skills Emit `allowed-tools: Bash(openspec:*)` in every generated SKILL.md so agents that honor the Agent Skills standard run `openspec` commands without prompting on each call. Scope is limited to the CLI; per the standard `allowed-tools` pre-approves rather than restricts, so every other tool a skill uses stays available under the user's normal permission settings. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(commands): auto-approve the openspec CLI in Claude slash commands Extend the allowed-tools pre-approval to the second surface: Claude Code /opsx:* slash commands share the skill frontmatter contract, so the Claude command adapter now emits `allowed-tools: Bash(openspec:*)` too. The value is single-sourced in `src/core/shared/allowed-tools.ts` (a leaf module both surfaces import). Other command adapters are unchanged — no other tool's slash-command format defines a per-command pre-approval field; on the skills side every tool already gets the standard field via generateSkillContent and non-implementing tools ignore the unknown key. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/add-skill-cli-auto-approval.md | 7 ++++ .../add-skill-cli-auto-approval/proposal.md | 27 +++++++++++++++ .../specs/cli-init/spec.md | 28 +++++++++++++++ .../specs/command-generation/spec.md | 32 +++++++++++++++++ .../add-skill-cli-auto-approval/tasks.md | 15 ++++++++ .../command-generation/adapters/claude.ts | 4 ++- src/core/shared/allowed-tools.ts | 11 ++++++ src/core/shared/skill-generation.ts | 2 ++ test/core/command-generation/adapters.test.ts | 1 + .../templates/skill-templates-parity.test.ts | 34 ++++++++++++------- 10 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 .changeset/add-skill-cli-auto-approval.md create mode 100644 openspec/changes/add-skill-cli-auto-approval/proposal.md create mode 100644 openspec/changes/add-skill-cli-auto-approval/specs/cli-init/spec.md create mode 100644 openspec/changes/add-skill-cli-auto-approval/specs/command-generation/spec.md create mode 100644 openspec/changes/add-skill-cli-auto-approval/tasks.md create mode 100644 src/core/shared/allowed-tools.ts diff --git a/.changeset/add-skill-cli-auto-approval.md b/.changeset/add-skill-cli-auto-approval.md new file mode 100644 index 0000000000..faab3fde92 --- /dev/null +++ b/.changeset/add-skill-cli-auto-approval.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": patch +--- + +### Features + +- **Auto-approve the OpenSpec CLI in generated skills and commands** — every generated `SKILL.md` (all tools) and every Claude Code `/opsx:*` slash command now carries `allowed-tools: Bash(openspec:*)` in its frontmatter, so agents that honor the Agent Skills standard run `openspec` commands without prompting for approval on each call; tools that don't recognize the field ignore it. Scope is limited to the `openspec` CLI; because `allowed-tools` pre-approves rather than restricts, every other tool a skill or command uses stays available under your normal permission settings. diff --git a/openspec/changes/add-skill-cli-auto-approval/proposal.md b/openspec/changes/add-skill-cli-auto-approval/proposal.md new file mode 100644 index 0000000000..5b00f40ead --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/proposal.md @@ -0,0 +1,27 @@ +## Why + +Every generated OpenSpec skill drives the `openspec` CLI (`openspec list`, `status`, `instructions`, …). Today the skill frontmatter never pre-approves those calls, so agents that gate Bash on permission prompt the user on every single `openspec` invocation. The workflow stalls on approvals for a first-party, read-mostly CLI the user already opted into by installing OpenSpec. + +The Agent Skills standard already solves this: an `allowed-tools` frontmatter field pre-approves listed tools while a skill is active. We just aren't emitting it. + +## What Changes + +- Every generated `SKILL.md` gains `allowed-tools: Bash(openspec:*)` in its YAML frontmatter, so agents run `openspec` commands from the skill without prompting. Emitted centrally in `generateSkillContent`, so `init`, `update`, every tool's skills directory, and every current and future skill get it uniformly. +- Claude Code slash commands (`.claude/commands/opsx/*.md`) gain the same field — commands share the skill frontmatter contract, so the same pre-approval applies when a user runs `/opsx:*`. +- Scope is deliberately narrow: only the `openspec` CLI is pre-approved. Per the standard, `allowed-tools` pre-approves rather than restricts — so any other tool a skill or command uses (Read, Write, or arbitrary Bash for builds/tests in `apply`/`onboard`) stays available under the user's normal permission settings, still prompting as before. +- Cross-tool: skills go to every supported tool's skills directory, and `allowed-tools` is an Agent Skills standard field — tools that implement the standard honor it; tools that don't ignore the unknown key. Only the Claude command adapter changes, because no other tool's slash-command format defines a per-command pre-approval field. + +## Capabilities + +### Modified Capabilities + +- `cli-init`: the Skill Generation requirement now specifies the `allowed-tools` pre-approval in generated skill frontmatter. +- `command-generation`: the Claude adapter frontmatter now includes the `allowed-tools` field. + +## Impact + +- `src/core/shared/allowed-tools.ts` — the shared `OPENSPEC_CLI_ALLOWED_TOOLS` constant (single source for both surfaces). +- `src/core/shared/skill-generation.ts` — emit `allowed-tools` in the SKILL.md frontmatter. +- `src/core/command-generation/adapters/claude.ts` — emit `allowed-tools` in the slash-command frontmatter. +- Tests: regenerated golden skill-content hashes; new assertions that every deployed skill and the Claude command format pre-approve the CLI. +- No behavior change for agents that ignore `allowed-tools`; pure upside for agents that honor it. diff --git a/openspec/changes/add-skill-cli-auto-approval/specs/cli-init/spec.md b/openspec/changes/add-skill-cli-auto-approval/specs/cli-init/spec.md new file mode 100644 index 0000000000..750194870e --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/specs/cli-init/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Skill Generation + +The command SHALL generate Agent Skills for selected AI tools. + +#### Scenario: Generating skills for a tool + +- **WHEN** a tool is selected during initialization +- **THEN** create 9 skill directories under `./skills/`: + - `openspec-explore/SKILL.md` + - `openspec-new-change/SKILL.md` + - `openspec-continue-change/SKILL.md` + - `openspec-apply-change/SKILL.md` + - `openspec-ff-change/SKILL.md` + - `openspec-verify-change/SKILL.md` + - `openspec-sync-specs/SKILL.md` + - `openspec-archive-change/SKILL.md` + - `openspec-bulk-archive-change/SKILL.md` +- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description +- **AND** each SKILL.md SHALL contain the skill instructions + +#### Scenario: Pre-approving the OpenSpec CLI in skill frontmatter + +- **WHEN** generating a skill's YAML frontmatter +- **THEN** the frontmatter SHALL include an `allowed-tools` field with the value `Bash(openspec:*)` +- **AND** an agent that honors `allowed-tools` SHALL run `openspec` commands from the skill without prompting for approval +- **AND** because `allowed-tools` pre-approves rather than restricts, any other tool the skill uses SHALL remain available under the user's existing permission settings diff --git a/openspec/changes/add-skill-cli-auto-approval/specs/command-generation/spec.md b/openspec/changes/add-skill-cli-auto-approval/specs/command-generation/spec.md new file mode 100644 index 0000000000..d593b38fa3 --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/specs/command-generation/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex) + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `allowed-tools`, `category`, `tags` fields +- **AND** the `allowed-tools` field SHALL have the value `Bash(openspec:*)` so Claude Code runs `openspec` commands from the slash command without prompting for approval +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` + +#### Scenario: Windsurf adapter formatting + +- **WHEN** formatting a command for Windsurf +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-.md` diff --git a/openspec/changes/add-skill-cli-auto-approval/tasks.md b/openspec/changes/add-skill-cli-auto-approval/tasks.md new file mode 100644 index 0000000000..8650e3c50a --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [x] 1.1 Add the shared `OPENSPEC_CLI_ALLOWED_TOOLS = 'Bash(openspec:*)'` constant (`src/core/shared/allowed-tools.ts`) and emit `allowed-tools` in the frontmatter built by `generateSkillContent` +- [x] 1.2 Emit the same `allowed-tools` field in the Claude command adapter's frontmatter (`src/core/command-generation/adapters/claude.ts`); other adapters unchanged — no other tool defines a per-command pre-approval field + +## 2. Tests + +- [x] 2.1 Regenerate the golden generated-content hashes in `skill-templates-parity.test.ts` +- [x] 2.2 Add a test asserting every deployed skill's generated content contains `allowed-tools: Bash(openspec:*)` (iterates the registry so new skills are covered) +- [x] 2.3 Assert the Claude adapter output contains the field (`adapters.test.ts`) +- [x] 2.4 Verify end-to-end: `openspec init --tools claude` emits the field in both SKILL.md and `.claude/commands/opsx/*.md`, and it parses as the YAML string `Bash(openspec:*)` + +## 3. Release + +- [x] 3.1 Add a changeset describing the auto-approval diff --git a/src/core/command-generation/adapters/claude.ts b/src/core/command-generation/adapters/claude.ts index b0f03a08e5..6211195913 100644 --- a/src/core/command-generation/adapters/claude.ts +++ b/src/core/command-generation/adapters/claude.ts @@ -7,6 +7,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; import { escapeYamlValue } from '../yaml.js'; +import { OPENSPEC_CLI_ALLOWED_TOOLS } from '../../shared/allowed-tools.js'; /** * Formats a tags array as a YAML array with proper escaping. @@ -19,7 +20,7 @@ function formatTagsArray(tags: string[]): string { /** * Claude Code adapter for command generation. * File path: .claude/commands/opsx/.md - * Frontmatter: name, description, category, tags + * Frontmatter: name, description, allowed-tools, category, tags */ export const claudeAdapter: ToolCommandAdapter = { toolId: 'claude', @@ -32,6 +33,7 @@ export const claudeAdapter: ToolCommandAdapter = { return `--- name: ${escapeYamlValue(content.name)} description: ${escapeYamlValue(content.description)} +allowed-tools: ${OPENSPEC_CLI_ALLOWED_TOOLS} category: ${escapeYamlValue(content.category)} tags: ${formatTagsArray(content.tags)} --- diff --git a/src/core/shared/allowed-tools.ts b/src/core/shared/allowed-tools.ts new file mode 100644 index 0000000000..2fc6d74dc1 --- /dev/null +++ b/src/core/shared/allowed-tools.ts @@ -0,0 +1,11 @@ +/** + * Pre-approved tools for generated skills and slash commands, emitted as the + * `allowed-tools` frontmatter field (Agent Skills standard for SKILL.md; + * same field for Claude Code slash commands). Scoped to the OpenSpec CLI so + * agents that honor it stop prompting on each `openspec` call; the field + * only pre-approves — it does not restrict — so any other tool a skill or + * command needs (Read, Write, arbitrary Bash for builds/tests) stays + * available under the user's normal permission settings. Tools that don't + * recognize the field ignore it. + */ +export const OPENSPEC_CLI_ALLOWED_TOOLS = 'Bash(openspec:*)'; diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 2570a95a3e..f671b4de73 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -32,6 +32,7 @@ import { type SkillTemplate, } from '../templates/skill-templates.js'; import type { CommandContent } from '../command-generation/index.js'; +import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js'; /** * Skill template with directory name and workflow ID mapping. @@ -140,6 +141,7 @@ export function generateSkillContent( return `--- name: ${template.name} description: ${template.description} +allowed-tools: ${OPENSPEC_CLI_ALLOWED_TOOLS} license: ${template.license || 'MIT'} compatibility: ${template.compatibility || 'Requires openspec CLI.'} metadata: diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024fb..ff25bf1b0f 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -57,6 +57,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('---\n'); expect(output).toContain('name: OpenSpec Explore'); expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('allowed-tools: Bash(openspec:*)'); expect(output).toContain('category: Workflow'); expect(output).toContain('tags: [workflow, explore, experimental]'); expect(output).toContain('---\n\n'); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 298d627c49..65202e9e6e 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -65,18 +65,18 @@ const EXPECTED_FUNCTION_HASHES: Record = { }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '08f905865f86e787262fed252c59ed343ac24db8befa31e5cf8fd99af947263b', - 'openspec-new-change': 'bdb534d6d5a00b235f63852af089f904fd20df34be526ef67990ec3183829f33', - 'openspec-continue-change': '5d2aea621310d74d89e547d705d2e08e6d5a44da7bca93ba049ed43ebf60295e', - 'openspec-apply-change': '54cffa61274c6a499d2b3775e9f6db29255fd8e5ad99d7352c1e3bbe2edb45ed', - 'openspec-ff-change': 'cbb7844c130bd188319ff2b3f0c0320243b5ae5b588a0f816cd4e29408f25676', - 'openspec-sync-specs': 'a81fd87f5e871874eab72e57c10a1949fde46d1d07d95f8ea3bc1a52b4e78c43', - 'openspec-archive-change': '833290ade47ddaed7f5e523d07437c7cef2497340021e944096bce449e290c22', - 'openspec-bulk-archive-change': '244b195e53d3f010a99892c1922c800fd8f02e7745d0f34ec18b5fe9b5548706', - 'openspec-verify-change': '97d1eed5b900788706c28339e27c1d2d9c548626316253f43ebd00d8d52d02d6', - 'openspec-onboard': 'd136b6ab7134d6bceeca73bc2f6037624506587e8df99059f77fe88874256ed1', - 'openspec-propose': '5c350d80247722489374a49ec9853d5fda55a827f421fbb32b6b6a078fcb69ee', - 'openspec-update-change': 'c755a35c44245326780a3df1342df15103ed6a9de7af864581844a10de4f554d', + 'openspec-explore': 'ba099821631ce75ee70af370917bbddbc88d0882ad0e50e91ed687d2185102ef', + 'openspec-new-change': 'd5b8909bea70a33b7a312b38ce204a91f40b6bb2bff12c4c06b3e11641b6a689', + 'openspec-continue-change': '39b4467a4873cde7c97d52c80d53ac647b220bf7c9d96f4e6505f3188e1a1642', + 'openspec-apply-change': '09c0e1cdf5ccc82416d0969d6bd715cc70616bdbc3531358a5c36057f78be55a', + 'openspec-ff-change': '8d5a8890eccbd97d714fbab1d73472f79ad9104b519e000264ae43d752cdf631', + 'openspec-sync-specs': 'f6a1581eb11a30061795c42582db6fa4f5e1f213b4b7cad9f3cbfbe3e9fb2d97', + 'openspec-archive-change': '1821aee5a06afd895d59d1e1d16495e484b6087ecf59ec93460d7d5e7851e772', + 'openspec-bulk-archive-change': '7b09b04a440809dd7dbf0b1d7b695cbb8c41184d8d104eb32e82d7cdfb476d18', + 'openspec-verify-change': '9a8735eaaa34c278d2193eb32fa736f4b111d1c47e675971c8df40f81d20c8c3', + 'openspec-onboard': 'b1b6fc9a1b3ff64dafe9b8c39a761ee1bd001b542d47b4e4deaf058e0aa21256', + 'openspec-propose': '0cfc9278123d973929cb4da3ea7ac8ae1b6c84b472eed4fb753657b8347eaeb9', + 'openspec-update-change': '77ff4d1f1cd08a57649cce1f25e0ebc4f55d6d032dfde5c301d1b479561b72fa', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -174,6 +174,16 @@ describe('skill templates split parity', () => { } }); + // Auto-approve the OpenSpec CLI: every generated skill carries + // `allowed-tools: Bash(openspec:*)` so agents that honor it stop prompting + // on each `openspec` call. Iterating the registry covers new skills too. + it('pre-approves the openspec CLI via allowed-tools in every deployed skill', () => { + for (const { template, dirName } of getSkillTemplates()) { + const content = generateSkillContent(template, 'PARITY-BASELINE'); + expect(content, dirName).toContain('allowed-tools: Bash(openspec:*)'); + } + }); + it('teaches store selection in every deployed opsx command template', () => { for (const entry of getCommandContents()) { expect(entry.body, entry.id).toContain(STORE_SELECTION_GUIDANCE); From 7e21cc59ef75b375036382579dc0db6c32010c96 Mon Sep 17 00:00:00 2001 From: zhangsan582 <1553977725@qq.com> Date: Wed, 8 Jul 2026 00:46:47 +0800 Subject: [PATCH 09/19] fix archive scenario drift for #1246 (#1252) * fix archive scenario drift for #1246 * fix archive scenario drift for #1246 * remove local openspec change docs --- src/core/specs-apply.ts | 49 +++++++++++++++++++++++- test/core/archive.test.ts | 78 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index ff399ec3e0..3cf81222af 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -47,6 +47,11 @@ export interface SpecsApplyOutput { noChanges: boolean; } +interface ScenarioBlock { + name: string; + raw: string; +} + // ----------------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------------- @@ -284,7 +289,8 @@ export async function buildUpdatedSpec( // MODIFIED for (const mod of plan.modified) { const key = normalizeRequirementName(mod.name); - if (!nameToBlock.has(key)) { + const currentBlock = nameToBlock.get(key); + if (!currentBlock) { throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`); } // Replace block with provided raw (ensure header line matches key) @@ -294,6 +300,12 @@ export async function buildUpdatedSpec( `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content` ); } + const missingScenarios = findMissingCurrentScenarios(currentBlock, mod); + if (missingScenarios.length > 0) { + throw new Error( + `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.` + ); + } nameToBlock.set(key, mod); } @@ -380,6 +392,41 @@ export function buildSpecSkeleton(specFolderName: string, changeName: string): s return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`; } +function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[] { + const incomingScenarioNames = new Set(parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name)); + return parseScenarioBlocks(current.raw) + .filter((scenario) => !incomingScenarioNames.has(scenario.name)) + .map((scenario) => scenario.name); +} + +function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[] { + const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n'); + const scenarios: ScenarioBlock[] = []; + let index = 0; + + while (index < lines.length) { + const headerMatch = lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/); + if (!headerMatch) { + index++; + continue; + } + + const start = index; + const name = headerMatch[1].trim(); + index++; + while (index < lines.length && !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index])) { + index++; + } + + scenarios.push({ + name, + raw: lines.slice(start, index).join('\n').trimEnd(), + }); + } + + return scenarios; +} + /** * Apply all delta specs from a change to main specs. * diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index d0d586862e..6724b8d530 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -624,6 +624,84 @@ new text await expect(fs.access(changeDir)).resolves.not.toThrow(); }); + it('should abort stale MODIFIED blocks that would drop current scenarios (issue #1246)', async () => { + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'stale-modified'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + const baseSpec = `# stale-modified Specification + +## Purpose +Stale modified purpose. + +## Requirements + +### Requirement: Shared Rule +The system SHALL support the shared rule. + +#### Scenario: Existing behavior +- **WHEN** the original behavior runs +- **THEN** it succeeds`; + await fs.writeFile(mainSpecPath, baseSpec); + + const changeA = 'modify-shared-a'; + const changeADir = path.join(tempDir, 'openspec', 'changes', changeA); + const changeASpecDir = path.join(changeADir, 'specs', 'stale-modified'); + await fs.mkdir(changeASpecDir, { recursive: true }); + await fs.writeFile(path.join(changeASpecDir, 'spec.md'), `# Stale Modified - Change A + +## MODIFIED Requirements + +### Requirement: Shared Rule +The system SHALL support the shared rule. + +#### Scenario: Existing behavior +- **WHEN** the original behavior runs +- **THEN** it succeeds + +#### Scenario: Behavior from A +- **WHEN** change A behavior runs +- **THEN** it succeeds`); + + const changeB = 'modify-shared-b'; + const changeBDir = path.join(tempDir, 'openspec', 'changes', changeB); + const changeBSpecDir = path.join(changeBDir, 'specs', 'stale-modified'); + await fs.mkdir(changeBSpecDir, { recursive: true }); + await fs.writeFile(path.join(changeBSpecDir, 'spec.md'), `# Stale Modified - Change B + +## MODIFIED Requirements + +### Requirement: Shared Rule +The system SHALL support the shared rule. + +#### Scenario: Existing behavior +- **WHEN** the original behavior runs +- **THEN** it succeeds + +#### Scenario: Behavior from B +- **WHEN** change B behavior runs +- **THEN** it succeeds`); + + await archiveCommand.execute(changeA, { yes: true, noValidate: true }); + await archiveCommand.execute(changeB, { yes: true, noValidate: true }); + + const updated = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updated).toContain('#### Scenario: Existing behavior'); + expect(updated).toContain('#### Scenario: Behavior from A'); + expect(updated).not.toContain('#### Scenario: Behavior from B'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + 'stale-modified MODIFIED failed for header "### Requirement: Shared Rule" - current spec contains scenario(s) not present in the modified block: "Behavior from A"' + ) + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + await expect(fs.access(changeBDir)).resolves.not.toThrow(); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeA))).toBe(true); + expect(archives.some(a => a.includes(changeB))).toBe(false); + }); + it('should abort with a structural error when target spec hides requirements outside ## Requirements', async () => { const changeName = 'hidden-requirement-target'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); From 4ef07610802276ef04235ce8d780cdc07b6b0ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ercan=20Erdo=C4=9Fan?= Date: Tue, 7 Jul 2026 18:46:56 +0200 Subject: [PATCH 10/19] docs: clarify change name format (#1261) --- docs/cli.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/cli.md b/docs/cli.md index 8f9c03baed..e9845da4b9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -620,6 +620,13 @@ Create a change directory and optional checked-in metadata in the resolved OpenS openspec new change [options] ``` +Change names must use lowercase kebab-case. They start with a lowercase letter, +then contain lowercase letters, numbers, and single hyphens. They cannot start +with a number, contain spaces, underscores, uppercase letters, consecutive +hyphens, or leading/trailing hyphens. When including an external ticket ID, +prefix it with a word, for example `ticket-123-add-notifications` instead of +`123-add-notifications`. + **Options:** | Option | Description | From 8ac624b279974d0aacb44a93d913f7129a784a66 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:08:25 +1000 Subject: [PATCH 11/19] chore: remove stale npm lockfile (#1319) * chore: remove stale npm lockfile * ci: use package manager metadata for pnpm setup * chore: scope npm lockfile ignore to root --- .github/workflows/ci.yml | 8 - .github/workflows/deploy-docs.yml | 2 - .github/workflows/release-prepare.yml | 2 - .gitignore | 1 + package-lock.json | 4978 ------------------------- package.json | 1 + 6 files changed, 2 insertions(+), 4990 deletions(-) delete mode 100644 package-lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 519aa41968..d3d1f6235c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 @@ -110,8 +108,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 @@ -149,8 +145,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 @@ -270,8 +264,6 @@ jobs: - name: Setup pnpm if: steps.changed-changesets.outputs.has_changesets == 'true' uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js if: steps.changed-changesets.outputs.has_changesets == 'true' diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 9fbe0e8a43..bfd347e538 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -44,8 +44,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 0a58d8e87c..e9221b2470 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -34,8 +34,6 @@ jobs: token: ${{ steps.app-token.outputs.token }} - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: diff --git a/.gitignore b/.gitignore index 3ed26016aa..58133adee1 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,7 @@ CLAUDE.md # Pnpm .pnpm-store/ +/package-lock.json result # OpenCode diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 03dad207d4..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,4978 +0,0 @@ -{ - "name": "@fission-ai/openspec", - "version": "1.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@fission-ai/openspec", - "version": "1.2.0", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/prompts": "^7.8.0", - "chalk": "^5.5.0", - "commander": "^14.0.0", - "fast-glob": "^3.3.3", - "ora": "^8.2.0", - "posthog-node": "^5.20.0", - "yaml": "^2.8.2", - "zod": "^4.0.17" - }, - "bin": { - "openspec": "bin/openspec.js" - }, - "devDependencies": { - "@changesets/changelog-github": "^0.5.2", - "@changesets/cli": "^2.27.7", - "@types/node": "^24.2.0", - "@vitest/ui": "^3.2.4", - "eslint": "^9.39.2", - "typescript": "^5.9.3", - "typescript-eslint": "^8.50.1", - "vitest": "^3.2.4" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@changesets/apply-release-plan": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz", - "integrity": "sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/config": "^3.1.2", - "@changesets/get-version-range-type": "^0.4.0", - "@changesets/git": "^3.0.4", - "@changesets/should-skip-package": "^0.1.2", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "detect-indent": "^6.0.0", - "fs-extra": "^7.0.1", - "lodash.startcase": "^4.4.0", - "outdent": "^0.5.0", - "prettier": "^2.7.1", - "resolve-from": "^5.0.0", - "semver": "^7.5.3" - } - }, - "node_modules/@changesets/assemble-release-plan": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.9.tgz", - "integrity": "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/should-skip-package": "^0.1.2", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "semver": "^7.5.3" - } - }, - "node_modules/@changesets/changelog-git": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz", - "integrity": "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0" - } - }, - "node_modules/@changesets/changelog-github": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@changesets/changelog-github/-/changelog-github-0.5.2.tgz", - "integrity": "sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/get-github-info": "^0.7.0", - "@changesets/types": "^6.1.0", - "dotenv": "^8.1.0" - } - }, - "node_modules/@changesets/cli": { - "version": "2.29.8", - "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.29.8.tgz", - "integrity": "sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/apply-release-plan": "^7.0.14", - "@changesets/assemble-release-plan": "^6.0.9", - "@changesets/changelog-git": "^0.2.1", - "@changesets/config": "^3.1.2", - "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/get-release-plan": "^4.0.14", - "@changesets/git": "^3.0.4", - "@changesets/logger": "^0.1.1", - "@changesets/pre": "^2.0.2", - "@changesets/read": "^0.6.6", - "@changesets/should-skip-package": "^0.1.2", - "@changesets/types": "^6.1.0", - "@changesets/write": "^0.4.0", - "@inquirer/external-editor": "^1.0.2", - "@manypkg/get-packages": "^1.1.3", - "ansi-colors": "^4.1.3", - "ci-info": "^3.7.0", - "enquirer": "^2.4.1", - "fs-extra": "^7.0.1", - "mri": "^1.2.0", - "p-limit": "^2.2.0", - "package-manager-detector": "^0.2.0", - "picocolors": "^1.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.5.3", - "spawndamnit": "^3.0.1", - "term-size": "^2.1.0" - }, - "bin": { - "changeset": "bin.js" - } - }, - "node_modules/@changesets/config": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.2.tgz", - "integrity": "sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/logger": "^0.1.1", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "fs-extra": "^7.0.1", - "micromatch": "^4.0.8" - } - }, - "node_modules/@changesets/errors": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz", - "integrity": "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==", - "dev": true, - "license": "MIT", - "dependencies": { - "extendable-error": "^0.1.5" - } - }, - "node_modules/@changesets/get-dependents-graph": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.3.tgz", - "integrity": "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "picocolors": "^1.1.0", - "semver": "^7.5.3" - } - }, - "node_modules/@changesets/get-github-info": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@changesets/get-github-info/-/get-github-info-0.7.0.tgz", - "integrity": "sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dataloader": "^1.4.0", - "node-fetch": "^2.5.0" - } - }, - "node_modules/@changesets/get-release-plan": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.14.tgz", - "integrity": "sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/assemble-release-plan": "^6.0.9", - "@changesets/config": "^3.1.2", - "@changesets/pre": "^2.0.2", - "@changesets/read": "^0.6.6", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3" - } - }, - "node_modules/@changesets/get-version-range-type": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.4.0.tgz", - "integrity": "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@changesets/git": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@changesets/git/-/git-3.0.4.tgz", - "integrity": "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@manypkg/get-packages": "^1.1.3", - "is-subdir": "^1.1.1", - "micromatch": "^4.0.8", - "spawndamnit": "^3.0.1" - } - }, - "node_modules/@changesets/logger": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@changesets/logger/-/logger-0.1.1.tgz", - "integrity": "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.1.0" - } - }, - "node_modules/@changesets/parse": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@changesets/parse/-/parse-0.4.2.tgz", - "integrity": "sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "js-yaml": "^4.1.1" - } - }, - "node_modules/@changesets/pre": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@changesets/pre/-/pre-2.0.2.tgz", - "integrity": "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "fs-extra": "^7.0.1" - } - }, - "node_modules/@changesets/read": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@changesets/read/-/read-0.6.6.tgz", - "integrity": "sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/git": "^3.0.4", - "@changesets/logger": "^0.1.1", - "@changesets/parse": "^0.4.2", - "@changesets/types": "^6.1.0", - "fs-extra": "^7.0.1", - "p-filter": "^2.1.0", - "picocolors": "^1.1.0" - } - }, - "node_modules/@changesets/should-skip-package": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", - "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3" - } - }, - "node_modules/@changesets/types": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", - "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@changesets/write": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", - "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "fs-extra": "^7.0.1", - "human-id": "^4.1.1", - "prettier": "^2.7.1" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@manypkg/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "@types/node": "^12.7.1", - "find-up": "^4.1.0", - "fs-extra": "^8.1.0" - } - }, - "node_modules/@manypkg/find-root/node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@manypkg/find-root/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@manypkg/get-packages": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", - "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "@changesets/types": "^4.0.1", - "@manypkg/find-root": "^1.1.0", - "fs-extra": "^8.1.0", - "globby": "^11.0.0", - "read-yaml-file": "^1.1.0" - } - }, - "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", - "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@manypkg/get-packages/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@posthog/core": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.1.tgz", - "integrity": "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", - "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", - "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", - "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", - "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", - "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", - "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", - "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", - "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", - "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", - "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", - "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", - "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", - "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", - "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", - "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", - "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", - "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", - "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", - "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", - "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", - "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", - "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", - "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", - "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", - "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", - "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/ui": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", - "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@vitest/utils": "3.2.4", - "fflate": "^0.8.2", - "flatted": "^3.3.3", - "pathe": "^2.0.3", - "sirv": "^3.0.1", - "tinyglobby": "^0.2.14", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "3.2.4" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/better-path-resolve": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", - "integrity": "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-windows": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/dataloader": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz", - "integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=10" - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extendable-error": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", - "integrity": "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/human-id": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", - "integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==", - "dev": true, - "license": "MIT", - "bin": { - "human-id": "dist/cli.js" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-subdir": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", - "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "better-path-resolve": "1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/outdent": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", - "integrity": "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/p-filter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", - "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-map": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", - "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-manager-detector": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", - "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "quansync": "^0.2.7" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/posthog-node": { - "version": "5.24.17", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.24.17.tgz", - "integrity": "sha512-mdb8TKt+YCRbGQdYar3AKNUPCyEiqcprScF4unYpGALF6HlBaEuO6wPuIqXXpCWkw4VclJYCKbb6lq6pH6bJeA==", - "license": "MIT", - "dependencies": { - "@posthog/core": "1.23.1" - }, - "engines": { - "node": "^20.20.0 || >=22.22.0" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/read-yaml-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", - "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.5", - "js-yaml": "^3.6.1", - "pify": "^4.0.1", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/read-yaml-file/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", - "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.58.0", - "@rollup/rollup-android-arm64": "4.58.0", - "@rollup/rollup-darwin-arm64": "4.58.0", - "@rollup/rollup-darwin-x64": "4.58.0", - "@rollup/rollup-freebsd-arm64": "4.58.0", - "@rollup/rollup-freebsd-x64": "4.58.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", - "@rollup/rollup-linux-arm-musleabihf": "4.58.0", - "@rollup/rollup-linux-arm64-gnu": "4.58.0", - "@rollup/rollup-linux-arm64-musl": "4.58.0", - "@rollup/rollup-linux-loong64-gnu": "4.58.0", - "@rollup/rollup-linux-loong64-musl": "4.58.0", - "@rollup/rollup-linux-ppc64-gnu": "4.58.0", - "@rollup/rollup-linux-ppc64-musl": "4.58.0", - "@rollup/rollup-linux-riscv64-gnu": "4.58.0", - "@rollup/rollup-linux-riscv64-musl": "4.58.0", - "@rollup/rollup-linux-s390x-gnu": "4.58.0", - "@rollup/rollup-linux-x64-gnu": "4.58.0", - "@rollup/rollup-linux-x64-musl": "4.58.0", - "@rollup/rollup-openbsd-x64": "4.58.0", - "@rollup/rollup-openharmony-arm64": "4.58.0", - "@rollup/rollup-win32-arm64-msvc": "4.58.0", - "@rollup/rollup-win32-ia32-msvc": "4.58.0", - "@rollup/rollup-win32-x64-gnu": "4.58.0", - "@rollup/rollup-win32-x64-msvc": "4.58.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spawndamnit": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", - "integrity": "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "cross-spawn": "^7.0.5", - "signal-exit": "^4.0.1" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/term-size": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", - "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json index a4420a24f3..c6516019d7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "license": "MIT", "author": "OpenSpec Contributors", "type": "module", + "packageManager": "pnpm@9.15.9", "publishConfig": { "access": "public" }, From 3f0ca3f6ce6f2ec41260c5cbe7954b7e46adcf43 Mon Sep 17 00:00:00 2001 From: shin <112563017+jjxyxsjr@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:51:42 +0800 Subject: [PATCH 12/19] feat: add Trae command adapter (#1090) * feat(tools): add Trae command adapter - Added Trae command adapter for generating `.trae/commands/opsx-.md` files - Complete unit tests (9 test cases) and integration tests - Updated documentation and .gitignore - Fixed YAML escaping for carriage returns (\r) Co-Authored-By: Claude Code * fix: handle empty string in YAML escaping - Add explicit check for empty string in escapeYamlValue - Return quoted empty string '""' instead of unquoted empty scalar - Update test to verify empty string is properly quoted Co-Authored-By: Claude Code * fix: address PR review feedback for Trae adapter - Update docs/commands.md Trae entry to reflect generated opsx-* commands - Export traeAdapter from adapters/index.ts Co-Authored-By: Claude Opus 4.7 * docs: align Trae command adapter docs --------- Co-authored-by: jjxyxsjr Co-authored-by: Claude Code Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Co-authored-by: TabishB --- .changeset/add-trae-command-adapter.md | 7 ++ .gitignore | 3 + docs/commands.md | 11 ++- docs/how-commands-work.md | 2 +- docs/supported-tools.md | 2 +- docs/troubleshooting.md | 2 +- .../proposal.md | 22 +++--- .../tasks.md | 12 +-- openspec/specs/command-generation/spec.md | 7 +- src/core/command-generation/adapters/index.ts | 1 + src/core/command-generation/adapters/trae.ts | 53 +++++++++++++ src/core/command-generation/registry.ts | 2 + test/core/command-generation/adapters.test.ts | 74 ++++++++++++++++++- test/core/init.test.ts | 23 ++++++ 14 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 .changeset/add-trae-command-adapter.md create mode 100644 src/core/command-generation/adapters/trae.ts diff --git a/.changeset/add-trae-command-adapter.md b/.changeset/add-trae-command-adapter.md new file mode 100644 index 0000000000..2566daaf37 --- /dev/null +++ b/.changeset/add-trae-command-adapter.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features + +- **TRAE command adapter** — Added command adapter for Trae IDE, enabling generation of `.trae/commands/opsx-.md` files for custom slash commands diff --git a/.gitignore b/.gitignore index 58133adee1..0455fee22c 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ opencode.json # Bob .bob/ + +# Trae +.trae/ diff --git a/docs/commands.md b/docs/commands.md index 57ede52aa2..8587fd062e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -323,16 +323,19 @@ AI: Implementing add-dark-mode... Revise a change's existing planning artifacts and keep them coherent with one another. Planning artifacts only - it never edits code. **Syntax:** -``` + +```text /opsx:update [change-name] ``` **Arguments:** + | Argument | Required | Description | |----------|----------|-------------| | `change-name` | No | Which change to update (inferred from context if not provided) | **What it does:** + - Reads the change's artifacts via `openspec status --change --json` - Applies your requested revision, or reviews the artifacts for contradictions if you didn't name one - Reconciles the other existing artifacts in any direction (a design edit may ripple back to the proposal) @@ -340,7 +343,8 @@ Revise a change's existing planning artifacts and keep them coherent with one an - Ends by recommending the next step: `/opsx:continue` (artifacts missing), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done) **Example:** -``` + +```text You: /opsx:update add-dark-mode - we're storing the theme in a cookie now, not localStorage AI: Reading add-dark-mode artifacts... @@ -356,6 +360,7 @@ AI: Reading add-dark-mode artifacts... ``` **Tips:** + - It won't create missing artifacts - that's `/opsx:continue` - If the change was already implemented, follow up with `/opsx:apply` so the code matches the revised plan - If your revision changes the *intent* of the change, start fresh with a new change instead (see [When to Update vs. Start Fresh](opsx.md#when-to-update-vs-start-fresh)) @@ -666,7 +671,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Windsurf | `/opsx-propose`, `/opsx-apply` | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | Kimi CLI | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) | -| Trae | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | +| Trae | `/opsx-propose`, `/opsx-apply` | The intent is the same across tools, but how commands are surfaced can differ by integration. diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index e60c9a7618..42d18d4161 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -80,7 +80,7 @@ The intent is identical everywhere. The punctuation differs. Use the form that m | Windsurf | `/opsx-propose`, `/opsx-apply` | | GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | Kimi CLI | skill-style, e.g. `/skill:openspec-propose` | -| Trae | skill-style, e.g. `/openspec-propose` | +| Trae | `/opsx-propose`, `/opsx-apply` | Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`). A few tools surface OpenSpec as named skills instead of slash commands; for those you invoke the skill by name. The full per-tool list, including exactly which files get written where, lives in [Supported Tools](supported-tools.md). diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 85b3ce25a7..7d20d2f17b 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -50,7 +50,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | | Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.toml` | | RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | -| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | +| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-.md` | | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | \* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 07b5bb725d..e2b69b3364 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -55,7 +55,7 @@ If `/opsx:propose` (or your tool's equivalent) doesn't appear or doesn't do anyt 5. **Check you initialized this project.** Skills are written per project. If you cloned a repo or switched folders, run `openspec init` (or `openspec update`) there. -6. **Confirm your tool supports command files.** A few tools (Kimi CLI, Trae, ForgeCode, Mistral Vibe) don't get generated `opsx-*` command files; they use skill-based invocations instead. The forms differ per tool: see [Supported Tools](supported-tools.md) and [How Commands Work](how-commands-work.md#slash-command-syntax-by-tool). +6. **Confirm your tool supports command files.** A few tools (Kimi CLI, ForgeCode, Mistral Vibe) don't get generated `opsx-*` command files; they use skill-based invocations instead. The forms differ per tool: see [Supported Tools](supported-tools.md) and [How Commands Work](how-commands-work.md#slash-command-syntax-by-tool). ## Working with changes diff --git a/openspec/changes/add-tool-command-surface-capabilities/proposal.md b/openspec/changes/add-tool-command-surface-capabilities/proposal.md index c9ad2909cc..33f7605067 100644 --- a/openspec/changes/add-tool-command-surface-capabilities/proposal.md +++ b/openspec/changes/add-tool-command-surface-capabilities/proposal.md @@ -2,13 +2,13 @@ OpenSpec currently assumes command delivery maps directly to command adapters. That assumption does not hold for all tools. -Trae is a concrete example: it invokes OpenSpec workflows via skill entries (for example `/openspec-new-change`) rather than adapter-generated command files. In this model, skills are the command surface. +Some tools expose OpenSpec workflows via skill entries rather than adapter-generated command files. Kimi CLI is a concrete example: it invokes skills with forms such as `/skill:openspec-new-change`. In this model, skills are the command surface. Today, this creates a behavior gap: - `delivery=commands` can remove skills - tools without adapters skip command generation -- result: selected tools like Trae can end up with no invocable workflow artifacts +- result: selected tools like Kimi CLI, ForgeCode, or Mistral Vibe can end up with no invocable workflow artifacts This is more than a prompt UX issue because non-interactive and CI flows bypass interactive guidance. We need a capability-aware model in core generation logic. @@ -25,9 +25,13 @@ Add an optional field in tool metadata to describe how a tool exposes commands: Field should be optional. Default behavior is inferred from adapter registry presence: tools with a registered adapter resolve to `adapter`; tools with no adapter registration and no explicit annotation resolve to `none`. Capability values use kebab-case string tokens for consistency with serialized metadata conventions. -Initial explicit override: +Initial explicit overrides: -- Trae -> `skills-invocable` +- ForgeCode -> `skills-invocable` +- Kimi CLI -> `skills-invocable` +- Mistral Vibe -> `skills-invocable` + +Trae no longer belongs in this override set once its `.trae/commands/opsx-.md` adapter is available; it should resolve to `adapter` like other file-backed command integrations. ### 2. Make delivery behavior capability-aware @@ -62,12 +66,12 @@ Update summaries to show effective delivery outcomes per tool (for example, when ### 4. Update docs and tests -- document capability model and Trae behavior under delivery modes +- document capability model and skills-invocable behavior under delivery modes - ensure CLI docs and supported-tools docs reflect effective behavior - add test coverage for: - - `init --tools trae` with `delivery=commands` - - `update` with Trae configured under `delivery=commands` - - mixed selections (`claude + trae`) across all delivery modes + - `init --tools kimi` with `delivery=commands` + - `update` with Kimi CLI configured under `delivery=commands` + - mixed selections (`claude + kimi`) across all delivery modes - explicit error path for tools with no command surface under `delivery=commands` ### 5. Coordinate with install-scope behavior @@ -94,7 +98,7 @@ Implementation tests should cover mixed-tool matrices to ensure deterministic be ## Impact -- `src/core/config.ts` - add optional command-surface metadata and Trae override +- `src/core/config.ts` - add optional command-surface metadata and skills-invocable tool overrides - `src/core/command-generation/registry.ts` (or shared helper) - capability inference from adapter presence - `src/core/init.ts` - capability-aware generation/removal planning + compatibility validation + summary messaging - `src/core/update.ts` - capability-aware sync/removal planning + compatibility validation + summary messaging diff --git a/openspec/changes/add-tool-command-surface-capabilities/tasks.md b/openspec/changes/add-tool-command-surface-capabilities/tasks.md index 0f2679b833..6a6b0b1b9f 100644 --- a/openspec/changes/add-tool-command-surface-capabilities/tasks.md +++ b/openspec/changes/add-tool-command-surface-capabilities/tasks.md @@ -9,7 +9,7 @@ - [ ] 1.1 Extend tool metadata in `src/core/config.ts` with an optional command-surface capability field - [ ] 1.2 Define supported capability values: `adapter`, `skills-invocable`, `none` -- [ ] 1.3 Mark Trae as `skills-invocable` +- [ ] 1.3 Mark known skills-invocable tools such as ForgeCode, Kimi CLI, and Mistral Vibe as `skills-invocable` - [ ] 1.4 Add a shared capability resolver (explicit metadata override first, inferred fallback from adapter presence second) - [ ] 1.5 Add focused unit tests for capability resolution (explicit override, inferred adapter, inferred none) @@ -20,7 +20,7 @@ - [ ] 2.3 In `delivery=commands`, fail fast before writes when any selected tool resolves to `none` - [ ] 2.4 Update init output to clearly report effective behavior for `skills-invocable` tools (skills used as command surface) - [ ] 2.5 Ensure init no longer reports "no adapter" for tools intentionally using `skills-invocable` -- [ ] 2.6 Add/adjust init tests for `delivery=commands` + `trae` (skills retained/generated, no adapter error), mixed tools (`claude,trae`) with per-tool expected outputs, and deterministic failure path for unsupported command surface (`none`) +- [ ] 2.6 Add/adjust init tests for `delivery=commands` + `kimi` (skills retained/generated, no adapter error), mixed tools (`claude,kimi`) with per-tool expected outputs, and deterministic failure path for unsupported command surface (`none`) ## 3. Update: Capability-Aware Sync and Drift Detection @@ -30,7 +30,7 @@ - [ ] 3.4 Update profile/delivery drift detection to avoid perpetual drift for `skills-invocable` tools under commands delivery - [ ] 3.5 Ensure configured-tool detection still includes `skills-invocable` tools under commands delivery when managed skills exist - [ ] 3.6 Update summary output so skills-invocable behavior is reported as expected behavior (not implicit skip/error) -- [ ] 3.7 Add/adjust update tests for `delivery=commands` + configured Trae (skills retained/generated), idempotent second update (no false drift loop), mixed configured tools (`claude` + `trae`), and deterministic preflight failure for unsupported command surface (`none`) +- [ ] 3.7 Add/adjust update tests for `delivery=commands` + configured Kimi CLI (skills retained/generated), idempotent second update (no false drift loop), mixed configured tools (`claude` + `kimi`), and deterministic preflight failure for unsupported command surface (`none`) ## 4. UX and Error Messaging @@ -40,7 +40,7 @@ ## 5. Documentation Updates -- [ ] 5.1 Update `docs/supported-tools.md` to document command-surface semantics for Trae and clarify delivery interactions +- [ ] 5.1 Update `docs/supported-tools.md` to document command-surface semantics for skills-invocable tools and clarify delivery interactions - [ ] 5.2 Update `docs/cli.md` delivery guidance to explain capability-aware behavior for `delivery=commands` - [ ] 5.3 Add a short troubleshooting note for "commands-only + unsupported tool" failures @@ -49,5 +49,5 @@ - [ ] 6.1 Run targeted tests: `test/core/init.test.ts` and `test/core/update.test.ts` - [ ] 6.2 Run any new capability/unit test files added in this change - [ ] 6.3 Run full test suite (`pnpm test`) and resolve regressions -- [ ] 6.4 Manual smoke check: `openspec init --tools trae` with `delivery=commands` -- [ ] 6.5 Manual smoke check: mixed tools (`claude,trae`) with `delivery=commands` +- [ ] 6.4 Manual smoke check: `openspec init --tools kimi` with `delivery=commands` +- [ ] 6.5 Manual smoke check: mixed tools (`claude,kimi`) with `delivery=commands` diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index ea598a75ae..cb0fb2c385 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -49,6 +49,12 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting - **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields - **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-.md` +#### Scenario: Trae adapter formatting + +- **WHEN** formatting a command for Trae +- **THEN** the adapter SHALL output YAML frontmatter with `name` and `description` fields +- **AND** file path SHALL follow pattern `.trae/commands/opsx-.md` + ### Requirement: Command generator function The system SHALL provide a `generateCommand` function that combines content with adapter. @@ -94,4 +100,3 @@ The body content of commands SHALL be shared across all tools. - **WHEN** generating the 'explore' command for Claude and Cursor - **THEN** both SHALL use the same `body` content - **AND** only the frontmatter and file path SHALL differ - diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d6..512a0d4de8 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -29,4 +29,5 @@ export { qoderAdapter } from './qoder.js'; export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.js'; +export { traeAdapter } from './trae.js'; export { windsurfAdapter } from './windsurf.js'; diff --git a/src/core/command-generation/adapters/trae.ts b/src/core/command-generation/adapters/trae.ts new file mode 100644 index 0000000000..6db48e6088 --- /dev/null +++ b/src/core/command-generation/adapters/trae.ts @@ -0,0 +1,53 @@ +/** + * Trae Command Adapter + * + * Formats commands for Trae IDE following its command specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + if (value === '') { + return '""'; + } + // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + // Use double quotes and escape internal double quotes, backslashes, and newlines + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); + return `"${escaped}"`; + } + return value; +} + +/** + * Trae adapter for command generation. + * File path: .trae/commands/opsx-.md + * Frontmatter: name, description + */ +export const traeAdapter: ToolCommandAdapter = { + toolId: 'trae', + + getFilePath(commandId: string): string { + return path.join('.trae', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707d..bd41245290 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -31,6 +31,7 @@ import { qoderAdapter } from './adapters/qoder.js'; import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; import { roocodeAdapter } from './adapters/roocode.js'; +import { traeAdapter } from './adapters/trae.js'; import { windsurfAdapter } from './adapters/windsurf.js'; /** @@ -66,6 +67,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(lingmaAdapter); CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); + CommandAdapterRegistry.register(traeAdapter); CommandAdapterRegistry.register(windsurfAdapter); } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index ff25bf1b0f..ae3a10a776 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -23,6 +23,7 @@ import { piAdapter } from '../../../src/core/command-generation/adapters/pi.js'; import { qoderAdapter } from '../../../src/core/command-generation/adapters/qoder.js'; import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js'; import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js'; +import { traeAdapter } from '../../../src/core/command-generation/adapters/trae.js'; import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; import type { CommandContent } from '../../../src/core/command-generation/types.js'; @@ -674,6 +675,77 @@ describe('command-generation/adapters', () => { }); }); + describe('traeAdapter', () => { + it('should have correct toolId', () => { + expect(traeAdapter.toolId).toBe('trae'); + }); + + it('should generate correct file path', () => { + const filePath = traeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.trae', 'commands', 'opsx-explore.md')); + }); + + it('should generate correct file paths for different commands', () => { + expect(traeAdapter.getFilePath('new')).toBe(path.join('.trae', 'commands', 'opsx-new.md')); + expect(traeAdapter.getFilePath('bulk-archive')).toBe(path.join('.trae', 'commands', 'opsx-bulk-archive.md')); + }); + + it('should format file with name and description frontmatter', () => { + const output = traeAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.\n\nWith multiple lines.'); + }); + + it('should escape YAML special characters in name', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + name: 'Test: Command', + }; + const output = traeAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('name: "Test: Command"'); + }); + + it('should escape YAML special characters in description', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + description: 'Fix: regression in "auth" feature', + }; + const output = traeAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"'); + }); + + it('should escape newlines in description', () => { + const contentWithNewline: CommandContent = { + ...sampleContent, + description: 'Line 1\nLine 2', + }; + const output = traeAdapter.formatFile(contentWithNewline); + expect(output).toContain('description: "Line 1\\nLine 2"'); + }); + + it('should handle empty description', () => { + const contentEmptyDesc: CommandContent = { + ...sampleContent, + description: '', + }; + const output = traeAdapter.formatFile(contentEmptyDesc); + expect(output).toContain('description: ""'); + }); + + it('should escape carriage returns in description', () => { + const contentWithCR: CommandContent = { + ...sampleContent, + description: 'Line 1\r\nLine 2', + }; + const output = traeAdapter.formatFile(contentWithCR); + expect(output).toContain('description: "Line 1\\r\\nLine 2"'); + }); + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators @@ -699,7 +771,7 @@ describe('command-generation/adapters', () => { codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter, - qwenAdapter, roocodeAdapter + qwenAdapter, roocodeAdapter, traeAdapter ]; for (const adapter of adapters) { const filePath = adapter.getFilePath('test'); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 39ae092a0a..a0ab03e384 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -194,6 +194,29 @@ describe('InitCommand', () => { ).toBe(true); }); + it('should create both skills and commands for Trae with adapter', async () => { + saveGlobalConfig({ + configuredTools: [], + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'trae', force: true }); + await initCommand.execute(testDir); + + // Skills should be created + const skillFile = path.join(testDir, '.trae', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + // Commands should also be created (Trae has an adapter) + const commandFile = path.join(testDir, '.trae', 'commands', 'opsx-explore.md'); + expect(await fileExists(commandFile)).toBe(true); + + const commandContent = await fs.readFile(commandFile, 'utf-8'); + expect(commandContent).toContain('---'); + expect(commandContent).toContain('name:'); + expect(commandContent).toContain('description:'); + }); + it('should create skills for multiple tools at once', async () => { const initCommand = new InitCommand({ tools: 'claude,cursor', force: true }); From 8886e3ae226a5ad70e1c65ece622ee409977a058 Mon Sep 17 00:00:00 2001 From: xianzheTM Date: Wed, 8 Jul 2026 02:03:24 +0800 Subject: [PATCH 13/19] feat: add Oh My Pi (OMP) tool support (#1276) * feat: add Oh My Pi (OMP) tool support Add ToolCommandAdapter for Oh My Pi terminal AI coding agent. - New adapter: src/core/command-generation/adapters/oh-my-pi.ts - Commands: .omp/commands/opsx-.md with description frontmatter - Hyphen transform: /opsx: -> /opsx- (filename = command name) - Argument injection: **Provided arguments**: $@ after **Input**: heading - escapeYamlValue applied to description field - Register in CommandAdapterRegistry and adapters/index.ts - Add oh-my-pi to AI_TOOLS with skillsDir: '.omp' - Add to hyphen command transformer whitelist in init.ts and update.ts - Full test coverage (10 cases) in adapters.test.ts - Update docs/supported-tools.md with directory reference and tool ID Closes #713 * fix: address CodeRabbit nitpicks - Move ohMyPiAdapter import before opencodeAdapter (alphabetical order) - Break long SHALL sentence and remove redundant 'follows after' in spec * docs: polish Oh My Pi support * docs: address Oh My Pi review nits --------- Co-authored-by: TabishB Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> --- docs/cli.md | 2 +- docs/commands.md | 1 + docs/how-commands-work.md | 1 + docs/supported-tools.md | 3 +- .../feat-add-omp-tool-support/.openspec.yaml | 2 + .../feat-add-omp-tool-support/design.md | 59 ++++++++++++ .../feat-add-omp-tool-support/proposal.md | 34 +++++++ .../specs/cli-init/spec.md | 15 +++ .../specs/cli-update/spec.md | 13 +++ .../specs/oh-my-pi-tool/spec.md | 48 ++++++++++ .../feat-add-omp-tool-support/tasks.md | 30 ++++++ src/core/command-generation/adapters/index.ts | 1 + .../command-generation/adapters/oh-my-pi.ts | 55 +++++++++++ src/core/command-generation/registry.ts | 2 + src/core/config.ts | 1 + src/core/init.ts | 4 +- src/core/update.ts | 8 +- test/core/available-tools.test.ts | 17 +++- test/core/command-generation/adapters.test.ts | 93 ++++++++++++++++++- 19 files changed, 379 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/feat-add-omp-tool-support/.openspec.yaml create mode 100644 openspec/changes/feat-add-omp-tool-support/design.md create mode 100644 openspec/changes/feat-add-omp-tool-support/proposal.md create mode 100644 openspec/changes/feat-add-omp-tool-support/specs/cli-init/spec.md create mode 100644 openspec/changes/feat-add-omp-tool-support/specs/cli-update/spec.md create mode 100644 openspec/changes/feat-add-omp-tool-support/specs/oh-my-pi-tool/spec.md create mode 100644 openspec/changes/feat-add-omp-tool-support/tasks.md create mode 100644 src/core/command-generation/adapters/oh-my-pi.ts diff --git a/docs/cli.md b/docs/cli.md index e9845da4b9..07a5daea63 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -104,7 +104,7 @@ openspec init [path] [options] `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). -**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf` +**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf` > This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths. diff --git a/docs/commands.md b/docs/commands.md index 8587fd062e..6737eb305c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -670,6 +670,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Cursor | `/opsx-propose`, `/opsx-apply` | | Windsurf | `/opsx-propose`, `/opsx-apply` | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | +| Oh My Pi | `/opsx-propose`, `/opsx-apply` | | Kimi CLI | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) | | Trae | `/opsx-propose`, `/opsx-apply` | diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 42d18d4161..29637a4927 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -79,6 +79,7 @@ The intent is identical everywhere. The punctuation differs. Use the form that m | Cursor | `/opsx-propose`, `/opsx-apply` | | Windsurf | `/opsx-propose`, `/opsx-apply` | | GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | +| Oh My Pi | `/opsx-propose`, `/opsx-apply` | | Kimi CLI | skill-style, e.g. `/skill:openspec-propose` | | Trae | `/opsx-propose`, `/opsx-apply` | diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 7d20d2f17b..fb568832c0 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -45,6 +45,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-.prompt.md` | | Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/.md` | | Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | +| Oh My Pi (`oh-my-pi`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-.md` | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | @@ -75,7 +76,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf` +**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf` ## Workflow-Dependent Installation diff --git a/openspec/changes/feat-add-omp-tool-support/.openspec.yaml b/openspec/changes/feat-add-omp-tool-support/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/feat-add-omp-tool-support/design.md b/openspec/changes/feat-add-omp-tool-support/design.md new file mode 100644 index 0000000000..d5de2cad5a --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/design.md @@ -0,0 +1,59 @@ +## Context + +OpenSpec supports AI coding assistants by generating two artifact types per tool: skill files (for agent instruction loading) and command files (for slash-command invocation). Each tool has a `ToolCommandAdapter` that controls the output path and file format. + +Oh My Pi (OMP) is a terminal AI coding agent that uses a `.omp/` project directory. Its command system uses the filename stem as the slash command name (e.g., `opsx-propose.md` → `/opsx-propose`), which requires command body references to be in hyphenated form (`/opsx-propose` rather than `/opsx:propose`). This is the same pattern already used by Pi and OpenCode. + +## Goals / Non-Goals + +**Goals:** +- Add a `ToolCommandAdapter` for Oh My Pi producing `.omp/commands/opsx-.md` with `description` frontmatter. +- Inject `**Provided arguments**: $@` after the `**Input**:` heading in command bodies so user-supplied arguments are visible to the agent when a command is invoked with arguments. +- Register the adapter so `init` and `update` can generate command files and skill files for OMP. +- Apply `transformToHyphenCommands` to OMP skill bodies so `/opsx:` references become `/opsx-` for consistency with the command naming convention. +- Add OMP to `AI_TOOLS` so it appears in tool selection and auto-detection. + +**Non-Goals:** +- Changing the file format used by Pi or OpenCode. +- Adding OMP-specific frontmatter fields beyond `description`. +- Auto-detecting OMP presence (the `.omp/` directory is sufficient as `skillsDir`). + +## Decisions + +### Reuse the existing `transformToHyphenCommands` transformer for skill files + +**Decision**: Add `'oh-my-pi'` to the `tool.value` conditional in `init.ts` and `update.ts` that selects the hyphen transformer. + +**Rationale**: Pi and OpenCode follow the same filename-as-command-name convention and are already handled by this branch. OMP has an identical convention. Extending the same conditional is minimal-diff and keeps the pattern consistent. + +**Alternative considered**: Storing the transformer flag on the `AIToolOption` object (e.g., `useHyphenCommands: true`). This is cleaner long-term but is a larger refactor than this change warrants. It can be done separately if more tools adopt this convention. + +### Use `description`-only frontmatter in command files + +**Decision**: The `formatFile` method outputs only a `description` YAML field in frontmatter. + +**Rationale**: OMP's command format uses filename for the slash command name and `description` for display. No additional frontmatter fields (name, category, tags) are needed, matching the minimalist approach used by Pi. + +### Inject `$@` into command bodies (matching Pi) + +**Decision**: Apply the same `injectArgs` logic as Pi's adapter — append `**Provided arguments**: $@` on the line after the `**Input**:` heading, skipping injection if `$@` or `$ARGUMENTS` is already present. + +**Rationale**: OpenSpec command templates contain an `**Input**:` heading that describes what arguments the command accepts (e.g., `**Input**: The argument after /opsx-propose is the change name…`). Without injecting `$@`, a user running `/opsx-propose my-feature` passes `my-feature` as `$@` but the agent never sees it — the argument is silently discarded. OMP's prompt template spec explicitly supports `$@` and positional forms. Pi faces the same problem and already solves it with identical injection logic. + +**Alternative considered**: Leaving injection out and relying on users to add `$@` manually to the template. Rejected: this would silently break argument passing for all OMP commands and diverge from Pi's established behavior. + +### Tool ID is `'oh-my-pi'`, skills directory is `'.omp'` + +**Decision**: `value: 'oh-my-pi'` in `AI_TOOLS`; `skillsDir: '.omp'`. + +**Rationale**: The tool ID uses the full kebab-case name for human clarity. The `.omp/` directory is the short canonical path users will see on disk. The two are independent and follow the precedent set by `kilocode` (ID) → `.kilocode` (dir). + +## Risks / Trade-offs + +- **`.omp/` directory collision**: If a project uses `.omp/` for another purpose, OMP detection will yield a false positive. → Mitigation: This is consistent with how every other tool is detected; no special handling is warranted. +- **Conditional growth in init.ts / update.ts**: Adding a third value to the `tool.value === 'opencode' || tool.value === 'pi'` checks makes the long-term refactor to a per-tool flag more urgent. → Mitigation: Document in tasks; the refactor is low-risk and can follow separately. +- **Adapter missing `escapeYamlValue`**: If a command description contains special YAML characters, the description frontmatter could be malformed. → Mitigation: `escapeYamlValue` is applied in this implementation (task 1.2), consistent with Pi adapter. + +## Open Questions + +None — implementation is well-defined by the existing Pi/OpenCode/OMP pattern. diff --git a/openspec/changes/feat-add-omp-tool-support/proposal.md b/openspec/changes/feat-add-omp-tool-support/proposal.md new file mode 100644 index 0000000000..6bb5c80781 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/proposal.md @@ -0,0 +1,34 @@ +## Why + +Oh My Pi (OMP) is a terminal AI coding agent whose users expect OpenSpec workflows to be available as slash commands. Without an adapter, users who have OMP configured in their project cannot generate OMP-native command files or get the correct skill transformations from `openspec init` or `openspec update`. + +## What Changes + +- Add a `ToolCommandAdapter` for Oh My Pi that generates command files at `.omp/commands/opsx-.md` with YAML `description` frontmatter, hyphen-based command references, and `$@` argument injection after the `**Input**:` heading (matching Pi's convention so user-supplied arguments are visible to the agent). +- Register `oh-my-pi` in `AI_TOOLS` with `skillsDir: '.omp'` so detection and skill generation work. +- Register the new adapter in `CommandAdapterRegistry` and `adapters/index.ts`. +- Add Oh My Pi to the `transformToHyphenCommands` whitelist in `init.ts` and `update.ts` so skill files use the correct `/opsx-*` invocation form that matches OMP's filename-based command naming. +- Add test coverage for the new adapter. +- Update `docs/supported-tools.md` with the new tool's directory reference. + +## Capabilities + +### New Capabilities + +- `oh-my-pi-tool`: Command and skill generation support for the Oh My Pi (OMP) AI coding agent, following its `.omp/commands/opsx-.md` format with `description` frontmatter, hyphen-based command references, and `$@` argument injection. + +### Modified Capabilities + +- `cli-init`: Oh My Pi is added to the supported tool list and the hyphen-command transformer whitelist. +- `cli-update`: Oh My Pi is added to the hyphen-command transformer whitelist for skill regeneration. + +## Impact + +- `src/core/command-generation/adapters/oh-my-pi.ts` — new adapter +- `src/core/command-generation/adapters/index.ts` — export new adapter +- `src/core/command-generation/registry.ts` — register adapter +- `src/core/config.ts` — add `oh-my-pi` entry to `AI_TOOLS` +- `src/core/init.ts` — extend hyphen-command transformer conditional +- `src/core/update.ts` — extend hyphen-command transformer conditional (two call sites) +- `test/core/command-generation/adapters.test.ts` — adapter unit tests +- `docs/supported-tools.md` — add Oh My Pi row to directory reference table diff --git a/openspec/changes/feat-add-omp-tool-support/specs/cli-init/spec.md b/openspec/changes/feat-add-omp-tool-support/specs/cli-init/spec.md new file mode 100644 index 0000000000..82fe16cac5 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/specs/cli-init/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Oh My Pi tool supported in init +The `openspec init` command SHALL support Oh My Pi as a configurable tool, generating both skill files and command files using Oh My Pi's conventions when selected. + +#### Scenario: Selecting Oh My Pi during init +- **WHEN** a user selects Oh My Pi during `openspec init` +- **THEN** skill files are written to `.omp/skills/openspec-/SKILL.md` for each active command +- **AND** command files are written to `.omp/commands/opsx-.md` for each active command +- **AND** skill file bodies use hyphen-based `/opsx-` command references +- **AND** command file bodies have `**Provided arguments**: $@` injected after any `**Input**:` heading + +#### Scenario: Oh My Pi listed when .omp directory is detected +- **WHEN** the project root contains a `.omp/` directory +- **THEN** Oh My Pi is pre-checked in the tool selection during `openspec init` diff --git a/openspec/changes/feat-add-omp-tool-support/specs/cli-update/spec.md b/openspec/changes/feat-add-omp-tool-support/specs/cli-update/spec.md new file mode 100644 index 0000000000..2457ea6b47 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/specs/cli-update/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Oh My Pi tool supported in update +The `openspec update` command SHALL refresh Oh My Pi skill files and command files when Oh My Pi is configured, using Oh My Pi's hyphen-based command reference convention. + +#### Scenario: Updating Oh My Pi skill files +- **WHEN** `openspec update` runs and Oh My Pi is a configured tool +- **THEN** skill files in `.omp/skills/openspec-/SKILL.md` are refreshed with the latest templates +- **AND** skill file bodies use hyphen-based `/opsx-` command references + +#### Scenario: Updating Oh My Pi command files +- **WHEN** `openspec update` runs and Oh My Pi is a configured tool +- **THEN** command files are written to `.omp/commands/opsx-.md` for each workflow in the active profile, creating them if they do not yet exist and overwriting them if they do diff --git a/openspec/changes/feat-add-omp-tool-support/specs/oh-my-pi-tool/spec.md b/openspec/changes/feat-add-omp-tool-support/specs/oh-my-pi-tool/spec.md new file mode 100644 index 0000000000..a7050e7a47 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/specs/oh-my-pi-tool/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Oh My Pi command file generation +OpenSpec SHALL generate command files for Oh My Pi in `.omp/commands/opsx-.md`, one per active workflow command. + +Each file SHALL include a YAML frontmatter block with a `description` field. The command body SHALL transform `/opsx:` references to `/opsx-` to match Oh My Pi's filename-based slash command naming (e.g., `opsx-propose.md` → `/opsx-propose`). It SHALL inject `**Provided arguments**: $@` on the line immediately following any `**Input**:` heading, unless `$@` or `$ARGUMENTS` is already present in the body. + +#### Scenario: Command file path follows OMP convention +- **WHEN** OpenSpec generates a command file for Oh My Pi for workflow command `propose` +- **THEN** the file is written to `.omp/commands/opsx-propose.md` + +#### Scenario: Command file format includes description frontmatter +- **WHEN** OpenSpec writes a command file for Oh My Pi +- **THEN** the file begins with a YAML frontmatter block containing only a `description` field +- **AND** the body follows the closing `---` + +#### Scenario: Command body uses hyphen-based references +- **WHEN** OpenSpec writes a command file for Oh My Pi whose body contains `/opsx:apply` or similar colon-style references +- **THEN** those references are transformed to `/opsx-apply` in the output file + +#### Scenario: Command body exposes user arguments via $@ +- **WHEN** OpenSpec writes a command file for Oh My Pi whose body contains a `**Input**:` heading and no existing `$@` or `$ARGUMENTS` reference +- **THEN** `**Provided arguments**: $@` is injected on the line immediately after the `**Input**:` heading +- **AND** when the user invokes `/opsx-propose my-feature`, the agent receives `my-feature` as the value of `$@` + +### Requirement: Oh My Pi skill file generation +OpenSpec SHALL generate skill files for Oh My Pi in `.omp/skills/openspec-/SKILL.md`, one per active workflow command. + +Skill file bodies SHALL have `/opsx:` references transformed to `/opsx-` so that skill invocations refer to the correct hyphen-based slash command names. + +#### Scenario: Skill file path follows OMP convention +- **WHEN** OpenSpec generates a skill file for Oh My Pi for workflow command `explore` +- **THEN** the file is written to `.omp/skills/openspec-explore/SKILL.md` + +#### Scenario: Skill body uses hyphen-based references +- **WHEN** OpenSpec writes a skill file for Oh My Pi whose body contains `/opsx:explore` +- **THEN** the reference is transformed to `/opsx-explore` in the output file + +### Requirement: Oh My Pi tool detection +OpenSpec SHALL detect an Oh My Pi installation when the `.omp/` directory exists at the project root, and SHALL present Oh My Pi as a selectable tool in `openspec init` and `openspec update`. + +#### Scenario: Auto-detection when .omp directory exists +- **WHEN** the project root contains a `.omp/` directory +- **THEN** Oh My Pi is listed as a detected tool during `openspec init` and `openspec update` + +#### Scenario: Oh My Pi appears in the tool selection list +- **WHEN** a user runs `openspec init` interactively +- **THEN** Oh My Pi appears as a selectable option in the tool list diff --git a/openspec/changes/feat-add-omp-tool-support/tasks.md b/openspec/changes/feat-add-omp-tool-support/tasks.md new file mode 100644 index 0000000000..ea394c137e --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/tasks.md @@ -0,0 +1,30 @@ +## 1. Adapter + +- [x] 1.1 Create `src/core/command-generation/adapters/oh-my-pi.ts` with `ohMyPiAdapter` (toolId `'oh-my-pi'`, path `.omp/commands/opsx-.md`, description-only frontmatter, `transformToHyphenCommands` on body) +- [x] 1.2 Use `escapeYamlValue` for the `description` frontmatter field (consistent with Pi adapter) +- [x] 1.3 Export `ohMyPiAdapter` from `src/core/command-generation/adapters/index.ts` +- [x] 1.4 Import and register `ohMyPiAdapter` in `src/core/command-generation/registry.ts` +- [x] 1.5 In `formatFile`, inject `**Provided arguments**: $@` on the line after the `**Input**:` heading (skip if `$@` or `$ARGUMENTS` already present) — matching Pi adapter's `injectPiArgs` logic + +## 2. Tool Registration + +- [x] 2.1 Add `{ name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' }` to `AI_TOOLS` in `src/core/config.ts` (alphabetical by name, between Mistral Vibe and OpenCode) + +## 3. Skill Transformer Wiring + +- [x] 3.1 In `src/core/init.ts`, extend the skill transformer conditional to include `tool.value === 'oh-my-pi'` alongside `'opencode'` and `'pi'` (one occurrence, in `generateSkillsAndCommands`) +- [x] 3.2 In `src/core/update.ts`, extend the skill transformer conditional to include `tool.value === 'oh-my-pi'` alongside `'opencode'` and `'pi'` (two occurrences: primary update loop and `upgradeLegacyTools`) + +## 4. Tests + +- [x] 4.1 In `test/core/command-generation/adapters.test.ts`, add unit tests for `ohMyPiAdapter`: verify `toolId`, `getFilePath` output uses `path.join('.omp', 'commands', 'opsx-.md')`, and `formatFile` produces correct description frontmatter and transformed body +- [x] 4.2 Verify all path assertions in the new tests use `path.join()` (not hardcoded slashes) for cross-platform correctness + +## 5. Documentation + +- [x] 5.1 Add Oh My Pi row to the tool directory reference table in `docs/supported-tools.md`: `| Oh My Pi (\`oh-my-pi\`) | \`.omp/skills/openspec-*/SKILL.md\` | \`.omp/commands/opsx-.md\` |` + +## 6. Verification + +- [x] 6.1 Run `pnpm test` and confirm all tests pass, including the new adapter tests +- [x] 6.2 Run `pnpm build` to confirm TypeScript compilation succeeds with the new adapter diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 512a0d4de8..89d0fe5201 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -23,6 +23,7 @@ export { iflowAdapter } from './iflow.js'; export { junieAdapter } from './junie.js'; export { kilocodeAdapter } from './kilocode.js'; export { kiroAdapter } from './kiro.js'; +export { ohMyPiAdapter } from './oh-my-pi.js'; export { opencodeAdapter } from './opencode.js'; export { piAdapter } from './pi.js'; export { qoderAdapter } from './qoder.js'; diff --git a/src/core/command-generation/adapters/oh-my-pi.ts b/src/core/command-generation/adapters/oh-my-pi.ts new file mode 100644 index 0000000000..0bbc7fb1a8 --- /dev/null +++ b/src/core/command-generation/adapters/oh-my-pi.ts @@ -0,0 +1,55 @@ +/** + * Oh My Pi (OMP) Command Adapter + * + * Formats commands for Oh My Pi following its slash command specification. + * OMP loads slash commands from .omp/commands/*.md with YAML frontmatter. + * The filename (minus .md) becomes the slash command name. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { transformToHyphenCommands } from '../../../utils/command-references.js'; +import { escapeYamlValue } from '../yaml.js'; + +const OMP_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; + +function injectOmpArgs(body: string): string { + if (body.includes('$@') || body.includes('$ARGUMENTS')) { + return body; + } + + return body.replace( + OMP_INPUT_HEADING, + (heading) => `${heading}\n**Provided arguments**: $@` + ); +} + +/** + * Oh My Pi adapter for command generation. + * File path: .omp/commands/opsx-.md + * Frontmatter: description + * + * OMP uses the filename (minus .md) as the slash command name, so + * opsx-propose.md → /opsx-propose. Command references in the body + * are transformed from /opsx: to /opsx- for consistency, and + * $@ is injected after **Input**: headings so user-supplied arguments + * (e.g. /opsx-propose my-feature) are visible to the agent. + */ +export const ohMyPiAdapter: ToolCommandAdapter = { + toolId: 'oh-my-pi', + + getFilePath(commandId: string): string { + return path.join('.omp', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + const transformedBody = transformToHyphenCommands(content.body); + + return `--- +description: ${escapeYamlValue(content.description)} +--- + +${injectOmpArgs(transformedBody)} +`; + }, +}; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index bd41245290..c2773ac410 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -25,6 +25,7 @@ import { iflowAdapter } from './adapters/iflow.js'; import { junieAdapter } from './adapters/junie.js'; import { kilocodeAdapter } from './adapters/kilocode.js'; import { kiroAdapter } from './adapters/kiro.js'; +import { ohMyPiAdapter } from './adapters/oh-my-pi.js'; import { opencodeAdapter } from './adapters/opencode.js'; import { piAdapter } from './adapters/pi.js'; import { qoderAdapter } from './adapters/qoder.js'; @@ -61,6 +62,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(junieAdapter); CommandAdapterRegistry.register(kilocodeAdapter); CommandAdapterRegistry.register(kiroAdapter); + CommandAdapterRegistry.register(ohMyPiAdapter); CommandAdapterRegistry.register(opencodeAdapter); CommandAdapterRegistry.register(piAdapter); CommandAdapterRegistry.register(qoderAdapter); diff --git a/src/core/config.ts b/src/core/config.ts index 3be428b26d..55062273d0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -42,6 +42,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' }, { name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' }, { name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' }, + { name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' }, { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' }, diff --git a/src/core/init.ts b/src/core/init.ts index fba6d80733..b6ab31ab77 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -567,8 +567,8 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - // Use hyphen-based command references for tools where filename = command name - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi) + const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b1..eab233c7d5 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -196,8 +196,8 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi) + const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -690,8 +690,8 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi) + const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 50d7580702..13a3fd7cd1 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -157,11 +157,26 @@ describe('available-tools', () => { const tools = getAvailableTools(testDir); const toolValues = tools.map((t) => t.value); expect(toolValues).toContain('vibe'); - + const vibeTool = tools.find((t) => t.value === 'vibe'); expect(vibeTool).toBeDefined(); expect(vibeTool?.name).toBe('Mistral Vibe'); expect(vibeTool?.skillsDir).toBe('.vibe'); }); + + it('should detect Oh My Pi when .omp directory exists', async () => { + // Oh My Pi uses skillsDir: '.omp' without detectionPaths + // This test ensures path semantics do not drift for Oh My Pi skill detection + await fs.mkdir(path.join(testDir, '.omp'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('oh-my-pi'); + + const ohMyPiTool = tools.find((t) => t.value === 'oh-my-pi'); + expect(ohMyPiTool).toBeDefined(); + expect(ohMyPiTool?.name).toBe('Oh My Pi'); + expect(ohMyPiTool?.skillsDir).toBe('.omp'); + }); }); }); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index ae3a10a776..6255a4fc7b 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -18,6 +18,7 @@ import { geminiAdapter } from '../../../src/core/command-generation/adapters/gem import { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js'; import { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js'; import { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js'; +import { ohMyPiAdapter } from '../../../src/core/command-generation/adapters/oh-my-pi.js'; import { opencodeAdapter } from '../../../src/core/command-generation/adapters/opencode.js'; import { piAdapter } from '../../../src/core/command-generation/adapters/pi.js'; import { qoderAdapter } from '../../../src/core/command-generation/adapters/qoder.js'; @@ -656,6 +657,96 @@ describe('command-generation/adapters', () => { }); }); + describe('ohMyPiAdapter', () => { + it('should have correct toolId', () => { + expect(ohMyPiAdapter.toolId).toBe('oh-my-pi'); + }); + + it('should generate correct file path', () => { + const filePath = ohMyPiAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.omp', 'commands', 'opsx-explore.md')); + }); + + it('should generate correct file paths for different commands', () => { + expect(ohMyPiAdapter.getFilePath('new')).toBe(path.join('.omp', 'commands', 'opsx-new.md')); + expect(ohMyPiAdapter.getFilePath('bulk-archive')).toBe(path.join('.omp', 'commands', 'opsx-bulk-archive.md')); + }); + + it('should format file with description frontmatter', () => { + const output = ohMyPiAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + + it('should transform command references from colon to hyphen format', () => { + const contentWithRefs: CommandContent = { + ...sampleContent, + body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', + }; + const output = ohMyPiAdapter.formatFile(contentWithRefs); + expect(output).toContain('/opsx-apply'); + expect(output).toContain('/opsx-archive'); + expect(output).not.toContain('/opsx:apply'); + }); + + it('should escape YAML special characters in description', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + description: 'Fix: regression in "auth" feature', + }; + const output = ohMyPiAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"'); + }); + + it('should escape newlines in description', () => { + const contentWithNewline: CommandContent = { + ...sampleContent, + description: 'Line 1\nLine 2', + }; + const output = ohMyPiAdapter.formatFile(contentWithNewline); + expect(output).toContain('description: "Line 1\\nLine 2"'); + }); + + it('should inject $@ after **Input**: heading when not already present', () => { + const contentWithInput: CommandContent = { + ...sampleContent, + body: '**Input**: The argument is the change name.\n\nDo the work.', + }; + const output = ohMyPiAdapter.formatFile(contentWithInput); + expect(output).toContain('**Input**: The argument is the change name.\n**Provided arguments**: $@'); + }); + + it('should inject $@ independently of hyphen transform', () => { + const contentWithInput: CommandContent = { + ...sampleContent, + body: '**Input**: The argument is the change name.\n\nRun /opsx:apply.', + }; + const output = ohMyPiAdapter.formatFile(contentWithInput); + expect(output).toContain('**Provided arguments**: $@'); + expect(output).toContain('/opsx-apply'); + }); + + it('should not inject $@ when $@ is already present in the body', () => { + const contentWithArgs: CommandContent = { + ...sampleContent, + body: '**Input**: Accepts arguments.\n\nUser said: $@', + }; + const output = ohMyPiAdapter.formatFile(contentWithArgs); + expect(output.match(/\$@/g)?.length).toBe(1); + }); + + it('should not inject $@ when $ARGUMENTS is already present in the body', () => { + const contentWithArguments: CommandContent = { + ...sampleContent, + body: '**Input**: Accepts arguments.\n\nUser said: $ARGUMENTS', + }; + const output = ohMyPiAdapter.formatFile(contentWithArguments); + expect(output).not.toContain('$@'); + }); + }); + describe('roocodeAdapter', () => { it('should have correct toolId', () => { expect(roocodeAdapter.toolId).toBe('roocode'); @@ -770,7 +861,7 @@ describe('command-generation/adapters', () => { amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter, codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, - iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter, + iflowAdapter, kilocodeAdapter, ohMyPiAdapter, opencodeAdapter, piAdapter, qoderAdapter, qwenAdapter, roocodeAdapter, traeAdapter ]; for (const adapter of adapters) { From 871dece1beb38b70a94b0999dfdc278764fe2856 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:15:18 +1000 Subject: [PATCH 14/19] chore: remove scheduled docs workflow (#1324) --- .github/workflows/deploy-docs.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index bfd347e538..02ddbfa86a 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -2,14 +2,14 @@ name: Docs site # The documentation site (website/) mirrors docs/*.md via scripts/sync-docs.mjs, # which runs as the first step of `pnpm run build`. This workflow rebuilds that -# mirror and deploys the static export to Cloudflare Pages: -# - on every push to main that touches docs/ or website/ (deploy immediately), -# - on a daily schedule (re-mirror the latest docs even if nothing pushed), +# mirror: +# - on every push to main that touches docs/ or website/, # - manually via the Actions tab, # - and as a build-only check on pull requests. # -# Deploys require two repository secrets: CLOUDFLARE_API_TOKEN and -# CLOUDFLARE_ACCOUNT_ID. Set the site's public URL via the DOCS_SITE_URL +# The Cloudflare Pages deploy step is temporarily disabled until setup is ready. +# When re-enabled, deploys require two repository secrets: CLOUDFLARE_API_TOKEN +# and CLOUDFLARE_ACCOUNT_ID. Set the site's public URL via the DOCS_SITE_URL # repository variable (used for OG/sitemap absolute URLs). on: @@ -24,18 +24,18 @@ on: - 'docs/**' - 'website/**' - '.github/workflows/deploy-docs.yml' - schedule: - # Daily at 06:00 UTC — picks up any docs changes merged since the last run. - - cron: '0 6 * * *' workflow_dispatch: -# Never run two deploys at once; let an in-flight deploy finish. +# Never run two docs site jobs at once; let an in-flight job finish. concurrency: group: deploy-docs cancel-in-progress: false jobs: build-and-deploy: + # Keep enabled forks from spending CI on their own copy of this workflow. + # PRs from forks into Fission-AI/OpenSpec still run in the base repository. + if: ${{ github.repository == 'Fission-AI/OpenSpec' }} runs-on: ubuntu-latest permissions: contents: read From 296ecbc20ab3d5617c980ec253adf539e12eb411 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:37:27 +1000 Subject: [PATCH 15/19] Fix Windows CI flake hardening (#1325) * fix windows ci test flake hardening * restore required test check status --- .github/workflows/ci.yml | 66 +++++------- test/cli-e2e/capstone-journeys.test.ts | 9 +- test/cli-e2e/store-lifecycle.test.ts | 10 +- test/commands/context.test.ts | 7 +- test/commands/doctor.test.ts | 3 +- test/commands/legacy-groups-removed.test.ts | 7 +- test/commands/store-git.test.ts | 3 +- test/commands/store-remote.test.ts | 6 +- test/commands/store-root-selection.test.ts | 3 +- test/commands/workset.test.ts | 11 +- test/helpers/run-cli.ts | 109 +++++++++++++++++--- test/helpers/temp-cleanup.ts | 14 +++ vitest.setup.ts | 9 +- 13 files changed, 173 insertions(+), 84 deletions(-) create mode 100644 test/helpers/temp-cleanup.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3d1f6235c..f983fc2749 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,48 +40,11 @@ jobs: - 'scripts/update-flake.sh' - '.github/workflows/ci.yml' - test_pr: - name: Test - runs-on: ubuntu-latest - timeout-minutes: 10 - if: github.event_name == 'pull_request' || github.event_name == 'merge_group' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.19.0' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build project - run: pnpm run build - - - name: Run tests - run: pnpm test - - - name: Upload test coverage - uses: actions/upload-artifact@v4 - with: - name: coverage-report-pr - path: coverage/ - retention-days: 7 - test_matrix: name: Test (${{ matrix.label }}) runs-on: ${{ matrix.os }} timeout-minutes: 15 - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' strategy: fail-fast: false matrix: @@ -89,12 +52,15 @@ jobs: - os: ubuntu-latest shell: bash label: linux-bash + vitest_workers: 4 - os: macos-latest shell: bash label: macos-bash + vitest_workers: 4 - os: windows-latest shell: pwsh label: windows-pwsh + vitest_workers: 2 defaults: run: @@ -126,16 +92,32 @@ jobs: run: pnpm run build - name: Run tests + env: + VITEST_MAX_WORKERS: ${{ matrix.vitest_workers }} run: pnpm test - name: Upload test coverage if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v4 with: - name: coverage-report-main + name: coverage-report-${{ github.event_name }} path: coverage/ retention-days: 7 + test_pr_required: + name: Test + runs-on: ubuntu-latest + needs: [test_matrix] + if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') + steps: + - name: Verify matrix tests passed + run: | + if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then + echo "Matrix test job failed" + exit 1 + fi + echo "All matrix tests passed!" + lint: name: Lint & Type Check runs-on: ubuntu-latest @@ -288,13 +270,13 @@ jobs: required-checks-pr: name: All checks passed runs-on: ubuntu-latest - needs: [test_pr, lint, nix-flake-validate] + needs: [test_matrix, lint, nix-flake-validate] if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') steps: - name: Verify all checks passed run: | - if [[ "${{ needs.test_pr.result }}" != "success" ]]; then - echo "Test job failed" + if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then + echo "Matrix test job failed" exit 1 fi if [[ "${{ needs.lint.result }}" != "success" ]]; then diff --git a/test/cli-e2e/capstone-journeys.test.ts b/test/cli-e2e/capstone-journeys.test.ts index 5b411cbe2b..adc4271a73 100644 --- a/test/cli-e2e/capstone-journeys.test.ts +++ b/test/cli-e2e/capstone-journeys.test.ts @@ -6,6 +6,9 @@ import * as path from 'node:path'; import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; import { runCLI } from '../helpers/run-cli.js'; import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const JOURNEY_TIMEOUT_MS = 30_000; /** * Capstone persona journeys (6.1). Journey 1 (fresh team) lives in @@ -31,7 +34,7 @@ describe('capstone persona journeys (6.1)', () => { }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); it('journey 2 — layered flow: app-repo agent discovers, cites, designs locally', async () => { @@ -97,7 +100,7 @@ describe('capstone persona journeys (6.1)', () => { // The store stayed read-only context throughout. const storeChanges = fs.readdirSync(path.join(storeRoot, 'openspec', 'changes')); expect(storeChanges.filter((name) => name !== 'archive' && name !== '.gitkeep')).toEqual([]); - }); + }, JOURNEY_TIMEOUT_MS); it('journey 3 — externalized planning: pointer repo runs the lifecycle without --store', async () => { const storeRoot = path.join(tempDir, 'team-planning'); @@ -174,5 +177,5 @@ describe('capstone persona journeys (6.1)', () => { // The code repo never grew planning state. expect(fs.readdirSync(path.join(codeRepo, 'openspec'))).toEqual(['config.yaml']); - }); + }, JOURNEY_TIMEOUT_MS); }); diff --git a/test/cli-e2e/store-lifecycle.test.ts b/test/cli-e2e/store-lifecycle.test.ts index 5fc6735432..4f0acd99c4 100644 --- a/test/cli-e2e/store-lifecycle.test.ts +++ b/test/cli-e2e/store-lifecycle.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import { tmpdir } from 'os'; import { promisify } from 'util'; import { runCLI } from '../helpers/run-cli.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; const execFileAsync = promisify(execFile); @@ -18,6 +19,7 @@ const execFileAsync = promisify(execFile); */ const STORE_ID = 'team-context'; +const JOURNEY_TIMEOUT_MS = 60_000; let base: string; let storeRoot: string; @@ -189,7 +191,7 @@ beforeAll(async () => { }, 120_000); afterAll(async () => { - await fs.rm(base, { recursive: true, force: true }); + cleanupTempPath(base); }); describe('standalone store lifecycle journey', () => { @@ -337,7 +339,7 @@ describe('standalone store lifecycle journey', () => { path.join(storeRoot, 'openspec', 'changes', 'archive') ); expect(archiveEntries.some((entry) => entry.endsWith(`-${changeId}`))).toBe(true); - }); + }, JOURNEY_TIMEOUT_MS); it('machine A: the project repo is byte-identical after the lifecycle', async () => { const after = await snapshotDirectory(projectDir); @@ -391,7 +393,7 @@ describe('standalone store lifecycle journey', () => { ); expect(shownSpec.exitCode).toBe(0); expect(shownSpec.stdout).toContain('billing SHALL work'); - }); + }, JOURNEY_TIMEOUT_MS); it('machine B: completes its own change through archive in the clone', async () => { const changeId = 'add-invoicing'; @@ -450,7 +452,7 @@ describe('standalone store lifecycle journey', () => { expect(failedApply.exitCode).not.toBe(0); expect(failedApply.stderr).toContain(`Using OpenSpec root: ${STORE_ID}`); expect(failedApply.stderr).toContain(`openspec new change --store ${STORE_ID}`); - }); + }, JOURNEY_TIMEOUT_MS); it('end state is just normal OpenSpec files in both checkouts', async () => { for (const root of [storeRoot, cloneRoot]) { diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 709a366a1c..14471afadc 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -7,6 +7,9 @@ import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const CONTEXT_MATRIX_TIMEOUT_MS = 30_000; describe('openspec context (4.1)', () => { let tempDir: string; @@ -41,7 +44,7 @@ describe('openspec context (4.1)', () => { }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function parseJson(result: RunCLIResult): any { @@ -193,7 +196,7 @@ describe('openspec context (4.1)', () => { ); expect(jsonBadDir.exitCode).toBe(1); expect(JSON.parse(jsonBadDir.stdout).status[0].code).toBe('context_output_dir_missing'); - }); + }, CONTEXT_MATRIX_TIMEOUT_MS); it('is read-only except the requested file and fails with the null shape', async () => { const rootBefore = snapshot(storeRoot); diff --git a/test/commands/doctor.test.ts b/test/commands/doctor.test.ts index a62b6d0242..f677da01e9 100644 --- a/test/commands/doctor.test.ts +++ b/test/commands/doctor.test.ts @@ -7,6 +7,7 @@ import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js'; import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; describe('openspec doctor (3.6)', () => { let tempDir: string; @@ -30,7 +31,7 @@ describe('openspec doctor (3.6)', () => { }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function parseJson(result: RunCLIResult): any { diff --git a/test/commands/legacy-groups-removed.test.ts b/test/commands/legacy-groups-removed.test.ts index f7d527f88f..99c9700abc 100644 --- a/test/commands/legacy-groups-removed.test.ts +++ b/test/commands/legacy-groups-removed.test.ts @@ -6,6 +6,9 @@ import * as path from 'node:path'; import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; import { runCLI } from '../helpers/run-cli.js'; import { createHealthyOpenSpecRoot } from '../helpers/store-git.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const SURVIVING_COMMANDS_TIMEOUT_MS = 30_000; describe('legacy command groups are removed', () => { let tempDir: string; @@ -24,7 +27,7 @@ describe('legacy command groups are removed', () => { }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function snapshotDirectory(root: string): Map { @@ -139,7 +142,7 @@ describe('legacy command groups are removed', () => { expect(snapshotDirectory(path.join(storeRoot, 'initiatives'))).toEqual(initiativeBefore); expect(snapshotDirectory(path.join(projectDir, '.openspec-workspace'))).toEqual(viewBefore); - }); + }, SURVIVING_COMMANDS_TIMEOUT_MS); it('tolerates legacy initiative metadata without re-emitting it', async () => { const projectDir = path.join(tempDir, 'legacy-project'); diff --git a/test/commands/store-git.test.ts b/test/commands/store-git.test.ts index e8bb06e65f..49dbccd7fc 100644 --- a/test/commands/store-git.test.ts +++ b/test/commands/store-git.test.ts @@ -12,6 +12,7 @@ import { } from '../../src/core/index.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; import { createHealthyOpenSpecRoot, isolatedGitEnv } from '../helpers/store-git.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; vi.mock('@inquirer/prompts', () => ({ input: vi.fn(), @@ -82,7 +83,7 @@ describe('store git lifecycle', () => { consoleLogSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); vi.clearAllMocks(); - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function mkdir(relativePath: string): string { diff --git a/test/commands/store-remote.test.ts b/test/commands/store-remote.test.ts index 5043d6f4c1..b51282c5a4 100644 --- a/test/commands/store-remote.test.ts +++ b/test/commands/store-remote.test.ts @@ -12,8 +12,10 @@ import { } from '../../src/core/index.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; import { createHealthyOpenSpecRoot, isolatedGitEnv } from '../helpers/store-git.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; const TEST_NET_URL = 'https://192.0.2.1/acme/team-context.git'; +const GIT_JOURNEY_TIMEOUT_MS = 60_000; describe('store canonical remote (3.3)', () => { let tempDir: string; @@ -33,7 +35,7 @@ describe('store canonical remote (3.3)', () => { }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function git(cwd: string, ...args: string[]): string { @@ -419,7 +421,7 @@ describe('store canonical remote (3.3)', () => { const resolvedEntry = parseJson(resolved).references[0]; expect(resolvedEntry.status).toEqual([]); expect(resolvedEntry.root).toBe(fs.realpathSync.native(expectedCheckout)); - }); + }, GIT_JOURNEY_TIMEOUT_MS); }); describe('doctor and resolution', () => { diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 2079887d48..082e5cdb1c 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -9,6 +9,7 @@ import { } from '../../src/core/index.js'; import { writeStoreMetadataState } from '../../src/core/store/foundation.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; const VALID_DELTA_SPEC = `## ADDED Requirements @@ -69,7 +70,7 @@ describe('store root selection for normal commands', () => { }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function createOpenSpecRoot(rootDir: string): void { diff --git a/test/commands/workset.test.ts b/test/commands/workset.test.ts index 2bc01da49d..e1ad7321e9 100644 --- a/test/commands/workset.test.ts +++ b/test/commands/workset.test.ts @@ -16,6 +16,7 @@ import { import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; import { createFakeTool, envWithFakeTools, readLaunchLog } from '../helpers/fake-tool.js'; import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; describe('openspec workset (7.1)', () => { let tempDir: string; @@ -56,7 +57,7 @@ describe('openspec workset (7.1)', () => { afterEach(() => { delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS; - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function parseJson(result: RunCLIResult): any { @@ -469,7 +470,7 @@ describe('openspec workset (7.1)', () => { it('skips a missing member and falls through to the next primary', async () => { await createPlatform(['--tool', 'claude']); const fakeClaude = createFakeTool(tempDir, 'claude'); - fs.rmSync(memberB, { recursive: true, force: true }); + cleanupTempPath(memberB); const result = await runCLI(['workset', 'open', 'platform'], { cwd: tempDir, @@ -494,7 +495,7 @@ describe('openspec workset (7.1)', () => { // Primary missing: the next surviving member becomes cwd, and // the reassignment is noted in the skip-line style. - fs.rmSync(memberA, { recursive: true, force: true }); + cleanupTempPath(memberA); const second = await runCLI(['workset', 'open', 'platform'], { cwd: tempDir, env: envWithFakeTools(env, [fakeClaude]), @@ -508,7 +509,7 @@ describe('openspec workset (7.1)', () => { ); // No member survives: a typed failure. - fs.rmSync(memberC, { recursive: true, force: true }); + cleanupTempPath(memberC); const third = await runCLI(['workset', 'open', 'platform'], { cwd: tempDir, env: envWithFakeTools(env, [fakeClaude]), @@ -871,7 +872,7 @@ describe('interactive compose cancellation (in-process)', () => { restoreTTY?.(); process.env = originalEnv; process.exitCode = originalExitCode; - fs.rmSync(tempDir, { recursive: true, force: true }); + cleanupTempPath(tempDir); }); function exitPromptError(): Error { diff --git a/test/helpers/run-cli.ts b/test/helpers/run-cli.ts index 69d67df7f2..6dd40304bd 100644 --- a/test/helpers/run-cli.ts +++ b/test/helpers/run-cli.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process'; +import { type ChildProcess, spawn } from 'child_process'; import { existsSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -8,8 +8,10 @@ const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..', '..'); const cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js'); +const DEFAULT_CLI_TIMEOUT_MS = 30_000; let buildPromise: Promise | undefined; +const activeCliChildren = new Set(); interface RunCommandOptions { cwd?: string; @@ -53,6 +55,65 @@ function runCommand(command: string, args: string[], options: RunCommandOptions }); } +function mergeEnv( + ...sources: Array +): NodeJS.ProcessEnv { + const merged: NodeJS.ProcessEnv = {}; + + for (const source of sources) { + if (!source) continue; + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + + if (process.platform === 'win32') { + const existingKey = Object.keys(merged).find( + (candidate) => candidate.toLowerCase() === key.toLowerCase() + ); + if (existingKey && existingKey !== key) { + delete merged[existingKey]; + } + } + + merged[key] = value; + } + } + + return merged; +} + +function terminateProcessTree(child: ChildProcess): void { + if (!child.pid || child.killed) { + return; + } + + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }).on('error', () => { + child.kill('SIGKILL'); + }); + return; + } + + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } +} + +function formatOutputTail(output: string): string { + const lines = output.trimEnd().split(/\r?\n/); + return lines.slice(-20).join('\n'); +} + +export function terminateActiveCliChildren(): void { + for (const child of activeCliChildren) { + terminateProcessTree(child); + } +} + export async function ensureCliBuilt() { if (existsSync(cliEntry)) { return; @@ -79,30 +140,34 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): const invocation = [cliEntry, ...finalArgs].join(' '); return new Promise((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS; const child = spawn(process.execPath, [cliEntry, ...finalArgs], { cwd: options.cwd ?? projectRoot, - env: { - ...process.env, - OPEN_SPEC_INTERACTIVE: '0', - ...options.env, - }, + env: mergeEnv( + process.env, + { + OPENSPEC_TELEMETRY: '0', + OPEN_SPEC_INTERACTIVE: '0', + }, + options.env + ), stdio: ['pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32', windowsHide: true, }); // Prevent child process from keeping the event loop alive child.unref(); + activeCliChildren.add(child); let stdout = ''; let stderr = ''; let timedOut = false; - const timeout = options.timeoutMs - ? setTimeout(() => { - timedOut = true; - child.kill('SIGKILL'); - }, options.timeoutMs) - : undefined; + const timeout = setTimeout(() => { + timedOut = true; + terminateProcessTree(child); + }, timeoutMs); child.stdout?.setEncoding('utf-8'); child.stdout?.on('data', (chunk) => { @@ -115,7 +180,8 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): }); child.on('error', (error) => { - if (timeout) clearTimeout(timeout); + clearTimeout(timeout); + activeCliChildren.delete(child); // Explicitly destroy streams to prevent hanging handles child.stdout?.destroy(); child.stderr?.destroy(); @@ -124,11 +190,26 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): }); child.on('close', (code, signal) => { - if (timeout) clearTimeout(timeout); + clearTimeout(timeout); + activeCliChildren.delete(child); // Explicitly destroy streams to prevent hanging handles child.stdout?.destroy(); child.stderr?.destroy(); child.stdin?.destroy(); + if (timedOut) { + reject( + new Error( + [ + `CLI command timed out after ${timeoutMs}ms: node ${invocation}`, + stderr ? `stderr tail:\n${formatOutputTail(stderr)}` : '', + stdout ? `stdout tail:\n${formatOutputTail(stdout)}` : '', + ] + .filter(Boolean) + .join('\n\n') + ) + ); + return; + } resolve({ exitCode: code, signal, diff --git a/test/helpers/temp-cleanup.ts b/test/helpers/temp-cleanup.ts new file mode 100644 index 0000000000..d1ffd1e58a --- /dev/null +++ b/test/helpers/temp-cleanup.ts @@ -0,0 +1,14 @@ +import * as fs from 'node:fs'; + +export function cleanupTempPath(target: string | undefined): void { + if (!target) { + return; + } + + fs.rmSync(target, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); +} diff --git a/vitest.setup.ts b/vitest.setup.ts index 1eea108ba1..f2f33da354 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,15 +1,10 @@ -import { ensureCliBuilt } from './test/helpers/run-cli.js'; +import { ensureCliBuilt, terminateActiveCliChildren } from './test/helpers/run-cli.js'; // Ensure the CLI bundle exists before tests execute export async function setup() { await ensureCliBuilt(); } -// Global teardown to ensure clean exit export async function teardown() { - // Force exit after a short grace period if the process hasn't exited cleanly. - // This handles cases where child processes or open handles keep the worker alive. - setTimeout(() => { - process.exit(0); - }, 1000).unref(); + terminateActiveCliChildren(); } From 8e9e457c05dc34015138856e2557704e0a07f311 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 8 Jul 2026 09:34:30 -0500 Subject: [PATCH 16/19] ci(release): add beta prerelease workflow (#1327) * ci(release): add beta prerelease workflow Co-Authored-By: Claude Fable 5 * fix: harden beta release workflow --------- Co-authored-by: Claude Fable 5 Co-authored-by: TabishB --- .github/workflows/release-prepare.yml | 128 +++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index e9221b2470..f51f506c13 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -1,8 +1,9 @@ -name: Release (prepare) +name: Release on: push: branches: [main] + workflow_dispatch: # manually cut a beta prerelease from main permissions: contents: write @@ -15,7 +16,7 @@ concurrency: jobs: prepare: - if: github.repository == 'Fission-AI/OpenSpec' + if: github.repository == 'Fission-AI/OpenSpec' && github.event_name == 'push' runs-on: ubuntu-latest steps: # Generate GitHub App token first - used for checkout and changesets @@ -56,3 +57,126 @@ jobs: env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # npm authentication handled via OIDC trusted publishing (no token needed) + + # Manually-dispatched beta prerelease from main: version is the next stable + # release per pending changesets with a -beta.N suffix (e.g. v1.6.0-beta.1), + # published to npm under the `beta` dist-tag and posted as a prerelease-flagged + # GitHub Release. Changesets are left unconsumed, so the stable flow above is + # unaffected. This job lives in this file because npm trusted publishing + # authorizes a single workflow file per package. + # + # Users opt in with: npm install -g @fission-ai/openspec@beta + beta: + if: github.repository == 'Fission-AI/OpenSpec' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - run: pnpm install --frozen-lockfile + + # Beta version = next stable version per pending changesets, plus a + # -beta.N suffix that increments over existing beta tags for that version. + - name: Compute beta version + id: version + env: + GH_TOKEN: ${{ github.token }} + run: | + git fetch --tags --force origin + pnpm exec changeset status --output=changeset-status.json + NEXT=$(node -p "JSON.parse(require('fs').readFileSync('changeset-status.json','utf8')).releases[0]?.newVersion ?? ''") + rm changeset-status.json + if [ -z "$NEXT" ]; then + echo "No pending changesets on main - nothing to cut a beta from." + exit 1 + fi + N=1 + while true; do + VERSION="${NEXT}-beta.${N}" + TAG="v${VERSION}" + TAG_EXISTS=false + NPM_EXISTS=false + RELEASE_EXISTS=false + + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + TAG_EXISTS=true + fi + if npm view "@fission-ai/openspec@${VERSION}" version >/dev/null 2>&1; then + NPM_EXISTS=true + fi + if gh release view "${TAG}" >/dev/null 2>&1; then + RELEASE_EXISTS=true + fi + + if [ "$TAG_EXISTS" = false ] && [ "$NPM_EXISTS" = false ] && [ "$RELEASE_EXISTS" = false ]; then + break + fi + if [ "$RELEASE_EXISTS" = false ]; then + echo "Resuming incomplete beta ${TAG}" + break + fi + + N=$((N + 1)) + done + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Cutting ${TAG}" + + - name: Set package version + env: + VERSION: ${{ steps.version.outputs.version }} + run: npm version "$VERSION" --no-git-tag-version + + # prepublishOnly runs the build. npm authentication handled via OIDC + # trusted publishing (no token needed). + - name: Publish to npm under the beta dist-tag + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if npm view "@fission-ai/openspec@${VERSION}" version >/dev/null 2>&1; then + echo "@fission-ai/openspec@${VERSION} is already on npm; skipping publish." + exit 0 + fi + npm publish --tag beta + + - name: Tag and create GitHub prerelease + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + TAG="v${VERSION}" + HEAD_SHA=$(git rev-parse HEAD) + + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + TAG_SHA=$(git rev-list -n 1 "${TAG}") + if [ "$TAG_SHA" != "$HEAD_SHA" ]; then + echo "${TAG} already exists at ${TAG_SHA}, not current HEAD ${HEAD_SHA}." + exit 1 + fi + else + git tag "${TAG}" + fi + + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "${TAG} already exists on origin; skipping tag push." + else + git push origin "${TAG}" + fi + + if gh release view "${TAG}" >/dev/null 2>&1; then + echo "GitHub Release ${TAG} already exists; skipping release creation." + else + gh release create "${TAG}" \ + --prerelease \ + --generate-notes \ + --title "${TAG}" \ + --notes "Beta prerelease. Install with \`npm install -g @fission-ai/openspec@beta\`." + fi From 93e27a755ce5386c66be7f3274a35f70018002bc Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:52:31 +1000 Subject: [PATCH 17/19] fix empty store registration (#1328) --- docs/agent-contract.md | 4 +- docs/cli.md | 7 +- docs/stores-beta/user-guide.md | 8 ++ src/core/archive.ts | 26 +++--- src/core/list.ts | 31 ++++--- src/core/openspec-root.ts | 71 +++++++++++----- src/core/store/operations.ts | 33 ++++++++ test/commands/store-git.test.ts | 96 +++++++++++++++++++++ test/commands/store-root-selection.test.ts | 62 ++++++++++++++ test/commands/store.test.ts | 98 +++++++++++++++++++++- test/core/archive.test.ts | 4 +- test/core/list.test.ts | 22 +++-- test/core/openspec-root.test.ts | 34 +++++++- 13 files changed, 438 insertions(+), 58 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 9f64d66d36..dae386b9f7 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -97,13 +97,13 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis `no_openspec_root`, `no_root_with_registered_stores`, `no_registered_stores`, `unknown_store`, `store_identity_mismatch`, `unhealthy_store_root`, `store_path_not_supported`, `invalid_store_pointer`, `initiative_option_removed`, `areas_option_removed`; pass-through: `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`. ### OpenSpec-root health (error, no fix) -`openspec_store_root_missing`, `openspec_root_missing`, `openspec_config_missing`, `openspec_specs_missing`, `openspec_changes_missing`, `openspec_archive_missing`, plus `_not_directory` variants of each. +`openspec_store_root_missing`, `openspec_store_root_not_directory`, `openspec_root_missing`, `openspec_root_not_directory`, `openspec_config_missing`, `openspec_config_not_file`, `openspec_specs_not_directory`, `openspec_changes_not_directory`, `openspec_archive_not_directory`. During the stores beta, `openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` may be absent in a healthy root; they are only health errors when present but not directories. ### Store registry/identity/state `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`, `store_registry_busy`, `store_not_found`, `no_store_registry`, `store_registry_changed`, `store_metadata_missing`, `store_metadata_id_mismatch`, `store_metadata_invalid`, `store_id_conflict`, `store_path_conflict`, `store_already_registered` (info). ### Store setup/register/remove -`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`. +`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_root_pointer_declared`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`. ### Store git `store_git_init_failed`, `store_git_identity_missing`, `store_git_commit_failed`, `store_git_no_commits` (warning), `store_clone_fragile_directories` (warning), `store_remote_divergence` (info, doctor). diff --git a/docs/cli.md b/docs/cli.md index 07a5daea63..fb591f2bcc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -215,7 +215,12 @@ openspec store setup team-context --path ~/openspec/team-context --no-init-git - ### `openspec store register` -Register an existing local store folder. +Register an existing local store folder. During the stores beta, a root may be +registered before any changes exist, specs have been applied, or changes have +been archived; in that case `openspec/changes/`, `openspec/specs/`, and +`openspec/changes/archive/` may be absent until normal commands create them. +A config-only repo that declares `store: ` remains a pointer to another +store and is not registered as a store root unless that pointer is removed. ```bash openspec store register [path] [options] diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 78433ef4d0..3711777e15 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -308,6 +308,14 @@ tells you which case you're in. - **No sync, ever — by design.** OpenSpec never clones, pulls, or pushes. A stale checkout shows stale specs until *you* pull; references are indexed live from whatever is on disk. +- **Empty planning folders can be absent.** A new store may not have + `openspec/changes/`, `openspec/specs/`, or `openspec/changes/archive/` in Git + yet. That is accepted during the beta; those folders appear once normal + commands create files for them. +- **Pointer repos stay pointers.** A config-only repo whose + `openspec/config.yaml` declares `store: ` is treated as externalized + planning, not as a store checkout to register. Remove the `store:` line first + if you intentionally want to convert that repo into a local store root. - **Some commands stay where they are.** `view`, `templates`, `schemas`, and the deprecated noun forms (`openspec change show`, ...) act on the current directory only — no `--store`. diff --git a/src/core/archive.ts b/src/core/archive.ts index 3e9bf80025..850d4cd5ba 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -19,6 +19,15 @@ import { type SpecUpdate, } from './specs-apply.js'; +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + async function listActiveChangeNames(changesDir: string): Promise { try { const entries = await fs.readdir(changesDir, { withFileTypes: true }); @@ -26,7 +35,8 @@ async function listActiveChangeNames(changesDir: string): Promise { .filter((entry) => entry.isDirectory() && entry.name !== 'archive') .map((entry) => entry.name) .sort(); - } catch { + } catch (error) { + if (!isMissingPathError(error)) throw error; return []; } } @@ -192,13 +202,6 @@ export class ArchiveCommand { const archiveDir = root.archiveDir; const mainSpecsDir = root.specsDir; - // Check if changes directory exists - try { - await fs.access(changesDir); - } catch { - throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); - } - // Get change name interactively if not provided if (!changeName) { if (json) { @@ -523,12 +526,7 @@ export class ArchiveCommand { private async selectChange(changesDir: string): Promise { const { select } = await import('@inquirer/prompts'); - // Get all directories in changes (excluding archive) - const entries = await fs.readdir(changesDir, { withFileTypes: true }); - const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') - .map(entry => entry.name) - .sort(); + const changeDirs = await listActiveChangeNames(changesDir); if (changeDirs.length === 0) { console.log('No active changes found.'); diff --git a/src/core/list.ts b/src/core/list.ts index 8e4d0a9ed7..0c19048e21 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; -import { readFileSync } from 'fs'; +import { readFileSync, type Dirent } from 'fs'; import { join } from 'path'; import { MarkdownParser } from './parsers/markdown-parser.js'; import type { RootOutput } from './root-selection.js'; @@ -19,6 +19,24 @@ interface ListOptions { root?: RootOutput; } +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +async function readChangeDirectoryEntries(changesDir: string): Promise { + try { + return await fs.readdir(changesDir, { withFileTypes: true }); + } catch (error) { + if (isMissingPathError(error)) return []; + throw error; + } +} + /** * Get the most recent modification time of any file in a directory (recursive). * Falls back to the directory's own mtime if no files are found. @@ -83,15 +101,8 @@ export class ListCommand { if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); - // Check if changes directory exists - try { - await fs.access(changesDir); - } catch { - throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); - } - // Get all directories in changes (excluding archive) - const entries = await fs.readdir(changesDir, { withFileTypes: true }); + const entries = await readChangeDirectoryEntries(changesDir); const changeDirs = entries .filter(entry => entry.isDirectory() && entry.name !== 'archive') .map(entry => entry.name); @@ -207,4 +218,4 @@ export class ListCommand { console.log(`${padding}${padded} requirements ${spec.requirementCount}`); } } -} \ No newline at end of file +} diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index c64c2912f4..d65882ee21 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -17,8 +17,8 @@ export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep'; -// Git cannot track empty directories, so clones of a fresh store would lose -// these and fail root-health checks. Anchored at setup time. +// Git cannot track empty directories, so setup anchors otherwise-empty +// conventional store directories for teammates who clone the repo later. export const ANCHORED_OPENSPEC_DIRS = [OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR] as const; type PathKind = 'missing' | 'directory' | 'file' | 'other'; @@ -99,6 +99,28 @@ function missingDirectoryDiagnostic( return makeStoreDiagnostic('error', code, message, { target }); } +type OptionalPlanningDirectoryKey = 'specs' | 'changes' | 'archive'; + +async function inspectOptionalPlanningDirectory( + inspection: OpenSpecRootInspection, + storeRoot: string, + key: OptionalPlanningDirectoryKey, + relativePath: string, + notDirectoryCode: string, + target: string +): Promise { + const kind = await pathKind(path.join(storeRoot, relativePath)); + inspection[key] = { present: kind === 'directory' }; + if (kind === 'directory' || kind === 'missing') return kind; + + inspection.diagnostics.push(missingDirectoryDiagnostic( + notDirectoryCode, + `${relativePath}/ exists but is not a directory.`, + target + )); + return kind; +} + export async function inspectOpenSpecRoot(storeRoot: string): Promise { const rootKind = await pathKind(storeRoot); const inspection = unresolvedInspection(); @@ -166,28 +188,39 @@ export async function inspectOpenSpecRoot(storeRoot: string): Promise { expect(fs.existsSync(path.join(cloneRoot, 'workspace.yaml'))).toBe(false); }); + it('registers a clone before any changes exist', async () => { + const storeRoot = mkdir('empty-team-context'); + const cloneRoot = path.join(tempDir, 'empty-team-clone'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + const gitExecEnv = { ...process.env, ...gitEnv }; + const teammateEnv = { + ...gitEnv, + XDG_DATA_HOME: path.join(tempDir, 'empty-teammate-data'), + XDG_CONFIG_HOME: path.join(tempDir, 'empty-teammate-config'), + }; + fs.mkdirSync(path.join(storeRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'empty-team-context' }); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'initialize empty store'], { + cwd: storeRoot, + env: gitExecEnv, + stdio: 'ignore', + }); + + execFileSync('git', ['clone', storeRoot, cloneRoot], { + env: gitExecEnv, + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'specs'))).toBe(false); + + const registered = await runCLI(['store', 'register', cloneRoot, '--json'], { + cwd: tempDir, + env: teammateEnv, + }); + expect(registered.exitCode).toBe(0); + expect(parseJson(registered).store.id).toBe('empty-team-context'); + }); + + it('registers a clone with active changes before specs or archive exist', async () => { + const storeRoot = mkdir('planned-context'); + const cloneRoot = path.join(tempDir, 'planned-clone'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + const gitExecEnv = { ...process.env, ...gitEnv }; + const teammateEnv = { + ...gitEnv, + XDG_DATA_HOME: path.join(tempDir, 'teammate-data'), + XDG_CONFIG_HOME: path.join(tempDir, 'teammate-config'), + }; + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes', 'add-widget'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'proposal.md'), + '# Proposal\n' + ); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'tasks.md'), + '# Tasks\n' + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'planned-context' }); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'draft changes'], { + cwd: storeRoot, + env: gitExecEnv, + stdio: 'ignore', + }); + + const committedFiles = execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'], { + cwd: storeRoot, + }) + .toString() + .trim() + .split('\n') + .sort(); + expect(committedFiles).toEqual([ + '.openspec-store/store.yaml', + 'openspec/changes/add-widget/proposal.md', + 'openspec/changes/add-widget/tasks.md', + 'openspec/config.yaml', + ]); + expect(committedFiles).not.toContain('openspec/specs/.gitkeep'); + expect(committedFiles).not.toContain('openspec/changes/archive/.gitkeep'); + + execFileSync('git', ['clone', storeRoot, cloneRoot], { + env: gitExecEnv, + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'changes', 'archive'))).toBe(false); + + const registered = await runCLI(['store', 'register', cloneRoot, '--json'], { + cwd: tempDir, + env: teammateEnv, + }); + expect(registered.exitCode).toBe(0); + expect(parseJson(registered).store.id).toBe('planned-context'); + }); + it('keeps pre-staged user files out of the setup commit', async () => { const storeRoot = mkdir('staged-context'); const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 082e5cdb1c..e50147e0d9 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -181,6 +181,37 @@ describe('store root selection for normal commands', () => { expect(json.root.store_id).toBe('team-context'); }); + it('lists an empty team store before any changes exist', async () => { + const blankStoreRoot = path.join(tempDir, 'stores', 'blank-context'); + fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(blankStoreRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + await writeStoreMetadataState(blankStoreRoot, { + version: 1, + id: 'blank-context', + }); + const registered = await runCLI( + ['store', 'register', blankStoreRoot, '--json'], + { cwd: appRepo, env } + ); + expect(registered.exitCode).toBe(0); + + const result = await runCLI(['list', '--json', '--store', 'blank-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.changes).toEqual([]); + expect(json.root).toEqual({ + path: fs.realpathSync.native(blankStoreRoot), + source: 'store', + store_id: 'blank-context', + }); + }); + it('reads, validates, shows, and reports status in the selected store', async () => { createChange(storeRoot, 'store-change'); @@ -506,6 +537,37 @@ describe('store root selection for normal commands', () => { expect(json.status[0].code).toBe('archive_change_name_required'); }); + it('reports no active changes for a selected empty store without init guidance', async () => { + const blankStoreRoot = path.join(tempDir, 'stores', 'archive-blank-context'); + fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(blankStoreRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + await writeStoreMetadataState(blankStoreRoot, { + version: 1, + id: 'archive-blank-context', + }); + const registered = await runCLI( + ['store', 'register', blankStoreRoot, '--json'], + { cwd: appRepo, env } + ); + expect(registered.exitCode).toBe(0); + + const result = await runCLI( + ['archive', 'missing-change', '--store', 'archive-blank-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0]).toEqual(expect.objectContaining({ + code: 'archive_change_not_found', + message: "Change 'missing-change' not found. No active changes exist in this root.", + })); + }); + it('reports validation failures as diagnostics without stdout prose', async () => { createChange(storeRoot, 'bad-change', { deltaSpec: INVALID_DELTA_SPEC }); diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index 171ac0a6c6..41a17a6377 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -370,6 +370,50 @@ describe('store command', () => { expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); }); + it('refuses to convert a config-only store pointer repo into a store', async () => { + const pointerRoot = mkdir('app-repo'); + fs.mkdirSync(path.join(pointerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRoot, 'openspec', 'config.yaml'), 'store: team-context\n'); + + const setup = await runCLI( + ['store', 'setup', 'app-context', '--path', pointerRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + const register = await runCLI( + ['store', 'register', pointerRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(setup.exitCode).toBe(1); + expect(parseJson(setup).status[0]).toEqual(expect.objectContaining({ + code: 'store_root_pointer_declared', + })); + expect(register.exitCode).toBe(1); + expect(parseJson(register).status[0]).toEqual(expect.objectContaining({ + code: 'store_root_pointer_declared', + })); + expect(fs.existsSync(path.join(pointerRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(pointerRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(getStoreMetadataPath(pointerRoot))).toBe(false); + }); + + it('refuses malformed config-only store pointer repos before registering', async () => { + const pointerRoot = mkdir('bad-app-repo'); + fs.mkdirSync(path.join(pointerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRoot, 'openspec', 'config.yaml'), 'store: [team-context]\n'); + + const result = await runCLI( + ['store', 'register', pointerRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual(expect.objectContaining({ + code: 'invalid_store_pointer', + })); + expect(fs.existsSync(getStoreMetadataPath(pointerRoot))).toBe(false); + }); + it('rejects explicit setup paths inside an existing Git repo in non-interactive mode', async () => { const repoRoot = mkdir('repo'); execFileSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }); @@ -515,6 +559,55 @@ describe('store command', () => { expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe('keep\n'); }); + it('registers a team store before any changes exist', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.store.id).toBe('team-context'); + expect(payload.created_files).toEqual([]); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + + it('registers a store with active changes before specs or archive exist', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes', 'add-widget'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'proposal.md'), + '# Proposal\n' + ); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.store.id).toBe('team-context'); + expect(payload.created_files).toEqual([]); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + it('requires confirmation before registering a healthy root without identity', async () => { const storeRoot = mkdir('team-context'); createHealthyOpenSpecRoot(storeRoot); @@ -979,6 +1072,7 @@ describe('store command', () => { const storeRoot = mkdir('team-context'); fs.mkdirSync(path.join(storeRoot, 'openspec', 'specs'), { recursive: true }); fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'not a dir\n'); fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); await writeStoreRegistryState( @@ -1006,10 +1100,10 @@ describe('store command', () => { expect(store.openspec_root.archive.present).toBe(false); expect(store.openspec_root.status[0]).toEqual( expect.objectContaining({ - code: 'openspec_archive_missing', + code: 'openspec_archive_not_directory', }) ); - expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'utf-8')).toBe('not a dir\n'); }); it('register errors are terminal: one-checkout rule, no circular fix texts', async () => { diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 6724b8d530..0d039f2026 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -1078,13 +1078,13 @@ The system SHALL do the thing differently. }); describe('error handling', () => { - it('should throw error when openspec directory does not exist', async () => { + it('should report no active changes when openspec directory does not exist', async () => { // Remove openspec directory await fs.rm(path.join(tempDir, 'openspec'), { recursive: true }); await expect( archiveCommand.execute('any-change', { yes: true }) - ).rejects.toThrow("No OpenSpec changes directory found. Run 'openspec init' first."); + ).rejects.toThrow("Change 'any-change' not found. No active changes exist in this root."); }); }); diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 5a678919af..096e46a1d8 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -31,12 +31,12 @@ describe('ListCommand', () => { }); describe('execute', () => { - it('should handle missing openspec/changes directory', async () => { + it('should treat a missing openspec/changes directory as no active changes', async () => { const listCommand = new ListCommand(); - - await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow( - "No OpenSpec changes directory found. Run 'openspec init' first." - ); + + await listCommand.execute(tempDir, 'changes'); + + expect(logOutput).toEqual(['No active changes found.']); }); it('should handle empty changes directory', async () => { @@ -49,6 +49,16 @@ describe('ListCommand', () => { expect(logOutput).toEqual(['No active changes found.']); }); + it('should not report a malformed openspec/changes path as empty', async () => { + await fs.mkdir(path.join(tempDir, 'openspec'), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'openspec', 'changes'), 'not a directory\n'); + + const listCommand = new ListCommand(); + + await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow(); + expect(logOutput).toEqual([]); + }); + it('should exclude archive directory', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true }); @@ -162,4 +172,4 @@ Regular text that should be ignored expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts index b0d27da485..d2059f31e0 100644 --- a/test/core/openspec-root.test.ts +++ b/test/core/openspec-root.test.ts @@ -64,12 +64,42 @@ describe('OpenSpec root helper', () => { expect(inspection.healthy).toBe(false); expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ 'openspec_config_missing', - 'openspec_specs_missing', - 'openspec_archive_missing', ]); expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(false); }); + it('accepts roots before changes, applied specs, or archives exist', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection).toEqual(expect.objectContaining({ + healthy: true, + specs: { present: false }, + changes: { present: false }, + archive: { present: false }, + diagnostics: [], + })); + }); + + it('reports malformed optional planning paths without throwing', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + fs.writeFileSync(path.join(root, 'openspec', 'changes'), 'not a directory\n'); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection.healthy).toBe(false); + expect(inspection.changes).toEqual({ present: false }); + expect(inspection.archive).toEqual({ present: false }); + expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'openspec_changes_not_directory', + ]); + }); + it('ensures the default root shape and records created paths', async () => { const root = path.join(tempDir, 'store'); From 3903e62afb7b495d2d6ad4db54199bfc67e6e1ef Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:44:00 +1000 Subject: [PATCH 18/19] chore: add changeset for stores beta and config JSON parsing (#1267) * Add changeset for stores beta and config JSON parsing * Remove leaked tool-wrapper lines from changeset --- .changeset/stores-beta-config-parse.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/stores-beta-config-parse.md diff --git a/.changeset/stores-beta-config-parse.md b/.changeset/stores-beta-config-parse.md new file mode 100644 index 0000000000..94d53cd7e8 --- /dev/null +++ b/.changeset/stores-beta-config-parse.md @@ -0,0 +1,11 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features + +- **Stores (very early beta)** — Introduces stores as a simpler way to organize specs and changes, replacing the workspace and initiative model. This feature is in very early beta — expect rough edges and breaking changes in upcoming releases. + +### Bug Fixes + +- **Config parsing** — Configuration values wrapped in JSON containers are now parsed correctly. From 95275be444ba8cee0846f5b86b4054708782d6bb Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 3 Jul 2026 03:00:44 -0500 Subject: [PATCH 19/19] fix(resolution): converge validate, view, and archive onto canonical resolution (#1182, #1202, #1156) (#1280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(openspec): propose resolution/validation parity bug bundle (#1182, #1202, #1156) Planning artifacts only (proposal/design/spec deltas/tasks) for a focused bug-fix bundle. Three read/validate paths silently diverge from the canonical logic a sibling command already gets right: - #1182 validate ignores workspace planning homes that status/instructions resolve - #1202 view counts only changes//tasks.md, ignoring the schema tasks glob - #1156 the SHALL/MUST body-keyword hint fires for deltas but not main specs Fix converges each divergent path onto the canonical one; parity is asserted by test. No new surface, no behavior change to the already-correct paths. Validates --strict. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): bulletproof the parity bundle after adversarial source review Hardened all three bugs after tracing each path to source with parallel verification agents. Material corrections: - #1182: reframed from "workspace planning home resolution" (planning homes are repo-only; the feature is now 'stores', and validate already accepts --store) to the real, reproducible-at-HEAD mechanism: validate's proposal.md membership gate (getActiveChangeIds) vs status/instructions' directory-existence rule (validateChangeExists). Pulled nested specs// delta discovery and bulk --all into scope; noted show.ts sibling. - #1202: widened from view-only to the shared helper's real blast radius — also the archive incomplete-task gate (silently archives unfinished glob-tasks changes: data safety) and a 2nd hardcoded copy in change.ts. Pinned apply.tracks as the source, change-dir scope containment, and the no-schema fallback. Added cli-archive delta for the gate. - #1156: the main-spec parser discards the requirement header before Zod runs, so the hint can't be "lifted" — fix needs header recovery (reuse requirement-blocks) + Zod de-dup, and the main-spec message can't be byte-identical to the delta's (no ADDED prefix). Pinned the actionable sentence + single-emission + regression scenarios across all main-spec surfaces. 4 deltas (cli-validate x2, cli-view, cli-archive). Validates --strict. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(openspec): deep-harden the parity bundle with empirical reproduction Round 2 of bulletproofing: 3 parallel agents reproduced every bug against the built (pre-fix) CLI and traced fix sites. This pass corrected two substantive errors in my own prior spec and closed several gaps. #1202 (two corrections to the prior draft): - apply.tracks is a FILENAME that selects the tracked artifact, NOT a glob; the glob is that artifact's `generates`. status resolves via resolveArtifactOutputs(changeDir, artifact.generates). Fixed all wording. - "view/archive counts equal status" is FALSE: status checks file EXISTENCE, not checkboxes (proven: status calls a 3/5 change isComplete:true). Deleted the two count-parity scenarios; reframed as resolution-mechanism parity (same files). - Added schema-resolution-failure fallback (resolveSchema throws; helper must catch or view/list/archive crash). Added projectRoot param + 6-site wiring. - Empirically PROVEN data-safety bug: archive moved a 3/5 unfinished change into changes/archive/. #1182: - Found a THIRD getActiveChangeIds site (interactive selector, validate.ts:97). - Proven: --all with a lone proposal-less change exits 0 silently. Added exit-code scenarios. Trimmed over-scope: getSpecIds spec-side is NOT a bug; no store-specific scenario needed; noun-form scoped out. #1156: - Refine-relaxation regression resolved: deltas don't use the Zod refine (validate imperatively), so REMOVE it (not relax) once applySpecRules owns both header-only and no-keyword cases. Added RENAMED (out-of-scope), lowercase, and the new no-body-line-valid-today scenarios; pinned exact message + prefix. Still 4 deltas; validates --strict; empirical evidence section added to design. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: converge validate/view/archive onto canonical resolution (#1182, #1202, #1156) Implements the resolution/validation parity bug bundle planned in openspec/changes/fix-validate-view-resolution-parity. Each fix points a divergent read/validate path at the canonical implementation a sibling command already gets right, with parity tests guarding against re-forking. #1182 — validate resolves changes like status. validate now resolves a change by directory existence (shared getAvailableChanges) instead of requiring proposal.md, at all three sites (targeted, bulk, interactive selector). A scaffolded/still-authoring change is validated rather than reported Unknown item; a resolved-but-invalid change exits non-zero. show.ts and the deprecated noun-form change validate are scoped out. #1182b — validateChangeDeltaSpecs recurses the nested multi-area layout (specs///spec.md) via a new findDeltaSpecFiles walker, so a resolved multi-area change validates its deltas instead of reporting "No delta sections found". #1202 — getTaskProgressForChange resolves task progress through the tracked-tasks artifact's generates glob (the same resolveArtifactOutputs status uses), aggregating checkboxes across every matched tasks.md scoped to the change dir, with a never-throw fallback to a single top-level tasks.md. Updates all four callers (view/list/archive x2) for the new projectRoot arg and folds the second copy in change.ts onto the helper. Fixes view's Draft misclassification and the archive incomplete-task gate that let an unfinished glob-tasks change archive (data safety). #1156 — the SHALL/MUST body-keyword hint applies to main specs. applySpecRules recovers the requirement header via extractRequirementsSection and emits the targeted hint (header-only) or generic message (no keyword), exactly once; the Zod refine is removed (deltas never used it). The actionable sentence is byte-identical to the change-delta path. Adds parity/regression tests (Decision 7): validate<->status resolution incl. exit code, view/archive resolve the same files as status, and the main-spec<->delta actionable-sentence parity. Full suite green (1791 passed; only the pre-existing, environment-specific zsh-installer failures remain). Change validates --strict; all 36 repo specs pass --specs --strict with no new false positives. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../fix-validate-view-resolution-parity.md | 9 + .../.openspec.yaml | 2 + .../design.md | 93 ++++++++++ .../proposal.md | 56 ++++++ .../specs/cli-archive/spec.md | 32 ++++ .../specs/cli-validate/spec.md | 113 ++++++++++++ .../specs/cli-view/spec.md | 58 ++++++ .../tasks.md | 46 +++++ src/commands/change.ts | 53 +----- src/commands/validate.ts | 21 ++- src/core/archive.ts | 4 +- src/core/list.ts | 2 +- src/core/schemas/base.schema.ts | 12 +- src/core/validation/validator.ts | 73 ++++++-- src/core/view.ts | 2 +- src/utils/task-progress.ts | 76 +++++++- test/commands/validate.test.ts | 53 ++++++ test/core/archive.test.ts | 44 +++++ test/core/validation.test.ts | 145 ++++++++++++++- test/core/view.test.ts | 49 +++++ test/utils/task-progress.test.ts | 168 ++++++++++++++++++ 21 files changed, 1033 insertions(+), 78 deletions(-) create mode 100644 .changeset/fix-validate-view-resolution-parity.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml create mode 100644 openspec/changes/fix-validate-view-resolution-parity/design.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/proposal.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md create mode 100644 openspec/changes/fix-validate-view-resolution-parity/tasks.md create mode 100644 test/utils/task-progress.test.ts diff --git a/.changeset/fix-validate-view-resolution-parity.md b/.changeset/fix-validate-view-resolution-parity.md new file mode 100644 index 0000000000..974055a1fe --- /dev/null +++ b/.changeset/fix-validate-view-resolution-parity.md @@ -0,0 +1,9 @@ +--- +"@fission-ai/openspec": patch +--- + +### Bug Fixes + +- **`validate` resolves changes like `status`** — `openspec validate ` (and `--all`/`--changes` and the interactive selector) now resolves a change by directory existence, matching `status`/`instructions`, instead of requiring `proposal.md`. A scaffolded or still-authoring change is validated rather than reported as `Unknown item`, and a resolved-but-invalid change now exits non-zero. Delta discovery also recurses the nested `specs///spec.md` layout. (#1182) +- **Task progress reads nested/glob `tasks.md`** — `openspec view`, `list`, and the `archive` incomplete-task gate now resolve task progress through the tracked-tasks artifact's `generates` glob (the same file-resolution `status` uses), so a change whose tasks live in nested `tasks.md` files is classified correctly and can no longer archive while unfinished. (#1202) +- **SHALL/MUST body-keyword hint applies to main specs** — A main-spec requirement whose normative keyword sits only in the `### Requirement:` header now receives the same targeted "move it to the body line" remediation as a change delta, emitted exactly once. (#1156) diff --git a/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml b/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/fix-validate-view-resolution-parity/design.md b/openspec/changes/fix-validate-view-resolution-parity/design.md new file mode 100644 index 0000000000..93ef6618b1 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/design.md @@ -0,0 +1,93 @@ +# Design + +## Context + +This is a bug-fix bundle, not a feature. The three issues are grouped because they share one structural defect: a read/validate command forks its own resolution or validation logic instead of reusing the canonical implementation a sibling command already gets right. Fixing them together lets the implementation converge the divergent paths onto shared helpers in one pass, and lets one set of *parity tests* guard all three against future drift. + +The unifying invariant this change establishes: + +> A command that reports on or validates a change MUST resolve files the same way `openspec status` does, and MUST produce the same requirement-quality messages the change-delta validator does. Divergence is a bug, and parity is asserted by test. + +Every claim below was verified against source at the base commit `546224e` **and reproduced empirically against the built (pre-fix) CLI**. The reproductions are summarized under "Empirical evidence." The framings that changed under this review are flagged inline; two of them (the #1202 `apply.tracks` mechanism and the "agrees with status" count claim) were corrections to an earlier draft of this very proposal. + +## Root causes (verified) + +| # | Symptom | Canonical path (correct) | Divergent path (bug) | Anchor | +|---|---------|--------------------------|----------------------|--------| +| #1182 | `validate ` → `Unknown item`; `--all` → "No items found" | `status`/`instructions` resolve by **directory existence** (`validateChangeExists`) | `validate` resolves via `getActiveChangeIds`, which **requires `proposal.md`** | `src/commands/validate.ts:97,120,238`; `src/utils/item-discovery.ts:11-16`; `src/commands/workflow/shared.ts:168-170`; scaffolder omits proposal.md `src/utils/change-utils.ts:121-210` | +| #1182b | resolved nested-layout change → "No delta sections found" | spec-driven specs glob is `specs/**/*.md` | `validateChangeDeltaSpecs` discovers deltas one level deep only | `src/core/validation/validator.ts:115-138,265` | +| #1202 | `view` shows a tasked change as `Draft`; `archive` archives an unfinished change | `status` tests the tasks **artifact's `generates` glob** via `resolveArtifactOutputs` | `getTaskProgressForChange` hardcodes `changes//tasks.md` | `src/utils/task-progress.ts:28`; callers `src/core/view.ts:100`, `src/core/list.ts:112`, `src/core/archive.ts:342,540`; 2nd copy `src/commands/change.ts:111,164`; helper `src/core/artifact-graph/outputs.ts:17`; tracks type `src/core/artifact-graph/types.ts:18` | +| #1156 | Main-spec SHALL-in-header-only → generic error; delta → targeted hint | Delta validator runs `containsShallOrMust` + `buildMissingShallOrMustMessage` | Main-spec requirement validation falls through to generic `REQUIREMENT_NO_SHALL`; header is discarded before validation | `src/core/validation/validator.ts:167-189,443-463`; `src/core/schemas/base.schema.ts:11-14`; `src/core/parsers/markdown-parser.ts:220-226` | + +## Empirical evidence (built pre-fix CLI, fresh `init`'d projects) + +- **#1182:** `new change foo` writes `changes/foo/.openspec.yaml` only. `status --change foo` resolves (exit 0); `validate foo` → `Unknown item 'foo'` (exit 1); `validate --all` with foo as the sole change → `No items found to validate` (**exit 0**, a silent CI failure). Writing `proposal.md` flips `validate` to resolve — confirming the exact lever. A valid two-level `specs///spec.md` change → `No deltas found` (#1182b); one-level control validates clean. +- **#1202:** project-local schema with tasks `generates: "**/tasks.md"`; change `foo` = `backend/tasks.md` (2/2) + `frontend/tasks.md` (1/3) = 3/5, no top-level file. `status` → `4/4 artifacts complete, isComplete:true` (file existence, not checkboxes); `view` → **Draft**; `list` → `No tasks`; `list --json` → `totalTasks:0`. `archive foo --skip-specs --no-validate --yes` **moved the unfinished change into `changes/archive/`** — the incomplete-task gate was wholly bypassed. Baselines (default schema top-level `tasks.md`; bare project) classify correctly and are preserved by the fix. +- **#1156:** main spec, SHALL in header only → generic `Requirement must contain SHALL or MUST keyword`. The same mistake as an ADDED/MODIFIED delta → the targeted hint. RENAMED delta → no error (no body). No-keyword-anywhere main spec → generic error. Lowercase `shall` → error on both paths. **Header-only with no body line at all → reported VALID today** (parser keeps `text` = header, which contains SHALL). + +## Decisions + +### Decision 1 — Converge, don't re-implement + +Each fix points the divergent path at the *existing* canonical implementation rather than writing a second copy. A second copy is what created every one of these bugs. + +### Decision 2 — #1182: the lever is the membership gate, not "workspace homes" + +The original framing (validate doesn't understand workspace planning homes) is wrong at HEAD: planning homes are repo-only (`PlanningHomeKind = 'repo'`), the workspace feature is now **stores**, and `validate` already accepts `--store` and resolves the store root through the same `resolveRootForCommand` as `status`. The actual, reproducible divergence is that `validate` gates change membership on `proposal.md` (`getActiveChangeIds`) at **three** sites — targeted (validate.ts:120), bulk (validate.ts:238), and the interactive "pick one" selector (validate.ts:97) — while `status`/`instructions` gate on directory existence (`validateChangeExists`). Since `createChange` writes `.openspec.yaml` but not `proposal.md`, any scaffolded or still-authoring change resolves everywhere except `validate`. + +The fix: `validate` resolves a change by directory existence within the already-resolved root, at all three sites. This is store-correct for free (the store root is resolved identically by all three commands), so the reported store/workspace symptom is covered transitively, and no store-specific scenario is needed. `getChangeDir`/`resolveCurrentPlanningHomeSync` are **not** the lever (the former is a pure path join with no membership decision). + +Two boundaries confirmed empirically and held out of scope: (a) the **spec** side is correct — `getSpecIds` requires `spec.md`, and `spec show` agrees, so a spec dir without `spec.md` is correctly "not found"; no spec-side scenario is added. (b) The deprecated noun-form `openspec change validate ` already resolves a passed name by directory existence (change.ts:215) but is cwd-based (cannot reach a `--store` root) and its JSON mode does not set a non-zero exit on invalid — pre-existing noun-form defects, explicitly not addressed here. + +### Decision 3 — #1182: nested delta discovery is in scope + +Resolution success is not validation success. `validateChangeDeltaSpecs` discovers deltas exactly one directory deep (`changeDir/specs//spec.md`), but the multi-area layout that motivates stores/workspaces is `changeDir/specs///spec.md`. Without recursing, a resolved multi-area change reports "No delta sections found" (reproduced). So delta discovery is extended to the nested layout in this change; otherwise the #1182 fix does not actually let the reported change validate. + +### Decision 4 — #1202: resolve via the tracked artifact's `generates` glob, and parity is resolution-only + +The fix lands in the shared helper `getTaskProgressForChange`, correcting all four call sites at once; the spec pins two consumers explicitly (`cli-view` — the filed Draft symptom; `cli-archive` — the incomplete-task gate, a data-safety regression that lets an unfinished change archive). `openspec list` is corrected by the same helper; the independent second copy in `openspec change list` (`change.ts:111,164`, its own `countTasks`) is folded onto the shared helper by a task — not left as an orphan. + +Two corrections to an earlier draft, both load-bearing: + +- **`apply.tracks` is a filename that *selects* the artifact; it is not the glob.** `apply.tracks` is typed `string | null` and is consumed elsewhere as a literal path (`path.join(changeDir, tracks)` + `existsSync`), so `apply.tracks: "**/tasks.md"` cannot match nested files. The glob `status` actually uses is the tracked artifact's **`generates`**, resolved by `resolveArtifactOutputs(changeDir, artifact.generates)`. So the fix identifies the tracked-tasks artifact (the artifact whose `generates` equals `apply.tracks`, falling back to artifact id `tasks` when no `apply` block is present), then counts checkboxes across `resolveArtifactOutputs(changeDir, thatArtifact.generates)`. `resolveArtifactOutputs` roots `fast-glob` at the change directory (so a sibling `changes/archive/` or another change's `tasks.md` cannot match) and de-dups via a `Set` (so no double counting). +- **`status` checks file *existence*, not checkbox completion.** Empirically `status` calls a 3/5 change `4/4 complete, isComplete:true`. So the parity established here is **resolution-mechanism parity** (`view`/`archive` resolve the same set of files `status` resolves), not count parity — `view`/`archive` additionally count checkboxes. Any "view/archive task counts equal status" claim is false and is removed from the spec. + +The signature gains `projectRoot` (needed to resolve project-local schemas via `resolveSchema`); all four call sites plus the two `change.ts` sites can derive it. `resolveSchema` **throws** on an unresolvable/misnamed schema, whereas the current helper never throws — so the helper MUST catch and fall back to single-file `tasks.md`, or `view`/`list`/`archive` would crash on a project whose config names a deleted schema. This fallback is specified and tested. + +### Decision 5 — #1156: recover the header, remove the refine, pin an exact (not byte-identical) message + +The targeted delta hint works because the delta parser keeps the requirement header (`RequirementBlock.name`) separate from the body. The **main-spec parser overwrites the header with the first body line** (`markdown-parser.ts:220-226`) before validation, so the Zod refine that emits `REQUIREMENT_NO_SHALL` never sees the header and cannot detect "keyword in header only." + +The fix: + +1. **Recover the header.** Reuse the header-preserving parser `src/core/parsers/requirement-blocks.ts` (`extractRequirementsSection`, which yields header+body pairs and is the same source the delta path trusts) and run the existing `containsShallOrMust` + `buildMissingShallOrMustMessage` detection in the imperative main-spec rules (`applySpecRules`, validator.ts:290-329), which already loops requirements and has the raw content. +2. **Remove the Zod refine, don't merely relax it.** Change deltas do **not** use the refine — they validate imperatively in `validateChangeDeltaSpecs` (proven: a no-keyword delta emits the imperative `must contain SHALL or MUST` base string, not the Zod `REQUIREMENT_NO_SHALL` string). So the refine is exercised only on the main-spec path. Once the imperative rule in `applySpecRules` owns **both** sub-cases — keyword-in-header-only → targeted hint, and keyword-nowhere → generic message — the `.refine` on `RequirementSchema` (base.schema.ts:11-14) is **removed entirely**. Keeping a conditional refine "for the no-keyword case only" risks double-emission on the header-only case, which the "exactly one issue" scenario forbids. +3. **Message.** The actionable sentence is byte-identical to the delta path; the prefix differs (main specs have no `ADDED`/`MODIFIED`). The main-spec message is: `Requirement "" must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.` Generalize `buildMissingShallOrMustMessage` to accept the prefix so the actionable sentence lives in one place and cannot drift between paths. Lowercase is rejected via the shared `\b(SHALL|MUST)\b` regex (converging the main-spec path off the case-sensitive Zod `.includes`). + +RENAMED requirements carry no body and are not subject to the hint (the `'ADDED' | 'MODIFIED'` action set is correct); a scenario pins this so it is not mistaken for a gap. + +### Decision 6 — Additive coverage, with one intended behavior change called out + +The fixes are additive coverage: changes that already have `proposal.md`, single-file `tasks.md` projects, projects with no resolvable schema, and delta-spec validation produce byte-identical output before and after. One main-spec case is an **intended** behavior change, not an unchanged case: a requirement with the keyword in the header and **no body line at all** is reported valid today and becomes a body-keyword hint under header recovery (the delta path already errors on this case). This is called out explicitly so it is not discovered as an accidental regression; every other previously-passing case is unchanged. + +### Decision 7 — Parity is the test strategy + +Tests assert *agreement*, not just fixed outputs in isolation: + +- a change that `status --change ` resolves (including a proposal-less and a store change) is also resolved by `validate `, included by `validate --all`, and listed by the interactive selector; a resolved-but-invalid change exits non-zero; +- for a schema whose tracked-tasks `generates` is `**/tasks.md`, `view`, `list`, and the `archive` gate resolve the **same set of files** `status` resolves (and additionally count checkboxes consistently with each other); +- a requirement with SHALL/MUST in the header only yields the same actionable sentence whether it appears in `openspec/specs/**` or a change delta, emitted exactly once. + +Parity assertions fail loudly if a future refactor re-forks any path. + +### Decision 8 — Scope boundary against sibling proposals + +- #1112 (delta header absent from base passing `validate`, aborting at `archive`) is an *authoring* false-positive resolved by the deterministic `sync --check` gate in the sync/unarchive proposal — out of scope here. +- Artifact *completeness* gaps (a half-written or skipped artifact reported as done, #1084/#1260) belong to the artifact-graph/update-workflow proposal — out of scope here. #1202 is narrower: *where* task counts are read from, not whether the tasks are complete. + +## Risks and mitigations + +- **Risk:** relaxing the change membership gate changes ambiguity behavior when a name exists as both a change directory and a spec. **Mitigation:** preserve the existing ambiguity/`--type` semantics; only swap the change-membership predicate (proposal.md → directory existence) at all three sites, keeping `getSpecIds` as the spec predicate. Covered by an ambiguity scenario. +- **Risk:** the task-progress signature change breaks the other call sites, or crashes on an unresolvable schema. **Mitigation:** update all six sites (four helper callers + two `change.ts` copies) in the same change; catch `resolveSchema` failure and fall back to single-file `tasks.md`; assert `view`/`archive` resolve the same files as `status`. +- **Risk:** the glob over-matches or the archive gate regresses. **Mitigation:** reuse `resolveArtifactOutputs` (rooted at the change dir, de-duped); add scope-containment and archive-gate scenarios. +- **Risk:** removing the Zod refine drops the no-keyword error on the main-spec path. **Mitigation:** the imperative `applySpecRules` rule must own the no-keyword case before the refine is removed; assert the no-keyword regression and single emission. Delta validation is untouched (it never used the refine). diff --git a/openspec/changes/fix-validate-view-resolution-parity/proposal.md b/openspec/changes/fix-validate-view-resolution-parity/proposal.md new file mode 100644 index 0000000000..d42af8dbb3 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/proposal.md @@ -0,0 +1,56 @@ +## Why + +Three commands silently give a wrong or incomplete answer about the spec source of truth, because sibling read/validate paths reimplement narrower logic than the canonical path each should share. + +- `openspec validate ` rejects a change as `Unknown item` whenever `proposal.md` is absent (a scaffolded or still-authoring change, in a repo or a store), though `status`/`instructions` resolve it by directory existence — so spec checks are skipped for the changes most likely to be malformed (#1182). +- `openspec view` labels a fully-tasked change `Draft` when its tasks live in nested/glob `tasks.md` files, contradicting `status`; the same blind spot lets `archive` silently archive an unfinished change (#1202). +- `openspec validate` gives the targeted "move SHALL/MUST onto the body line" hint for deltas, but only the generic message for the same mistake in a main spec (#1156). + +Each is deterministic and fixed by converging a divergent path onto the canonical one. + +## Background: one root cause, three commands + +OpenSpec sells one promise — the specs are the source of truth and the CLI tells you the truth about them. These three bugs break that promise the same way: a command that *reads* or *validates* state quietly forks its own resolution logic instead of reusing the canonical implementation a sibling command already gets right. The fork is invisible until the two paths disagree, and then the tool reports a confident falsehood (`Unknown item`, `Draft`, a clean archive of an unfinished change, a worse error message) with no signal that anything diverged. + +This proposal was hardened by tracing each path to source (anchors in `design.md`). Two framings changed during that review and are called out so reviewers can check them: + +- **#1182 is a membership-gate bug, not a "home" bug.** Planning homes are repo-only today (`PlanningHomeKind = 'repo'`); the "managed workspace planning home" from the 1.4.1 issue is the feature since renamed **stores**, and `validate` already accepts `--store`. The real divergence is narrower and reproducible at HEAD: `status`/`instructions` resolve a change by **directory existence** (`validateChangeExists`), while `validate` resolves it through `getActiveChangeIds`, which **requires `proposal.md`**. `createChange` does not write `proposal.md`, so a scaffolded change — including a store change still being authored — resolves everywhere except `validate`. Sharing the canonical resolution covers the reported store/workspace symptom transitively, because the store root is already resolved identically by all three commands. +- **#1202 is wider than `view`.** The buggy helper `getTaskProgressForChange` is consumed by `view`, `list`, and the `archive` incomplete-task gate. The `archive` case is a correctness/data-safety risk, not a cosmetic mislabel: under a glob-tasks schema it reads zero tasks, finds nothing incomplete, and archives a change whose work is not done. A second, independent hardcoded copy lives in `openspec change list`. + +## What Changes + +- **`validate` shares the canonical change-resolution rule (#1182).** `openspec validate ` resolves a change by directory existence — the same rule `status`/`instructions` use — instead of requiring `proposal.md`. This applies to targeted `validate `, bulk `validate --all`/`--changes`, **and** the interactive "pick one" selector, within both the repo root and a `--store`-selected root. Spec/change ambiguity handling and `--type` overrides are preserved. Delta discovery is extended to the nested `specs///spec.md` layout so a resolved multi-area change actually validates its deltas instead of reporting "no deltas found." +- **`view`/`archive`/`list` resolve tasks through the tracked-tasks artifact glob (#1202).** Task progress for a change is resolved through the tracked-tasks artifact's `generates` glob — the same file-resolution `status` uses — counting every matching `tasks.md` scoped to the change directory, with the single-file `tasks.md` and no-resolvable-schema cases preserved as today. (The tracked artifact is selected via `apply.tracks`, which is a filename, not a glob; the glob is that artifact's `generates`.) As a result `view`'s Draft/Active/Completed classification stops being blind to nested files, and `archive`'s incomplete-task gate no longer passes an unfinished glob-tasks change. The second hardcoded copy in `openspec change list` is folded onto the same shared resolution. Because `status` checks task-file *existence* (not checkboxes), the guarantee is that these commands resolve the *same files* `status` resolves — not that they reproduce a count `status` does not compute. +- **The SHALL/MUST body-keyword hint applies to main specs (#1156).** A main-spec requirement whose normative keyword sits only in the `### Requirement:` header receives the same targeted "move it to the body line" remediation as a change delta, instead of the generic message — emitted exactly once (no duplicate generic error), across every main-spec surface (`validate `, `--all`, JSON, `spec validate`, and rebuilt-spec validation). + +### What this deliberately does *not* change + +- The canonical paths (`status`, `instructions`, the delta-spec validator) are not changed in behavior — the divergent paths are moved onto them. +- No new command, flag, schema field, or output format. Existing JSON shapes are preserved; only the values they carry become correct. +- Resolution for changes that already have `proposal.md`, single-file `tasks.md` projects, projects with no resolvable schema, and delta-spec validation are byte-for-byte unchanged — these fixes only add coverage where a path was previously blind. +- It does not address the #1112 authoring false-positive (a delta MODIFIED/REMOVED header absent from the base spec passing `validate`, aborting at `archive`); that is handled by the deterministic `sync --check` gate in the separate sync/unarchive proposal. The overlap is intentionally avoided. +- It does not change artifact *completeness* semantics (whether a half-written artifact counts as done, #1084/#1260); #1202 here is strictly about *where* task counts are read from, not whether the tasks are complete. + +## Capabilities + +### Modified Capabilities + +- `cli-validate`: resolves a change by directory existence (matching `status`/`instructions`) for targeted, bulk, and interactive-selector validation in repo and store roots; discovers deltas under nested `specs/**` layouts; and emits the targeted SHALL/MUST body-keyword hint for main specs, once, across all surfaces. +- `cli-view`: resolves task progress through the tracked-tasks artifact's `generates` glob (the same file-resolution `status` uses), so Draft/Active/Completed classification stops being blind to nested `tasks.md` files. +- `cli-archive`: the incomplete-task gate reads task progress through the same tracked-tasks resolution, so a glob-tasks change with unfinished work cannot pass the gate. + +## Impact + +- **Affected specs:** `cli-validate` (2 added requirements), `cli-view` (1 added requirement), `cli-archive` (1 added requirement). +- **Affected code (implementation follow-up, not in this planning PR):** + - `src/commands/validate.ts` — replace the `getActiveChangeIds` membership gate with directory-existence resolution mirroring `validateChangeExists` (`src/commands/workflow/shared.ts:168-170`) at all three sites: targeted (line 120), bulk (line 238), interactive selector (line 97). Reconcile with `getSpecIds` for the change/spec ambiguity path (leave `getSpecIds` unchanged — it is correct). Sibling `src/commands/show.ts:81,115,121` shares the gate and should be folded in or explicitly scoped out; the deprecated noun-form `change validate` is out of scope. + - `src/core/validation/validator.ts` — extend delta discovery (`validateChangeDeltaSpecs`, lines 115-138) to recurse the nested `specs///spec.md` layout. + - `src/utils/task-progress.ts` — `getTaskProgressForChange` gains a `projectRoot` param, identifies the tracked-tasks artifact (artifact whose `generates` equals the schema `apply.tracks`, fallback id `tasks`), counts checkboxes across `resolveArtifactOutputs(changeDir, artifact.generates)` (`src/core/artifact-graph/outputs.ts:17`, de-duped, change-rooted). `apply.tracks` selects the artifact; the glob is its `generates`. Catch `resolveSchema` failure → fall back to single-file `tasks.md` (never throw). Update all four call sites (`src/core/view.ts:100`, `src/core/list.ts:112`, `src/core/archive.ts:342`, `:540`) for the new arg; fold the second copy in `src/commands/change.ts:111,164` onto the helper. + - `src/core/validation/validator.ts` + `src/core/parsers/requirement-blocks.ts` — recover the requirement header (lost at `markdown-parser.ts:220-226`) via `extractRequirementsSection` so the main-spec rule in `applySpecRules` can detect "keyword in header only" and emit the targeted hint via a prefix-generalized `buildMissingShallOrMustMessage` (lines 443-463); **remove** the Zod refine (`src/core/schemas/base.schema.ts:11-14`) once the imperative rule owns both the header-only and no-keyword cases (deltas validate imperatively and never used the refine, so removal cannot regress them). +- **Risk:** low-to-moderate. Each fix points a command at logic that already exists for the canonical path; the larger surface is the task-progress signature change (six sites incl. schema-failure fallback) and the validator header recovery. Regression risk is bounded by parity tests asserting `validate`/`view`/`archive`/the main-spec validator agree with their canonical counterparts, plus explicit no-regression scenarios for the unchanged cases. + +## Issues addressed + +- [#1182](https://github.com/Fission-AI/OpenSpec/issues/1182) — `openspec validate` cannot resolve a change that `status`/`instructions` resolve (reported for a managed workspace/store home; root cause is the `proposal.md` membership gate). +- [#1202](https://github.com/Fission-AI/OpenSpec/issues/1202) — `openspec view` does not detect nested/glob `tasks.md`, classifying complete changes as `Draft` (and the same helper silently weakens the `archive` incomplete-task gate). +- [#1156](https://github.com/Fission-AI/OpenSpec/issues/1156) — the 1.4.0 SHALL/MUST body-keyword hint applies to change deltas but not main specs. diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md new file mode 100644 index 0000000000..f6bb55dfa2 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Archive incomplete-task gate SHALL use the tracked-tasks artifact glob + +`openspec archive`'s incomplete-task gate — the check that prevents archiving a change whose tasks are not all complete — SHALL read task progress through the change's tracked-tasks artifact glob, the same file-resolution `openspec status` and `openspec view` use, rather than a fixed `changes//tasks.md` path. The tracked-tasks artifact SHALL be identified as the artifact whose `generates` equals the schema's `apply.tracks` value, falling back to the artifact with id `tasks` when no `apply` block is present; checkbox counts SHALL be aggregated across every file matched by that artifact's `generates` glob, scoped to the change directory. When the schema cannot be resolved or no tracked-tasks artifact is found, the gate SHALL fall back to a single top-level `tasks.md` exactly as today and SHALL NOT crash. This closes the data-safety gap where a change whose tasks live in nested/glob `tasks.md` files is read as having zero tasks, no incomplete work, and is allowed to archive while unfinished. + +#### Scenario: Glob-tasks change with unfinished work cannot archive + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **AND** a change with `backend/tasks.md` containing unchecked tasks and no top-level `tasks.md` +- **WHEN** running `openspec archive` on that change +- **THEN** the incomplete-task gate SHALL detect the unfinished tasks and block (or require explicit override of) the archive +- **AND** SHALL NOT treat the change as having zero tasks + +#### Scenario: Archive gate resolves the same tracked files as view + +- **GIVEN** any change with a tracked-tasks glob +- **WHEN** the `archive` incomplete-task gate and `openspec view` each compute task progress for that change +- **THEN** they SHALL resolve the same set of `tasks.md` files and count the same checkboxes + +#### Scenario: Unresolvable schema falls back without error + +- **GIVEN** a change whose configured schema cannot be resolved +- **WHEN** running `openspec archive` on that change +- **THEN** the incomplete-task gate SHALL fall back to a single top-level `tasks.md` +- **AND** SHALL NOT crash + +#### Scenario: Single top-level tasks file archiving is unchanged + +- **GIVEN** a change with a single top-level `changes//tasks.md`, or a project with no resolvable schema +- **WHEN** running `openspec archive` +- **THEN** the incomplete-task gate SHALL behave exactly as today diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md new file mode 100644 index 0000000000..84c85fa6ef --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Validate SHALL resolve changes by directory existence, matching status + +`openspec validate` SHALL resolve whether a named item is a change using the same rule `openspec status` and `openspec instructions` use — directory existence within the resolved root — rather than requiring a `proposal.md` to be present. This SHALL apply to targeted validation (`openspec validate `), bulk validation (`openspec validate --all` / `--changes`), and the interactive "pick one" selector shown when no item is given in a TTY — within both the repository root and a `--store`-selected root. A resolved change with a nested multi-area spec layout SHALL have its deltas discovered and validated. Spec/change ambiguity handling and `--type` overrides SHALL remain unchanged. The spec-resolution side (a spec is resolved by the presence of its `spec.md`) is correct today and SHALL be left unchanged. + +#### Scenario: Scaffolded change without proposal.md + +- **GIVEN** a change directory created by `openspec new change ` that has not yet had `proposal.md` written +- **WHEN** executing `openspec validate ` +- **THEN** validate resolves the change and validates it +- **AND** it SHALL NOT print `Unknown item ''` + +#### Scenario: Targeted-resolution parity with status + +- **GIVEN** any change that `openspec status --change ` resolves, including a change in a `--store`-selected root +- **WHEN** executing `openspec validate ` (passing the same `--store` when applicable) +- **THEN** validate SHALL resolve the same change that status resolved, and SHALL NOT report it as unknown + +#### Scenario: Bulk validation includes a sole proposal-less change + +- **GIVEN** a repository whose only active change lacks `proposal.md` and is listed by `openspec status` +- **WHEN** executing `openspec validate --all` (or `--changes`) +- **THEN** validate SHALL validate that change, and SHALL NOT print "No items found to validate" +- **AND** the exit status SHALL reflect the change's validity + +#### Scenario: Interactive selector lists proposal-less changes + +- **GIVEN** a TTY and a change directory without `proposal.md` that `openspec status` lists +- **WHEN** executing `openspec validate` with no item name +- **THEN** the interactive "pick one" selector SHALL include that change + +#### Scenario: Resolved-but-invalid change exits non-zero + +- **GIVEN** a change that resolves by directory existence but fails validation +- **WHEN** executing `openspec validate ` or `openspec validate --all` +- **THEN** validate SHALL exit with a non-zero status +- **AND** SHALL NOT exit 0 while reporting the change as having issues + +#### Scenario: Nested multi-area delta discovery + +- **GIVEN** a resolved change whose deltas live at `specs///spec.md` (nested deeper than one directory) +- **WHEN** validating that change +- **THEN** validate SHALL discover and validate those delta specs +- **AND** SHALL NOT report "No delta sections found" for a change that does contain deltas + +#### Scenario: Change/spec ambiguity is preserved + +- **GIVEN** a name that exists both as a change directory and as a spec +- **WHEN** executing `openspec validate ` +- **THEN** validate SHALL print the ambiguity error and respect `--type change` / `--type spec`, exactly as before + +#### Scenario: Changes with proposal.md are unaffected + +- **GIVEN** a change that already contains `proposal.md` +- **WHEN** validating it targeted or in bulk +- **THEN** resolution and validation behavior SHALL be byte-for-byte unchanged from today + +### Requirement: SHALL/MUST body-keyword hint SHALL apply to main specs + +When a requirement places the normative keyword (SHALL or MUST) only in its `### Requirement:` header and omits it from the requirement body line, `openspec validate` SHALL emit the same targeted remediation guidance for main specs under `openspec/specs/**` as it already does for change delta specs, instead of the generic "must contain SHALL or MUST" message. The targeted message SHALL be emitted exactly once for such a requirement, the generic `REQUIREMENT_NO_SHALL` message SHALL no longer be emitted on the main-spec path, and the behavior SHALL be uniform across every main-spec validation surface (`openspec validate `, `--all`, JSON output, `openspec spec validate`, and rebuilt-spec validation via `validateSpecContent`). The main-spec message's actionable sentence SHALL be byte-identical to the change-delta message; only the leading prefix differs (main specs have no `ADDED`/`MODIFIED` action). + +#### Scenario: Main spec with the keyword in the header only + +- **GIVEN** a main spec requirement whose header contains SHALL or MUST but whose body line omits it +- **WHEN** running `openspec validate` over that spec +- **THEN** the error message SHALL contain the actionable sentence: "must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the \"### Requirement: ...\" header." +- **AND** SHALL NOT be the generic "Requirement must contain SHALL or MUST keyword" message + +#### Scenario: Actionable-sentence parity with change deltas + +- **GIVEN** the identical header-only-keyword mistake authored once in a main spec and once in a change delta +- **WHEN** validating each +- **THEN** the actionable remediation sentence SHALL be byte-identical between the two (the change-delta `ADDED`/`MODIFIED` prefix is not required for the main-spec message) + +#### Scenario: Exactly one issue is emitted + +- **GIVEN** a main spec requirement with the keyword in the header only +- **WHEN** validating it +- **THEN** validate SHALL emit exactly one issue for the missing body keyword +- **AND** SHALL NOT emit both the generic message and the targeted message for the same requirement + +#### Scenario: Requirement missing the keyword entirely still errors + +- **GIVEN** a main spec requirement that contains no SHALL or MUST in either the header or the body +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL report that the requirement must contain SHALL or MUST, as it does today + +#### Scenario: Keyword present in the body is not flagged + +- **GIVEN** a main spec requirement whose body line contains SHALL or MUST (whether or not the header also does) +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL NOT raise a missing-keyword error for that requirement + +#### Scenario: Lowercase keyword does not satisfy the body requirement + +- **GIVEN** a main spec requirement whose only "shall"/"must" is lowercase +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL report a missing-keyword error, matching the change-delta behavior for the same lowercase mistake + +#### Scenario: Header keyword with no body line emits the hint + +- **GIVEN** a main spec requirement whose header contains SHALL or MUST and that has no body line before its first scenario +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL emit the body-keyword hint (the keyword is only in the header) +- **AND** this case, which is reported valid today, becomes a deliberate, additive validation improvement + +#### Scenario: Renamed requirements are not subject to the body-keyword hint + +- **GIVEN** a change delta `## RENAMED Requirements` whose TO header contains SHALL or MUST +- **WHEN** validating that change +- **THEN** validate SHALL NOT emit the body-keyword hint for the renamed pair +- **AND** RENAMED validation behavior SHALL be byte-for-byte unchanged diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md new file mode 100644 index 0000000000..de664bf8b9 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Task progress SHALL be resolved through the tracked-tasks artifact glob + +`openspec view` SHALL determine a change's task progress by resolving its tracked-tasks artifact and counting checkboxes across that artifact's output glob (`generates`) — the same file-resolution `openspec status` uses to detect the tasks artifact — rather than assuming a fixed `changes//tasks.md` path. The tracked-tasks artifact SHALL be identified as the artifact whose `generates` equals the schema's `apply.tracks` value, falling back to the artifact with id `tasks` when no `apply` block is present. (`apply.tracks` is a filename that selects the artifact; the glob is that artifact's `generates`.) Resolution SHALL be scoped to the change directory, SHALL aggregate completed and total checkbox counts across every matching file, and SHALL NOT double-count. When the schema cannot be resolved, no tracked-tasks artifact is found, or the glob matches no file, `view` SHALL fall back to counting a single top-level `tasks.md` exactly as today, and SHALL NOT raise an error. + +Note on scope: `openspec status` detects whether the tasks artifact *file exists*; it does not count checkboxes (a change whose nested `tasks.md` files exist is reported by `status` as having the tasks artifact complete even when boxes are unchecked). The parity established here is therefore **resolution-mechanism parity** — `view` resolves the same set of `tasks.md` files `status` resolves — and `view` additionally counts checkboxes within them. The fix removes `view`'s blindness to nested files; it does not make `view` agree with a task count `status` does not produce. + +#### Scenario: Nested tasks files under a glob schema + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **AND** a change with `backend/tasks.md` and `frontend/tasks.md` and no top-level `tasks.md` +- **WHEN** running `openspec view` +- **THEN** the change SHALL show aggregated task progress summed across both files +- **AND** SHALL NOT be classified as a Draft change solely because no top-level `tasks.md` exists + +#### Scenario: Tracked-tasks files resolve the same as status + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **WHEN** running `openspec view` and `openspec status --change ` +- **THEN** both SHALL resolve the same set of `tasks.md` files for the change — `status` to detect the tasks artifact, `view` to count checkboxes within them + +#### Scenario: Files exist but tasks unchecked are not Completed + +- **GIVEN** a glob-tasks change whose matched `tasks.md` files contain unchecked boxes +- **WHEN** running `openspec view` +- **THEN** the change SHALL be classified Active (not Completed), even though `status` reports the tasks artifact as present + +#### Scenario: Tracked-tasks artifact identified by apply.tracks, not a fixed id + +- **GIVEN** a custom schema whose tracked-tasks artifact is not named `tasks` but is selected by `apply.tracks` +- **WHEN** running `openspec view` +- **THEN** task progress SHALL be resolved from that artifact's `generates` glob + +#### Scenario: Resolution stays scoped to the change directory + +- **WHEN** resolving a change's `tasks.md` files +- **THEN** matching SHALL be rooted at `changes//` only +- **AND** SHALL NOT count `tasks.md` files belonging to another change or under `changes/archive/` + +#### Scenario: Unresolvable schema falls back without error + +- **GIVEN** a change whose configured schema cannot be resolved (for example, the config names a missing schema) +- **WHEN** running `openspec view` +- **THEN** task progress SHALL fall back to counting a single top-level `tasks.md` +- **AND** `view` SHALL NOT crash + +#### Scenario: Single top-level tasks file is unchanged + +- **GIVEN** a change with exactly one top-level `changes//tasks.md`, or a project with no resolvable schema +- **WHEN** running `openspec view` +- **THEN** task progress SHALL be counted from that single file exactly as before + +#### Scenario: A change with no tasks anywhere stays Draft + +- **GIVEN** a change with no `tasks.md` matching the tracked-tasks glob +- **WHEN** running `openspec view` +- **THEN** the change SHALL report zero tasks and be classified as Draft, as today diff --git a/openspec/changes/fix-validate-view-resolution-parity/tasks.md b/openspec/changes/fix-validate-view-resolution-parity/tasks.md new file mode 100644 index 0000000000..ff105ed4b1 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/tasks.md @@ -0,0 +1,46 @@ +# Tasks + +## 1. #1182 — validate resolves changes like status (membership gate) + +- [x] 1.1 Reproduce at HEAD: `openspec new change X` (creates dir + `.openspec.yaml`, no `proposal.md`); confirm `status --change X` resolves it (exit 0) but `validate X` prints `Unknown item` and `validate --all` (X alone) prints "No items found" and exits 0. +- [x] 1.2 In `src/commands/validate.ts`, replace the `getActiveChangeIds` membership gate for change resolution with directory-existence resolution mirroring `validateChangeExists` (`src/commands/workflow/shared.ts:168-170`); keep `getSpecIds` as the spec predicate. Apply at all THREE sites: targeted (line 120), bulk `--all`/`--changes` (line 238), and the interactive "pick one" selector (line 97). _Converged onto the canonical `getAvailableChanges` lister via a private `listChangeIds` helper (sorted to preserve prior ordering)._ +- [x] 1.3 Confirm correctness within a `--store`-selected root (resolution already shares `resolveRootForCommand`); add a store-root resolution test. No store-specific scenario beyond parity is required. _Store-correct for free: `validate` resolves the store root through the same `resolveRootForCommand` as `status`, and the change predicate now matches; no dedicated store fixture added, per the design note._ +- [x] 1.4 Preserve change/spec ambiguity and `--type` override behavior; reconcile the directory-existence change predicate with the spec predicate. Leave the spec-resolution side (`getSpecIds`) unchanged — it is correct. _`getSpecIds` untouched; ambiguity test still green._ +- [x] 1.5 Sibling `src/commands/show.ts:81,115,121` shares the `getActiveChangeIds` gate — fold it onto the same resolution or record an explicit out-of-scope note. Add a one-line scope note that the deprecated noun-form `change validate` already resolves by directory existence but is cwd-based and its JSON mode does not set a non-zero exit (pre-existing, out of scope). _DECISION: `show.ts` scoped OUT. `ChangeCommand.show` hard-requires `proposal.md` (throws "not found at .../proposal.md"), so folding it in would only convert "Unknown item" into a different downstream proposal-read error in a path no `cli-*` spec scenario covers. The deprecated noun-form `change validate` is likewise out of scope (cwd-based; JSON mode does not set a non-zero exit)._ +- [x] 1.6 Tests: proposal-less change resolves (targeted + bulk + interactive selector); store change resolves; ambiguity/`--type` unchanged; changes with `proposal.md` byte-identical; a resolved-but-invalid change exits non-zero (regression guard for the `--all` exit-0 observation). _Added to `test/commands/validate.test.ts`: scaffolded resolves (targeted), sole proposal-less change in `--all`, resolved-but-invalid exits non-zero. Interactive selector uses the same `listChangeIds`._ + +## 2. #1182b — nested multi-area delta discovery + +- [x] 2.1 Reproduce: a resolved change with deltas at `specs///spec.md` reports "No delta sections found"; one-level `specs//spec.md` is the control. +- [x] 2.2 Extend delta discovery in `src/core/validation/validator.ts` `validateChangeDeltaSpecs` (lines 115-138) to recurse the nested `specs/**` layout (the spec-driven specs glob is `specs/**/*.md`). _Added a recursive `findDeltaSpecFiles` walker collecting every `spec.md`; `entryPath` is now the POSIX relative path from `specs/`._ +- [x] 2.3 Tests: nested-layout change discovers and validates its deltas; single-level layout unchanged. _Added to `test/core/validation.test.ts`._ + +## 3. #1202 — task progress through the tracked-tasks artifact glob (view + archive + list) + +- [x] 3.1 Reproduce: project-local schema with tasks artifact `generates: "**/tasks.md"`; a change with `backend/tasks.md` + `frontend/tasks.md` (some unchecked); confirm `status` reports the tasks artifact present while `view` shows `Draft`, `list` shows "No tasks", and `archive` would let it archive unfinished. +- [x] 3.2 In `src/utils/task-progress.ts`, change `getTaskProgressForChange` to: identify the tracked-tasks artifact (the artifact whose `generates` equals the schema `apply.tracks` value, falling back to artifact id `tasks` when no `apply` block), then count checkboxes across `resolveArtifactOutputs(changeDir, artifact.generates)` (`src/core/artifact-graph/outputs.ts:17`, returns a de-duped, change-rooted path list). NOTE: `apply.tracks` is a filename that selects the artifact, NOT a glob — the glob is the artifact's `generates`. +- [x] 3.3 Add a required `projectRoot` parameter (needed for `resolveSchema` / project-local schemas); resolve schema → tracked artifact → `generates` inside the helper. +- [x] 3.4 Catch `resolveSchema` failure (it throws on an unresolvable/misnamed schema) and fall back to a single top-level `tasks.md`; preserve the no-schema / no-tracked-artifact / zero-match fallback and the swallowed-missing-file behavior. The helper MUST NOT throw. +- [x] 3.5 Update all four call sites for the new `projectRoot` argument: `src/core/view.ts:100` (`path.dirname(openspecDir)`), `src/core/list.ts:112` (`targetPath`), `src/core/archive.ts:342` and `:540` (`path.resolve(changesDir,'..','..')`). +- [x] 3.6 Fold the independent second copy in `src/commands/change.ts:111,164` (its own `countTasks`, JSON list + long list) onto the shared helper passing `process.cwd()`; drop the now-orphan `countTasks` and unused `TASK_PATTERN`/`COMPLETED_TASK_PATTERN` consts. +- [x] 3.7 Tests: nested-glob change aggregates and is not `Draft`; files-exist-but-unchecked is Active not Completed; `apply.tracks`-selected artifact resolves; resolution scoped to the change dir (archive/ and sibling changes excluded); no double-count; unresolvable-schema falls back without crashing; single-file and no-schema unchanged; zero-match stays Draft; `view`/`list`/`archive` resolve the same files as `status`. _`test/utils/task-progress.test.ts` (unit) + `test/core/view.test.ts` (Active classification) + `test/core/archive.test.ts` (gate)._ + +## 4. #1202 — archive incomplete-task gate (data safety) + +- [x] 4.1 Confirm `src/core/archive.ts:342,540` feed the incomplete-task gate (`archive.ts:348-353`). +- [x] 4.2 With the shared-helper fix in place, verify the gate sees nested/glob tasks (the empirical repro archived a 3/5 change — this must now block). _Verified end-to-end against the built CLI: `archive` now reports "2 incomplete task(s)" and exits non-zero for a 3/5 glob-tasks change._ +- [x] 4.3 Tests: a glob-tasks change with unchecked tasks is blocked (or requires explicit override); the gate resolves the same files as `view`; unresolvable-schema falls back without crash; single-file behavior unchanged. _Added to `test/core/archive.test.ts`; helper-level fallback/parity covered in `test/utils/task-progress.test.ts`._ + +## 5. #1156 — SHALL/MUST hint on main specs (header recovery + remove refine) + +- [x] 5.1 Reproduce: a main spec requirement with SHALL/MUST in the header only emits the generic message while the equivalent ADDED/MODIFIED delta emits the targeted hint; a RENAMED delta emits no hint; a header-only-no-body main spec is valid today. +- [x] 5.2 Recover the requirement header for main specs (lost at `src/core/parsers/markdown-parser.ts:220-226`) by reusing `src/core/parsers/requirement-blocks.ts` (`extractRequirementsSection`, header+body pairs). +- [x] 5.3 In `src/core/validation/validator.ts` `applySpecRules` (lines 290-329), run `containsShallOrMust` + `buildMissingShallOrMustMessage` on the recovered header/body so the imperative rule owns BOTH the header-only case (targeted hint) and the no-keyword-anywhere case (generic message). +- [x] 5.4 REMOVE the Zod refine from `RequirementSchema` (`src/core/schemas/base.schema.ts:11-14`) entirely (not merely relax it) — deltas never used it (they validate imperatively in `validateChangeDeltaSpecs`), so removal cannot regress the delta path, and it prevents double-emission on the main-spec path. +- [x] 5.5 Generalize `buildMissingShallOrMustMessage` to accept a prefix; main-spec prefix = `Requirement ""`, so the actionable sentence stays in one place and is byte-identical across paths. Converge lowercase handling onto the shared `\b(SHALL|MUST)\b` regex. Keep the delta-path message string unchanged. _Delta call sites now pass `ADDED ""` / `MODIFIED ""` prefixes, producing byte-identical strings._ +- [x] 5.6 Tests (assert across `validate `, `--all`, `--json`, `spec validate`, and `validateSpecContent`): header-only main spec → actionable sentence byte-identical to delta; exactly one issue; no-keyword-anywhere still errors; body-keyword not flagged; lowercase `shall` errors; header-only-no-body emits the hint (intended change); RENAMED emits no hint and is byte-for-byte unchanged. _Added a `main-spec SHALL/MUST body-keyword hint (#1156)` describe in `test/core/validation.test.ts` driving `validateSpecContent` (the shared surface for `validate`/`--all`/`--json`/`spec validate`/rebuilt-spec validation); the obsolete schema-refine unit test was updated to reflect the moved enforcement. End-to-end cases A–D verified against the built CLI._ + +## 6. Parity guard and verification + +- [x] 6.1 Add the cross-command parity assertions from design Decision 7 as regression tests (validate↔status resolution incl. exit code; view/list/archive resolve the same files as status; main-spec↔delta actionable sentence). +- [x] 6.2 Run `openspec validate fix-validate-view-resolution-parity --strict` and the full test suite; confirm no behavior change on the canonical paths and the documented unchanged cases (the header-only-no-body main-spec case is the one intended exception, per design Decision 6). _Change validates `--strict` (exit 0); all 36 repo specs pass `--specs --strict` (no #1156 false positives); full suite 1791 passed with only the pre-existing, environment-specific zsh-installer failures unchanged._ diff --git a/src/commands/change.ts b/src/commands/change.ts index eae9fffd48..561fb3d6ab 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -7,11 +7,10 @@ import { Change } from '../core/schemas/index.js'; import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; +import { getTaskProgressForChange } from '../utils/task-progress.js'; // Constants for better maintainability const ARCHIVE_DIR = 'archive'; -const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; -const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; export class ChangeCommand { private converter: JsonConverter; @@ -108,25 +107,17 @@ export class ChangeCommand { const changeDetails = await Promise.all( changes.map(async (changeName) => { const proposalPath = path.join(changesPath, changeName, 'proposal.md'); - const tasksPath = path.join(changesPath, changeName, 'tasks.md'); - + try { const content = await fs.readFile(proposalPath, 'utf-8'); const changeDir = path.join(changesPath, changeName); const parser = new ChangeParser(content, changeDir); const change = await parser.parseChangeWithDeltas(changeName); - - let taskStatus = { total: 0, completed: 0 }; - try { - const tasksContent = await fs.readFile(tasksPath, 'utf-8'); - taskStatus = this.countTasks(tasksContent); - } catch (error) { - // Tasks file may not exist, which is okay - if (process.env.DEBUG) { - console.error(`Failed to read tasks file at ${tasksPath}:`, error); - } - } - + + // Resolve task progress through the shared tracked-tasks helper so + // this deprecated noun-form list cannot re-fork the resolution (#1202). + const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + return { id: changeName, title: this.extractTitle(content, changeName), @@ -161,20 +152,11 @@ export class ChangeCommand { // Long format: id: title and minimal counts for (const changeName of sorted) { const proposalPath = path.join(changesPath, changeName, 'proposal.md'); - const tasksPath = path.join(changesPath, changeName, 'tasks.md'); try { const content = await fs.readFile(proposalPath, 'utf-8'); const title = this.extractTitle(content, changeName); - let taskStatusText = ''; - try { - const tasksContent = await fs.readFile(tasksPath, 'utf-8'); - const { total, completed } = this.countTasks(tasksContent); - taskStatusText = ` [tasks ${completed}/${total}]`; - } catch (error) { - if (process.env.DEBUG) { - console.error(`Failed to read tasks file at ${tasksPath}:`, error); - } - } + const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : ''; const changeDir = path.join(changesPath, changeName); const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir); const change = await parser.parseChangeWithDeltas(changeName); @@ -269,23 +251,6 @@ export class ChangeCommand { return match ? match[1].trim() : changeName; } - private countTasks(content: string): { total: number; completed: number } { - const lines = content.split('\n'); - let total = 0; - let completed = 0; - - for (const line of lines) { - if (line.match(TASK_PATTERN)) { - total++; - if (line.match(COMPLETED_TASK_PATTERN)) { - completed++; - } - } - } - - return { total, completed }; - } - private printNextSteps(): void { const bullets: string[] = []; bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 16b8161510..3ceccde9e5 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -10,7 +10,8 @@ import { isStoreSelectedRoot, } from '../core/root-selection.js'; import { isInteractive, resolveNoInteractive } from '../utils/interactive.js'; -import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; +import { getSpecIds } from '../utils/item-discovery.js'; +import { getAvailableChanges } from './workflow/shared.js'; import { nearestMatches } from '../utils/match.js'; type ItemType = 'change' | 'spec'; @@ -78,6 +79,18 @@ export class ValidateCommand { return undefined; } + /** + * Resolve change IDs by directory existence within the resolved root — the + * same rule `openspec status`/`instructions` use (`getAvailableChanges`) — + * rather than requiring `proposal.md`. This lets `validate` resolve a + * scaffolded or still-authoring change that the sibling commands already + * resolve (#1182). Sorted to preserve the prior `getActiveChangeIds` ordering. + */ + private async listChangeIds(root: ResolvedOpenSpecRoot): Promise { + const ids = await getAvailableChanges(root.path, root.changesDir); + return ids.sort(); + } + private async runInteractiveSelector(root: ResolvedOpenSpecRoot, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise { const { select } = await import('@inquirer/prompts'); const choice = await select({ @@ -95,7 +108,7 @@ export class ValidateCommand { if (choice === 'specs') return this.runBulkValidation(root, { changes: false, specs: true }, opts); // one - const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); + const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]); const items: { name: string; value: { type: ItemType; id: string } }[] = []; items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } }))); items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } }))); @@ -118,7 +131,7 @@ export class ValidateCommand { } private async validateDirectItem(root: ResolvedOpenSpecRoot, itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { - const [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); + const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); @@ -237,7 +250,7 @@ export class ValidateCommand { private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ - scope.changes ? getActiveChangeIds(root.path) : Promise.resolve([]), + scope.changes ? this.listChangeIds(root) : Promise.resolve([]), scope.specs ? getSpecIds(root.path) : Promise.resolve([]), ]); diff --git a/src/core/archive.ts b/src/core/archive.ts index f35e48d181..b5fbb3e7c0 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -341,7 +341,7 @@ export class ArchiveCommand { } // Show progress and check for incomplete tasks - const progress = await getTaskProgressForChange(changesDir, changeName); + const progress = await getTaskProgressForChange(changesDir, changeName, path.resolve(changesDir, '..', '..')); if (!json) { const status = formatTaskStatus(progress); console.log(`Task status: ${status}`); @@ -561,7 +561,7 @@ export class ArchiveCommand { try { const progressList: Array<{ id: string; status: string }> = []; for (const id of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, id); + const progress = await getTaskProgressForChange(changesDir, id, path.resolve(changesDir, '..', '..')); const status = formatTaskStatus(progress); progressList.push({ id, status }); } diff --git a/src/core/list.ts b/src/core/list.ts index 28e4c2fc27..8e4d0a9ed7 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -109,7 +109,7 @@ export class ListCommand { const changes: ChangeInfo[] = []; for (const changeDir of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, changeDir); + const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); const changePath = path.join(changesDir, changeDir); const lastModified = await getLastModified(changePath); changes.push({ diff --git a/src/core/schemas/base.schema.ts b/src/core/schemas/base.schema.ts index 548ef35e56..aa08cea1e9 100644 --- a/src/core/schemas/base.schema.ts +++ b/src/core/schemas/base.schema.ts @@ -6,12 +6,14 @@ export const ScenarioSchema = z.object({ }); export const RequirementSchema = z.object({ + // SHALL/MUST body-keyword enforcement lives in the imperative validator + // (Validator.applySpecRules), not here: the parser collapses the requirement + // header into `text`, so a Zod refine on `text` cannot tell "keyword in header + // only" from "keyword in body" and emits a misleading generic error. The + // validator recovers the header and emits the targeted hint for both the + // main-spec and change-delta paths (#1156). text: z.string() - .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY) - .refine( - (text) => text.includes('SHALL') || text.includes('MUST'), - VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL - ), + .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY), scenarios: z.array(ScenarioSchema) .min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS), }); diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 47071ed477..4f896fb3a4 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -10,7 +10,7 @@ import { MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js'; -import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js'; +import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js'; import { findMainSpecStructureIssues } from '../parsers/spec-structure.js'; import { FileSystemUtils } from '../../utils/file-system.js'; @@ -120,11 +120,12 @@ export class Validator { const emptySectionSpecs: Array<{ path: string; sections: string[] }> = []; try { - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const specName = entry.name; - const specFile = path.join(specsDir, specName, 'spec.md'); + // Discover delta specs at any depth so the nested multi-area layout + // (specs///spec.md) is validated, not just the + // one-level specs//spec.md layout (#1182b). The spec-driven + // specs glob is specs/**/*.md; delta files are always named spec.md. + const specFiles = await this.findDeltaSpecFiles(specsDir); + for (const specFile of specFiles) { let content: string | undefined; try { content = await fs.readFile(specFile, 'utf-8'); @@ -133,7 +134,7 @@ export class Validator { } const plan = parseDeltaSpec(content); - const entryPath = `${specName}/spec.md`; + const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile)); const sectionNames: string[] = []; if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements'); if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements'); @@ -165,7 +166,7 @@ export class Validator { if (!requirementText) { issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` }); } else if (!this.containsShallOrMust(requirementText)) { - issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('ADDED', block.name) }); + issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) }); } const scenarioCount = this.countScenarios(block.raw); if (scenarioCount < 1) { @@ -186,7 +187,7 @@ export class Validator { if (!requirementText) { issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` }); } else if (!this.containsShallOrMust(requirementText)) { - issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage('MODIFIED', block.name) }); + issues.push({ level: 'ERROR', path: entryPath, message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) }); } const scenarioCount = this.countScenarios(block.raw); if (scenarioCount < 1) { @@ -273,6 +274,34 @@ export class Validator { return this.createReport(issues); } + /** + * Recursively collect every delta `spec.md` under a change's specs directory, + * so both the one-level (specs//spec.md) and nested multi-area + * (specs///spec.md) layouts are discovered (#1182b). + * Returns absolute paths, sorted for deterministic issue ordering. + */ + private async findDeltaSpecFiles(specsDir: string): Promise { + const results: string[] = []; + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile() && entry.name === 'spec.md') { + results.push(full); + } + } + }; + await walk(specsDir); + return results.sort(); + } + private convertZodErrors(error: ZodError): ValidationIssue[] { return error.issues.map(err => { let message = err.message; @@ -315,7 +344,7 @@ export class Validator { message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG, }); } - + if (req.scenarios.length === 0) { issues.push({ level: 'WARNING', @@ -324,7 +353,25 @@ export class Validator { }); } }); - + + // SHALL/MUST body-keyword enforcement for main specs (#1156). The main-spec + // parser collapses the requirement header into `text`, so we recover the + // header+body pairs here (the same source the delta path trusts) and reuse + // the delta detection: a body that omits the keyword errors, with the + // targeted "move it to the body line" hint when the keyword is in the header + // only and the generic message otherwise. Emitted exactly once per + // requirement (the Zod refine that used to emit a generic error is removed). + extractRequirementsSection(content).bodyBlocks.forEach((block, index) => { + const requirementText = this.extractRequirementText(block.raw); + if (!requirementText || !this.containsShallOrMust(requirementText)) { + issues.push({ + level: 'ERROR', + path: `requirements[${index}]`, + message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name), + }); + } + }); + return issues; } @@ -454,8 +501,8 @@ export class Validator { * on the requirement body line (the line right after the header), so we point * the author at that exact fix when the keyword is found in the header only. */ - private buildMissingShallOrMustMessage(action: 'ADDED' | 'MODIFIED', blockName: string): string { - const base = `${action} "${blockName}" must contain SHALL or MUST`; + private buildMissingShallOrMustMessage(prefix: string, blockName: string): string { + const base = `${prefix} must contain SHALL or MUST`; if (this.containsShallOrMust(blockName)) { return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`; } diff --git a/src/core/view.ts b/src/core/view.ts index e67c352688..343775bb5d 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -97,7 +97,7 @@ export class ViewCommand { for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'archive') { - const progress = await getTaskProgressForChange(changesDir, entry.name); + const progress = await getTaskProgressForChange(changesDir, entry.name, path.dirname(openspecDir)); if (progress.total === 0) { // No tasks defined yet - still in planning/draft phase diff --git a/src/utils/task-progress.ts b/src/utils/task-progress.ts index a14b866f08..e45c274162 100644 --- a/src/utils/task-progress.ts +++ b/src/utils/task-progress.ts @@ -1,5 +1,8 @@ import { promises as fs } from 'fs'; import path from 'path'; +import type { Artifact, SchemaYaml } from '../core/artifact-graph/index.js'; +import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/index.js'; +import { resolveSchemaForChange } from './change-metadata.js'; const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; @@ -24,8 +27,38 @@ export function countTasksFromContent(content: string): TaskProgress { return { total, completed }; } -export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise { - const tasksPath = path.join(changesDir, changeName, 'tasks.md'); +/** + * Identifies the change's tracked-tasks artifact: the artifact whose `generates` + * equals the schema's `apply.tracks` value, falling back to the artifact with id + * `tasks` when no `apply` block declares what it tracks. (`apply.tracks` is a + * filename that *selects* the artifact; the glob is that artifact's `generates`.) + */ +function findTrackedTasksArtifact(schema: SchemaYaml): Artifact | undefined { + const tracks = schema.apply?.tracks; + if (tracks != null) { + return schema.artifacts.find((a) => a.generates === tracks); + } + return schema.artifacts.find((a) => a.id === 'tasks'); +} + +/** + * Resolves the tracked-tasks artifact's output glob for a change, or undefined + * when the schema cannot be resolved or no tracked-tasks artifact exists. + * `resolveSchema` throws on an unresolvable/misnamed schema; we swallow that so + * the caller falls back to a single top-level `tasks.md` and never crashes. + */ +function resolveTrackedTasksGlob(changeDir: string, projectRoot: string): string | undefined { + try { + const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); + return findTrackedTasksArtifact(schema)?.generates; + } catch { + return undefined; + } +} + +async function countSingleTopLevelTasksFile(changeDir: string): Promise { + const tasksPath = path.join(changeDir, 'tasks.md'); try { const content = await fs.readFile(tasksPath, 'utf-8'); return countTasksFromContent(content); @@ -34,6 +67,45 @@ export async function getTaskProgressForChange(changesDir: string, changeName: s } } +/** + * Computes a change's task progress by resolving its tracked-tasks artifact and + * counting checkboxes across every file matched by that artifact's `generates` + * glob — the same file-resolution `openspec status` uses to detect the tasks + * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested + * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly + * as before) when the schema is unresolvable, no tracked-tasks artifact is found, + * or the glob matches no file. Never throws. + */ +export async function getTaskProgressForChange( + changesDir: string, + changeName: string, + projectRoot: string +): Promise { + const changeDir = path.join(changesDir, changeName); + + const generates = resolveTrackedTasksGlob(changeDir, projectRoot); + if (generates) { + const files = resolveArtifactOutputs(changeDir, generates); + if (files.length > 0) { + let total = 0; + let completed = 0; + for (const file of files) { + try { + const content = await fs.readFile(file, 'utf-8'); + const progress = countTasksFromContent(content); + total += progress.total; + completed += progress.completed; + } catch { + // Swallow files that vanish between glob and read, as before. + } + } + return { total, completed }; + } + } + + return countSingleTopLevelTasksFile(changeDir); +} + export function formatTaskStatus(progress: TaskProgress): string { if (progress.total === 0) return 'No tasks'; if (progress.completed === progress.total) return '✓ Complete'; diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index b94f72d351..65e9ce80e2 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -131,6 +131,59 @@ describe('top-level validate command', () => { expect(result.exitCode).toBe(0); }); + // #1182 — validate resolves a change by directory existence (matching + // status/instructions), not by requiring proposal.md. + const validDelta = [ + '## ADDED Requirements', + '### Requirement: Scaffolded change SHALL validate without a proposal', + 'The change SHALL validate by directory existence without a proposal file.', + '', + '#### Scenario: Validate scaffolded change', + '- **GIVEN** a change directory with no proposal.md', + '- **WHEN** openspec validate runs', + '- **THEN** the change resolves and its deltas are validated', + ].join('\n'); + + it('resolves and validates a scaffolded change without proposal.md (#1182)', async () => { + const changeDir = path.join(changesDir, 'scaffolded'); + const deltaDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + await fs.writeFile(path.join(deltaDir, 'spec.md'), validDelta, 'utf-8'); + + const result = await runCLI(['validate', 'scaffolded'], { cwd: testDir }); + expect(result.stderr).not.toContain('Unknown item'); + expect(result.exitCode).toBe(0); + }); + + it('a resolved-but-invalid proposal-less change exits non-zero, not "Unknown item" (#1182)', async () => { + // Resolves by directory existence, then fails validation (no deltas). + const changeDir = path.join(changesDir, 'scaffolded-empty'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + + const result = await runCLI(['validate', 'scaffolded-empty'], { cwd: testDir }); + expect(result.stderr).not.toContain('Unknown item'); + expect(result.exitCode).toBe(1); + }); + + it('includes a sole proposal-less change in --all (not "No items found") (#1182)', async () => { + const isoRoot = path.join(projectRoot, 'test-validate-iso-tmp'); + const isoChanges = path.join(isoRoot, 'openspec', 'changes'); + const deltaDir = path.join(isoChanges, 'only', 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + try { + await fs.writeFile(path.join(isoChanges, 'only', '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + await fs.writeFile(path.join(deltaDir, 'spec.md'), validDelta, 'utf-8'); + + const result = await runCLI(['validate', '--all'], { cwd: isoRoot }); + expect(result.stdout + result.stderr).not.toContain('No items found to validate'); + expect(result.exitCode).toBe(0); + } finally { + await fs.rm(isoRoot, { recursive: true, force: true }); + } + }); + it('respects --no-interactive flag passed via CLI', async () => { // This test ensures Commander.js --no-interactive flag is correctly parsed // and passed to the validate command. The flag sets options.interactive = false diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 977508929c..ddd0658bec 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -105,6 +105,50 @@ describe('ArchiveCommand', () => { ); }); + it('detects incomplete tasks in nested glob tasks.md files (#1202 data-safety gate)', async () => { + // Before the fix the gate read a fixed changes//tasks.md, saw zero + // tasks for a glob-tasks change, and let an unfinished change archive. + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: glob-tasks', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n') + ); + + const changeName = 'glob-incomplete-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true }); + await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n'); + await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n'); + await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n'); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true }); + + // The gate now sees 5 tasks / 2 incomplete across the nested files. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('2 incomplete task(s) found') + ); + }); + it('should update specs when archiving (delta-based ADDED) and include change name in skeleton', async () => { const changeName = 'spec-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); diff --git a/test/core/validation.test.ts b/test/core/validation.test.ts index 72ebc2aba6..d7104aa42d 100644 --- a/test/core/validation.test.ts +++ b/test/core/validation.test.ts @@ -49,7 +49,11 @@ describe('Validation Schemas', () => { expect(result.success).toBe(true); }); - it('should reject requirement without SHALL or MUST', () => { + it('no longer enforces SHALL or MUST at the schema level (moved to the validator)', () => { + // SHALL/MUST body-keyword enforcement moved out of the Zod refine and into + // Validator.applySpecRules so it can recover the requirement header and + // emit the targeted body-keyword hint (#1156). The schema therefore accepts + // a body without the keyword; the validator (exercised below) reports it. const requirement = { text: 'The system provides user authentication', scenarios: [ @@ -58,12 +62,9 @@ describe('Validation Schemas', () => { }, ], }; - + const result = RequirementSchema.safeParse(requirement); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toBe('Requirement must contain SHALL or MUST keyword'); - } + expect(result.success).toBe(true); }); it('should reject requirement without scenarios', () => { @@ -676,5 +677,137 @@ The system MUST support mixed case delta headers. expect(report.summary.warnings).toBe(0); expect(report.summary.info).toBe(0); }); + + // #1182b — delta discovery recurses the nested multi-area layout. + it('discovers and validates deltas in a nested specs// layout (#1182b)', async () => { + const changeDir = path.join(testDir, 'test-change-nested'); + const nestedDir = path.join(changeDir, 'specs', 'area-one', 'cap-a'); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile( + path.join(nestedDir, 'spec.md'), + `## ADDED Requirements\n\n### Requirement: Nested capability\nThe system SHALL support nested multi-area delta layouts.\n\n#### Scenario: Nested delta is discovered\n- **WHEN** validating a change with nested specs\n- **THEN** the delta is found and validated` + ); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.issues.some(i => i.message.includes('No delta sections found'))).toBe(false); + expect(report.issues.some(i => i.message.includes('No deltas found'))).toBe(false); + expect(report.valid).toBe(true); + }); + + it('still validates a single-level layout unchanged (#1182b control)', async () => { + const changeDir = path.join(testDir, 'test-change-onelevel'); + const oneLevelDir = path.join(changeDir, 'specs', 'cap-a'); + await fs.mkdir(oneLevelDir, { recursive: true }); + await fs.writeFile( + path.join(oneLevelDir, 'spec.md'), + `## ADDED Requirements\n\n### Requirement: One level capability\nThe system SHALL support a one-level layout.\n\n#### Scenario: One level delta\n- **WHEN** validating\n- **THEN** the delta is found` + ); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + }); + + // #1156 — the SHALL/MUST body-keyword hint applies to main specs too, with the + // actionable sentence byte-identical to the change-delta path, emitted once. + describe('main-spec SHALL/MUST body-keyword hint (#1156)', () => { + const ACTIONABLE_SENTENCE = + 'must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.'; + + const buildSpec = (requirementBlock: string): string => + [ + '# Demo Spec', + '', + '## Purpose', + 'A purpose long enough to satisfy the validator length threshold for tests.', + '', + '## Requirements', + '', + requirementBlock, + ].join('\n'); + + const shallIssues = (issues: { message: string }[]) => + issues.filter(i => i.message.includes('SHALL or MUST')); + + it('emits the targeted hint when the keyword is in the header only (with a body line)', async () => { + const content = buildSpec( + '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); // exactly one, no duplicate generic + expect(issues[0].message).toContain('not only in the header'); + expect(issues[0].message).toContain(ACTIONABLE_SENTENCE); + }); + + it('uses an actionable sentence byte-identical to the change-delta message', async () => { + const block = + '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'; + + const specReport = await new Validator().validateSpecContent('demo', buildSpec(block)); + const specMsg = shallIssues(specReport.issues)[0].message; + + const changeDir = path.join(testDir, 'change-parity-sentence'); + const deltaDir = path.join(changeDir, 'specs', 'cap'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), `## ADDED Requirements\n\n${block}`); + const deltaReport = await new Validator().validateChangeDeltaSpecs(changeDir); + const deltaMsg = shallIssues(deltaReport.issues)[0].message; + + // Same actionable sentence; only the leading prefix differs. + expect(specMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true); + expect(deltaMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true); + expect(specMsg.startsWith('Requirement "The system SHALL log"')).toBe(true); + expect(deltaMsg.startsWith('ADDED "The system SHALL log"')).toBe(true); + }); + + it('keeps a generic missing-keyword error when neither header nor body has the keyword', async () => { + const content = buildSpec( + '### Requirement: Logging\nThe system will log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); + expect(issues[0].message).not.toContain('not only in the header'); + }); + + it('does not flag a requirement whose body line contains the keyword', async () => { + const content = buildSpec( + '### Requirement: Logging\nThe system SHALL log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + expect(shallIssues(report.issues)).toHaveLength(0); + }); + + it('rejects a lowercase shall/must in the body (matching the delta path)', async () => { + const content = buildSpec( + '### Requirement: Logging\nthe system shall log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + expect(shallIssues(report.issues)).toHaveLength(1); + }); + + it('emits the hint for a header-only requirement with no body line (intended additive change)', async () => { + const content = buildSpec( + '### Requirement: The system MUST be available\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('not only in the header'); + }); + + it('does not subject RENAMED requirements to the hint (byte-for-byte unchanged)', async () => { + const changeDir = path.join(testDir, 'change-renamed'); + const deltaDir = path.join(changeDir, 'specs', 'cap'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + '## RENAMED Requirements\n\n- FROM: `### Requirement: Old name`\n- TO: `### Requirement: The system SHALL do the new thing`\n' + ); + const report = await new Validator().validateChangeDeltaSpecs(changeDir); + expect(report.issues.some(i => i.message.includes('not only in the header'))).toBe(false); + }); }); }); diff --git a/test/core/view.test.ts b/test/core/view.test.ts index b8b56df1e5..653bb8624e 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -125,5 +125,54 @@ describe('ViewCommand', () => { 'gamma-change' ]); }); + + it('classifies a nested glob-tasks change as Active, not Draft (#1202)', async () => { + const openspecDir = path.join(tempDir, 'openspec'); + const changesDir = path.join(openspecDir, 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + + // Project-local schema whose tasks artifact resolves a nested glob. + const schemaDir = path.join(openspecDir, 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: glob-tasks', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n') + ); + + const changeDir = path.join(changesDir, 'nested-change'); + await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true }); + await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n'); + await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n'); + await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n'); + + await new ViewCommand().execute(tempDir); + const output = logOutput.map(stripAnsi).join('\n'); + + // Active section lists the change with aggregated 3/5 progress; not Draft. + const activeLines = logOutput.map(stripAnsi).filter(line => line.includes('◉')); + expect(activeLines.some(line => line.includes('nested-change'))).toBe(true); + const draftLines = logOutput.map(stripAnsi).filter(line => line.includes('○')); + expect(draftLines.some(line => line.includes('nested-change'))).toBe(false); + expect(output).toContain('60%'); + }); }); diff --git a/test/utils/task-progress.test.ts b/test/utils/task-progress.test.ts new file mode 100644 index 0000000000..33f89794a9 --- /dev/null +++ b/test/utils/task-progress.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { getTaskProgressForChange } from '../../src/utils/task-progress.js'; +import { resolveArtifactOutputs } from '../../src/core/artifact-graph/index.js'; + +/** + * #1202 — task progress is resolved through the tracked-tasks artifact's + * `generates` glob (the same file-resolution `openspec status` uses), not a + * fixed `changes//tasks.md` path. + */ +describe('getTaskProgressForChange (#1202 tracked-tasks resolution)', () => { + let projectRoot: string; + let changesDir: string; + + const GLOB_SCHEMA = [ + 'name: glob-tasks', + 'version: 1', + 'description: tasks artifact uses a nested glob', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n'); + + beforeEach(async () => { + projectRoot = path.join(os.tmpdir(), `openspec-taskprogress-${Date.now()}-${Math.round(performance.now())}`); + changesDir = path.join(projectRoot, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + async function writeGlobSchema(): Promise { + const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile(path.join(schemaDir, 'schema.yaml'), GLOB_SCHEMA, 'utf-8'); + } + + async function writeChange(name: string, files: Record, schema = 'glob-tasks'): Promise { + const changeDir = path.join(changesDir, name); + await fs.mkdir(changeDir, { recursive: true }); + if (schema) { + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), `schema: ${schema}\n`, 'utf-8'); + } + for (const [rel, content] of Object.entries(files)) { + const full = path.join(changeDir, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, content, 'utf-8'); + } + return changeDir; + } + + it('aggregates checkboxes across nested tasks.md files matched by the glob', async () => { + await writeGlobSchema(); + await writeChange('globchange', { + 'backend/tasks.md': '- [x] 1.1 a\n- [x] 1.2 b\n', + 'frontend/tasks.md': '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n', + }); + + const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot); + expect(progress).toEqual({ total: 5, completed: 3 }); + }); + + it('resolves the same set of files status resolves (resolution-mechanism parity)', async () => { + await writeGlobSchema(); + const changeDir = await writeChange('globchange', { + 'backend/tasks.md': '- [x] a\n- [x] b\n', + 'frontend/tasks.md': '- [x] a\n- [ ] b\n- [ ] c\n', + }); + + // `status` detects the tasks artifact via resolveArtifactOutputs(changeDir, generates). + const statusFiles = resolveArtifactOutputs(changeDir, '**/tasks.md'); + expect(statusFiles).toHaveLength(2); + + // The helper's aggregate equals the checkbox sum over exactly those files. + let total = 0; + let completed = 0; + for (const file of statusFiles) { + const content = await fs.readFile(file, 'utf-8'); + total += (content.match(/^[-*]\s+\[[\sx]\]/gim) ?? []).length; + completed += (content.match(/^[-*]\s+\[x\]/gim) ?? []).length; + } + const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot); + expect(progress).toEqual({ total, completed }); + }); + + it('scopes resolution to the change dir (excludes archive/ and sibling changes)', async () => { + await writeGlobSchema(); + await writeChange('target', { 'backend/tasks.md': '- [x] a\n- [ ] b\n' }); + // Decoys that must NOT be counted. + await fs.mkdir(path.join(changesDir, 'archive', 'old'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'archive', 'old', 'tasks.md'), '- [x] x\n- [x] y\n', 'utf-8'); + await writeChange('sibling', { 'backend/tasks.md': '- [x] s1\n- [x] s2\n' }); + + const progress = await getTaskProgressForChange(changesDir, 'target', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('identifies the tracked artifact by apply.tracks even when it is not named "tasks"', async () => { + const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'custom-track'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: custom-track', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: checklist', + ' generates: "work/*.md"', + ' description: Work checklist', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [checklist]', + ' tracks: "work/*.md"', + '', + ].join('\n'), + 'utf-8' + ); + await writeChange('customchange', { 'work/a.md': '- [x] a\n- [ ] b\n' }, 'custom-track'); + + const progress = await getTaskProgressForChange(changesDir, 'customchange', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('falls back to a single top-level tasks.md when the schema cannot be resolved (no crash)', async () => { + await writeChange('badschema', { 'tasks.md': '- [x] a\n- [ ] b\n' }, 'does-not-exist'); + + const progress = await getTaskProgressForChange(changesDir, 'badschema', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('counts a single top-level tasks.md unchanged under the default schema', async () => { + // No project-local schema, no .openspec.yaml -> default spec-driven (tracks tasks.md). + await writeChange('plain', { 'tasks.md': '- [x] a\n- [x] b\n- [ ] c\n' }, ''); + + const progress = await getTaskProgressForChange(changesDir, 'plain', projectRoot); + expect(progress).toEqual({ total: 3, completed: 2 }); + }); + + it('reports zero tasks when no file matches the tracked glob', async () => { + await writeGlobSchema(); + await writeChange('notasks', {}); // schema set, but no tasks.md anywhere + + const progress = await getTaskProgressForChange(changesDir, 'notasks', projectRoot); + expect(progress).toEqual({ total: 0, completed: 0 }); + }); +});