diff --git a/.changeset/share-schema-stores.md b/.changeset/share-schema-stores.md new file mode 100644 index 0000000000..20bd0a4cd4 --- /dev/null +++ b/.changeset/share-schema-stores.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features + +- **Schema Store sources** — Select a registered Store as the shared source for workflow schemas and templates, independently from the local or Store-backed planning root, with optional exact-name visibility controls. diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 65e2004ae7..8cd8dbe290 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -137,5 +137,5 @@ Recorded by the capstone audit; published-key renames are product decisions defe 4. Four parallel envelope type declarations exist in src; archive diagnostics never carry `target`. 5. `list --json` reuses the `status` key as a string enum per change. 6. Only `validate` output carries a `version` field. -7. `schemas`/`templates` ignore root selection (cwd-based, no `--store`). +7. `schemas`/`templates` do not accept `--store`; they resolve schemas from the nearest consumer config, including its `schemaStore`. 8. Deprecated noun forms (`change`/`spec` subcommands) emit unenveloped payloads without `root`/`status`. diff --git a/docs/cli.md b/docs/cli.md index 04cb514d2d..fffe263b30 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -361,6 +361,24 @@ store: team-context Normal commands then resolve to the declared store automatically; the root banner and JSON `root` block report `source: "declared"` with the store id, and printed hints still carry `--store `. The declaration is a fallback, never an override: explicit `--store` always wins, and a directory with real planning folders ignores the pointer (with a warning). To convert a pointer repo into a local OpenSpec root, remove the `store:` line and run `openspec init` — init refuses to scaffold while the declaration is present. +Select a registered Store as the schema and template source without changing +the planning root: + +```yaml +schema: qeda-sdd +schemaStore: + id: department-schemas + schemas: [qeda-sdd, frontend-sdd] +``` + +`schemaStore: department-schemas`, an omitted `schemas` field, and +`schemas: ["*"]` all expose every schema. An exact list exposes only those +Store schemas; hidden names can still fall back to user or package schemas. +Schema Store entries take precedence over user and package entries, and replace +consumer-local schemas while configured. `schemas`, `schema which`, schema +validation/forking, templates, and workflow commands all apply the same filter. +JSON source reporting uses `source: "store"` plus `storeId`. + A machine-level variant covers every repo at once: `openspec config set defaultStore ` (see Configuration). It is consulted only after `--store`, a local root, and a project pointer have all failed to resolve; the root banner and JSON `root` block then report `source: "global_default"`. ## Doctor (relationship health) @@ -915,6 +933,10 @@ Commands for creating and managing custom workflow schemas. Create a new project-local schema. +This command refuses to create an invisible project-local schema when +`schemaStore` is configured. Edit the registered Schema Store directly, or +remove `schemaStore` first. + ``` openspec schema init [options] ``` @@ -967,6 +989,10 @@ openspec/schemas// Copy an existing schema to your project for customization. +This command refuses to create an invisible project-local schema when +`schemaStore` is configured. Edit the registered Schema Store directly, or +remove `schemaStore` first. + ``` openspec schema fork [name] [options] ``` diff --git a/docs/customization.md b/docs/customization.md index cf8c145752..489b5e4331 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -170,6 +170,53 @@ your-project/ └── src/ ``` +### Share Schemas from a Store + +For schemas maintained by another team or department, register that repository +as a normal OpenSpec Store and select it independently from the location that +owns changes and specs: + +```yaml +# openspec/config.yaml +schema: qeda-sdd +schemaStore: + id: department-schemas + schemas: + - qeda-sdd + - frontend-sdd +``` + +The scalar form exposes every schema in the Store: + +```yaml +schemaStore: department-schemas +``` + +Omitting `schemas`, or writing `schemas: ["*"]`, has the same all-visible +behavior. Otherwise the list contains exact schema names; `*` cannot be mixed +with names. + +When `schemaStore` is configured, its visible schemas replace the project's +local schema layer. Resolution order is: + +1. visible schemas from the configured Schema Store; +2. user schemas; +3. package schemas. + +The visibility list filters only the Store. A hidden Store schema can still +resolve from the user or package layer. Without `schemaStore`, existing +project → user → package behavior is unchanged. + +OpenSpec reads the registered checkout's current files. It does not fetch, +pull, pin, or otherwise synchronize that repository during schema or workflow +commands. Update the checkout with normal Git commands; one registered checkout +is shared by every local consumer that names that Store. + +Because a configured Schema Store replaces the project-local schema layer, +`openspec schema init` and `openspec schema fork` do not create local schemas +in that consumer. Edit the registered Store directly, or remove `schemaStore` +before creating a project-local schema. + ### Fork an Existing Schema The fastest way to customize is to fork a built-in schema: diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index be7cdab35d..62d0663f88 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -148,6 +148,49 @@ The pointer is a fallback, never an override: an explicit `--store` always wins, and if the repo grows real planning folders of its own, those win (with a warning to remove the stale pointer). +**Sharing workflow schemas independently.** A Store can also be selected only +for schemas and templates. This keeps changes and specs in the local repo: + +```yaml +# web-app/openspec/config.yaml +schema: qeda-sdd +schemaStore: + id: department-schemas + schemas: [qeda-sdd, frontend-sdd] +``` + +Or combine two Store roles explicitly: + +```yaml +store: department-planning +schema: qeda-sdd +schemaStore: department-schemas +``` + +Here `department-planning` owns specs, changes, and archives, while +`department-schemas` contributes the allowed schemas. Use +`schemaStore: department-schemas` when every schema should be visible; the +object form also defaults to `*` when `schemas` is omitted. + +Set up or clone the schema repository normally, then register it on each +machine: + +```bash +git clone git@github.com:acme/department-schemas.git +openspec store register ./department-schemas --id department-schemas +``` + +Schema resolution never runs Git commands. Pull or switch the registered +checkout yourself; its current working tree is immediately visible to every +local consumer. OpenSpec does not pin separate commits per project. Use +`openspec schema which ` to see the winning source and +`openspec store doctor department-schemas` when Store identity is unhealthy. + +Use `openspec schema init` and `openspec schema fork` only when the consumer +owns its project-local schema layer; while `schemaStore` is configured, those +commands fail before writing an invisible local schema. Edit the registered +Schema Store directly, or remove `schemaStore` first. + **One default for every repo on your machine.** If you work across many code repos that all plan into the same store, set it once, globally, instead of adding the `store:` line to each repo: @@ -335,9 +378,10 @@ tells you which case you're in. `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`. +- **Schema inspection follows the consumer config.** `templates` and `schemas` + do not accept `--store`; they resolve the nearest consumer project's + `schemaStore`. Deprecated noun forms (`openspec change show`, ...) remain + current-directory commands. - **Per-machine state is per-machine.** The store registry and worksets are local settings. Nothing about your machine's layout is ever committed to shared planning. diff --git a/openspec/changes/add-schema-store-sources/.openspec.yaml b/openspec/changes/add-schema-store-sources/.openspec.yaml new file mode 100644 index 0000000000..e8209ffaac --- /dev/null +++ b/openspec/changes/add-schema-store-sources/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-28 diff --git a/openspec/changes/add-schema-store-sources/design.md b/openspec/changes/add-schema-store-sources/design.md new file mode 100644 index 0000000000..47f00d33ec --- /dev/null +++ b/openspec/changes/add-schema-store-sources/design.md @@ -0,0 +1,203 @@ +## Context + +OpenSpec currently resolves one active planning root. That root owns `openspec/specs`, `openspec/changes`, project configuration, and project-local schemas. A config-only consumer repository may redirect planning to a registered Store with `store: `, but schema resolution then follows the planning Store as part of the same root. + +The department use case needs two independent roles: + +- a planning root that owns specs, changes, and archives; and +- a schema root that owns reusable workflow schemas and templates. + +Both roots can already be represented by registered Store checkouts. Store checkout synchronization deliberately remains a normal Git responsibility. The design must therefore reuse Store identity and registry resolution, preserve synchronous schema parsing, and avoid the Git fetch, lockfile, cache, and integrity machinery of a separate remote-source subsystem. + +## Goals / Non-Goals + +**Goals:** + +- Allow a consumer project to select one registered Store as its schema source without redirecting planning. +- Allow a project to combine local planning, a planning Store, and a different schema Store. +- Let the consumer restrict which schemas from the schema Store participate in discovery and resolution. +- Keep current schema behavior byte-compatible when `schemaStore` is absent. +- Resolve Store IDs once through the existing registry and pass canonical local paths to synchronous schema code. +- Produce clear, machine-readable diagnostics for invalid declarations and unavailable Store checkouts. + +**Non-Goals:** + +- Fetching, cloning, pulling, committing, or pushing Store repositories. +- Pinning a schema Store to a commit per consumer project. +- Content-addressed caches, schema lockfiles, or bundle integrity hashes. +- Combining schemas from multiple schema Stores in one consumer. +- Glob matching beyond the special all-visible token `*`. +- Schema merging or inheritance across roots. +- Changing how Store Git drift is detected or repaired. + +## Decisions + +### 1. Use role-specific Store declarations + +The existing `store` field continues to select the planning Store. A new `schemaStore` field selects the schema Store: + +```yaml +store: department-planning +schema: qeda-sdd +schemaStore: department-schemas +``` + +Scalar `schemaStore` is shorthand for the object form with every schema visible: + +```yaml +schemaStore: + id: department-schemas + schemas: + - "*" +``` + +This is preferred over overloading `store` with a mode flag because the configuration states both roles directly and remains reproducible for humans, agents, and CI. + +Alternative considered: a command-only `--schema-store` flag. Rejected as the primary contract because every lifecycle command would need the flag and omissions could resolve a different schema. A future CLI override can be added independently if a concrete use case appears. + +Alternative considered: a general array of mounted Stores with arbitrary roles. Rejected because the current requirement has exactly two roles and a generalized mount graph would add ordering, conflict, and diagnostic complexity without a demonstrated need. + +### 2. Normalize one strict visibility model + +The normalized declaration is: + +```ts +interface SchemaStoreDeclaration { + id: string; + schemas: '*' | string[]; +} +``` + +Rules: + +- a scalar declaration normalizes to `{ id, schemas: '*' }`; +- an object without `schemas` also defaults to `'*'`; +- `schemas: ["*"]` is the explicit all-visible form; +- otherwise `schemas` is a non-empty, duplicate-free list of exact valid schema names; +- `*` cannot be combined with names; +- empty lists, unsupported fields, invalid Store IDs, invalid schema names, and non-string values are invalid declarations. + +The visibility filter applies only to schemas contributed by the schema Store. User and package schemas retain their existing behavior. A hidden Store schema does not participate in discovery, resolution, shadow reporting, or suggestions. + +Exact names are preferred over general globs because schema names are already a finite discoverable set and exact matching avoids platform-dependent pattern behavior. + +### 3. Fail closed for an explicitly invalid or unavailable schema Store + +Generic project-config loading remains resilient and warns field-by-field. Schema context resolution additionally reads the declaration as an authority-bearing field: + +- malformed `schemaStore` fails schema-context resolution instead of silently falling back; +- an unknown Store ID points to `openspec store register`; +- missing or mismatched Store identity points to `openspec store doctor `; +- a missing `openspec/schemas` directory is treated as an empty schema Store, so a newly created Store can be populated incrementally; +- a configured schema that is absent or hidden reports the visible Store schemas and normal fallback candidates. + +Failing closed prevents a typo in `schemaStore` from silently selecting a user or package schema with the same name. + +### 4. Resolve Store registry state before synchronous schema lookup + +Store registry APIs are asynchronous, while schema directory loading is intentionally synchronous. Root selection already occurs asynchronously for workflow commands. + +Introduce a resolved command context with three explicit ownership locations: + +```ts +interface ResolvedOpenSpecRoot { + path: string; // planning root + consumerRoot: string; // config owner + schemaContext: { + root: string; // consumer root or registered schema Store root + source: 'project' | 'store'; + storeId?: string; + visibleSchemas: '*' | readonly string[]; + }; + // existing changes/specs/archive fields +} +``` + +Resolution sequence: + +1. Canonicalize the command start path. +2. Find the consumer repository containing the controlling config, when present. +3. Resolve the planning root using existing `--store`, local-root, `store:`, and global-default precedence. +4. Read `schemaStore` from the consumer root, falling back to the planning root only when no consumer root exists. +5. Resolve the schema Store ID through the existing registry and validate Store identity. +6. Return canonical local paths and normalized visibility to downstream synchronous schema resolution. + +Schema-only commands use the same schema-context resolver rather than duplicating registry lookup. + +Alternative considered: make `getSchemaDir`, `resolveSchema`, and every caller asynchronous. Rejected because registry lookup is the only asynchronous requirement and can be completed at the command boundary. + +Operational configuration remains backward-compatible with Planning Store +selection. When planning is redirected and the consumer does not declare +`schemaStore`, commands continue to use the Planning Store's configuration. +When the consumer does declare `schemaStore`, its configuration overlays the +Planning Store configuration: consumer-authored schema choices and rules can +target the selected schema authority, while omitted fields such as +`references`, context, and operation guidance remain inherited from the +Planning Store. + +### 5. Treat a schema Store as the project schema layer + +When `schemaStore` is configured, its visible schemas replace the consumer repository's project-local schema layer. Resolution precedence becomes: + +1. visible schema Store schema; +2. user schema; +3. package schema. + +When `schemaStore` is absent, precedence remains: + +1. consumer/project-local schema; +2. user schema; +3. package schema. + +The planning Store is never searched for schemas merely because it owns the active changes. If a project wants the same Store for both roles, it declares the same ID in `store` and `schemaStore`. + +This avoids implicit coupling and makes the schema authority visible in consumer configuration. + +Because the Store replaces the consumer-local project layer, `schema init` and +`schema fork` MUST NOT write into the consumer repository while `schemaStore` +is configured. Such files would be immediately invisible to resolution. +Instead, both commands fail before mutation, identify the configured Store, and +direct the user to edit that Store or remove `schemaStore` before creating a +project-local schema. + +### 6. Report Store provenance consistently + +Schema discovery records extend the source union with `store`. Store-backed results include the Store ID and canonical schema directory path. + +The following surfaces use the same resolved schema context and visibility: + +- `openspec schemas`; +- `openspec schema which `; +- `openspec schema which --all`; +- template reporting; +- schema validation; +- change creation, status, instructions, apply, verify, and archive. + +Human output labels Store schemas with the Store ID. JSON output adds `source: "store"` and `storeId` without changing existing fields for project, user, or package sources. + +### 7. Keep Git synchronization external + +The schema Store is an ordinary registered Store checkout. OpenSpec reads its current working tree and never contacts its remote during normal commands. Teams update it with normal Git operations and can use existing Store doctor output to inspect Git drift. + +This is an explicit trade-off: consumers do not get per-project commit pinning, but the implementation remains aligned with the existing Store contract and the stated departmental workflow. + +## Risks / Trade-offs + +- **One checkout serves every consumer on a machine** → Document that updating the registered schema Store changes its schemas for all local consumers; teams that need version isolation must register differently named Store checkouts. +- **A dirty schema Store can affect consumers immediately** → Preserve normal Git ownership and surface the canonical Store/path in `schema which`; do not imply OpenSpec has pinned or synchronized it. +- **Root context still touches workflow commands** → Pass one resolved schema context through existing command boundaries and cover local, planning-Store, and split-Store journeys with integration tests. +- **Visibility can hide a schema that remains available elsewhere** → Treat the Store filter as source-specific, label the winning source, and include available-source diagnostics. +- **Invalid authority could otherwise fall back silently** → Strict schema-context resolution fails closed whenever the `schemaStore` field is present but unusable. +- **Windows path and case behavior differs** → Use existing canonicalization and Store registry helpers, `path.join`, and platform-neutral temporary-directory tests. + +## Migration Plan + +1. Add parsing and normalization while leaving absent-field behavior unchanged. +2. Add schema-context resolution and tests without changing existing command output for non-users. +3. Route schema consumers through the resolved context and add Store provenance. +4. Document creation, registration, Git update, visibility, and split planning/schema examples. +5. Release as an additive experimental capability. Rollback consists of removing `schemaStore`; planning and schema resolution then use their previous roots and precedence. + +## Open Questions + +None for the initial scope. Multiple schema Stores, CLI overrides, per-consumer commit pinning, and pattern visibility require separate proposals. diff --git a/openspec/changes/add-schema-store-sources/proposal.md b/openspec/changes/add-schema-store-sources/proposal.md new file mode 100644 index 0000000000..a36cd59f16 --- /dev/null +++ b/openspec/changes/add-schema-store-sources/proposal.md @@ -0,0 +1,28 @@ +## Why + +Departments already use registered OpenSpec Stores to share planning repositories, but teams that only want to share workflow schemas must also share the Store's specs and changes or copy schemas into every consumer repository. Reusing the existing Store registry as a schema-only source lets teams share schemas through normal Git workflows without adding a second Git synchronization, lockfile, or cache subsystem to OpenSpec. + +## What Changes + +- Add a consumer-owned `schemaStore` project configuration that selects one registered Store as the source of project schemas without changing where specs, changes, or archives live. +- Support scalar shorthand (`schemaStore: department-schemas`) and an object form with an exact schema visibility allowlist. +- Make all Store schemas visible by default; support `schemas: ["*"]` for the explicit all-visible form and exact schema names for restricted visibility. +- Resolve visible Store schemas ahead of user and package schemas, report their source as `store`, and preserve existing behavior when `schemaStore` is absent. +- Keep Store synchronization user-managed through normal Git clone, pull, commit, and push operations. +- Provide actionable diagnostics for malformed configuration, unknown or unhealthy Store registrations, invalid visibility declarations, and configured schema names that are not visible. + +## Capabilities + +### New Capabilities +- `schema-store-sources`: Select a registered Store as a schema-only source, independently from the planning Store, with consumer-controlled schema visibility. + +### Modified Capabilities +- `config-loading`: Parse scalar and object `schemaStore` declarations resiliently. +- `schema-resolution`: Resolve and list visible Store schemas with defined precedence while leaving planning data rooted independently. +- `schema-which-command`: Report Store-backed schema paths, source, and shadowing information. + +## Impact + +- Affects project configuration parsing, Store lookup and health validation, schema discovery/resolution, schema reporting commands, and every workflow path that resolves a schema while operating on a potentially separate planning root. +- Adds no network client, lockfile, content cache, or dependency; Store checkout synchronization remains outside OpenSpec. +- Requires documentation and cross-platform tests for local planning, planning-Store, schema-Store, visibility, missing registration, and backward compatibility scenarios. diff --git a/openspec/changes/add-schema-store-sources/specs/config-loading/spec.md b/openspec/changes/add-schema-store-sources/specs/config-loading/spec.md new file mode 100644 index 0000000000..e76f8fafaa --- /dev/null +++ b/openspec/changes/add-schema-store-sources/specs/config-loading/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Parse schema Store declarations + +The system SHALL parse `schemaStore` independently from other project configuration fields and normalize valid scalar and object forms. + +#### Scenario: Scalar schema Store +- **WHEN** config contains `schemaStore: department-schemas` +- **THEN** the parsed configuration SHALL contain Store ID `department-schemas` +- **AND** SHALL normalize visibility to all schemas + +#### Scenario: Object schema Store without visibility +- **WHEN** config contains an object with `id: department-schemas` and no `schemas` +- **THEN** the parsed configuration SHALL normalize visibility to all schemas + +#### Scenario: Object schema Store with exact visibility +- **WHEN** config contains `id: department-schemas` and `schemas: ["qeda-sdd"]` +- **THEN** the parsed configuration SHALL contain an exact visibility allowlist with `qeda-sdd` + +#### Scenario: Explicit wildcard visibility +- **WHEN** config contains `schemas: ["*"]` +- **THEN** the parsed configuration SHALL normalize visibility to all schemas + +#### Scenario: Wildcard mixed with names +- **WHEN** config contains `schemas: ["*", "qeda-sdd"]` +- **THEN** the declaration SHALL be invalid +- **AND** the system SHALL identify that wildcard visibility cannot be combined with names + +#### Scenario: Empty visibility list +- **WHEN** config contains `schemas: []` +- **THEN** the declaration SHALL be invalid +- **AND** the system SHALL identify that at least one schema or `*` is required + +#### Scenario: Invalid schema Store field does not discard other fields +- **WHEN** `schemaStore` is invalid but `schema` and `context` are valid +- **THEN** generic project-config loading SHALL retain the valid fields +- **AND** SHALL warn about the invalid schema Store declaration + +#### Scenario: Authority resolution rejects invalid declaration +- **WHEN** a command needs schema resolution and the config explicitly contains an invalid `schemaStore` +- **THEN** the command SHALL fail instead of silently using another schema source diff --git a/openspec/changes/add-schema-store-sources/specs/schema-resolution/spec.md b/openspec/changes/add-schema-store-sources/specs/schema-resolution/spec.md new file mode 100644 index 0000000000..8b17992cae --- /dev/null +++ b/openspec/changes/add-schema-store-sources/specs/schema-resolution/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: Resolve schemas from a configured schema Store + +The system SHALL treat visible schemas from the configured schema Store as the project schema layer without changing the planning root. + +#### Scenario: Store schema resolves ahead of user schema +- **WHEN** a visible schema with the same name exists in the schema Store and user schema directory +- **THEN** the Store schema SHALL resolve + +#### Scenario: Store schema resolves ahead of package schema +- **WHEN** a visible schema with the same name exists in the schema Store and package +- **THEN** the Store schema SHALL resolve + +#### Scenario: Hidden Store schema does not participate +- **WHEN** a schema exists in the schema Store but is excluded by the visibility allowlist +- **THEN** it SHALL NOT participate in resolution, listing, suggestions, or shadow reporting + +#### Scenario: User fallback remains available +- **WHEN** the requested schema is not visible in the schema Store but exists in the user schema directory +- **THEN** the user schema SHALL resolve + +#### Scenario: Package fallback remains available +- **WHEN** the requested schema is not visible in the schema Store or user directory but exists in the package +- **THEN** the package schema SHALL resolve + +#### Scenario: Consumer-local project schemas are replaced +- **WHEN** `schemaStore` is configured +- **AND** the consumer repository also contains a project-local schema +- **THEN** the schema Store SHALL be the only project-layer schema source +- **AND** the consumer-local schema SHALL NOT participate + +### Requirement: List schemas includes visible schema Store entries + +The system SHALL list each visible valid schema from the configured schema Store once and report its Store provenance. + +#### Scenario: Store schema appears in listing +- **WHEN** `qeda-sdd` is visible in the configured schema Store +- **THEN** schema listing SHALL include `qeda-sdd` with source `store` +- **AND** SHALL include the schema Store ID + +#### Scenario: Store schema shadows lower precedence +- **WHEN** a visible Store schema has the same name as user and package schemas +- **THEN** listing SHALL include the name once as the active Store schema +- **AND** SHALL report the lower-precedence sources as shadows + +#### Scenario: Existing project behavior is unchanged without schema Store +- **WHEN** `schemaStore` is absent +- **THEN** project, user, and package precedence and source labels SHALL remain unchanged + +### Requirement: Workflow commands share one schema context + +The system SHALL use the same resolved schema Store, visibility, and provenance across schema inspection and the complete change lifecycle. + +#### Scenario: Change lifecycle uses schema Store with local planning +- **WHEN** a local project selects a visible Store schema +- **THEN** change creation, status, instructions, validation, and archive SHALL resolve that same Store schema +- **AND** SHALL keep planning artifacts in the local project + +#### Scenario: Change lifecycle uses separate planning and schema Stores +- **WHEN** a consumer selects different planning and schema Stores +- **THEN** change creation, status, instructions, validation, and archive SHALL use the schema Store schema +- **AND** SHALL keep planning artifacts in the planning Store + +#### Scenario: Schema commands use the same context +- **WHEN** the user runs schema listing, which, validation, or template reporting from the consumer project +- **THEN** each command SHALL apply the same Store and visibility resolution + +### Requirement: Schema Store replacement prevents invisible local writes + +The system SHALL reject commands that would create a consumer-local project +schema while a schema Store replaces the project schema layer. + +#### Scenario: Fork is rejected while schema Store is configured +- **WHEN** the user runs `openspec schema fork` from a consumer with `schemaStore` +- **THEN** the command SHALL fail before creating a consumer-local schema +- **AND** the diagnostic SHALL identify the schema Store and explain how to edit the Store or restore local schema ownership + +#### Scenario: Init is rejected while schema Store is configured +- **WHEN** the user runs `openspec schema init` from a consumer with `schemaStore` +- **THEN** the command SHALL fail before creating a consumer-local schema +- **AND** the diagnostic SHALL identify the schema Store and explain how to edit the Store or restore local schema ownership diff --git a/openspec/changes/add-schema-store-sources/specs/schema-store-sources/spec.md b/openspec/changes/add-schema-store-sources/specs/schema-store-sources/spec.md new file mode 100644 index 0000000000..af56352a03 --- /dev/null +++ b/openspec/changes/add-schema-store-sources/specs/schema-store-sources/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: Consumer selects one registered schema Store + +The system SHALL allow a consumer project to select one registered Store as its schema source independently from the Store or local root that owns specs and changes. + +#### Scenario: Local planning with schema Store +- **WHEN** a consumer project declares `schemaStore: department-schemas` +- **AND** `department-schemas` is registered on the machine +- **THEN** normal commands SHALL keep specs, changes, and archives in the consumer project +- **AND** SHALL resolve project-layer schemas from the registered Store + +#### Scenario: Different planning and schema Stores +- **WHEN** a consumer project declares `store: department-planning` +- **AND** declares `schemaStore: department-schemas` +- **THEN** normal commands SHALL read and write specs, changes, and archives in `department-planning` +- **AND** SHALL resolve project-layer schemas from `department-schemas` + +#### Scenario: Same Store fills both roles explicitly +- **WHEN** a consumer project declares the same registered Store ID in `store` and `schemaStore` +- **THEN** that Store SHALL own both planning data and project-layer schemas + +#### Scenario: No schema Store declaration +- **WHEN** a project does not declare `schemaStore` +- **THEN** schema and planning resolution SHALL retain their existing behavior + +#### Scenario: Redirected planning retains Planning Store configuration +- **WHEN** a consumer redirects planning to a Store +- **AND** does not declare `schemaStore` +- **THEN** workflow commands SHALL continue to use the Planning Store's project configuration + +#### Scenario: Schema consumer configuration overlays planning configuration +- **WHEN** a consumer redirects planning to one Store +- **AND** declares a separate `schemaStore` +- **THEN** consumer configuration fields SHALL override corresponding Planning Store fields +- **AND** fields omitted by the consumer SHALL remain inherited from the Planning Store + +### Requirement: Schema Store visibility is consumer-controlled + +The system SHALL let the consumer select which schemas from its schema Store participate in discovery and resolution. + +#### Scenario: Scalar declaration exposes every Store schema +- **WHEN** config contains `schemaStore: department-schemas` +- **THEN** every valid schema in that Store SHALL be visible + +#### Scenario: Object declaration defaults to every Store schema +- **WHEN** config contains `schemaStore: { id: department-schemas }` +- **THEN** every valid schema in that Store SHALL be visible + +#### Scenario: Explicit wildcard exposes every Store schema +- **WHEN** the declaration contains `schemas: ["*"]` +- **THEN** every valid schema in that Store SHALL be visible + +#### Scenario: Exact allowlist restricts Store schemas +- **WHEN** the declaration contains `schemas: ["qeda-sdd", "frontend-sdd"]` +- **THEN** only those named schemas from the Store SHALL participate in discovery and resolution + +#### Scenario: Visibility does not hide other source classes +- **WHEN** a schema is excluded from the schema Store allowlist +- **THEN** a same-named user or package schema SHALL remain eligible under existing precedence + +### Requirement: Schema Store synchronization remains user-managed + +The system SHALL use the registered Store's current local checkout and SHALL NOT contact its Git remote while resolving schemas. + +#### Scenario: Normal command uses local checkout +- **WHEN** a user runs a schema or workflow command with a configured schema Store +- **THEN** OpenSpec SHALL read schemas from the registered local Store path +- **AND** SHALL perform no Git fetch, pull, push, or clone + +#### Scenario: Store checkout changes +- **WHEN** a user updates the registered schema Store with normal Git commands +- **THEN** subsequent OpenSpec commands SHALL observe the updated local schema content + +### Requirement: Unavailable schema Store produces actionable diagnostics + +The system SHALL fail schema-context resolution when an explicitly configured schema Store cannot be used. + +#### Scenario: Store is not registered +- **WHEN** `schemaStore` names an unregistered Store +- **THEN** the command SHALL fail with the Store ID +- **AND** SHALL direct the user to register the Store + +#### Scenario: Store identity is invalid +- **WHEN** the registered checkout does not have valid matching Store identity +- **THEN** the command SHALL fail with a diagnostic naming the Store +- **AND** SHALL direct the user to inspect or repair it with Store tooling + +#### Scenario: Schema directory is initially absent +- **WHEN** the registered schema Store has no `openspec/schemas` directory +- **THEN** the Store SHALL contribute no schemas +- **AND** schema listing SHALL continue with user and package schemas + +#### Scenario: Configured schema is not visible +- **WHEN** the selected schema exists in the Store but is excluded by the consumer allowlist +- **THEN** the Store copy SHALL NOT be selected +- **AND** the resulting resolution or error SHALL identify the actual winning or available sources + +### Requirement: Paths are resolved portably + +The system SHALL use the canonical registered Store checkout and platform-native path handling for schema directories. + +#### Scenario: Store path contains platform-specific separators +- **WHEN** a schema Store is registered on macOS, Linux, or Windows +- **THEN** schema paths SHALL be constructed with platform-native path semantics +- **AND** reported paths SHALL identify the canonical local Store checkout diff --git a/openspec/changes/add-schema-store-sources/specs/schema-which-command/spec.md b/openspec/changes/add-schema-store-sources/specs/schema-which-command/spec.md new file mode 100644 index 0000000000..68a2a85c65 --- /dev/null +++ b/openspec/changes/add-schema-store-sources/specs/schema-which-command/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Schema which reports Store-backed schemas + +The CLI SHALL report Store provenance when a schema resolves from the configured schema Store. + +#### Scenario: Human output identifies schema Store +- **WHEN** a user runs `openspec schema which qeda-sdd` +- **AND** `qeda-sdd` resolves from schema Store `department-schemas` +- **THEN** output SHALL identify source `store` +- **AND** SHALL identify Store ID `department-schemas` +- **AND** SHALL display the canonical schema directory path + +#### Scenario: JSON output identifies schema Store +- **WHEN** a user runs `openspec schema which qeda-sdd --json` +- **AND** the schema resolves from a schema Store +- **THEN** JSON SHALL contain `source: "store"` +- **AND** SHALL contain `storeId` +- **AND** SHALL preserve the existing `name`, `path`, and `shadows` fields + +#### Scenario: List mode applies visibility +- **WHEN** a user runs `openspec schema which --all` +- **THEN** output SHALL include visible schema Store schemas +- **AND** SHALL exclude Store schemas hidden by the consumer allowlist + +#### Scenario: Shadow output identifies lower sources +- **WHEN** a Store schema shadows user or package schemas +- **THEN** `schema which` SHALL identify the Store schema as active +- **AND** SHALL list lower-precedence sources in existing precedence order diff --git a/openspec/changes/add-schema-store-sources/tasks.md b/openspec/changes/add-schema-store-sources/tasks.md new file mode 100644 index 0000000000..f24f1be2ab --- /dev/null +++ b/openspec/changes/add-schema-store-sources/tasks.md @@ -0,0 +1,57 @@ +## 1. Configuration Contract + +- [x] 1.1 Add failing project-config tests for scalar `schemaStore`, object form, omitted visibility, explicit `["*"]`, exact-name allowlists, duplicate normalization, and preservation of unrelated valid fields +- [x] 1.2 Add failing strict-declaration tests for empty lists, wildcard/name mixing, invalid Store IDs, invalid schema names, unsupported fields, and malformed YAML +- [x] 1.3 Implement normalized `SchemaStoreDeclaration` parsing plus a strict authority reader while preserving resilient generic config loading +- [x] 1.4 Run `pnpm exec vitest run test/core/project-config.test.ts` and confirm the configuration contract is green + +## 2. Resolved Schema Context + +- [x] 2.1 Add failing root-selection tests for local planning plus schema Store, separate planning/schema Stores, the same Store in both roles, explicit `--store`, config-only pointers, and absent `schemaStore` +- [x] 2.2 Add failing diagnostics tests for unregistered Store IDs, missing or mismatched Store identity, and canonical paths on platform-native temporary directories +- [x] 2.3 Implement consumer-root preservation and asynchronous schema Store registry resolution at the command/root boundary without making schema parsing asynchronous +- [x] 2.4 Represent the resolved planning root, consumer config root, schema root, Store provenance, and normalized visibility in one command context +- [x] 2.5 Run the root-selection and Store registry focused suites and confirm existing Store precedence remains unchanged + +## 3. Schema Discovery and Resolution + +- [x] 3.1 Add failing resolver tests for Store-over-user/package precedence, exact visibility, wildcard visibility, hidden Store schemas, empty Store schema directories, and no-declaration compatibility +- [x] 3.2 Add failing tests proving a configured schema Store replaces consumer-local project schemas and never implicitly searches the planning Store +- [x] 3.3 Extend schema discovery/resolution with the resolved schema context and `store` provenance, including Store ID and canonical path +- [x] 3.4 Ensure suggestions, validation, template loading, and shadow reporting consume the same filtered candidate set +- [x] 3.5 Run artifact-graph schema, resolver, directory-validation, and configuration tests + +## 4. Workflow Lifecycle Integration + +- [x] 4.1 Add failing integration tests for new change, status, instructions, apply, validation, list, task progress, and archive with local planning plus a schema Store +- [x] 4.2 Add failing integration tests for the same lifecycle with separate planning and schema Stores, asserting all planning writes stay in the planning Store +- [x] 4.3 Route the resolved schema context through change metadata, change creation, instruction loading, validation, listing, task progress, and archive boundaries +- [x] 4.4 Add backward-compatibility assertions showing projects without `schemaStore` retain existing paths, output, and schema precedence +- [x] 4.5 Run all affected workflow, Store root-selection, archive, validation, and change utility suites +- [x] 4.6 Preserve Planning Store configuration when `schemaStore` is absent, and layer explicit schema-consumer configuration without dropping inherited references +- [x] 4.7 Route deprecated `change validate` through the resolved planning root and schema context + +## 5. Schema CLI and Reporting + +- [x] 5.1 Add failing command tests for `openspec schemas`, `schema which`, `schema which --all`, schema validation, schema fork, and template reporting with visible and hidden Store schemas +- [x] 5.2 Extend human output with Store source labels and Store IDs while preserving existing project/user/package wording +- [x] 5.3 Extend JSON output with `source: "store"` and `storeId` while preserving existing fields and unavailable/error shapes +- [x] 5.4 Verify every schema-oriented command resolves Store registration once at its asynchronous boundary and performs no Git network operation +- [x] 5.5 Run the complete schema command and artifact-workflow focused suites +- [x] 5.6 Reject project-local `schema init` and `schema fork` writes while a schema Store replaces that layer, with actionable Store-aware diagnostics +- [x] 5.7 Key schema completion caching by the effective schema resolution target + +## 6. Documentation and Release Contract + +- [x] 6.1 Document `schemaStore` scalar/object syntax, default `*`, exact allowlists, source precedence, and diagnostics in the CLI and customization guides +- [x] 6.2 Document department setup: create or clone a schema Store, register it per machine, update it with normal Git, and combine it with local or Store-backed planning +- [x] 6.3 State explicitly that OpenSpec does not synchronize or pin schema Store checkouts and that one registered checkout serves all local consumers +- [x] 6.4 Add a Changesets entry describing schema-only Store sourcing as an additive experimental feature + +## 7. Cross-Platform and Final Verification + +- [x] 7.1 Add Windows-safe path assertions using `path.join` and canonical temporary directories; rely on the existing Windows CI job for platform execution +- [x] 7.2 Run `pnpm exec openspec validate add-schema-store-sources --strict` +- [x] 7.3 Run `pnpm run build`, `pnpm exec tsc --noEmit`, and `pnpm lint` +- [x] 7.4 Run `pnpm test` and confirm the complete suite passes +- [x] 7.5 Run `git diff --check` and review the final diff for unrelated Remote Schema fetch, lockfile, cache, or synchronization machinery diff --git a/src/cli/index.ts b/src/cli/index.ts index 902c46d0a1..a94c853fa2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -51,6 +51,7 @@ import { import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { isInteractive } from '../utils/interactive.js'; +import { readResolvedProjectConfig } from '../core/root-selection.js'; const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description; @@ -304,6 +305,8 @@ program await listCommand.execute(root.path, mode, { sort, json: options?.json, + schemaTarget: root.schemaContext, + projectConfig: readResolvedProjectConfig(root), ...(options?.json ? { root: toRootOutput(root) } : {}), }); } catch (error) { @@ -331,7 +334,10 @@ program return; } const viewCommand = new ViewCommand(); - await viewCommand.execute(root.path); + await viewCommand.execute(root.path, { + schemaTarget: root.schemaContext, + projectConfig: readResolvedProjectConfig(root), + }); } catch (error) { failWithError(error); process.exit(1); @@ -373,7 +379,13 @@ changeCmd .action(async (options?: { json?: boolean; long?: boolean }) => { try { console.error('Warning: "openspec change list" is deprecated. Use "openspec list".'); - const changeCommand = new ChangeCommand(); + const root = await resolveRootForCommand({}, { json: options?.json }); + if (!root) return; + const changeCommand = new ChangeCommand( + root.path, + root.schemaContext, + readResolvedProjectConfig(root) + ); await changeCommand.list(options); } catch (error) { console.error(`Error: ${(error as Error).message}`); @@ -389,7 +401,13 @@ changeCmd .option('--no-interactive', 'Disable interactive prompts') .action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => { try { - const changeCommand = new ChangeCommand(); + const root = await resolveRootForCommand({}, { json: options?.json }); + if (!root) return; + const changeCommand = new ChangeCommand( + root.path, + root.schemaContext, + readResolvedProjectConfig(root) + ); await changeCommand.validate(changeName, options); if (typeof process.exitCode === 'number' && process.exitCode !== 0) { process.exit(process.exitCode); diff --git a/src/commands/change.ts b/src/commands/change.ts index f9a1995496..a1d88003d3 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -9,6 +9,8 @@ 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'; +import type { SchemaResolutionTarget } from '../core/artifact-graph/index.js'; +import type { ProjectConfig } from '../core/project-config.js'; /** * True only when `target` is definitively absent. An EACCES or I/O failure @@ -34,12 +36,20 @@ function isChangeDirectoryName(changesPath: string, changeDir: string): boolean export class ChangeCommand { private converter: JsonConverter; private rootPath?: string; + private schemaTarget?: SchemaResolutionTarget; + private projectConfig?: ProjectConfig | null; - // rootPath is set only by root-aware callers (top-level `show`); the - // deprecated noun-form commands stay cwd-based. - constructor(rootPath?: string) { + // rootPath and schemaTarget let both verb-first and deprecated noun-form + // callers use the same planning and schema authorities. + constructor( + rootPath?: string, + schemaTarget?: SchemaResolutionTarget, + projectConfig?: ProjectConfig | null + ) { this.converter = new JsonConverter(); this.rootPath = rootPath; + this.schemaTarget = schemaTarget; + this.projectConfig = projectConfig; } private getChangesPath(): string { @@ -140,12 +150,13 @@ export class ChangeCommand { * - JSON: array of { id, title, deltaCount, taskStatus }, sorted by id */ async list(options?: { json?: boolean; long?: boolean }): Promise { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); + const projectRoot = this.rootPath ?? process.cwd(); + const changesPath = this.getChangesPath(); // Same directory-based resolution as `openspec list`, the command this // deprecated alias points users at. Every output path below already // tolerates a change whose proposal.md is missing or unreadable. - const changes = await getActiveChangeIds(); + const changes = await getActiveChangeIds(projectRoot); if (options?.json) { const changeDetails = await Promise.all( @@ -157,7 +168,13 @@ export class ChangeCommand { // this deprecated noun-form list cannot re-fork the resolution // (#1202). Tasks are independent of the proposal: a change can carry // tasks before, or without, a proposal.md. - const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + const taskStatus = await getTaskProgressForChange( + changesPath, + changeName, + projectRoot, + this.schemaTarget ?? projectRoot, + this.projectConfig + ); // No proposal yet is an ordinary state (scaffolded change, or a // schema with no proposal artifact), so name the change rather than @@ -202,7 +219,13 @@ export class ChangeCommand { for (const changeName of sorted) { const changeDir = path.join(changesPath, changeName); const proposalPath = path.join(changeDir, 'proposal.md'); - const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + const { total, completed } = await getTaskProgressForChange( + changesPath, + changeName, + projectRoot, + this.schemaTarget ?? projectRoot, + this.projectConfig + ); const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : ''; if (await isDefinitelyMissing(proposalPath)) { console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`); @@ -223,11 +246,12 @@ export class ChangeCommand { } async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }): Promise { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); + const projectRoot = this.rootPath ?? process.cwd(); + const changesPath = this.getChangesPath(); if (!changeName) { const canPrompt = isInteractive(options); - const changes = await getActiveChangeIds(); + const changes = await getActiveChangeIds(projectRoot); if (canPrompt && changes.length > 0) { const { select } = await import('@inquirer/prompts'); const selected = await select({ @@ -255,7 +279,7 @@ export class ChangeCommand { throw new Error(`Change "${changeName}" not found at ${changeDir}`); } - const validator = new Validator(options?.strict || false); + const validator = new Validator(options?.strict || false, this.schemaTarget); const report = await validator.validateChangeDeltaSpecs(changeDir); if (options?.json) { diff --git a/src/commands/completion.ts b/src/commands/completion.ts index a0487e5740..97f81551a1 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -4,6 +4,10 @@ import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; import { detectShell, SupportedShell } from '../utils/shell-detection.js'; import { CompletionProvider } from '../core/completions/completion-provider.js'; import { getArchivedChangeIds } from '../utils/item-discovery.js'; +import { + isRootSelectionError, + resolveOpenSpecRoot, +} from '../core/root-selection.js'; interface GenerateOptions { shell?: string; @@ -28,9 +32,11 @@ interface CompleteOptions { */ export class CompletionCommand { private completionProvider: CompletionProvider; + private readonly projectRoot: string; - constructor() { - this.completionProvider = new CompletionProvider(); + constructor(projectRoot: string = process.cwd()) { + this.projectRoot = projectRoot; + this.completionProvider = new CompletionProvider(2000, projectRoot); } /** * Resolve shell parameter or exit with error @@ -280,7 +286,22 @@ export class CompletionCommand { break; } case 'schemas': { - const schemaNames = await this.completionProvider.getSchemaNames(); + let schemaContext; + try { + const root = await resolveOpenSpecRoot({ + startPath: this.projectRoot, + }); + schemaContext = root.schemaContext; + } catch (error) { + if ( + !isRootSelectionError(error) || + error.diagnostic.code !== 'no_root_with_registered_stores' + ) { + throw error; + } + } + const schemaNames = + await this.completionProvider.getSchemaNames(schemaContext); for (const name of schemaNames) { console.log(`${name}\tschema`); } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 5c01570beb..2caaa50f10 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -10,14 +10,16 @@ import { getPackageSchemasDir, isSchemaDir, listSchemas, + type SchemaResolutionContext, } from '../core/artifact-graph/resolver.js'; import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js'; import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js'; +import { resolveRootForCommand } from '../core/root-selection.js'; /** * Schema source location type */ -type SchemaSource = 'project' | 'user' | 'package'; +type SchemaSource = 'project' | 'store' | 'user' | 'package'; /** * Result of checking a schema location @@ -26,6 +28,7 @@ interface SchemaLocation { source: SchemaSource; path: string; exists: boolean; + storeId?: string; } /** @@ -35,7 +38,28 @@ interface SchemaResolution { name: string; source: SchemaSource; path: string; - shadows: Array<{ source: SchemaSource; path: string }>; + storeId?: string; + shadows: Array<{ source: SchemaSource; path: string; storeId?: string }>; +} + +function schemaStoreWriteError( + schemaContext: SchemaResolutionContext, + command: 'fork' | 'init' +): { message: string; storeId?: string } | null { + if (schemaContext.source !== 'store') { + return null; + } + + const storeLabel = schemaContext.storeId + ? ` '${schemaContext.storeId}'` + : ''; + return { + message: + `Cannot create project-local schemas while schemaStore${storeLabel} is configured. ` + + `Edit the registered Schema Store directly, or remove schemaStore from openspec/config.yaml ` + + `before using "openspec schema ${command}".`, + ...(schemaContext.storeId ? { storeId: schemaContext.storeId } : {}), + }; } /** @@ -52,18 +76,27 @@ interface ValidationIssue { */ function checkAllLocations( name: string, - projectRoot: string + schemaContext: SchemaResolutionContext ): SchemaLocation[] { const locations: SchemaLocation[] = []; - // Project location - const projectDir = path.join(getProjectSchemasDir(projectRoot), name); + // Active project layer: local project or configured schema Store. + const projectDir = path.join(getProjectSchemasDir(schemaContext.root), name); const projectSchemaPath = path.join(projectDir, 'schema.yaml'); - locations.push({ - source: 'project', - path: projectDir, - exists: fs.existsSync(projectSchemaPath), - }); + const visible = + schemaContext.source === 'project' || + schemaContext.visibleSchemas === '*' || + schemaContext.visibleSchemas.includes(name); + if (visible) { + locations.push({ + source: schemaContext.source, + path: projectDir, + exists: fs.existsSync(projectSchemaPath), + ...(schemaContext.source === 'store' && schemaContext.storeId + ? { storeId: schemaContext.storeId } + : {}), + }); + } // User location const userDir = path.join(getUserSchemasDir(), name); @@ -91,9 +124,9 @@ function checkAllLocations( */ function getSchemaResolution( name: string, - projectRoot: string + schemaContext: SchemaResolutionContext ): SchemaResolution | null { - const locations = checkAllLocations(name, projectRoot); + const locations = checkAllLocations(name, schemaContext); const existingLocations = locations.filter((loc) => loc.exists); if (existingLocations.length === 0) { @@ -104,12 +137,14 @@ function getSchemaResolution( const shadows = existingLocations.slice(1).map((loc) => ({ source: loc.source, path: loc.path, + ...(loc.storeId ? { storeId: loc.storeId } : {}), })); return { name, source: active.source, path: active.path, + ...(active.storeId ? { storeId: active.storeId } : {}), shadows, }; } @@ -118,13 +153,13 @@ function getSchemaResolution( * Get all schemas with resolution info. */ function getAllSchemasWithResolution( - projectRoot: string + schemaContext: SchemaResolutionContext ): SchemaResolution[] { - const schemaNames = listSchemas(projectRoot); + const schemaNames = listSchemas(schemaContext); const results: SchemaResolution[] = []; for (const name of schemaNames) { - const resolution = getSchemaResolution(name, projectRoot); + const resolution = getSchemaResolution(name, schemaContext); if (resolution) { results.push(resolution); } @@ -306,11 +341,13 @@ export function registerSchemaCommand(program: Command): void { .option('--all', 'List all schemas with their resolution sources') .action(async (name?: string, options?: { json?: boolean; all?: boolean }) => { try { - const projectRoot = process.cwd(); + const root = await resolveRootForCommand({}, { json: options?.json }); + if (!root) return; + const schemaContext = root.schemaContext; if (options?.all) { // List all schemas - const schemas = getAllSchemasWithResolution(projectRoot); + const schemas = getAllSchemasWithResolution(schemaContext); if (options?.json) { console.log(JSON.stringify(schemas, null, 2)); @@ -323,6 +360,7 @@ export function registerSchemaCommand(program: Command): void { // Group by source const bySource = { project: schemas.filter((s) => s.source === 'project'), + store: schemas.filter((s) => s.source === 'store'), user: schemas.filter((s) => s.source === 'user'), package: schemas.filter((s) => s.source === 'package'), }; @@ -337,6 +375,19 @@ export function registerSchemaCommand(program: Command): void { } } + if (bySource.store.length > 0) { + console.log('\nStore schemas:'); + for (const schema of bySource.store) { + const shadowInfo = + schema.shadows.length > 0 + ? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})` + : ''; + console.log( + ` ${schema.name} (${schema.storeId})${shadowInfo}` + ); + } + } + if (bySource.user.length > 0) { console.log('\nUser schemas:'); for (const schema of bySource.user) { @@ -363,10 +414,10 @@ export function registerSchemaCommand(program: Command): void { return; } - const resolution = getSchemaResolution(name, projectRoot); + const resolution = getSchemaResolution(name, schemaContext); if (!resolution) { - const available = listSchemas(projectRoot); + const available = listSchemas(schemaContext); if (options?.json) { console.log(JSON.stringify({ error: `Schema '${name}' not found`, @@ -384,7 +435,13 @@ export function registerSchemaCommand(program: Command): void { console.log(JSON.stringify(resolution, null, 2)); } else { console.log(`Schema: ${resolution.name}`); - console.log(`Source: ${resolution.source}`); + console.log( + `Source: ${ + resolution.source === 'store' + ? `Store (${resolution.storeId})` + : resolution.source + }` + ); console.log(`Path: ${resolution.path}`); if (resolution.shadows.length > 0) { @@ -408,11 +465,13 @@ export function registerSchemaCommand(program: Command): void { .option('--verbose', 'Show detailed validation steps') .action(async (name?: string, options?: { json?: boolean; verbose?: boolean }) => { try { - const projectRoot = process.cwd(); + const root = await resolveRootForCommand({}, { json: options?.json }); + if (!root) return; + const schemaContext = root.schemaContext; if (!name) { - // Validate all project schemas - const projectSchemasDir = getProjectSchemasDir(projectRoot); + // Validate all schemas in the active project layer. + const projectSchemasDir = getProjectSchemasDir(schemaContext.root); if (!fs.existsSync(projectSchemasDir)) { if (options?.json) { @@ -439,6 +498,13 @@ export function registerSchemaCommand(program: Command): void { for (const entry of entries) { if (!isSchemaDir(projectSchemasDir, entry)) continue; + if ( + schemaContext.source === 'store' && + schemaContext.visibleSchemas !== '*' && + !schemaContext.visibleSchemas.includes(entry.name) + ) { + continue; + } const schemaDir = path.join(projectSchemasDir, entry.name); const schemaPath = path.join(schemaDir, 'schema.yaml'); @@ -490,10 +556,10 @@ export function registerSchemaCommand(program: Command): void { } // Validate specific schema - const schemaDir = getSchemaDir(name, projectRoot); + const schemaDir = getSchemaDir(name, schemaContext); if (!schemaDir) { - const available = listSchemas(projectRoot); + const available = listSchemas(schemaContext); if (options?.json) { console.log(JSON.stringify({ valid: false, @@ -555,8 +621,28 @@ export function registerSchemaCommand(program: Command): void { const spinner = options?.json ? null : ora(); try { - const projectRoot = process.cwd(); + const root = await resolveRootForCommand({}, { json: options?.json }); + if (!root) { + spinner?.stop(); + return; + } + const projectRoot = root.consumerRoot; + const schemaContext = root.schemaContext; const destinationName = name || `${source}-custom`; + const writeError = schemaStoreWriteError(schemaContext, 'fork'); + if (writeError) { + if (options?.json) { + console.log(JSON.stringify({ + forked: false, + error: writeError.message, + ...(writeError.storeId ? { storeId: writeError.storeId } : {}), + }, null, 2)); + } else { + console.error(`Error: ${writeError.message}`); + } + process.exitCode = 1; + return; + } // Validate destination name if (!isValidSchemaName(destinationName)) { @@ -574,9 +660,9 @@ export function registerSchemaCommand(program: Command): void { } // Find source schema - const sourceDir = getSchemaDir(source, projectRoot); + const sourceDir = getSchemaDir(source, schemaContext); if (!sourceDir) { - const available = listSchemas(projectRoot); + const available = listSchemas(schemaContext); if (options?.json) { console.log(JSON.stringify({ forked: false, @@ -592,7 +678,7 @@ export function registerSchemaCommand(program: Command): void { } // Determine source location - const sourceResolution = getSchemaResolution(source, projectRoot); + const sourceResolution = getSchemaResolution(source, schemaContext); const sourceLocation = sourceResolution?.source || 'package'; // Check destination @@ -685,7 +771,26 @@ export function registerSchemaCommand(program: Command): void { const spinner = options?.json ? null : ora(); try { - const projectRoot = process.cwd(); + const root = await resolveRootForCommand({}, { json: options?.json }); + if (!root) { + spinner?.stop(); + return; + } + const projectRoot = root.consumerRoot; + const writeError = schemaStoreWriteError(root.schemaContext, 'init'); + if (writeError) { + if (options?.json) { + console.log(JSON.stringify({ + created: false, + error: writeError.message, + ...(writeError.storeId ? { storeId: writeError.storeId } : {}), + }, null, 2)); + } else { + console.error(`Error: ${writeError.message}`); + } + process.exitCode = 1; + return; + } // Validate name if (!isValidSchemaName(name)) { diff --git a/src/commands/validate.ts b/src/commands/validate.ts index eb44ede0f9..d607074e2e 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -193,7 +193,7 @@ export class ValidateCommand { } private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { - const validator = new Validator(opts.strict); + const validator = new Validator(opts.strict, root.schemaContext); if (type === 'change') { const changeDir = path.join(root.changesDir, id); const start = Date.now(); @@ -272,7 +272,7 @@ export class ValidateCommand { const DEFAULT_CONCURRENCY = 6; const maxSuggestions = 5; // used by nearestMatches const concurrency = normalizeConcurrency(opts.concurrency) ?? normalizeConcurrency(process.env.OPENSPEC_CONCURRENCY) ?? DEFAULT_CONCURRENCY; - const validator = new Validator(opts.strict); + const validator = new Validator(opts.strict, root.schemaContext); const queue: Array<() => Promise> = []; for (const id of changeIds) { diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 5c5d9b3488..6492c69474 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -14,6 +14,7 @@ import { resolveSchema, resolveArtifactOutputs, type ArtifactInstructions, + type SchemaResolutionTarget, } from '../../core/artifact-graph/index.js'; import { getChangeDir, @@ -22,6 +23,7 @@ import { } from '../../core/planning-home.js'; import { resolveRootForCommand, + readResolvedProjectConfig, withStoreFlag, toPlanningHome, toRootOutput, @@ -36,7 +38,6 @@ import { import { readRegistrySnapshot } from '../../core/store/registry.js'; import { loadOperationInputs, - readProjectConfig, type ProjectConfig, } from '../../core/project-config.js'; import { @@ -83,7 +84,7 @@ async function loadRootConfigContext(root: ResolvedOpenSpecRoot): Promise<{ references: ReferenceIndexEntry[] | undefined; }> { // readProjectConfig never throws: missing/unparseable configs are null. - const projectConfig = readProjectConfig(root.path); + const projectConfig = readResolvedProjectConfig(root); // One registry read serves every relationship consumer in this // output so it never carries a torn snapshot. @@ -128,7 +129,7 @@ export async function instructionsCommand( // Validate schema if explicitly provided if (options.schema) { - validateSchemaExists(options.schema, projectRoot); + validateSchemaExists(options.schema, root.schemaContext); } const { projectConfig, references } = await loadRootConfigContext(root); @@ -138,6 +139,7 @@ export async function instructionsCommand( changeDir: getChangeDir(planningHome, changeName), planningHome, projectConfig, + schemaTarget: root.schemaContext, }); if (!artifactId) { @@ -352,6 +354,7 @@ export interface GenerateApplyInstructionsOptions { planningHome?: PlanningHome; references?: ReferenceIndexEntry[]; projectConfig?: ProjectConfig | null; + schemaTarget?: SchemaResolutionTarget; } /** @@ -373,11 +376,12 @@ export async function generateApplyInstructions( changeDir: getChangeDir(planningHome, changeName), planningHome, projectConfig: options.projectConfig, + schemaTarget: options.schemaTarget, }); const changeDir = context.changeDir; // Get the full schema to access the apply phase configuration - const schema = resolveSchema(context.schemaName, projectRoot); + const schema = resolveSchema(context.schemaName, context.schemaTarget); const applyConfig = schema.apply; // Determine required artifacts and tracking file from schema @@ -492,7 +496,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions // Validate schema if explicitly provided if (options.schema) { - validateSchemaExists(options.schema, projectRoot); + validateSchemaExists(options.schema, root.schemaContext); } // One parsed config snapshot supplies schema fallback, references, context, @@ -502,6 +506,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions planningHome, references, projectConfig, + schemaTarget: root.schemaContext, }); spinner?.stop(); @@ -607,7 +612,7 @@ export async function archiveInstructionsCommand( root.changesDir, { newChangeHint: withStoreFlag(root, 'openspec new change ') } ); - const projectConfig = readProjectConfig(root.path); + const projectConfig = readResolvedProjectConfig(root); const instructions = generateArchiveInstructions(changeName, projectConfig); spinner?.stop(); diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 3e059242dc..35ebe63e59 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -22,6 +22,7 @@ import { isStoreSelectedRoot, } from '../../core/root-selection.js'; import { printJson, statusFromError, validateSchemaExists } from './shared.js'; +import { readResolvedProjectConfig } from '../../core/root-selection.js'; // ----------------------------------------------------------------------------- // Types @@ -109,13 +110,15 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } const projectRoot = root.path; + const projectConfig = readResolvedProjectConfig(root); // Validate schema if provided if (options.schema) { - validateSchemaExists(options.schema, projectRoot); + validateSchemaExists(options.schema, root.schemaContext); } - const resolvedSchema = options.schema ?? root.defaultSchema; + const resolvedSchema = + options.schema ?? projectConfig?.schema ?? root.defaultSchema; if (spinner) { spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`); } @@ -124,6 +127,8 @@ export async function newChangeCommand(name: string | undefined, options: NewCha schema: options.schema, defaultSchema: root.defaultSchema, changesDir: root.changesDir, + projectConfig, + schemaTarget: root.schemaContext, metadata: { ...(options.goal ? { goal: options.goal } : {}), }, diff --git a/src/commands/workflow/schemas.ts b/src/commands/workflow/schemas.ts index b9af74a677..554851c7cc 100644 --- a/src/commands/workflow/schemas.ts +++ b/src/commands/workflow/schemas.ts @@ -6,6 +6,7 @@ import chalk from 'chalk'; import { listSchemasWithInfo } from '../../core/artifact-graph/index.js'; +import { resolveRootForCommand } from '../../core/root-selection.js'; // ----------------------------------------------------------------------------- // Types @@ -20,8 +21,9 @@ export interface SchemasOptions { // ----------------------------------------------------------------------------- export async function schemasCommand(options: SchemasOptions): Promise { - const projectRoot = process.cwd(); - const schemas = listSchemasWithInfo(projectRoot); + const root = await resolveRootForCommand({}, { json: options.json }); + if (!root) return; + const schemas = listSchemasWithInfo(root.schemaContext); if (options.json) { console.log(JSON.stringify(schemas, null, 2)); @@ -35,6 +37,8 @@ export async function schemasCommand(options: SchemasOptions): Promise { let sourceLabel = ''; if (schema.source === 'project') { sourceLabel = chalk.cyan(' (project)'); + } else if (schema.source === 'store') { + sourceLabel = chalk.cyan(` (Store: ${schema.storeId})`); } else if (schema.source === 'user') { sourceLabel = chalk.dim(' (user override)'); } diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index 2840e004ed..f8b3ad6076 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -9,6 +9,7 @@ import chalk from 'chalk'; import path from 'path'; import * as fs from 'fs'; import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; +import type { SchemaResolutionTarget } from '../../core/artifact-graph/index.js'; import type { ReferenceIndexEntry } from '../../core/references.js'; import { isRootSelectionError } from '../../core/root-selection.js'; @@ -232,10 +233,13 @@ export async function validateChangeExists( * @param schemaName - The schema name to validate * @param projectRoot - Optional project root for project-local schema resolution */ -export function validateSchemaExists(schemaName: string, projectRoot?: string): string { - const schemaDir = getSchemaDir(schemaName, projectRoot); +export function validateSchemaExists( + schemaName: string, + schemaTarget?: SchemaResolutionTarget +): string { + const schemaDir = getSchemaDir(schemaName, schemaTarget); if (!schemaDir) { - const availableSchemas = listSchemas(projectRoot); + const availableSchemas = listSchemas(schemaTarget); throw new Error( `Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}` ); diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 2a09b48edb..fad7a1594c 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -26,6 +26,7 @@ import { getStatusIndicator, getStatusColor, } from './shared.js'; +import { readResolvedProjectConfig } from '../../core/root-selection.js'; // ----------------------------------------------------------------------------- // Types @@ -94,13 +95,15 @@ export async function statusCommand(options: StatusOptions): Promise { // Validate schema if explicitly provided if (options.schema) { - validateSchemaExists(options.schema, projectRoot); + validateSchemaExists(options.schema, root.schemaContext); } // loadChangeContext will auto-detect schema from metadata if not provided const context = loadChangeContext(projectRoot, changeName, options.schema, { changeDir: getChangeDir(planningHome, changeName), planningHome, + projectConfig: readResolvedProjectConfig(root), + schemaTarget: root.schemaContext, }); const status = formatChangeStatus( context, diff --git a/src/commands/workflow/templates.ts b/src/commands/workflow/templates.ts index fedd323e0d..9be26c56a8 100644 --- a/src/commands/workflow/templates.ts +++ b/src/commands/workflow/templates.ts @@ -13,6 +13,7 @@ import { } from '../../core/artifact-graph/index.js'; import { FileSystemUtils } from '../../utils/file-system.js'; import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js'; +import { resolveRootForCommand } from '../../core/root-selection.js'; // ----------------------------------------------------------------------------- // Types @@ -26,7 +27,8 @@ export interface TemplatesOptions { export interface TemplateInfo { artifactId: string; templatePath: string; - source: 'project' | 'user' | 'package'; + source: 'project' | 'store' | 'user' | 'package'; + storeId?: string; } // ----------------------------------------------------------------------------- @@ -37,18 +39,25 @@ export async function templatesCommand(options: TemplatesOptions): Promise const spinner = options.json ? undefined : ora('Loading templates...').start(); try { - const projectRoot = process.cwd(); - const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot); - const schema = resolveSchema(schemaName, projectRoot); + const root = await resolveRootForCommand({}, { json: options.json }); + if (!root) { + spinner?.stop(); + return; + } + const schemaName = validateSchemaExists( + options.schema ?? DEFAULT_SCHEMA, + root.schemaContext + ); + const schema = resolveSchema(schemaName, root.schemaContext); const graph = ArtifactGraph.fromSchema(schema); - const schemaDir = getSchemaDir(schemaName, projectRoot)!; + const schemaDir = getSchemaDir(schemaName, root.schemaContext)!; // Determine the source (project, user, or package) const { getUserSchemasDir, getProjectSchemasDir, } = await import('../../core/artifact-graph/resolver.js'); - const projectSchemasDir = getProjectSchemasDir(projectRoot); + const projectSchemasDir = getProjectSchemasDir(root.schemaContext.root); const userSchemasDir = getUserSchemasDir(); // Determine source by checking if schemaDir is inside each base directory @@ -58,9 +67,9 @@ export async function templatesCommand(options: TemplatesOptions): Promise return !relative.startsWith('..') && !path.isAbsolute(relative); }; - let source: 'project' | 'user' | 'package'; + let source: 'project' | 'store' | 'user' | 'package'; if (isInsideDir(schemaDir, projectSchemasDir)) { - source = 'project'; + source = root.schemaContext.source; } else if (isInsideDir(schemaDir, userSchemasDir)) { source = 'user'; } else { @@ -73,21 +82,33 @@ export async function templatesCommand(options: TemplatesOptions): Promise path.join(schemaDir, 'templates', artifact.template) ), source, + ...(source === 'store' && root.schemaContext.storeId + ? { storeId: root.schemaContext.storeId } + : {}), })); spinner?.stop(); if (options.json) { - const output: Record = {}; + const output: Record< + string, + { path: string; source: string; storeId?: string } + > = {}; for (const t of templates) { - output[t.artifactId] = { path: t.templatePath, source: t.source }; + output[t.artifactId] = { + path: t.templatePath, + source: t.source, + ...(t.storeId ? { storeId: t.storeId } : {}), + }; } console.log(JSON.stringify(output, null, 2)); return; } console.log(`Schema: ${schemaName}`); - console.log(`Source: ${source}`); + console.log( + `Source: ${source === 'store' ? `Store (${root.schemaContext.storeId})` : source}` + ); console.log(); for (const t of templates) { diff --git a/src/core/archive.ts b/src/core/archive.ts index f0f6013b3e..ca1e2e406a 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -21,6 +21,8 @@ import { } from './specs-apply.js'; import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js'; import { readSkipSpecsMarker } from '../utils/change-metadata.js'; +import { type ProjectConfig } from './project-config.js'; +import { readResolvedProjectConfig } from './root-selection.js'; function isMissingPathError(error: unknown): boolean { return ( @@ -213,6 +215,7 @@ export class ArchiveCommand { const changesDir = root.changesDir; const archiveDir = root.archiveDir; const mainSpecsDir = root.specsDir; + const projectConfig = readResolvedProjectConfig(root); // Get change name interactively if not provided if (!changeName) { @@ -223,7 +226,11 @@ export class ArchiveCommand { withStoreFlag(root, 'openspec archive --json') ); } - const selectedChange = await this.selectChange(changesDir); + const selectedChange = await this.selectChange( + changesDir, + root, + projectConfig + ); if (!selectedChange) { console.log('No change selected. Aborting.'); return null; @@ -253,7 +260,7 @@ export class ArchiveCommand { // Validate specs and change before archiving if (!skipValidation) { - const validator = new Validator(); + const validator = new Validator(false, root.schemaContext); let hasValidationErrors = false; // Validate proposal.md (informative only; human mode prints warnings) @@ -309,7 +316,7 @@ export class ArchiveCommand { // proposal warnings — a gap that predates the marker and is left // unchanged here.) if (!hasDeltaSpecs) { - const marker = readSkipSpecsMarker(changeDir); + const marker = readSkipSpecsMarker(changeDir, root.schemaContext); if (marker.invalidReason) { hasDeltaSpecs = true; } else if (marker.declared) { @@ -394,7 +401,13 @@ export class ArchiveCommand { } // Show progress and check for incomplete tasks - const progress = await getTaskProgressForChange(changesDir, changeName, path.resolve(changesDir, '..', '..')); + const progress = await getTaskProgressForChange( + changesDir, + changeName, + root.path, + root.schemaContext, + projectConfig + ); if (!json) { const status = formatTaskStatus(progress); console.log(`Task status: ${status}`); @@ -496,7 +509,10 @@ export class ArchiveCommand { if (!skipValidation) { for (const p of prepared) { const specName = p.update.id; - const report = await new Validator().validateSpecContent(specName, p.rebuilt); + const report = await new Validator( + false, + root.schemaContext + ).validateSpecContent(specName, p.rebuilt); if (!report.valid) { if (json) { throw new ArchiveBlockedError( @@ -597,7 +613,11 @@ export class ArchiveCommand { }; } - private async selectChange(changesDir: string): Promise { + private async selectChange( + changesDir: string, + root: ResolvedOpenSpecRoot, + projectConfig: ProjectConfig | null + ): Promise { const { select } = await import('@inquirer/prompts'); const changeDirs = await listActiveChangeNames(changesDir); @@ -611,7 +631,13 @@ export class ArchiveCommand { try { const progressList: Array<{ id: string; status: string }> = []; for (const id of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, id, path.resolve(changesDir, '..', '..')); + const progress = await getTaskProgressForChange( + changesDir, + id, + root.path, + root.schemaContext, + projectConfig + ); const status = formatTaskStatus(progress); progressList.push({ id, status }); } diff --git a/src/core/artifact-graph/index.ts b/src/core/artifact-graph/index.ts index a042e3b7ae..9da4f5530d 100644 --- a/src/core/artifact-graph/index.ts +++ b/src/core/artifact-graph/index.ts @@ -28,6 +28,8 @@ export { getUserSchemasDir, SchemaLoadError, type SchemaInfo, + type SchemaResolutionContext, + type SchemaResolutionTarget, } from './resolver.js'; // Instruction loading diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index 0b4f65c650..87fbff2eab 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -1,6 +1,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js'; +import { + getSchemaDir, + resolveSchema, + listSchemasWithInfo, + type SchemaResolutionTarget, +} from './resolver.js'; import { ArtifactGraph } from './graph.js'; import { detectCompleted } from './state.js'; import { resolveArtifactOutputs } from './outputs.js'; @@ -51,6 +56,8 @@ export interface ChangeContext { changeDir: string; /** Project root directory */ projectRoot: string; + /** Project or schema Store authority used throughout this loaded context. */ + schemaTarget: SchemaResolutionTarget; /** Resolved planning home for this change */ planningHome?: PlanningHome; /** Parsed change metadata, when present */ @@ -68,6 +75,8 @@ export interface LoadChangeContextOptions { planningHome?: PlanningHome; /** Pre-read project config; suppresses schema resolution's fallback config read. */ projectConfig?: ProjectConfig | null; + /** Project or schema Store authority for schema and template resolution. */ + schemaTarget?: SchemaResolutionTarget; } /** @@ -201,9 +210,9 @@ export interface ArtifactPathSummary { export function loadTemplate( schemaName: string, templatePath: string, - projectRoot?: string + schemaTarget?: SchemaResolutionTarget ): string { - const schemaDir = getSchemaDir(schemaName, projectRoot); + const schemaDir = getSchemaDir(schemaName, schemaTarget); if (!schemaDir) { throw new TemplateLoadError( `Schema '${schemaName}' not found`, @@ -256,13 +265,15 @@ export function loadChangeContext( options.changeDir ?? path.join(projectRoot, 'openspec', 'changes', changeName) ); - const metadata = readChangeMetadata(changeDir, projectRoot) ?? undefined; + const schemaTarget = options.schemaTarget ?? projectRoot; + const metadata = readChangeMetadata(changeDir, schemaTarget) ?? undefined; const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName, projectRoot, { metadata: metadata ?? null, projectConfig: options.projectConfig, + schemaTarget, }); - const schema = resolveSchema(resolvedSchemaName, projectRoot); + const schema = resolveSchema(resolvedSchemaName, schemaTarget); const graph = ArtifactGraph.fromSchema(schema); const completed = detectCompleted(graph, changeDir); @@ -292,6 +303,7 @@ export function loadChangeContext( changeName, changeDir, projectRoot, + schemaTarget, ...(options.planningHome ? { planningHome: options.planningHome } : {}), ...(metadata ? { metadata } : {}), ...(skippedArtifacts.size > 0 ? { skippedArtifacts } : {}), @@ -330,7 +342,11 @@ export function generateInstructions( throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`); } - const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot); + const templateContent = loadTemplate( + context.schemaName, + artifact.template, + context.schemaTarget + ); const dependencies = getDependencyInfo(artifact, context.graph, context.completed, context.skippedArtifacts); const unlocks = getUnlockedArtifacts(context.graph, artifactId); @@ -352,7 +368,7 @@ export function generateInstructions( // key is only "unknown" when it matches no artifact in ANY available schema. if (projectConfig?.rules) { const validArtifactIds = new Set( - listSchemasWithInfo(effectiveProjectRoot ?? undefined).flatMap((s) => s.artifacts) + listSchemasWithInfo(context.schemaTarget).flatMap((s) => s.artifacts) ); const warnings = validateConfigRules(projectConfig.rules, validArtifactIds); @@ -444,7 +460,7 @@ export function formatChangeStatus( options: { storeId?: string } = {} ): ChangeStatus { // Load schema to get apply phase configuration - const schema = resolveSchema(context.schemaName, context.projectRoot); + const schema = resolveSchema(context.schemaName, context.schemaTarget); const applyRequires = schema.apply?.requires ?? schema.artifacts.map(a => a.id); const artifacts = context.graph.getAllArtifacts(); diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index b444245f11..c0dd991428 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -5,6 +5,43 @@ import { getGlobalDataDir } from '../global-config.js'; import { parseSchema, SchemaValidationError } from './schema.js'; import type { SchemaYaml } from './types.js'; +export interface SchemaResolutionContext { + /** Project or registered Store root whose openspec/schemas directory is used. */ + root: string; + source: 'project' | 'store'; + storeId?: string; + visibleSchemas: '*' | readonly string[]; +} + +export type SchemaResolutionTarget = string | SchemaResolutionContext; + +function toSchemaContext( + target?: SchemaResolutionTarget +): SchemaResolutionContext | undefined { + if (target === undefined) { + return undefined; + } + if (typeof target === 'string') { + return { + root: target, + source: 'project', + visibleSchemas: '*', + }; + } + return target; +} + +function isVisibleFromPrimarySource( + name: string, + context: SchemaResolutionContext +): boolean { + return ( + context.source === 'project' || + context.visibleSchemas === '*' || + context.visibleSchemas.includes(name) + ); +} + /** * Error thrown when loading a schema fails. */ @@ -90,11 +127,13 @@ export function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean { */ export function getSchemaDir( name: string, - projectRoot?: string + target?: SchemaResolutionTarget ): string | null { - // 1. Check project-local directory (if projectRoot provided) - if (projectRoot) { - const projectDir = path.join(getProjectSchemasDir(projectRoot), name); + const context = toSchemaContext(target); + + // 1. Check the project layer (local project or configured schema Store). + if (context && isVisibleFromPrimarySource(name, context)) { + const projectDir = path.join(getProjectSchemasDir(context.root), name); const projectSchemaPath = path.join(projectDir, 'schema.yaml'); if (fs.existsSync(projectSchemaPath)) { return projectDir; @@ -134,13 +173,16 @@ export function getSchemaDir( * @returns The resolved schema object * @throws Error if schema is not found in any location */ -export function resolveSchema(name: string, projectRoot?: string): SchemaYaml { +export function resolveSchema( + name: string, + target?: SchemaResolutionTarget +): SchemaYaml { // Normalize name (remove .yaml extension if provided) const normalizedName = name.replace(/\.ya?ml$/, ''); - const schemaDir = getSchemaDir(normalizedName, projectRoot); + const schemaDir = getSchemaDir(normalizedName, target); if (!schemaDir) { - const availableSchemas = listSchemas(projectRoot); + const availableSchemas = listSchemas(target); throw new Error( `Schema '${normalizedName}' not found. Available schemas: ${availableSchemas.join(', ')}` ); @@ -186,8 +228,9 @@ export function resolveSchema(name: string, projectRoot?: string): SchemaYaml { * * @param projectRoot - Optional project root directory for project-local schema resolution */ -export function listSchemas(projectRoot?: string): string[] { +export function listSchemas(target?: SchemaResolutionTarget): string[] { const schemas = new Set(); + const context = toSchemaContext(target); // Add package built-in schemas const packageDir = getPackageSchemasDir(); @@ -215,12 +258,15 @@ export function listSchemas(projectRoot?: string): string[] { } } - // Add project-local schemas (if projectRoot provided) - if (projectRoot) { - const projectDir = getProjectSchemasDir(projectRoot); + // Add schemas from the active project layer (project or schema Store). + if (context) { + const projectDir = getProjectSchemasDir(context.root); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (isSchemaDir(projectDir, entry)) { + if ( + isVisibleFromPrimarySource(entry.name, context) && + isSchemaDir(projectDir, entry) + ) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -240,7 +286,8 @@ export interface SchemaInfo { name: string; description: string; artifacts: string[]; - source: 'project' | 'user' | 'package'; + source: 'project' | 'store' | 'user' | 'package'; + storeId?: string; } /** @@ -249,16 +296,22 @@ export interface SchemaInfo { * * @param projectRoot - Optional project root directory for project-local schema resolution */ -export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { +export function listSchemasWithInfo( + target?: SchemaResolutionTarget +): SchemaInfo[] { const schemas: SchemaInfo[] = []; const seenNames = new Set(); + const context = toSchemaContext(target); - // Add project-local schemas first (highest priority, if projectRoot provided) - if (projectRoot) { - const projectDir = getProjectSchemasDir(projectRoot); + // Add the active project layer first (local project or schema Store). + if (context) { + const projectDir = getProjectSchemasDir(context.root); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (isSchemaDir(projectDir, entry)) { + if ( + isVisibleFromPrimarySource(entry.name, context) && + isSchemaDir(projectDir, entry) + ) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { @@ -267,7 +320,10 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { name: entry.name, description: schema.description || '', artifacts: schema.artifacts.map((a) => a.id), - source: 'project', + source: context.source, + ...(context.source === 'store' && context.storeId + ? { storeId: context.storeId } + : {}), }); seenNames.add(entry.name); } catch { diff --git a/src/core/completions/completion-provider.ts b/src/core/completions/completion-provider.ts index 0159131486..b6cd5b2070 100644 --- a/src/core/completions/completion-provider.ts +++ b/src/core/completions/completion-provider.ts @@ -1,5 +1,6 @@ import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js'; import { listSchemas } from '../artifact-graph/index.js'; +import type { SchemaResolutionTarget } from '../artifact-graph/index.js'; /** * Cache entry for completion data @@ -9,6 +10,22 @@ interface CacheEntry { timestamp: number; } +function schemaTargetCacheKey(target: SchemaResolutionTarget): string { + if (typeof target === 'string') { + return JSON.stringify({ root: target, source: 'project', visibleSchemas: '*' }); + } + + return JSON.stringify({ + root: target.root, + source: target.source, + storeId: target.storeId, + visibleSchemas: + target.visibleSchemas === '*' + ? '*' + : [...target.visibleSchemas].sort(), + }); +} + /** * Provides dynamic completion suggestions for OpenSpec items (changes and specs). * Implements a 2-second cache to avoid excessive file system operations during @@ -19,6 +36,7 @@ export class CompletionProvider { private changeCache: CacheEntry | null = null; private specCache: CacheEntry | null = null; private schemaCache: CacheEntry | null = null; + private schemaCacheKey: string | null = null; /** * Creates a new completion provider @@ -28,7 +46,8 @@ export class CompletionProvider { */ constructor( private readonly cacheTTLMs: number = 2000, - private readonly projectRoot: string = process.cwd() + private readonly projectRoot: string = process.cwd(), + private readonly schemaTarget?: SchemaResolutionTarget ) { this.cacheTTL = cacheTTLMs; } @@ -88,22 +107,31 @@ export class CompletionProvider { * * @returns Array of schema names */ - async getSchemaNames(): Promise { + async getSchemaNames( + schemaTarget: SchemaResolutionTarget | undefined = this.schemaTarget + ): Promise { const now = Date.now(); + const effectiveTarget = schemaTarget ?? this.projectRoot; + const cacheKey = schemaTargetCacheKey(effectiveTarget); // Check if cache is valid - if (this.schemaCache && now - this.schemaCache.timestamp < this.cacheTTL) { + if ( + this.schemaCache && + this.schemaCacheKey === cacheKey && + now - this.schemaCache.timestamp < this.cacheTTL + ) { return this.schemaCache.data; } // Fetch fresh data - const schemaNames = listSchemas(this.projectRoot); + const schemaNames = listSchemas(effectiveTarget); // Update cache this.schemaCache = { data: schemaNames, timestamp: now, }; + this.schemaCacheKey = cacheKey; return schemaNames; } @@ -129,6 +157,7 @@ export class CompletionProvider { this.changeCache = null; this.specCache = null; this.schemaCache = null; + this.schemaCacheKey = null; } /** diff --git a/src/core/list.ts b/src/core/list.ts index f6b6faf2f8..4a1d783237 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -4,6 +4,8 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre import { readFileSync, type Dirent } from 'fs'; import { MarkdownParser } from './parsers/markdown-parser.js'; import type { RootOutput } from './root-selection.js'; +import type { SchemaResolutionTarget } from './artifact-graph/index.js'; +import type { ProjectConfig } from './project-config.js'; import { discoverSpecFiles } from '../utils/spec-discovery.js'; interface ChangeInfo { @@ -17,6 +19,8 @@ interface ListOptions { sort?: 'recent' | 'name'; json?: boolean; root?: RootOutput; + schemaTarget?: SchemaResolutionTarget; + projectConfig?: ProjectConfig | null; } function isMissingPathError(error: unknown): boolean { @@ -96,7 +100,13 @@ function formatRelativeTime(date: Date): string { export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { - const { sort = 'recent', json = false, root } = options; + const { + sort = 'recent', + json = false, + root, + schemaTarget, + projectConfig, + } = options; if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); @@ -120,7 +130,13 @@ export class ListCommand { const changes: ChangeInfo[] = []; for (const changeDir of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); + const progress = await getTaskProgressForChange( + changesDir, + changeDir, + targetPath, + schemaTarget ?? targetPath, + projectConfig + ); const changePath = path.join(changesDir, changeDir); const lastModified = await getLastModified(changePath); changes.push({ diff --git a/src/core/project-config.ts b/src/core/project-config.ts index f385443191..b4639a8023 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'fs'; import path from 'path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +import { isValidStoreId } from './store/foundation.js'; export const OPERATION_IDS = ['apply', 'archive'] as const; export type OperationId = (typeof OPERATION_IDS)[number]; @@ -82,8 +83,16 @@ export interface DeclarationEntry { remote?: string; } +/** Normalized schema-only Store declaration from project configuration. */ +export interface SchemaStoreDeclaration { + id: string; + /** `*` exposes every Store schema; an array exposes exact schema names. */ + schemas: '*' | string[]; +} + export type ProjectConfig = z.infer & { references?: DeclarationEntry[]; + schemaStore?: SchemaStoreDeclaration; }; export interface OperationInputs { @@ -233,6 +242,100 @@ function parseDeclarationList(raw: unknown): DeclarationEntry[] | undefined { return byId.size > 0 ? [...byId.values()] : undefined; } +type SchemaStoreParseResult = + | { success: true; value: SchemaStoreDeclaration } + | { success: false; problem: string }; + +const SCHEMA_NAME_REGEX = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; + +function parseSchemaStoreDeclaration(raw: unknown): SchemaStoreParseResult { + if (typeof raw === 'string') { + if (!isValidStoreId(raw)) { + return { + success: false, + problem: 'id must be a valid kebab-case Store id', + }; + } + return { success: true, value: { id: raw, schemas: '*' } }; + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { + success: false, + problem: 'must be a Store id string or an object', + }; + } + + const declaration = raw as Record; + const unknownFields = Object.keys(declaration).filter( + (field) => field !== 'id' && field !== 'schemas' + ); + if (unknownFields.length > 0) { + return { + success: false, + problem: `contains unsupported field(s): ${unknownFields.join(', ')}`, + }; + } + + if (typeof declaration.id !== 'string' || !isValidStoreId(declaration.id)) { + return { + success: false, + problem: 'id must be a valid kebab-case Store id', + }; + } + + if (declaration.schemas === undefined) { + return { + success: true, + value: { id: declaration.id, schemas: '*' }, + }; + } + + if (!Array.isArray(declaration.schemas) || declaration.schemas.length === 0) { + return { + success: false, + problem: 'schemas must contain at least one schema name or "*"', + }; + } + + if (!declaration.schemas.every((entry) => typeof entry === 'string')) { + return { + success: false, + problem: 'schemas must be an array of schema name strings', + }; + } + + const names = declaration.schemas as string[]; + if (names.includes('*')) { + if (names.length !== 1) { + return { + success: false, + problem: 'schemas wildcard "*" cannot be combined with schema names', + }; + } + return { + success: true, + value: { id: declaration.id, schemas: '*' }, + }; + } + + const invalidName = names.find((name) => !SCHEMA_NAME_REGEX.test(name)); + if (invalidName !== undefined) { + return { + success: false, + problem: `schemas contains invalid schema name '${invalidName}'`, + }; + } + + return { + success: true, + value: { + id: declaration.id, + schemas: [...new Set(names)], + }, + }; +} + export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the references index /** @@ -362,6 +465,17 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + if (raw.schemaStore !== undefined) { + const schemaStore = parseSchemaStoreDeclaration(raw.schemaStore); + if (schemaStore.success) { + config.schemaStore = schemaStore.value; + } else { + console.warn( + `Invalid 'schemaStore' field in config (${schemaStore.problem}); ignoring it` + ); + } + } + // Return partial config even if some fields failed return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; } catch (error) { @@ -482,6 +596,60 @@ export function suggestSchemas( // Store pointer (declared default store) // ----------------------------------------------------------------------------- +export interface SchemaStoreDeclarationRead { + /** Normalized declaration when schemaStore is present and valid. */ + value?: SchemaStoreDeclaration; + /** Invalid declarations and malformed YAML must fail closed at authority + * resolution instead of silently falling back to another schema source. */ + malformed?: 'unparseable' | 'invalid_declaration'; + /** Concise, user-facing reason for a malformed result. */ + problem?: string; + /** Absolute path of the config file actually read, or null when none exists. */ + filePath: string | null; +} + +/** + * Warning-silent authoritative read of `schemaStore`. Generic config loading is + * resilient, but schema authority selection must distinguish absent from invalid. + */ +export function readSchemaStoreDeclaration( + projectRoot: string +): SchemaStoreDeclarationRead { + const configPath = resolveConfigFilePath(projectRoot); + if (configPath === null) { + return { filePath: null }; + } + + try { + const raw = parseYaml(readFileSync(configPath, 'utf-8')); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { filePath: configPath }; + } + + const value = (raw as Record).schemaStore; + if (value === undefined) { + return { filePath: configPath }; + } + + const result = parseSchemaStoreDeclaration(value); + if (result.success) { + return { value: result.value, filePath: configPath }; + } + + return { + malformed: 'invalid_declaration', + problem: result.problem, + filePath: configPath, + }; + } catch { + return { + malformed: 'unparseable', + problem: 'the config file could not be read as YAML', + filePath: configPath, + }; + } +} + export interface StorePointerRead { /** The declared store id, when present and a string. */ value?: string; diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts index 21108f5967..4b59174c08 100644 --- a/src/core/root-selection.ts +++ b/src/core/root-selection.ts @@ -33,12 +33,23 @@ import { readOptionalStoreMetadataState, validateStoreId, } from './store/foundation.js'; -import { getStoreRootForBackend } from './store/registry.js'; +import { + getStoreRootForBackend, + resolveRegisteredStore, +} from './store/registry.js'; import { inspectOpenSpecRoot } from './openspec-root.js'; import { findRepoPlanningRootSync, type PlanningHome } from './planning-home.js'; -import { classifyOpenSpecDir, storePointerProblem } from './project-config.js'; +import { + classifyOpenSpecDir, + readProjectConfig, + readSchemaStoreDeclaration, + storePointerProblem, + type ProjectConfig, + type SchemaStoreDeclaration, +} from './project-config.js'; import { getGlobalConfig } from './global-config.js'; import { FileSystemUtils } from '../utils/file-system.js'; +import type { SchemaResolutionContext } from './artifact-graph/resolver.js'; export type OpenSpecRootSource = | 'store' @@ -60,6 +71,10 @@ export interface ResolveOpenSpecRootOptions extends StoreSelectorOptions { export interface ResolvedOpenSpecRoot { path: string; + /** Repository that owns the controlling config, even when planning is redirected. */ + consumerRoot: string; + /** Synchronously consumable schema authority resolved at the async root boundary. */ + schemaContext: SchemaResolutionContext; changesDir: string; specsDir: string; archiveDir: string; @@ -99,6 +114,31 @@ export function isRootSelectionError(error: unknown): error is RootSelectionErro return error instanceof RootSelectionError; } +/** + * Read the operational config for a resolved root without changing existing + * Planning Store semantics. A consumer config becomes an overlay only when it + * explicitly declares a schema Store; this lets a consumer select its schema + * authority while retaining planning-owned references, context, and rules. + */ +export function readResolvedProjectConfig( + root: ResolvedOpenSpecRoot +): ProjectConfig | null { + const planningConfig = readProjectConfig(root.path); + if (root.consumerRoot === root.path) { + return planningConfig; + } + + const consumerConfig = readProjectConfig(root.consumerRoot); + if (!consumerConfig?.schemaStore) { + return planningConfig; + } + + return { + ...(planningConfig ?? {}), + ...consumerConfig, + } as ProjectConfig; +} + function fromStoreError(error: unknown): never { if (error instanceof StoreError) { throw new RootSelectionError(error.message, error.diagnostic.code, { @@ -121,6 +161,12 @@ function makeRoot( ): ResolvedOpenSpecRoot { return { path: rootPath, + consumerRoot: rootPath, + schemaContext: { + root: rootPath, + source: 'project', + visibleSchemas: '*', + }, changesDir: path.join(rootPath, 'openspec', 'changes'), specsDir: path.join(rootPath, 'openspec', 'specs'), archiveDir: path.join(rootPath, 'openspec', 'changes', 'archive'), @@ -130,6 +176,104 @@ function makeRoot( }; } +function schemaStoreFix( + declaration: SchemaStoreDeclaration, + error: StoreError +): string | undefined { + if ( + error.diagnostic.code === 'store_not_found' || + error.diagnostic.code === 'no_store_registry' + ) { + return `Register the schema Store with openspec store register --id ${declaration.id}.`; + } + if ( + error.diagnostic.code === 'store_metadata_missing' || + error.diagnostic.code === 'store_metadata_id_mismatch' + ) { + return `Run openspec store doctor ${declaration.id} to inspect or repair the schema Store.`; + } + return error.diagnostic.fix; +} + +async function withSchemaContext( + planningRoot: ResolvedOpenSpecRoot, + consumerRoot: string | null, + globalDataDir?: string +): Promise { + const configRoot = consumerRoot ?? planningRoot.path; + const declarationRead = readSchemaStoreDeclaration(configRoot); + + if (declarationRead.malformed) { + throw new RootSelectionError( + `Invalid schemaStore declaration in ${declarationRead.filePath}: ${declarationRead.problem}.`, + 'invalid_schema_store_declaration', + { + target: 'schemaStore', + fix: + declarationRead.malformed === 'unparseable' + ? `Fix the YAML syntax in ${declarationRead.filePath}.` + : `Edit ${declarationRead.filePath} so schemaStore names a registered Store and valid visible schemas.`, + } + ); + } + + if (!declarationRead.value) { + return { + ...planningRoot, + consumerRoot: configRoot, + schemaContext: { + root: planningRoot.path, + source: 'project', + visibleSchemas: '*', + }, + }; + } + + const declaration = declarationRead.value; + if (planningRoot.storeId === declaration.id) { + return { + ...planningRoot, + consumerRoot: configRoot, + schemaContext: { + root: planningRoot.path, + source: 'store', + storeId: declaration.id, + visibleSchemas: declaration.schemas, + }, + }; + } + + try { + const schemaStore = await resolveRegisteredStore({ + id: declaration.id, + ...(globalDataDir ? { globalDataDir } : {}), + }); + return { + ...planningRoot, + consumerRoot: configRoot, + schemaContext: { + root: FileSystemUtils.canonicalizeExistingPath(schemaStore.storeRoot), + source: 'store', + storeId: declaration.id, + visibleSchemas: declaration.schemas, + }, + }; + } catch (error) { + if (error instanceof StoreError) { + const fix = schemaStoreFix(declaration, error); + throw new RootSelectionError( + `Schema Store '${declaration.id}' declared in ${declarationRead.filePath}: ${error.message}`, + error.diagnostic.code, + { + target: 'schemaStore.id', + ...(fix ? { fix } : {}), + } + ); + } + throw error; + } +} + function canonicalDirectory(startPath: string): string { const resolved = path.resolve(startPath); @@ -403,14 +547,20 @@ export async function resolveOpenSpecRoot( ); } + const startPath = options.startPath ?? process.cwd(); + const nearestRoot = findQualifyingRootSync(startPath); + if (options.store !== undefined) { - return resolveStoreRoot(options.store, options.globalDataDir); + const planningRoot = await resolveStoreRoot(options.store, options.globalDataDir); + return withSchemaContext(planningRoot, nearestRoot, options.globalDataDir); } - const startPath = options.startPath ?? process.cwd(); - const nearestRoot = findQualifyingRootSync(startPath); if (nearestRoot) { - return resolveNearestOrDeclaredRoot(nearestRoot, options.globalDataDir); + const planningRoot = await resolveNearestOrDeclaredRoot( + nearestRoot, + options.globalDataDir + ); + return withSchemaContext(planningRoot, nearestRoot, options.globalDataDir); } // Machine-level fallback: a global defaultStore is consulted only after @@ -418,7 +568,11 @@ export async function resolveOpenSpecRoot( // failed to resolve — it changes the failure path, never the precedence. const defaultStore = getGlobalConfig().defaultStore; if (defaultStore) { - return resolveDefaultStoreRoot(defaultStore, options.globalDataDir); + const planningRoot = await resolveDefaultStoreRoot( + defaultStore, + options.globalDataDir + ); + return withSchemaContext(planningRoot, null, options.globalDataDir); } let registry; @@ -452,7 +606,11 @@ export async function resolveOpenSpecRoot( ); } - return makeRoot(canonicalDirectory(startPath), 'implicit'); + return withSchemaContext( + makeRoot(canonicalDirectory(startPath), 'implicit'), + null, + options.globalDataDir + ); } // ----------------------------------------------------------------------------- diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 0086c12766..fe6d7b2408 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -20,12 +20,18 @@ import { findMainSpecStructureIssues } from '../parsers/spec-structure.js'; import { FileSystemUtils } from '../../utils/file-system.js'; import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js'; import { METADATA_FILENAME, readSkipSpecsMarker } from '../../utils/change-metadata.js'; +import type { SchemaResolutionTarget } from '../artifact-graph/index.js'; export class Validator { private strictMode: boolean; + private schemaTarget?: SchemaResolutionTarget; - constructor(strictMode: boolean = false) { + constructor( + strictMode: boolean = false, + schemaTarget?: SchemaResolutionTarget + ) { this.strictMode = strictMode; + this.schemaTarget = schemaTarget; } async validateSpec(filePath: string): Promise { @@ -91,7 +97,7 @@ export class Validator { const result = ChangeSchema.safeParse(change); - const marker = readSkipSpecsMarker(changeDir); + const marker = readSkipSpecsMarker(changeDir, this.schemaTarget); if (marker.invalidReason) { issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) }); } @@ -361,7 +367,7 @@ export class Validator { }); } - const marker = readSkipSpecsMarker(changeDir); + const marker = readSkipSpecsMarker(changeDir, this.schemaTarget); if (marker.invalidReason) { issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) }); } diff --git a/src/core/view.ts b/src/core/view.ts index e79c1905a7..4666cd9694 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -4,9 +4,19 @@ import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { MarkdownParser } from './parsers/markdown-parser.js'; import { discoverSpecFiles } from '../utils/spec-discovery.js'; +import type { SchemaResolutionTarget } from './artifact-graph/index.js'; +import type { ProjectConfig } from './project-config.js'; + +export interface ViewOptions { + schemaTarget?: SchemaResolutionTarget; + projectConfig?: ProjectConfig | null; +} export class ViewCommand { - async execute(targetPath: string = '.'): Promise { + async execute( + targetPath: string = '.', + options: ViewOptions = {} + ): Promise { const openspecDir = path.join(targetPath, 'openspec'); if (!fs.existsSync(openspecDir)) { @@ -18,7 +28,7 @@ export class ViewCommand { console.log('═'.repeat(60)); // Get changes and specs data - const changesData = await this.getChangesData(openspecDir); + const changesData = await this.getChangesData(openspecDir, options); const specsData = await this.getSpecsData(openspecDir); // Display summary metrics @@ -79,7 +89,10 @@ export class ViewCommand { console.log(chalk.dim(`\nUse ${chalk.white('openspec list --changes')} or ${chalk.white('openspec list --specs')} for detailed views`)); } - private async getChangesData(openspecDir: string): Promise<{ + private async getChangesData( + openspecDir: string, + options: ViewOptions + ): Promise<{ draft: Array<{ name: string }>; active: Array<{ name: string; progress: { total: number; completed: number } }>; completed: Array<{ name: string }>; @@ -98,7 +111,14 @@ export class ViewCommand { for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'archive') { - const progress = await getTaskProgressForChange(changesDir, entry.name, path.dirname(openspecDir)); + const projectRoot = path.dirname(openspecDir); + const progress = await getTaskProgressForChange( + changesDir, + entry.name, + projectRoot, + options.schemaTarget ?? projectRoot, + options.projectConfig + ); if (progress.total === 0) { // No tasks defined yet - still in planning/draft phase @@ -210,4 +230,4 @@ export class ViewCommand { return `[${filledBar}${emptyBar}]`; } -} \ No newline at end of file +} diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts index 57ed446c74..78dd4b2dbe 100644 --- a/src/utils/change-metadata.ts +++ b/src/utils/change-metadata.ts @@ -2,7 +2,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'yaml'; import { ChangeMetadataSchema, type ChangeMetadata } from '../core/change-metadata/index.js'; -import { listSchemas, resolveSchema } from '../core/artifact-graph/resolver.js'; +import { + listSchemas, + resolveSchema, + type SchemaResolutionTarget, +} from '../core/artifact-graph/resolver.js'; import { readProjectConfig, type ProjectConfig } from '../core/project-config.js'; export const METADATA_FILENAME = '.openspec.yaml'; @@ -31,9 +35,9 @@ export class ChangeMetadataError extends Error { */ export function validateSchemaName( schemaName: string, - projectRoot?: string + schemaTarget?: SchemaResolutionTarget ): string { - const availableSchemas = listSchemas(projectRoot); + const availableSchemas = listSchemas(schemaTarget); if (!availableSchemas.includes(schemaName)) { throw new Error( `Unknown schema '${schemaName}'. Available: ${availableSchemas.join(', ')}` @@ -53,12 +57,12 @@ export function validateSchemaName( export function writeChangeMetadata( changeDir: string, metadata: ChangeMetadata, - projectRoot?: string + schemaTarget?: SchemaResolutionTarget ): void { const metaPath = path.join(changeDir, METADATA_FILENAME); // Validate schema exists - validateSchemaName(metadata.schema, projectRoot); + validateSchemaName(metadata.schema, schemaTarget); // Validate with Zod const parseResult = ChangeMetadataSchema.safeParse(metadata); @@ -93,7 +97,7 @@ export function writeChangeMetadata( */ export function readChangeMetadata( changeDir: string, - projectRoot?: string + schemaTarget?: SchemaResolutionTarget ): ChangeMetadata | null { const metaPath = path.join(changeDir, METADATA_FILENAME); @@ -135,7 +139,7 @@ export function readChangeMetadata( } // Validate that the schema exists - const availableSchemas = listSchemas(projectRoot); + const availableSchemas = listSchemas(schemaTarget); if (!availableSchemas.includes(parseResult.data.schema)) { throw new ChangeMetadataError( `Unknown schema '${parseResult.data.schema}'. Available: ${availableSchemas.join(', ')}`, @@ -150,6 +154,8 @@ export interface ResolveSchemaForChangeOptions { metadata?: ChangeMetadata | null; /** Pre-read project config; suppresses the fallback config read when provided. */ projectConfig?: ProjectConfig | null; + /** Resolved project or schema Store authority for metadata validation. */ + schemaTarget?: SchemaResolutionTarget; } /** @@ -180,7 +186,9 @@ export function resolveSchemaForChange( } const metadata = - options.metadata !== undefined ? options.metadata : readChangeMetadata(changeDir, projectRoot); + options.metadata !== undefined + ? options.metadata + : readChangeMetadata(changeDir, options.schemaTarget ?? projectRoot); if (metadata?.schema) { return metadata.schema; } @@ -232,7 +240,10 @@ export interface SkipSpecsMarker { * Missing metadata means "not declared"; a marker that cannot be honored * yields invalidReason so callers can say why. */ -export function readSkipSpecsMarker(changeDir: string): SkipSpecsMarker { +export function readSkipSpecsMarker( + changeDir: string, + schemaTarget?: SchemaResolutionTarget +): SkipSpecsMarker { let raw: string; try { raw = fs.readFileSync(path.join(changeDir, METADATA_FILENAME), 'utf-8'); @@ -277,13 +288,14 @@ export function readSkipSpecsMarker(changeDir: string): SkipSpecsMarker { // proves the schema actually parses. Any failure fails closed. try { const projectRoot = path.resolve(changeDir, '../../..'); - if (!listSchemas(projectRoot).includes(result.data.schema)) { + const effectiveSchemaTarget = schemaTarget ?? projectRoot; + if (!listSchemas(effectiveSchemaTarget).includes(result.data.schema)) { return { declared: false, invalidReason: `schema: unknown schema '${result.data.schema}'`, }; } - resolveSchema(result.data.schema, projectRoot); + resolveSchema(result.data.schema, effectiveSchemaTarget); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { declared: false, invalidReason: message }; diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index f73ba61bce..3c0ab5a071 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -5,6 +5,8 @@ import { formatLocalDate } from './date.js'; import { readProjectConfig } from '../core/project-config.js'; import { isKebabId } from '../core/id.js'; import type { ChangeMetadata } from '../core/change-metadata/index.js'; +import type { SchemaResolutionTarget } from '../core/artifact-graph/resolver.js'; +import type { ProjectConfig } from '../core/project-config.js'; const DEFAULT_SCHEMA = 'spec-driven'; @@ -20,6 +22,10 @@ export interface CreateChangeOptions { changesDir?: string; /** Additional metadata to persist in the change's .openspec.yaml */ metadata?: Partial>; + /** Consumer config snapshot when planning lives in a different Store. */ + projectConfig?: ProjectConfig | null; + /** Resolved project or schema Store authority used to validate the schema. */ + schemaTarget?: SchemaResolutionTarget; } /** @@ -146,7 +152,10 @@ export async function createChange( } else { // Try to read from project config try { - const config = readProjectConfig(projectRoot); + const config = + options.projectConfig !== undefined + ? options.projectConfig + : readProjectConfig(projectRoot); schemaName = config?.schema ?? defaultSchema; } catch { // If config read fails, use default @@ -155,7 +164,7 @@ export async function createChange( } // Validate the resolved schema - validateSchemaName(schemaName, projectRoot); + validateSchemaName(schemaName, options.schemaTarget ?? projectRoot); // Build the change directory path const changeDir = path.join(options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), name); @@ -191,7 +200,7 @@ export async function createChange( schema: schemaName, created: formatLocalDate(), ...options.metadata, - }, projectRoot); + }, options.schemaTarget ?? projectRoot); return { schema: schemaName, changeDir }; } diff --git a/src/utils/task-progress.ts b/src/utils/task-progress.ts index e45c274162..47109296b8 100644 --- a/src/utils/task-progress.ts +++ b/src/utils/task-progress.ts @@ -2,6 +2,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 type { SchemaResolutionTarget } from '../core/artifact-graph/index.js'; +import type { ProjectConfig } from '../core/project-config.js'; import { resolveSchemaForChange } from './change-metadata.js'; const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; @@ -47,10 +49,18 @@ function findTrackedTasksArtifact(schema: SchemaYaml): Artifact | undefined { * `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 { +function resolveTrackedTasksGlob( + changeDir: string, + projectRoot: string, + schemaTarget: SchemaResolutionTarget, + projectConfig?: ProjectConfig | null +): string | undefined { try { - const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot); - const schema = resolveSchema(schemaName, projectRoot); + const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot, { + schemaTarget, + ...(projectConfig !== undefined ? { projectConfig } : {}), + }); + const schema = resolveSchema(schemaName, schemaTarget); return findTrackedTasksArtifact(schema)?.generates; } catch { return undefined; @@ -79,11 +89,18 @@ async function countSingleTopLevelTasksFile(changeDir: string): Promise { const changeDir = path.join(changesDir, changeName); - const generates = resolveTrackedTasksGlob(changeDir, projectRoot); + const generates = resolveTrackedTasksGlob( + changeDir, + projectRoot, + schemaTarget, + projectConfig + ); if (generates) { const files = resolveArtifactOutputs(changeDir, generates); if (files.length > 0) { @@ -111,5 +128,3 @@ export function formatTaskStatus(progress: TaskProgress): string { if (progress.completed === progress.total) return '✓ Complete'; return `${progress.completed}/${progress.total} tasks`; } - - diff --git a/test/commands/completion.test.ts b/test/commands/completion.test.ts index 435c30d2bb..1d96e7dbdc 100644 --- a/test/commands/completion.test.ts +++ b/test/commands/completion.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { CompletionCommand } from '../../src/commands/completion.js'; +import { CompletionProvider } from '../../src/core/completions/completion-provider.js'; import * as shellDetection from '../../src/utils/shell-detection.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; // Mock the shell detection module vi.mock('../../src/utils/shell-detection.js', () => ({ @@ -251,6 +255,154 @@ describe('CompletionCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith('spec-driven\tschema'); expect(process.exitCode).toBe(0); }); + + it('applies schema Store visibility and replaces consumer-local schemas', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'openspec-completion-store-') + ); + try { + const schemaStoreRoot = path.join(tempDir, 'schema-store'); + for (const name of ['visible-flow', 'hidden-flow']) { + const schemaDir = path.join( + schemaStoreRoot, + 'openspec', + 'schemas', + name + ); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + `name: ${name}\n` + ); + } + const consumerSchemaDir = path.join( + tempDir, + 'consumer', + 'openspec', + 'schemas', + 'consumer-only' + ); + fs.mkdirSync(consumerSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(consumerSchemaDir, 'schema.yaml'), + 'name: consumer-only\n' + ); + + const provider = new CompletionProvider( + 0, + path.join(tempDir, 'consumer'), + { + root: schemaStoreRoot, + source: 'store', + storeId: 'department-schemas', + visibleSchemas: ['visible-flow'], + } + ); + + const schemas = await provider.getSchemaNames(); + expect(schemas).toContain('visible-flow'); + expect(schemas).not.toContain('hidden-flow'); + expect(schemas).not.toContain('consumer-only'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not reuse schema completion cache entries across resolution targets', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'openspec-completion-target-cache-') + ); + try { + const firstRoot = path.join(tempDir, 'first'); + const secondRoot = path.join(tempDir, 'second'); + for (const [root, name] of [ + [firstRoot, 'first-flow'], + [secondRoot, 'second-flow'], + ]) { + const schemaDir = path.join(root, 'openspec', 'schemas', name); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), `name: ${name}\n`); + } + + const provider = new CompletionProvider(60_000, tempDir); + expect(await provider.getSchemaNames(firstRoot)).toContain('first-flow'); + + const secondSchemas = await provider.getSchemaNames({ + root: secondRoot, + source: 'store', + storeId: 'second-store', + visibleSchemas: '*', + }); + expect(secondSchemas).toContain('second-flow'); + expect(secondSchemas).not.toContain('first-flow'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('fails silently instead of falling back for an invalid schemaStore authority', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'openspec-completion-invalid-') + ); + try { + fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schemaStore:\n id: department-schemas\n schemas: []\n' + ); + + await new CompletionCommand(tempDir).complete({ type: 'schemas' }); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('lists ordinary schemas outside a project even when Stores are registered', async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'openspec-completion-no-root-') + ); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + try { + process.env.XDG_DATA_HOME = path.join(tempDir, 'data'); + const registryDir = path.join( + process.env.XDG_DATA_HOME, + 'openspec', + 'stores' + ); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, 'registry.yaml'), + [ + 'version: 1', + 'stores:', + ' registered-store:', + ' backend:', + ' type: git', + ` local_path: ${path.join(tempDir, 'registered-store')}`, + '', + ].join('\n') + ); + + await new CompletionCommand(path.join(tempDir, 'outside')).complete({ + type: 'schemas', + }); + + expect(consoleLogSpy).toHaveBeenCalledWith('spec-driven\tschema'); + expect(process.exitCode).toBe(0); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); describe('shell detection integration', () => { diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 190276dc35..40834c93e6 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -123,6 +123,413 @@ describe('store root selection for normal commands', () => { expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false); } + function createSchema(rootDir: string, name: string): void { + const schemaDir = path.join(rootDir, 'openspec', 'schemas', name); + fs.mkdirSync(path.join(schemaDir, 'templates'), { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + `name: ${name} +version: 1 +description: Department workflow +artifacts: + - id: brief + generates: brief.md + description: Department brief + template: brief.md + - id: checklist + generates: department-tasks.md + description: Department checklist + template: checklist.md + requires: [brief] +apply: + requires: [brief, checklist] + tracks: department-tasks.md +` + ); + fs.writeFileSync( + path.join(schemaDir, 'templates', 'brief.md'), + '# Department Brief\n\nStore-backed template.\n' + ); + fs.writeFileSync( + path.join(schemaDir, 'templates', 'checklist.md'), + '# Department Checklist\n\n- [ ] Implement the change.\n' + ); + } + + describe('schema Store workflow context', () => { + it('uses a schema Store across new change, status, and instructions with local planning', async () => { + const schemaStoreRoot = await registerStoreFixture('department-schemas'); + createSchema(schemaStoreRoot, 'department-flow'); + createSchema(schemaStoreRoot, 'hidden-flow'); + const localRepo = path.join(tempDir, 'schema-consumer'); + createOpenSpecRoot(localRepo); + fs.writeFileSync( + path.join(localRepo, 'openspec', 'config.yaml'), + `schema: department-flow +schemaStore: + id: department-schemas + schemas: [department-flow] +` + ); + + const created = await runCLI(['new', 'change', 'use-department-flow'], { + cwd: localRepo, + env, + }); + expect(created.exitCode).toBe(0); + expect( + fs.existsSync( + path.join( + localRepo, + 'openspec', + 'changes', + 'use-department-flow', + '.openspec.yaml' + ) + ) + ).toBe(true); + + const status = await runCLI( + ['status', '--change', 'use-department-flow', '--json'], + { cwd: localRepo, env } + ); + expect(status.exitCode).toBe(0); + expect(parseJson(status)).toMatchObject({ + schemaName: 'department-flow', + artifacts: [ + { id: 'brief', status: 'ready' }, + { id: 'checklist', status: 'blocked' }, + ], + }); + + const instructions = await runCLI( + [ + 'instructions', + 'brief', + '--change', + 'use-department-flow', + '--json', + ], + { cwd: localRepo, env } + ); + expect(instructions.exitCode).toBe(0); + expect(parseJson(instructions).template).toContain('Store-backed template'); + + const schemas = await runCLI(['schemas', '--json'], { + cwd: localRepo, + env, + }); + expect(schemas.exitCode).toBe(0); + const schemaList = parseJson(schemas); + expect(schemaList).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'department-flow', + source: 'store', + storeId: 'department-schemas', + }), + ]) + ); + expect( + schemaList.some((schema: { name: string }) => schema.name === 'hidden-flow') + ).toBe(false); + + const which = await runCLI( + ['schema', 'which', 'department-flow', '--json'], + { cwd: localRepo, env } + ); + expect(which.exitCode).toBe(0); + expect(parseJson(which)).toMatchObject({ + name: 'department-flow', + source: 'store', + storeId: 'department-schemas', + path: path.join( + schemaStoreRoot, + 'openspec', + 'schemas', + 'department-flow' + ), + }); + + const allSchemas = await runCLI( + ['schema', 'which', '--all', '--json'], + { cwd: localRepo, env } + ); + expect(allSchemas.exitCode).toBe(0); + expect( + parseJson(allSchemas).some( + (schema: { name: string }) => schema.name === 'hidden-flow' + ) + ).toBe(false); + + const schemaValidation = await runCLI( + ['schema', 'validate', 'department-flow', '--json'], + { cwd: localRepo, env } + ); + expect(schemaValidation.exitCode).toBe(0); + + const forked = await runCLI( + ['schema', 'fork', 'department-flow', 'forked-flow', '--json'], + { cwd: localRepo, env } + ); + expect(forked.exitCode).toBe(1); + expect(parseJson(forked)).toMatchObject({ + forked: false, + storeId: 'department-schemas', + error: expect.stringContaining('remove schemaStore'), + }); + expect( + fs.existsSync( + path.join( + localRepo, + 'openspec', + 'schemas', + 'forked-flow', + 'schema.yaml' + ) + ) + ).toBe(false); + + const initialized = await runCLI( + ['schema', 'init', 'local-flow', '--json'], + { cwd: localRepo, env } + ); + expect(initialized.exitCode).toBe(1); + expect(parseJson(initialized)).toMatchObject({ + created: false, + storeId: 'department-schemas', + error: expect.stringContaining('remove schemaStore'), + }); + expect( + fs.existsSync( + path.join( + localRepo, + 'openspec', + 'schemas', + 'local-flow', + 'schema.yaml' + ) + ) + ).toBe(false); + + const templates = await runCLI( + ['templates', '--schema', 'department-flow', '--json'], + { cwd: localRepo, env } + ); + expect(templates.exitCode).toBe(0); + expect(parseJson(templates).brief).toMatchObject({ + source: 'store', + storeId: 'department-schemas', + }); + + const changeDir = path.join( + localRepo, + 'openspec', + 'changes', + 'use-department-flow' + ); + fs.writeFileSync(path.join(changeDir, 'brief.md'), '# Brief\n'); + fs.writeFileSync( + path.join(changeDir, 'department-tasks.md'), + '- [x] Implement the change.\n' + ); + fs.writeFileSync( + path.join(changeDir, 'proposal.md'), + '## Why\nThis department change is needed.\n\n## What Changes\n- Update the department flow.\n' + ); + fs.appendFileSync(path.join(changeDir, '.openspec.yaml'), 'skip_specs: true\n'); + + const apply = await runCLI( + ['instructions', 'apply', '--change', 'use-department-flow', '--json'], + { cwd: localRepo, env } + ); + expect(apply.exitCode).toBe(0); + expect(parseJson(apply)).toMatchObject({ + schemaName: 'department-flow', + state: 'all_done', + progress: { total: 1, complete: 1, remaining: 0 }, + }); + + const listed = await runCLI(['list', '--json'], { cwd: localRepo, env }); + expect(listed.exitCode).toBe(0); + expect(parseJson(listed).changes[0]).toMatchObject({ + name: 'use-department-flow', + completedTasks: 1, + totalTasks: 1, + }); + + const validated = await runCLI( + ['validate', 'use-department-flow', '--json'], + { cwd: localRepo, env } + ); + expect(validated.exitCode).toBe(0); + + const archived = await runCLI( + ['archive', 'use-department-flow', '--json', '--yes'], + { cwd: localRepo, env } + ); + expect(archived.exitCode).toBe(0); + expect( + fs.existsSync( + path.join(localRepo, 'openspec', 'changes', 'use-department-flow') + ) + ).toBe(false); + }); + + it('keeps writes in the planning Store while loading schemas from another Store', async () => { + const schemaStoreRoot = await registerStoreFixture('department-schemas'); + createSchema(schemaStoreRoot, 'department-flow'); + fs.mkdirSync(path.join(appRepo, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(appRepo, 'openspec', 'config.yaml'), + `store: team-context +schema: department-flow +schemaStore: department-schemas +` + ); + + const result = await runCLI(['new', 'change', 'split-store-flow'], { + cwd: appRepo, + env, + }); + + expect(result.exitCode).toBe(0); + expect( + fs.existsSync( + path.join(storeRoot, 'openspec', 'changes', 'split-store-flow') + ) + ).toBe(true); + expect( + fs.readFileSync( + path.join( + storeRoot, + 'openspec', + 'changes', + 'split-store-flow', + '.openspec.yaml' + ), + 'utf-8' + ) + ).toContain('schema: department-flow'); + expect( + fs.existsSync( + path.join(appRepo, 'openspec', 'changes', 'split-store-flow') + ) + ).toBe(false); + + const changeDir = path.join( + storeRoot, + 'openspec', + 'changes', + 'split-store-flow' + ); + const status = await runCLI( + ['status', '--change', 'split-store-flow', '--json'], + { cwd: appRepo, env } + ); + expect(status.exitCode).toBe(0); + expect(parseJson(status).schemaName).toBe('department-flow'); + + const instructions = await runCLI( + ['instructions', 'brief', '--change', 'split-store-flow', '--json'], + { cwd: appRepo, env } + ); + expect(instructions.exitCode).toBe(0); + expect(parseJson(instructions).template).toContain('Store-backed template'); + + fs.writeFileSync(path.join(changeDir, 'brief.md'), '# Brief\n'); + fs.writeFileSync( + path.join(changeDir, 'department-tasks.md'), + '- [x] Implement the change.\n' + ); + fs.writeFileSync( + path.join(changeDir, 'proposal.md'), + '## Why\nThis department change is needed.\n\n## What Changes\n- Update the department flow.\n' + ); + fs.appendFileSync(path.join(changeDir, '.openspec.yaml'), 'skip_specs: true\n'); + + const deprecatedValidation = await runCLI( + ['change', 'validate', 'split-store-flow', '--json'], + { cwd: appRepo, env } + ); + expect(deprecatedValidation.exitCode).toBe(0); + expect(parseJson(deprecatedValidation)).toMatchObject({ valid: true }); + + const apply = await runCLI( + ['instructions', 'apply', '--change', 'split-store-flow', '--json'], + { cwd: appRepo, env } + ); + expect(apply.exitCode).toBe(0); + expect(parseJson(apply)).toMatchObject({ + schemaName: 'department-flow', + state: 'all_done', + }); + + const listed = await runCLI(['list', '--json'], { cwd: appRepo, env }); + expect(listed.exitCode).toBe(0); + expect(parseJson(listed).changes[0]).toMatchObject({ + name: 'split-store-flow', + completedTasks: 1, + totalTasks: 1, + }); + + const legacyChangeDir = path.join( + storeRoot, + 'openspec', + 'changes', + 'legacy-split-flow' + ); + fs.mkdirSync(legacyChangeDir, { recursive: true }); + fs.writeFileSync( + path.join(legacyChangeDir, 'department-tasks.md'), + '- [ ] Legacy task.\n' + ); + const listedWithLegacy = parseJson( + await runCLI(['list', '--json'], { cwd: appRepo, env }) + ); + expect( + listedWithLegacy.changes.find( + (change: { name: string }) => change.name === 'legacy-split-flow' + ) + ).toMatchObject({ + completedTasks: 0, + totalTasks: 1, + }); + + const deprecatedList = await runCLI(['change', 'list', '--json'], { + cwd: appRepo, + env, + }); + expect(deprecatedList.exitCode).toBe(0); + expect( + parseJson(deprecatedList).find( + (change: { id: string }) => change.id === 'legacy-split-flow' + ) + ).toMatchObject({ + taskStatus: { completed: 0, total: 1 }, + }); + + const validated = await runCLI( + ['validate', 'split-store-flow', '--json'], + { cwd: appRepo, env } + ); + expect(validated.exitCode).toBe(0); + + const archived = await runCLI( + ['archive', 'split-store-flow', '--json', '--yes'], + { cwd: appRepo, env } + ); + expect(archived.exitCode).toBe(0); + expect(fs.existsSync(changeDir)).toBe(false); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive')) + ).toBe(true); + expect(fs.existsSync(path.join(appRepo, 'openspec', 'changes'))).toBe( + false + ); + }); + }); + describe('selecting a registered store by id', () => { it('creates a change only in the store and names the root on stderr', async () => { const result = await runCLI(['new', 'change', 'add-billing', '--store', 'team-context'], { diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index b37c745eb9..45954d9151 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -649,6 +649,146 @@ artifacts: }); }); + describe('schema Store context', () => { + function writeSchema( + root: string, + name: string, + schemaName: string, + description = schemaName + ): string { + const schemaDir = path.join(root, 'openspec', 'schemas', name); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + `name: ${schemaName} +version: 1 +description: ${description} +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md +` + ); + return schemaDir; + } + + function storeContext( + root: string, + visibleSchemas: '*' | readonly string[] = '*' + ) { + return { + root, + source: 'store' as const, + storeId: 'department-schemas', + visibleSchemas, + }; + } + + it('resolves a visible Store schema ahead of user and package schemas', () => { + process.env.XDG_DATA_HOME = path.join(tempDir, 'user-data'); + const storeRoot = path.join(tempDir, 'schema-store'); + const storeSchemaDir = writeSchema( + storeRoot, + 'spec-driven', + 'store-spec-driven' + ); + writeSchema( + process.env.XDG_DATA_HOME, + 'spec-driven', + 'unused-user' + ); + + expect(getSchemaDir('spec-driven', storeContext(storeRoot))).toBe( + storeSchemaDir + ); + expect(resolveSchema('spec-driven', storeContext(storeRoot)).name).toBe( + 'store-spec-driven' + ); + }); + + it('applies exact visibility only to the Store source', () => { + process.env.XDG_DATA_HOME = path.join(tempDir, 'user-data'); + const storeRoot = path.join(tempDir, 'schema-store'); + writeSchema(storeRoot, 'visible-schema', 'visible-store'); + writeSchema(storeRoot, 'hidden-schema', 'hidden-store'); + const userSchemaDir = path.join( + process.env.XDG_DATA_HOME, + 'openspec', + 'schemas', + 'hidden-schema' + ); + fs.mkdirSync(userSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(userSchemaDir, 'schema.yaml'), + `name: hidden-user +version: 1 +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md +` + ); + const context = storeContext(storeRoot, ['visible-schema']); + + expect(listSchemas(context)).toContain('visible-schema'); + expect(resolveSchema('visible-schema', context).name).toBe('visible-store'); + expect(getSchemaDir('hidden-schema', context)).toBe(userSchemaDir); + expect(resolveSchema('hidden-schema', context).name).toBe('hidden-user'); + }); + + it('uses wildcard visibility and tolerates an empty Store schema directory', () => { + const storeRoot = path.join(tempDir, 'schema-store'); + writeSchema(storeRoot, 'alpha-schema', 'alpha-store'); + + expect(listSchemas(storeContext(storeRoot))).toContain('alpha-schema'); + + const emptyStoreRoot = path.join(tempDir, 'empty-schema-store'); + fs.mkdirSync(emptyStoreRoot, { recursive: true }); + expect(listSchemas(storeContext(emptyStoreRoot))).toContain('spec-driven'); + }); + + it('replaces consumer-local project schemas when a Store is configured', () => { + process.env.XDG_DATA_HOME = path.join(tempDir, 'user-data'); + const consumerRoot = path.join(tempDir, 'consumer'); + const storeRoot = path.join(tempDir, 'schema-store'); + writeSchema(consumerRoot, 'shared-schema', 'consumer-version'); + writeSchema(consumerRoot, 'consumer-only', 'consumer-version'); + writeSchema(storeRoot, 'shared-schema', 'store-version'); + writeSchema(storeRoot, 'store-only', 'store-version'); + + const consumerSchemas = listSchemas(consumerRoot); + expect(consumerSchemas).toContain('consumer-only'); + expect(resolveSchema('shared-schema', consumerRoot).name).toBe( + 'consumer-version' + ); + + const context = storeContext(storeRoot); + const storeSchemas = listSchemas(context); + expect(storeSchemas).toContain('store-only'); + expect(storeSchemas).not.toContain('consumer-only'); + expect(resolveSchema('shared-schema', context).name).toBe('store-version'); + expect(getSchemaDir('consumer-only', context)).toBeNull(); + }); + + it('reports Store provenance and exact Store id', () => { + const storeRoot = path.join(tempDir, 'schema-store'); + writeSchema(storeRoot, 'team-workflow', 'team-workflow', 'Team workflow'); + + const info = listSchemasWithInfo(storeContext(storeRoot)).find( + (schema) => schema.name === 'team-workflow' + ); + + expect(info).toMatchObject({ + name: 'team-workflow', + description: 'Team workflow', + source: 'store', + storeId: 'department-schemas', + }); + }); + }); + // ========================================================================= // Symlinked schema directory tests // ========================================================================= diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 1e739023f6..5fdaa20fb6 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -6,6 +6,7 @@ import { loadOperationInputs, OPERATION_IDS, readProjectConfig, + readSchemaStoreDeclaration, validateConfigRules, suggestSchemas, } from '../../src/core/project-config.js'; @@ -25,6 +26,12 @@ describe('project-config', () => { }); describe('readProjectConfig', () => { + function writeConfig(body: string): void { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.yaml'), body); + } + describe('resilient parsing', () => { it('should parse complete valid config', () => { const configDir = path.join(tempDir, 'openspec'); @@ -486,12 +493,6 @@ rules: }); describe('references parsing', () => { - function writeConfig(body: string): void { - const configDir = path.join(tempDir, 'openspec'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, 'config.yaml'), body); - } - it('keeps entries deduplicated and order-preserving, including invalid grammar', () => { writeConfig( 'schema: spec-driven\nreferences:\n - team-context\n - team-context\n - "BAD ID"\n - other-context\n - 7\n' @@ -567,6 +568,159 @@ rules: }); }); + describe('schemaStore parsing', () => { + it('normalizes the scalar form to wildcard visibility', () => { + writeConfig('schema: qeda-sdd\nschemaStore: department-schemas\n'); + + expect(readProjectConfig(tempDir)).toEqual({ + schema: 'qeda-sdd', + schemaStore: { + id: 'department-schemas', + schemas: '*', + }, + }); + }); + + it('normalizes omitted and explicit wildcard visibility', () => { + writeConfig( + 'schema: qeda-sdd\nschemaStore:\n id: department-schemas\n' + ); + expect(readProjectConfig(tempDir)?.schemaStore).toEqual({ + id: 'department-schemas', + schemas: '*', + }); + + writeConfig( + 'schema: qeda-sdd\nschemaStore:\n id: department-schemas\n schemas: ["*"]\n' + ); + expect(readProjectConfig(tempDir)?.schemaStore).toEqual({ + id: 'department-schemas', + schemas: '*', + }); + }); + + it('normalizes an exact allowlist by deduplicating in declaration order', () => { + writeConfig( + 'schema: qeda-sdd\nschemaStore:\n id: department-schemas\n schemas:\n - qeda-sdd\n - api-contract\n - qeda-sdd\n' + ); + + expect(readProjectConfig(tempDir)?.schemaStore).toEqual({ + id: 'department-schemas', + schemas: ['qeda-sdd', 'api-contract'], + }); + }); + + it.each([ + { + label: 'empty visibility', + declaration: 'id: department-schemas\n schemas: []', + }, + { + label: 'mixed wildcard visibility', + declaration: + 'id: department-schemas\n schemas:\n - "*"\n - qeda-sdd', + }, + { + label: 'invalid Store id', + declaration: 'id: "Department Schemas"', + }, + { + label: 'invalid schema name', + declaration: + 'id: department-schemas\n schemas:\n - Not-A-Schema', + }, + { + label: 'unsupported field', + declaration: + 'id: department-schemas\n schemas: ["*"]\n remote: https://example.com/schemas.git', + }, + ])( + 'drops $label while preserving unrelated valid fields', + ({ declaration }) => { + writeConfig( + `schema: spec-driven +context: Keep this context +schemaStore: + ${declaration} +` + ); + + expect(readProjectConfig(tempDir)).toEqual({ + schema: 'spec-driven', + context: 'Keep this context', + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'schemaStore' field") + ); + } + ); + + it('strictly distinguishes an absent declaration from a valid declaration', () => { + writeConfig('schema: spec-driven\n'); + expect(readSchemaStoreDeclaration(tempDir)).toEqual({ + filePath: path.join(tempDir, 'openspec', 'config.yaml'), + }); + + writeConfig( + 'schema: qeda-sdd\nschemaStore:\n id: department-schemas\n schemas: [qeda-sdd, qeda-sdd]\n' + ); + expect(readSchemaStoreDeclaration(tempDir)).toEqual({ + value: { + id: 'department-schemas', + schemas: ['qeda-sdd'], + }, + filePath: path.join(tempDir, 'openspec', 'config.yaml'), + }); + }); + + it.each([ + { + body: 'schemaStore:\n id: department-schemas\n schemas: []\n', + problem: 'at least one schema', + }, + { + body: + 'schemaStore:\n id: department-schemas\n schemas: ["*", qeda-sdd]\n', + problem: 'cannot be combined', + }, + { + body: 'schemaStore: "Department Schemas"\n', + problem: 'valid kebab-case Store id', + }, + { + body: + 'schemaStore:\n id: department-schemas\n schemas: [Not-A-Schema]\n', + problem: 'invalid schema name', + }, + { + body: + 'schemaStore:\n id: department-schemas\n remote: https://example.com/schemas.git\n', + problem: 'unsupported field', + }, + ])( + 'reports an invalid explicit declaration without dropping it', + ({ body, problem }) => { + writeConfig(body); + + expect(readSchemaStoreDeclaration(tempDir)).toEqual({ + malformed: 'invalid_declaration', + problem: expect.stringContaining(problem), + filePath: path.join(tempDir, 'openspec', 'config.yaml'), + }); + } + ); + + it('reports malformed YAML as unparseable', () => { + writeConfig('schemaStore: [unclosed'); + + expect(readSchemaStoreDeclaration(tempDir)).toEqual({ + malformed: 'unparseable', + problem: 'the config file could not be read as YAML', + filePath: path.join(tempDir, 'openspec', 'config.yaml'), + }); + }); + }); + describe('context size limit enforcement', () => { it('should accept context under 50KB limit', () => { const configDir = path.join(tempDir, 'openspec'); diff --git a/test/core/root-selection.test.ts b/test/core/root-selection.test.ts index 3a09d2ee55..2a0f0965d9 100644 --- a/test/core/root-selection.test.ts +++ b/test/core/root-selection.test.ts @@ -298,6 +298,222 @@ describe('resolveOpenSpecRoot', () => { expect(root.path).toBe(storeRoot); }); + describe('schema Store context', () => { + it('keeps local planning while resolving a separate schema Store', async () => { + const schemaStoreRoot = await registerStore('department-schemas', { + healthyRoot: false, + }); + const repoRoot = mkdir('local-planning'); + createOpenSpecRoot(repoRoot); + fs.writeFileSync( + path.join(repoRoot, 'openspec', 'config.yaml'), + 'schema: qeda-sdd\nschemaStore: department-schemas\n' + ); + + const root = await resolveOpenSpecRoot({ + startPath: path.join(repoRoot, 'openspec'), + globalDataDir, + }); + + expect(root.path).toBe(repoRoot); + expect(root.consumerRoot).toBe(repoRoot); + expect(root.schemaContext).toEqual({ + root: schemaStoreRoot, + source: 'store', + storeId: 'department-schemas', + visibleSchemas: '*', + }); + }); + + it('resolves different planning and schema Stores from the consumer config', async () => { + const planningStoreRoot = await registerStore('department-planning'); + const schemaStoreRoot = await registerStore('department-schemas', { + healthyRoot: false, + }); + const consumerRoot = mkdir('split-store-consumer'); + fs.mkdirSync(path.join(consumerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(consumerRoot, 'openspec', 'config.yaml'), + `store: department-planning +schema: qeda-sdd +schemaStore: + id: department-schemas + schemas: [qeda-sdd, frontend-sdd] +` + ); + + const root = await resolveOpenSpecRoot({ + startPath: consumerRoot, + globalDataDir, + }); + + expect(root.path).toBe(planningStoreRoot); + expect(root.storeId).toBe('department-planning'); + expect(root.consumerRoot).toBe(consumerRoot); + expect(root.schemaContext).toEqual({ + root: schemaStoreRoot, + source: 'store', + storeId: 'department-schemas', + visibleSchemas: ['qeda-sdd', 'frontend-sdd'], + }); + }); + + it('allows the same Store to fill both roles only when explicitly declared', async () => { + const sharedStoreRoot = await registerStore('department-shared'); + const consumerRoot = mkdir('shared-store-consumer'); + fs.mkdirSync(path.join(consumerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(consumerRoot, 'openspec', 'config.yaml'), + 'store: department-shared\nschemaStore: department-shared\n' + ); + + const root = await resolveOpenSpecRoot({ + startPath: consumerRoot, + globalDataDir, + }); + + expect(root.path).toBe(sharedStoreRoot); + expect(root.consumerRoot).toBe(consumerRoot); + expect(root.schemaContext.root).toBe(sharedStoreRoot); + expect(root.schemaContext.storeId).toBe('department-shared'); + }); + + it('lets --store select planning without losing the consumer schema declaration', async () => { + const planningStoreRoot = await registerStore('selected-planning'); + const schemaStoreRoot = await registerStore('department-schemas', { + healthyRoot: false, + }); + const consumerRoot = mkdir('explicit-planning-consumer'); + createOpenSpecRoot(consumerRoot); + fs.writeFileSync( + path.join(consumerRoot, 'openspec', 'config.yaml'), + 'schemaStore: department-schemas\n' + ); + + const root = await resolveOpenSpecRoot({ + startPath: consumerRoot, + store: 'selected-planning', + globalDataDir, + }); + + expect(root.path).toBe(planningStoreRoot); + expect(root.consumerRoot).toBe(consumerRoot); + expect(root.schemaContext).toMatchObject({ + root: schemaStoreRoot, + source: 'store', + storeId: 'department-schemas', + }); + }); + + it('retains existing schema-root behavior when schemaStore is absent', async () => { + const planningStoreRoot = await registerStore('department-planning'); + const consumerRoot = mkdir('existing-behavior-consumer'); + fs.mkdirSync(path.join(consumerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(consumerRoot, 'openspec', 'config.yaml'), + 'store: department-planning\nschema: spec-driven\n' + ); + + const root = await resolveOpenSpecRoot({ + startPath: consumerRoot, + globalDataDir, + }); + + expect(root.path).toBe(planningStoreRoot); + expect(root.consumerRoot).toBe(consumerRoot); + expect(root.schemaContext).toEqual({ + root: planningStoreRoot, + source: 'project', + visibleSchemas: '*', + }); + }); + + it('fails closed for an invalid schemaStore declaration', async () => { + const repoRoot = mkdir('invalid-schema-store'); + createOpenSpecRoot(repoRoot); + fs.writeFileSync( + path.join(repoRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nschemaStore:\n id: department-schemas\n schemas: []\n' + ); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: repoRoot, globalDataDir }), + 'invalid_schema_store_declaration' + ); + expect(error.message).toContain('schemaStore'); + expect(error.message).toContain(path.join(repoRoot, 'openspec', 'config.yaml')); + }); + + it('directs an unknown schema Store declaration to registration', async () => { + await registerStore('some-other-store'); + const repoRoot = mkdir('unknown-schema-store'); + createOpenSpecRoot(repoRoot); + fs.writeFileSync( + path.join(repoRoot, 'openspec', 'config.yaml'), + 'schemaStore: department-schemas\n' + ); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: repoRoot, globalDataDir }), + 'store_not_found' + ); + expect(error.message).toContain("Schema Store 'department-schemas'"); + expect(error.diagnostic.fix).toContain( + 'openspec store register --id department-schemas' + ); + }); + + it.each([ + { + label: 'missing identity metadata', + options: { healthyRoot: false, metadataId: null }, + code: 'store_metadata_missing', + }, + { + label: 'mismatched identity metadata', + options: { healthyRoot: false, metadataId: 'other-schemas' }, + code: 'store_metadata_id_mismatch', + }, + ])('directs $label to Store doctor', async ({ options, code }) => { + await registerStore('department-schemas', options); + const repoRoot = mkdir(`bad-schema-store-${code}`); + createOpenSpecRoot(repoRoot); + fs.writeFileSync( + path.join(repoRoot, 'openspec', 'config.yaml'), + 'schemaStore: department-schemas\n' + ); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: repoRoot, globalDataDir }), + code + ); + expect(error.message).toContain("Schema Store 'department-schemas'"); + expect(error.diagnostic.fix).toContain( + 'openspec store doctor department-schemas' + ); + }); + + it('returns the platform-native canonical schema Store path', async () => { + const schemaStoreRoot = await registerStore('department-schemas', { + healthyRoot: false, + }); + const repoRoot = mkdir(path.join('native path', 'consumer')); + createOpenSpecRoot(repoRoot); + fs.writeFileSync( + path.join(repoRoot, 'openspec', 'config.yaml'), + 'schemaStore: department-schemas\n' + ); + + const root = await resolveOpenSpecRoot({ + startPath: path.join(repoRoot, 'openspec', 'config.yaml'), + globalDataDir, + }); + + expect(root.schemaContext.root).toBe(fs.realpathSync.native(schemaStoreRoot)); + expect(root.consumerRoot).toBe(fs.realpathSync.native(repoRoot)); + }); + }); + describe('declared store fallback (3.2)', () => { function createPointerDir(relativePath: string, configBody: string): string { const dir = mkdir(relativePath); diff --git a/test/core/view.test.ts b/test/core/view.test.ts index 896f88ed6d..232686d27d 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -173,5 +173,66 @@ describe('ViewCommand', () => { expect(draftLines.some(line => line.includes('nested-change'))).toBe(false); expect(output).toContain('60%'); }); -}); + it('uses the resolved schema Store context for task progress', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + const changeDir = path.join(changesDir, 'store-schema-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: department-flow\n' + ); + await fs.writeFile( + path.join(changeDir, 'department-tasks.md'), + '- [ ] Store-backed task\n' + ); + + const schemaStoreRoot = path.join(tempDir, 'department-schemas'); + const schemaDir = path.join( + schemaStoreRoot, + 'openspec', + 'schemas', + 'department-flow' + ); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + `name: department-flow +version: 1 +artifacts: + - id: tasks + generates: department-tasks.md + description: Department tasks + template: tasks.md +apply: + requires: [tasks] + tracks: department-tasks.md +` + ); + + await new ViewCommand().execute(tempDir, { + schemaTarget: { + root: schemaStoreRoot, + source: 'store', + storeId: 'department-schemas', + visibleSchemas: '*', + }, + projectConfig: { + schema: 'department-flow', + }, + }); + + const activeLines = logOutput.map(stripAnsi).filter((line) => + line.includes('◉') + ); + expect( + activeLines.some((line) => line.includes('store-schema-change')) + ).toBe(true); + const draftLines = logOutput.map(stripAnsi).filter((line) => + line.includes('○') + ); + expect( + draftLines.some((line) => line.includes('store-schema-change')) + ).toBe(false); + }); +});