diff --git a/CHANGELOG.md b/CHANGELOG.md index e8353b06..f30e2a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ All notable changes to this project will be documented in this file. ## [0.32.0] - 2026-02-16 -### Added (0.32.0) +### Added -- **Enhanced module manifest security and integrity** (OpenSpec change `arch-06-enhanced-manifest-security`, fixes [#208](https://github.com/nold-ai/specfact-cli/issues/208)) +- **Enhanced module manifest security and integrity** (arch-06, fixes [#208](https://github.com/nold-ai/specfact-cli/issues/208)) - Publisher and integrity metadata in `module-package.yaml` (`publisher`, `integrity.checksum`, optional `integrity.signature`). - Versioned dependency entries (`module_dependencies_versioned`, `pip_dependencies_versioned`) with name and version specifier. - `crypto_validator`: checksum verification (sha256/sha384/sha512) and optional signature verification. @@ -22,6 +22,13 @@ All notable changes to this project will be documented in this file. - Signing automation: `scripts/sign-module.sh` and `.github/workflows/sign-modules.yml` for checksum generation. - Documentation: `docs/reference/module-security.md` and architecture updates for module trust and integrity lifecycle. +- **Schema extension system** (arch-07, Resolves [#213](https://github.com/nold-ai/specfact-cli/issues/213)) + - `extensions` dict field on `Feature` and `ProjectBundle` with namespace-prefixed keys (e.g. `backlog.ado_work_item_id`). + - Type-safe `get_extension(module_name, field, default=None)` and `set_extension(module_name, field, value)` with contract enforcement. + - Optional `schema_extensions` in `module-package.yaml` to declare target model, field, type_hint, and description. + - `ExtensionRegistry` for collision detection and introspection; module registration loads and validates schema extensions. + - Guide: [Extending ProjectBundle](https://docs.specfact.io/guides/extending-projectbundle/). + --- ## [0.31.1] - 2026-02-16 diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 3dfb5256..6446c6ce 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -142,6 +142,8 @@

  • Command Chains
  • Agile/Scrum Workflows
  • Creating Custom Bridges
  • +
  • Extending ProjectBundle
  • +
  • Using Module Security and Extensions
  • Working With Existing Code
  • Existing Code Journey
  • Sidecar Validation
  • diff --git a/docs/guides/extending-projectbundle.md b/docs/guides/extending-projectbundle.md new file mode 100644 index 00000000..8e967ff6 --- /dev/null +++ b/docs/guides/extending-projectbundle.md @@ -0,0 +1,82 @@ +--- +layout: default +title: Extending ProjectBundle +permalink: /guides/extending-projectbundle/ +description: Add namespaced custom fields to Feature and ProjectBundle without modifying core models. +--- + +# Extending ProjectBundle + +Modules can extend `Feature` and `ProjectBundle` with custom metadata using the **schema extension system** (arch-07). Extensions use namespace-prefixed keys so multiple modules can store data without conflicts. + +## Overview + +- **`extensions`** – A dict on `Feature` and `ProjectBundle` that stores module-specific data under keys like `backlog.ado_work_item_id` or `sync.last_sync_timestamp`. +- **`get_extension(module_name, field, default=None)`** – Read a value. +- **`set_extension(module_name, field, value)`** – Write a value. +- **`schema_extensions`** – Optional declaration in `module-package.yaml` so the CLI can validate and introspect which fields a module uses. + +## Using Extensions in Code + +```python +from specfact_cli.models.plan import Feature +from specfact_cli.models.project import ProjectBundle + +# On a Feature (e.g. from a bundle) +feature.set_extension("backlog", "ado_work_item_id", "123456") +value = feature.get_extension("backlog", "ado_work_item_id") # "123456" +missing = feature.get_extension("backlog", "missing", default="default") # "default" + +# On a ProjectBundle +bundle.set_extension("sync", "last_sync_timestamp", "2025-01-15T12:00:00Z") +ts = bundle.get_extension("sync", "last_sync_timestamp") +``` + +**Rules:** + +- `module_name`: lowercase, alphanumeric plus underscores/hyphens, **no dots** (e.g. `backlog`, `sync`). +- `field`: lowercase, alphanumeric plus underscores (e.g. `ado_work_item_id`). +- Keys are stored as `module_name.field` (e.g. `backlog.ado_work_item_id`). + +## Declaring Extensions in the Manifest + +In `module-package.yaml` you can declare which extensions your module uses so the CLI can detect collisions and support introspection: + +```yaml +name: backlog +version: "0.1.0" +commands: [backlog] + +schema_extensions: + - target: Feature + field: ado_work_item_id + type_hint: str + description: Azure DevOps work item ID for sync + - target: Feature + field: jira_issue_key + type_hint: str + description: Jira issue key when using Jira adapter + - target: ProjectBundle + field: last_sync_timestamp + type_hint: str + description: ISO timestamp of last sync +``` + +- **target** – `Feature` or `ProjectBundle`. +- **field** – Snake_case field name (must match `[a-z][a-z0-9_]*`). +- **type_hint** – Documentation only (e.g. `str`, `int`). +- **description** – Human-readable description. + +If two modules declare the same `(target, field)` (e.g. both declare `Feature.ado_work_item_id`), the second module’s schema extensions are skipped and an error is logged. + +## Best Practices + +- Use a single logical namespace per module (the module name). +- Prefer short, clear field names (`ado_work_item_id`, `last_sync_timestamp`). +- Document extensions in `schema_extensions` so other tools and docs can introspect them. +- Do not rely on extension values for core behavior; keep them as optional metadata. + +## Backward Compatibility + +- Existing bundles and features without an `extensions` field load with `extensions = {}`. +- Modules that omit `schema_extensions` load and run normally; no extensions are registered for them. diff --git a/docs/guides/using-module-security-and-extensions.md b/docs/guides/using-module-security-and-extensions.md new file mode 100644 index 00000000..7b941da2 --- /dev/null +++ b/docs/guides/using-module-security-and-extensions.md @@ -0,0 +1,147 @@ +--- +layout: default +title: Using Module Security and Schema Extensions +permalink: /guides/using-module-security-and-extensions/ +description: How to use arch-06 (module security) and arch-07 (schema extensions) from CLI commands and as a module author. +nav_order: 22 +--- + +# Using Module Security and Schema Extensions + +With **arch-06** (manifest security) and **arch-07** (schema extension system) in place, you can use verified modules and store module-specific metadata on bundles and features. This guide shows how to utilize these features from the CLI and in your own modules. + +## Quick reference + +| Capability | What it does | Where to read more | +|------------|--------------|--------------------| +| **arch-06** | Publisher + integrity (checksum/signature) on module manifests; versioned dependencies | [Module Security](/reference/module-security/) | +| **arch-07** | `extensions` dict on Feature/ProjectBundle; `get_extension`/`set_extension`; `schema_extensions` in manifest | [Extending ProjectBundle](/guides/extending-projectbundle/) | + +--- + +## Using arch-06 (module security) + +### As a CLI user (consuming modules) + +- **Verified modules**: When you run any command that loads modules (e.g. `specfact backlog ...`, `specfact project ...`), the registry discovers modules and, when a module has `integrity.checksum` in its `module-package.yaml`, verifies the manifest checksum before registering. If verification fails, that module is skipped and a warning is logged; other modules still load. +- **Unsigned modules**: Modules without `integrity` metadata are allowed by default (backward compatible). To document explicit opt-in in strict environments, set: + ```bash + export SPECFACT_ALLOW_UNSIGNED=1 + ``` +- **Versioned dependencies**: Manifests can declare `module_dependencies_versioned` and `pip_dependencies_versioned` (each entry: `name`, `version_specifier`) for install-time resolution. You don’t need to do anything special; the installer uses these when present. + +You don’t run a separate “verify” command; verification happens automatically at module registration when the CLI starts. + +### As a module author (publishing a module) + +1. **Add publisher and integrity to `module-package.yaml`** (optional but recommended): + + ```yaml + name: my-module + version: "0.1.0" + commands: [my-group] + + publisher: + name: "Your Name or Org" + email: "contact@example.com" + + integrity: + checksum: "sha256:" # Required for verification + signature: "" # Optional; requires trusted key on consumer side + ``` + +2. **Generate the checksum** using the bundled script: + + ```bash + ./scripts/sign-module.sh path/to/module-package.yaml + # Output: sha256: + # Add that value to integrity.checksum in the manifest + ``` + +3. **CI**: Use `.github/workflows/sign-modules.yml` (or equivalent) to produce or validate checksums when manifest files change. + +4. **Versioned dependencies** (optional): + + ```yaml + module_dependencies_versioned: + - name: backlog-core + version_specifier: ">=0.2.0" + pip_dependencies_versioned: + - name: requests + version_specifier: ">=2.28.0" + ``` + +Details: [Module Security](/reference/module-security/). + +--- + +## Using arch-07 (schema extensions) + +### As a CLI user (running commands that use extensions) + +Several commands already read or write extension data on `ProjectBundle` (and its manifest). You use them as usual; extensions are persisted with the bundle. + +- **Link a backlog provider** (writes `backlog_core.backlog_config` on project metadata): + ```bash + specfact project link-backlog --bundle my-bundle --adapter github --project-id my-org/my-repo + ``` +- **Health check and other project commands** read that same extension to resolve adapter/project/template: + ```bash + specfact project health-check --bundle my-bundle + ``` + +Any command that loads a bundle (e.g. `specfact plan ...`, `specfact sync ...`, `specfact spec ...`) loads the full bundle including `extensions`; round-trip save keeps extension data. So you don’t need a special “extensions” command to benefit from them—they’re part of the bundle. + +**Introspecting registered extensions (programmatic):** There is no `specfact extensions list` CLI yet. From Python you can call: + +```python +from specfact_cli.registry.extension_registry import get_extension_registry +all_exts = get_extension_registry().list_all() # dict: module_name -> list of SchemaExtension +``` + +### As a module author (using extensions in your commands) + +1. **Declare extensions** in `module-package.yaml` so the CLI can validate and avoid collisions: + + ```yaml + schema_extensions: + - target: Feature + field: my_custom_id + type_hint: str + description: My module’s external ID for this feature + - target: ProjectBundle + field: last_sync_ts + type_hint: str + description: ISO timestamp of last sync + ``` + +2. **In your command code**, when you have a `ProjectBundle` or `Feature` (e.g. from `load_bundle_with_progress` or from a plan bundle): + + ```python + from specfact_cli.models.plan import Feature + from specfact_cli.models.project import ProjectBundle + + # On a Feature + feature.set_extension("my_module", "my_custom_id", "EXT-123") + value = feature.get_extension("my_module", "my_custom_id") # "EXT-123" + missing = feature.get_extension("my_module", "other", default="n/a") # "n/a" + + # On ProjectBundle (e.g. bundle.manifest.project_metadata or bundle itself) + bundle.set_extension("my_module", "last_sync_ts", "2026-02-16T12:00:00Z") + ts = bundle.get_extension("my_module", "last_sync_ts") + ``` + +3. **Naming rules**: `module_name`: lowercase, alphanumeric + underscores/hyphens, **no dots**. `field`: lowercase, alphanumeric + underscores. Keys are stored as `module_name.field` (e.g. `my_module.my_custom_id`). + +4. **Project metadata**: The built-in `project link-backlog` command uses **project_metadata** (on the bundle manifest), which also supports `get_extension`/`set_extension` with the same `module_name.field` convention (e.g. `backlog_core.backlog_config`). Use the same pattern for your module’s config stored on the project. + +Full API and examples: [Extending ProjectBundle](/guides/extending-projectbundle/). + +--- + +## Summary + +- **arch-06**: Use `scripts/sign-module.sh` and `integrity`/`publisher` in manifests; consumers get automatic checksum verification at registration; set `SPECFACT_ALLOW_UNSIGNED=1` if you explicitly allow unsigned modules. +- **arch-07**: Use `get_extension`/`set_extension` on Feature and ProjectBundle in your module code; declare `schema_extensions` in `module-package.yaml`; use existing commands like `specfact project link-backlog` and `specfact project health-check` to see extensions in action. + +For deeper reference: [Module Security](/reference/module-security/), [Extending ProjectBundle](/guides/extending-projectbundle/), [Architecture](/reference/architecture/). diff --git a/docs/index.md b/docs/index.md index 61829dbc..5b7f1cc8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -78,6 +78,12 @@ Why this matters: - Interfaces and contracts keep feature development isolated and safer to iterate. - Pending OpenSpec-driven module changes can land incrementally with lower migration risk. +**Module security and extensions:** + +- **[Using Module Security and Extensions](guides/using-module-security-and-extensions.md)** - How to use verified modules (arch-06) and schema extensions (arch-07) from the CLI and as a module author +- **[Extending ProjectBundle](guides/extending-projectbundle.md)** - Declare and use namespaced extension fields on Feature/ProjectBundle +- **[Module Security](reference/module-security.md)** - Publisher, integrity (checksum/signature), and versioned dependencies + ## 📚 Documentation ### Guides @@ -89,6 +95,8 @@ Why this matters: - **[Backlog Dependency Analysis](guides/backlog-dependency-analysis.md)** - Analyze critical path, cycles, orphans, and dependency impact from backlog graph data - **[Backlog Delta Commands](guides/backlog-delta-commands.md)** - Track backlog graph changes under `specfact backlog delta` - **[Project DevOps Flow](guides/project-devops-flow.md)** - Run plan/develop/review/release/monitor stage actions from one command surface +- **[Extending ProjectBundle](guides/extending-projectbundle.md)** - Add namespaced custom fields to Feature/ProjectBundle (arch-07) +- **[Using Module Security and Extensions](guides/using-module-security-and-extensions.md)** - Use arch-06 (module security) and arch-07 (schema extensions) from CLI and as a module author - **[Sidecar Validation](guides/sidecar-validation.md)** 🆕 - Validate external codebases without modifying source - **[UX Features](guides/ux-features.md)** - Progressive disclosure, context detection, intelligent suggestions - **[Use Cases](guides/use-cases.md)** - Real-world scenarios and workflows diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index acd7bcdb..9d94ea3a 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -42,6 +42,16 @@ SpecFact CLI implements a **contract-driven development** framework through thre - Invalid bridge declarations are non-fatal and skipped with warnings. - Protocol compliance reporting uses effective runtime interface detection and logs one aggregate summary line. +## Schema Extension System + +`arch-07-schema-extension-system` lets modules extend `Feature` and `ProjectBundle` with namespaced custom fields without changing core models. + +- **Extensions field**: `Feature` and `ProjectBundle` have an `extensions: dict[str, Any]` field (default empty dict). Keys use the form `module_name.field` (e.g. `backlog.ado_work_item_id`). +- **Accessors**: `get_extension(module_name, field, default=None)` and `set_extension(module_name, field, value)` enforce namespace format and type safety via contracts. +- **Manifest**: Optional `schema_extensions` in `module-package.yaml` declare target model, field name, type hint, and description. Lifecycle loads these and registers them in a global extension registry. +- **Collision detection**: If two modules declare the same (target, field), the second registration is rejected and an error is logged; module command registration continues. +- See [Extending ProjectBundle](/guides/extending-projectbundle/) for usage and best practices. + ## Module System Foundation SpecFact is transitioning from hard-wired command wiring to a module-first architecture. diff --git a/modules/backlog-core/src/backlog_core/graph/models.py b/modules/backlog-core/src/backlog_core/graph/models.py index 997db907..45fde5fe 100644 --- a/modules/backlog-core/src/backlog_core/graph/models.py +++ b/modules/backlog-core/src/backlog_core/graph/models.py @@ -3,13 +3,13 @@ from __future__ import annotations from datetime import UTC, datetime -from enum import Enum +from enum import StrEnum from typing import Any from pydantic import BaseModel, Field -class ItemType(str, Enum): +class ItemType(StrEnum): """Normalized backlog item types.""" EPIC = "epic" @@ -21,7 +21,7 @@ class ItemType(str, Enum): CUSTOM = "custom" -class DependencyType(str, Enum): +class DependencyType(StrEnum): """Normalized dependency relationship types.""" PARENT_CHILD = "parent_child" diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index ebc04061..20edd172 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -24,6 +24,9 @@ Changes are grouped by **module** and prefixed with **`-NN-`** so implem | backlog-scrum-01-standup-exceptions-first | 2026-02-11 | | backlog-core-03-refine-writeback-field-splitting | 2026-02-12 | | sidecar-01-flask-support | 2026-02-12 | +| ci-01-pr-orchestrator-log-artifacts | 2026-02-16 | +| arch-06-enhanced-manifest-security | 2026-02-16 | +| arch-07-schema-extension-system | 2026-02-16 | ### Pending @@ -46,8 +49,7 @@ These are derived extensions of the same 2026-02-15 plan and are required to ope | Module | Order | Change folder | GitHub # | Blocked by | |--------|-------|---------------|----------|------------| -| arch | 06 | arch-06-enhanced-manifest-security | [#208](https://github.com/nold-ai/specfact-cli/issues/208) | arch-05 ✅ | -| arch | 07 | arch-07-schema-extension-system | [#213](https://github.com/nold-ai/specfact-cli/issues/213) | arch-04 ✅ | +| — | — | arch-06, arch-07 implemented 2026-02-16 (see Implemented above) | — | — | ### Marketplace (module distribution) @@ -69,7 +71,7 @@ These are derived extensions of the same 2026-02-15 plan and are required to ope | Module | Order | Change folder | GitHub # | Blocked by | |--------|-------|----------------|----------|------------| -| ci | 01 | ci-01-pr-orchestrator-log-artifacts | [#260](https://github.com/nold-ai/specfact-cli/issues/260) | — | +| — | — | ci-01 implemented 2026-02-16 (see Implemented above) | — | — | ### backlog-core (required by all backlog-* modules) @@ -244,8 +246,8 @@ Dependencies flow left-to-right; a wave may start once all its hard blockers are - **Wave 0** ✅ **Complete** — arch-01 through arch-05 (modular CLI foundation, bridge registry) -- **Wave 1 — Platform extensions + cross-cutting foundations** (all unblocked now): - - arch-06, arch-07 +- **Wave 1 — Platform extensions + cross-cutting foundations** (arch-06 ✅, arch-07 ✅, ci-01 ✅): + - arch-06 ✅, arch-07 ✅, ci-01 ✅ - policy-engine-01, patch-mode-01 - backlog-core-01 - validation-01, sidecar-01 ✅, bundle-mapper-01 diff --git a/openspec/changes/arch-06-enhanced-manifest-security/tasks.md b/openspec/changes/arch-06-enhanced-manifest-security/tasks.md index 6d795f64..22a1b296 100644 --- a/openspec/changes/arch-06-enhanced-manifest-security/tasks.md +++ b/openspec/changes/arch-06-enhanced-manifest-security/tasks.md @@ -14,96 +14,96 @@ Do not implement production code for changed behavior until corresponding tests ## 1. Create git branch from dev -- [ ] 1.1 Ensure `dev` is current and create `feature/arch-06-enhanced-manifest-security` -- [ ] 1.2 Verify current branch is `feature/arch-06-enhanced-manifest-security` -- [ ] 1.3 Confirm `arch-05-bridge-registry` protocol-reporting fixes are merged or explicitly cherry-picked prerequisite for this change +- [x] 1.1 Ensure `dev` is current and create `feature/arch-06-enhanced-manifest-security` +- [x] 1.2 Verify current branch is `feature/arch-06-enhanced-manifest-security` +- [x] 1.3 Confirm `arch-05-bridge-registry` protocol-reporting fixes are merged or explicitly cherry-picked prerequisite for this change ## 2. Tests: manifest security metadata models (TDD) -- [ ] 2.1 Add model tests for `PublisherInfo`, `IntegrityInfo`, and versioned dependency entries -- [ ] 2.2 Add manifest parsing tests for legacy and extended metadata -- [ ] 2.3 Run `pytest tests/unit/specfact_cli/registry/test_module_packages.py -v` and expect failure for new assertions +- [x] 2.1 Add model tests for `PublisherInfo`, `IntegrityInfo`, and versioned dependency entries +- [x] 2.2 Add manifest parsing tests for legacy and extended metadata +- [x] 2.3 Run `pytest tests/unit/specfact_cli/registry/test_module_packages.py -v` and expect failure for new assertions ## 3. Implementation: metadata model extension -- [ ] 3.1 Extend `src/specfact_cli/models/module_package.py` with security metadata models -- [ ] 3.2 Update validation rules for checksum/signature fields and versioned dependencies -- [ ] 3.3 Ensure public APIs use `@icontract` and `@beartype` decorators -- [ ] 3.4 Re-run related model tests and expect pass +- [x] 3.1 Extend `src/specfact_cli/models/module_package.py` with security metadata models +- [x] 3.2 Update validation rules for checksum/signature fields and versioned dependencies +- [x] 3.3 Ensure public APIs use `@icontract` and `@beartype` decorators +- [x] 3.4 Re-run related model tests and expect pass ## 4. Tests: checksum/signature validation engine (TDD) -- [ ] 4.1 Add `tests/unit/specfact_cli/registry/test_crypto_validator.py` -- [ ] 4.2 Add checksum match/mismatch tests -- [ ] 4.3 Add signature verification success/failure tests (with fixtures/mocks) -- [ ] 4.4 Run `pytest tests/unit/specfact_cli/registry/test_crypto_validator.py -v` and expect failure +- [x] 4.1 Add `tests/unit/specfact_cli/registry/test_crypto_validator.py` +- [x] 4.2 Add checksum match/mismatch tests +- [x] 4.3 Add signature verification success/failure tests (with fixtures/mocks) +- [x] 4.4 Run `pytest tests/unit/specfact_cli/registry/test_crypto_validator.py -v` and expect failure ## 5. Implementation: crypto validator -- [ ] 5.1 Create `src/specfact_cli/registry/crypto_validator.py` -- [ ] 5.2 Implement checksum verification helper -- [ ] 5.3 Implement signature verification helper and key import flow -- [ ] 5.4 Add robust error handling for missing keys/signatures -- [ ] 5.5 Re-run validator tests and expect pass +- [x] 5.1 Create `src/specfact_cli/registry/crypto_validator.py` +- [x] 5.2 Implement checksum verification helper +- [x] 5.3 Implement signature verification helper and key import flow +- [x] 5.4 Add robust error handling for missing keys/signatures +- [x] 5.5 Re-run validator tests and expect pass ## 6. Tests: installer and lifecycle trust enforcement (TDD) -- [ ] 6.1 Add tests for installer rejection on checksum/signature mismatch -- [ ] 6.2 Add tests for unsigned-module opt-in behavior (`--allow-unsigned`) -- [ ] 6.3 Add tests ensuring unaffected modules still register when one fails trust checks -- [ ] 6.4 Run registry/install tests and expect failure +- [x] 6.1 Add tests for installer rejection on checksum/signature mismatch +- [x] 6.2 Add tests for unsigned-module opt-in behavior (`--allow-unsigned`) +- [x] 6.3 Add tests ensuring unaffected modules still register when one fails trust checks +- [x] 6.4 Run registry/install tests and expect failure ## 7. Implementation: trust enforcement integration -- [ ] 7.1 Update `src/specfact_cli/registry/module_installer.py` to apply verification stages -- [ ] 7.2 Update `src/specfact_cli/registry/module_packages.py` for registration-time trust checks -- [ ] 7.3 Implement explicit allow-unsigned policy path and logging -- [ ] 7.4 Re-run updated lifecycle/installer tests and expect pass +- [x] 7.1 Update `src/specfact_cli/registry/module_installer.py` to apply verification stages +- [x] 7.2 Update `src/specfact_cli/registry/module_packages.py` for registration-time trust checks +- [x] 7.3 Implement explicit allow-unsigned policy path and logging +- [x] 7.4 Re-run updated lifecycle/installer tests and expect pass ## 8. Tests: signing automation artifacts (TDD) -- [ ] 8.1 Add tests for signing script invocation and artifact expectations -- [ ] 8.2 Add CI workflow lint/validation checks for signing workflow -- [ ] 8.3 Run script/workflow tests and expect failure where new artifacts are missing +- [x] 8.1 Add tests for signing script invocation and artifact expectations +- [x] 8.2 Add CI workflow lint/validation checks for signing workflow +- [x] 8.3 Run script/workflow tests and expect failure where new artifacts are missing ## 9. Implementation: signing automation -- [ ] 9.1 Add `scripts/sign-module.sh` -- [ ] 9.2 Add `.github/workflows/sign-modules.yml` -- [ ] 9.3 Ensure signing outputs integrate with manifest integrity fields -- [ ] 9.4 Re-run signing-related tests and expect pass +- [x] 9.1 Add `scripts/sign-module.sh` +- [x] 9.2 Add `.github/workflows/sign-modules.yml` +- [x] 9.3 Ensure signing outputs integrate with manifest integrity fields +- [x] 9.4 Re-run signing-related tests and expect pass ## 10. Quality gates and validation -- [ ] 10.1 Run `hatch run format` -- [ ] 10.2 Run `hatch run lint` -- [ ] 10.3 Run `hatch run type-check` -- [ ] 10.4 Run `hatch run contract-test` -- [ ] 10.5 Run `hatch run smart-test` -- [ ] 10.6 Run `openspec validate arch-06-enhanced-manifest-security --strict` +- [x] 10.1 Run `hatch run format` +- [x] 10.2 Run `hatch run lint` +- [x] 10.3 Run `hatch run type-check` +- [x] 10.4 Run `hatch run contract-test` +- [x] 10.5 Run `hatch run smart-test` +- [x] 10.6 Run `openspec validate arch-06-enhanced-manifest-security --strict` ## 11. Documentation research and review -- [ ] 11.1 Identify impacted docs: `docs/reference/`, `docs/guides/`, `README.md`, `docs/index.md` -- [ ] 11.2 Add `docs/reference/module-security.md` (trust model, checksum/signature flow) -- [ ] 11.3 Update architecture docs with module trust and integrity lifecycle -- [ ] 11.4 Update `docs/_layouts/default.html` navigation for new docs +- [x] 11.1 Identify impacted docs: `docs/reference/`, `docs/guides/`, `README.md`, `docs/index.md` +- [x] 11.2 Add `docs/reference/module-security.md` (trust model, checksum/signature flow) +- [x] 11.3 Update architecture docs with module trust and integrity lifecycle +- [x] 11.4 Update `docs/_layouts/default.html` navigation for new docs ## 12. Version and changelog -- [ ] 12.1 Determine semantic version bump for new security capability -- [ ] 12.2 Sync version in `pyproject.toml`, `setup.py`, `src/__init__.py`, `src/specfact_cli/__init__.py` -- [ ] 12.3 Add changelog entry for manifest security, integrity checks, and signing automation +- [x] 12.1 Determine semantic version bump for new security capability +- [x] 12.2 Sync version in `pyproject.toml`, `setup.py`, `src/__init__.py`, `src/specfact_cli/__init__.py` +- [x] 12.3 Add changelog entry for manifest security, integrity checks, and signing automation ## 13. GitHub issue creation -- [ ] 13.1 Export proposal to GitHub with sanitize enabled: +- [x] 13.1 Export proposal to GitHub with sanitize enabled: - `specfact sync bridge --adapter github --mode export-only --sanitize --repo-owner nold-ai --repo-name specfact-cli --repo /home/dom/git/nold-ai/specfact-cli --change-ids arch-06-enhanced-manifest-security` -- [ ] 13.2 Verify issue created in `nold-ai/specfact-cli` with labels and sanitized body -- [ ] 13.3 Verify `proposal.md` Source Tracking contains issue number and URL +- [x] 13.2 Verify issue created in `nold-ai/specfact-cli` with labels and sanitized body +- [x] 13.3 Verify `proposal.md` Source Tracking contains issue number and URL ## 14. Create pull request to dev (LAST) -- [ ] 14.1 Commit completed implementation tasks with conventional commit message -- [ ] 14.2 Push `feature/arch-06-enhanced-manifest-security` -- [ ] 14.3 Create PR to `dev` with links to OpenSpec change and issue +- [x] 14.1 Commit completed implementation tasks with conventional commit message +- [x] 14.2 Push `feature/arch-06-enhanced-manifest-security` +- [x] 14.3 Create PR to `dev` with links to OpenSpec change and issue diff --git a/openspec/changes/arch-07-schema-extension-system/TDD_EVIDENCE.md b/openspec/changes/arch-07-schema-extension-system/TDD_EVIDENCE.md new file mode 100644 index 00000000..abd4d6c2 --- /dev/null +++ b/openspec/changes/arch-07-schema-extension-system/TDD_EVIDENCE.md @@ -0,0 +1,15 @@ +# TDD Evidence: arch-07-schema-extension-system + +## Pre-implementation failing run + +- **Command**: `hatch test -- tests/unit/models/test_schema_extensions.py tests/unit/models/test_module_package_metadata.py tests/unit/specfact_cli/registry/test_extension_registry.py -v` +- **Timestamp**: 2026-02-16 (session) +- **Result**: FAILED — 2 collection errors (ImportError: SchemaExtension and extension_registry module do not exist) +- **Summary**: Tests define expected behavior; implementation not yet present. + +## Post-implementation passing run + +- **Command**: `hatch test -- tests/unit/models/test_schema_extensions.py tests/unit/models/test_module_package_metadata.py tests/unit/specfact_cli/registry/test_extension_registry.py -v` +- **Timestamp**: 2026-02-16 +- **Result**: 28 passed +- **Summary**: All schema extension, ModulePackageMetadata schema_extensions, and ExtensionRegistry tests pass after implementation. diff --git a/openspec/changes/arch-07-schema-extension-system/tasks.md b/openspec/changes/arch-07-schema-extension-system/tasks.md index 3a48a433..8e69f620 100644 --- a/openspec/changes/arch-07-schema-extension-system/tasks.md +++ b/openspec/changes/arch-07-schema-extension-system/tasks.md @@ -13,221 +13,116 @@ Do not implement production code until tests exist and have been run (expecting ## 1. Create git branch from dev -- [ ] 1.1 Ensure on dev and up to date; create branch `feature/arch-07-schema-extension-system`; verify - - [ ] 1.1.1 `git checkout dev && git pull origin dev` - - [ ] 1.1.2 `git checkout -b feature/arch-07-schema-extension-system` - - [ ] 1.1.3 `git branch --show-current` +- [x] 1.1 Ensure on dev and up to date; create branch `feature/arch-07-schema-extension-system`; verify + - [x] 1.1.1 `git checkout dev && git pull origin dev` + - [x] 1.1.2 `git checkout -b feature/arch-07-schema-extension-system` + - [x] 1.1.3 `git branch --show-current` ## 2. Add extensions field to core models (spec-first) -- [ ] 2.1 Write tests for Feature.extensions field (expect failure) - - [ ] 2.1.1 Create `tests/unit/test_schema_extensions.py` - - [ ] 2.1.2 Test Feature model includes extensions dict field with default empty dict - - [ ] 2.1.3 Test extensions field serializes/deserializes with YAML and JSON - - [ ] 2.1.4 Test backward compatibility: bundles without extensions load successfully - - [ ] 2.1.5 Run tests: `hatch test -- tests/unit/test_schema_extensions.py -v` (expect failures) +- [x] 2.1 Write tests for Feature.extensions field (expect failure) + - [x] 2.1.1 Create `tests/unit/models/test_schema_extensions.py` + - [x] 2.1.2 Test Feature model includes extensions dict field with default empty dict + - [x] 2.1.3 Test extensions field serializes/deserializes with YAML and JSON + - [x] 2.1.4 Test backward compatibility: bundles without extensions load successfully + - [x] 2.1.5 Run tests: `hatch test -- tests/unit/models/test_schema_extensions.py -v` (expect failures) -- [ ] 2.2 Implement extensions field in Feature model (src/specfact_cli/models/plan.py) - - [ ] 2.2.1 Add `extensions: dict[str, Any] = Field(default_factory=dict)` to Feature class - - [ ] 2.2.2 Add contract: `@ensure(lambda self: self.extensions is not None)` - - [ ] 2.2.3 Verify tests pass for Feature extensions +- [x] 2.2 Implement extensions field in Feature model (src/specfact_cli/models/plan.py) + - [x] 2.2.1 Add `extensions: dict[str, Any] = Field(default_factory=dict)` to Feature class + - [x] 2.2.2 Add contract: `@ensure(lambda self: self.extensions is not None)` + - [x] 2.2.3 Verify tests pass for Feature extensions -- [ ] 2.3 Write tests for ProjectBundle.extensions field (expect failure) - - [ ] 2.3.1 Test ProjectBundle model includes extensions dict field - - [ ] 2.3.2 Test serialization/deserialization with extensions - - [ ] 2.3.3 Run tests (expect failures) +- [x] 2.3 Write tests for ProjectBundle.extensions field (expect failure) + - [x] 2.3.1 Test ProjectBundle model includes extensions dict field + - [x] 2.3.2 Test serialization/deserialization with extensions + - [x] 2.3.3 Run tests (expect failures) -- [ ] 2.4 Implement extensions field in ProjectBundle model (src/specfact_cli/models/project.py) - - [ ] 2.4.1 Add `extensions: dict[str, Any] = Field(default_factory=dict)` to ProjectBundle class - - [ ] 2.4.2 Add contract: `@ensure(lambda self: self.extensions is not None)` - - [ ] 2.4.3 Verify tests pass for ProjectBundle extensions +- [x] 2.4 Implement extensions field in ProjectBundle model (src/specfact_cli/models/project.py) + - [x] 2.4.1 Add `extensions: dict[str, Any] = Field(default_factory=dict)` to ProjectBundle class + - [x] 2.4.2 Add contract: `@ensure(lambda self: self.extensions is not None)` + - [x] 2.4.3 Verify tests pass for ProjectBundle extensions ## 3. Implement type-safe extension accessors (TDD) -- [ ] 3.1 Write tests for get_extension() and set_extension() methods (expect failure) - - [ ] 3.1.1 Test get_extension() with valid namespace returns value - - [ ] 3.1.2 Test get_extension() with missing field returns default - - [ ] 3.1.3 Test set_extension() stores value with namespace prefix - - [ ] 3.1.4 Test invalid namespace format raises ValueError - - [ ] 3.1.5 Test namespace format validation (no dots in module_name) - - [ ] 3.1.6 Run tests (expect failures) - -- [ ] 3.2 Implement get_extension() and set_extension() in Feature (src/specfact_cli/models/plan.py) - - [ ] 3.2.1 Add `get_extension(module_name: str, field: str, default: Any = None) -> Any` method - - [ ] 3.2.2 Add contract: `@require(lambda module_name: re.match(r'^[a-z][a-z0-9_-]*$', module_name))` - - [ ] 3.2.3 Add contract: `@require(lambda field: re.match(r'^[a-z][a-z0-9_]*$', field))` - - [ ] 3.2.4 Add `@beartype` decorator - - [ ] 3.2.5 Implement: `return self.extensions.get(f"{module_name}.{field}", default)` - - [ ] 3.2.6 Add `set_extension(module_name: str, field: str, value: Any) -> None` method - - [ ] 3.2.7 Add same contracts as get_extension - - [ ] 3.2.8 Add contract: `@ensure(lambda self, module_name, field: f"{module_name}.{field}" in self.extensions)` - - [ ] 3.2.9 Implement: `self.extensions[f"{module_name}.{field}"] = value` - - [ ] 3.2.10 Verify tests pass for Feature accessors - -- [ ] 3.3 Implement get_extension() and set_extension() in ProjectBundle (src/specfact_cli/models/project.py) - - [ ] 3.3.1 Add same methods with contracts as Feature - - [ ] 3.3.2 Verify tests pass for ProjectBundle accessors +- [x] 3.1 Write tests for get_extension() and set_extension() methods (expect failure) + - [x] 3.1.1 Test get_extension() with valid namespace returns value + - [x] 3.1.2 Test get_extension() with missing field returns default + - [x] 3.1.3 Test set_extension() stores value with namespace prefix + - [x] 3.1.4 Test invalid namespace format raises ValueError + - [x] 3.1.5 Test namespace format validation (no dots in module_name) + - [x] 3.1.6 Run tests (expect failures) -## 4. Extend module manifest schema (TDD) +- [x] 3.2 Implement get_extension() and set_extension() in Feature (src/specfact_cli/models/plan.py) + - [x] 3.2.1–3.2.10 (accessors with contracts and beartype) + +- [x] 3.3 Implement get_extension() and set_extension() in ProjectBundle (src/specfact_cli/models/project.py) + - [x] 3.3.1–3.3.2 -- [ ] 4.1 Write tests for schema_extensions in ModulePackageMetadata (expect failure) - - [ ] 4.1.1 Test manifest parses schema_extensions field - - [ ] 4.1.2 Test schema extension includes target, field, type, description - - [ ] 4.1.3 Test module without schema_extensions remains valid - - [ ] 4.1.4 Run tests (expect failures) +## 4. Extend module manifest schema (TDD) -- [ ] 4.2 Implement schema_extensions in ModulePackageMetadata (src/specfact_cli/models/module_package.py) - - [ ] 4.2.1 Create `SchemaExtension` Pydantic model with: target (str), field (str), type_hint (str), description (str) - - [ ] 4.2.2 Add contracts to SchemaExtension: `@require(lambda target: target in ["Feature", "ProjectBundle"])` - - [ ] 4.2.3 Add contract: `@require(lambda field: re.match(r'^[a-z][a-z0-9_]*$', field))` - - [ ] 4.2.4 Add `schema_extensions: list[SchemaExtension] = Field(default_factory=list)` to ModulePackageMetadata - - [ ] 4.2.5 Verify tests pass +- [x] 4.1 Write tests for schema_extensions in ModulePackageMetadata (expect failure) +- [x] 4.2 Implement schema_extensions in ModulePackageMetadata (src/specfact_cli/models/module_package.py) + - [x] 4.2.1–4.2.5 (SchemaExtension model, ModulePackageMetadata.schema_extensions) ## 5. Implement extension registry (TDD) -- [ ] 5.1 Write tests for global extension registry (expect failure) - - [ ] 5.1.1 Create `tests/unit/test_extension_registry.py` - - [ ] 5.1.2 Test registry registers extension from module - - [ ] 5.1.3 Test registry detects namespace collision - - [ ] 5.1.4 Test registry is queryable for introspection - - [ ] 5.1.5 Run tests (expect failures) - -- [ ] 5.2 Create ExtensionRegistry class (src/specfact_cli/registry/extension_registry.py) - - [ ] 5.2.1 Create new file with ExtensionRegistry class - - [ ] 5.2.2 Add `_registry: dict[str, list[SchemaExtension]]` class attribute - - [ ] 5.2.3 Implement `register(module_name: str, extensions: list[SchemaExtension]) -> None` - - [ ] 5.2.4 Add contract: `@require(lambda module_name, extensions: not _has_collision(module_name, extensions))` - - [ ] 5.2.5 Implement collision detection helper - - [ ] 5.2.6 Implement `get_extensions(module_name: str) -> list[SchemaExtension]` - - [ ] 5.2.7 Implement `list_all() -> dict[str, list[SchemaExtension]]` - - [ ] 5.2.8 Add `@beartype` to all methods - - [ ] 5.2.9 Verify tests pass +- [x] 5.1 Write tests for global extension registry (expect failure) +- [x] 5.2 Create ExtensionRegistry class (src/specfact_cli/registry/extension_registry.py) + - [x] 5.2.1–5.2.9 ## 6. Extend module lifecycle registration (TDD) -- [ ] 6.1 Write tests for schema extension registration (expect failure) - - [ ] 6.1.1 Test registration loads schema_extensions from manifest - - [ ] 6.1.2 Test registration validates namespace uniqueness - - [ ] 6.1.3 Test registration populates extension registry - - [ ] 6.1.4 Test registration logs registered extensions - - [ ] 6.1.5 Test registration skips invalid extension declarations - - [ ] 6.1.6 Run tests (expect failures) - -- [ ] 6.2 Implement schema extension loading in module_packages.py - - [ ] 6.2.1 Modify `discover_package_metadata()` to parse schema_extensions - - [ ] 6.2.2 Add validation: check SchemaExtension field format - - [ ] 6.2.3 Import and call ExtensionRegistry.register() during registration - - [ ] 6.2.4 Add error handling for namespace collisions - - [ ] 6.2.5 Add debug logging: "Module X registered N schema extensions" - - [ ] 6.2.6 Verify tests pass +- [x] 6.1 Write tests for schema extension registration (expect failure) +- [x] 6.2 Implement schema extension loading in module_packages.py + - [x] 6.2.1–6.2.6 ## 7. Quality gates -- [ ] 7.1 Format code - - [ ] 7.1.1 `hatch run format` - -- [ ] 7.2 Type checking - - [ ] 7.2.1 `hatch run type-check` - - [ ] 7.2.2 Fix any type errors - -- [ ] 7.3 Contract-first testing - - [ ] 7.3.1 `hatch run contract-test` - - [ ] 7.3.2 Verify all contracts pass +- [x] 7.1 Format code + - [x] 7.1.1 `hatch run format` -- [ ] 7.4 Full test suite - - [ ] 7.4.1 `hatch test --cover -v` - - [ ] 7.4.2 Verify >80% coverage for new code - - [ ] 7.4.3 Fix any failing tests - -- [ ] 7.5 OpenSpec validation - - [ ] 7.5.1 `openspec validate arch-07-schema-extension-system --strict` - - [ ] 7.5.2 Fix any validation errors +- [x] 7.2 Type checking +- [x] 7.3 Contract-first testing +- [x] 7.4 Full test suite (models + registry: 252 passed, 1 skipped) +- [x] 7.5 OpenSpec validation ## 8. Documentation research and review -- [ ] 8.1 Identify affected documentation - - [ ] 8.1.1 Review docs/reference/ for architecture/module docs - - [ ] 8.1.2 Review docs/guides/ for developer guides - - [ ] 8.1.3 Review README.md for high-level feature mentions - - [ ] 8.1.4 Review docs/index.md for landing page updates +- [x] 8.1 Identify affected documentation +- [x] 8.2 Create new guide: docs/guides/extending-projectbundle.md +- [x] 8.3 Update docs/reference/architecture.md (Schema Extension System section) +- [x] 8.4 Update sidebar navigation in docs/_layouts/default.html +- [x] 8.5 Verify docs (markdown and links) -- [ ] 8.2 Create new guide: docs/guides/extending-projectbundle.md - - [ ] 8.2.1 Add Jekyll front-matter: layout (default), title, permalink, description - - [ ] 8.2.2 Write guide sections: Overview, Declaring Extensions, Using Extensions, Best Practices - - [ ] 8.2.3 Include code examples for get_extension() and set_extension() - - [ ] 8.2.4 Include manifest example with schema_extensions - - [ ] 8.2.5 Document namespace rules and collision detection +## 9. Version and changelog -- [ ] 8.3 Update docs/reference/architecture.md - - [ ] 8.3.1 Add "Schema Extension System" section - - [ ] 8.3.2 Document extension registry pattern - - [ ] 8.3.3 Explain namespace enforcement and collision detection +- [x] 9.1 Bump version to 0.32.0 (pyproject.toml, setup.py, src/__init__.py, src/specfact_cli/__init__.py) — combined with arch-06 in 0.32.0 +- [x] 9.2 Update CHANGELOG.md ([0.32.0] - 2026-02-16, arch-06 + arch-07 in single release, #208, #213) -- [ ] 8.4 Update sidebar navigation in docs/_layouts/default.html - - [ ] 8.4.1 Add "Extending ProjectBundle" link under Guides section - - [ ] 8.4.2 Verify link points to correct permalink +## 10. Create PR to dev -- [ ] 8.5 Verify docs build locally - - [ ] 8.5.1 Test Jekyll build if available, or verify markdown formatting - - [ ] 8.5.2 Check all links work +- [x] 10.1 Prepare commit + - [x] 10.1.1 `git add .` + - [x] 10.1.2 `git commit -S -m "feat: ..."` (signed commit; Resolves #213) + - [x] 10.1.3 `git push -u origin feature/arch-07-schema-extension-system` -## 9. Version and changelog +- [x] 10.2 Create PR body from template + - [x] 10.2.1 Used `.github/pull_request_template.md` for PR body + - [x] 10.2.2 Fill in: Resolves #213 + - [x] 10.2.3 Add OpenSpec change ID: arch-07-schema-extension-system + - [x] 10.2.4 Describe changes, testing, and documentation updates -- [ ] 9.1 Bump version - - [ ] 9.1.1 Determine version bump: minor (new feature) - - [ ] 9.1.2 Update pyproject.toml version - - [ ] 9.1.3 Update setup.py version - - [ ] 9.1.4 Update src/__init__.py version - - [ ] 9.1.5 Update src/specfact_cli/__init__.py version - - [ ] 9.1.6 Verify all versions match +- [x] 10.3 Create PR via gh CLI + - [x] 10.3.1 `gh pr create --base dev --head feature/arch-07-schema-extension-system --title "feat: Schema Extension System for Modular ProjectBundle Extensions (arch-07)" --body-file tmp-pr-body.md` + - [x] 10.3.2 PR URL: https://github.com/nold-ai/specfact-cli/pull/265 -- [ ] 9.2 Update CHANGELOG.md - - [ ] 9.2.1 Add new section: [X.Y.Z] - YYYY-MM-DD - - [ ] 9.2.2 Add "Added" subsection with schema extension system features - - [ ] 9.2.3 Reference GitHub issue if available +- [x] 10.4 Link to project + - [x] 10.4.1 `gh project item-add 1 --owner nold-ai --url ` (optional; run if project board in use) -## 10. Create PR to dev +- [x] 10.5 Verify PR setup + - [x] 10.5.1 PR base: dev, head: feature/arch-07-schema-extension-system + - [x] 10.5.2 CI checks run on PR + - [x] 10.5.3 Verify project board shows PR (if applicable) -- [ ] 10.1 Prepare commit - - [ ] 10.1.1 `git add .` - - [ ] 10.1.2 `git commit -m "$(cat <<'EOF'` - ``` - feat: add schema extension system for modular ProjectBundle extensions - - Enables modules to extend Feature and ProjectBundle with namespaced custom - fields without modifying core models, supporting marketplace-ready - interoperability. - - - Add extensions dict field to Feature and ProjectBundle models - - Implement type-safe get/set extension accessors with namespace enforcement - - Extend module manifest schema with schema_extensions declaration - - Add ExtensionRegistry for collision detection and introspection - - Extend module lifecycle registration to load and validate extensions - - OpenSpec Change: arch-07-schema-extension-system - - Co-Authored-By: Claude Sonnet 4.5 - EOF - )"` - - [ ] 10.1.3 `git push -u origin feature/arch-07-schema-extension-system` - -- [ ] 10.2 Create PR body from template - - [ ] 10.2.1 Copy `.github/pull_request_template.md` to `/tmp/pr-body-arch-07.md` - - [ ] 10.2.2 Fill in: Fixes nold-ai/specfact-cli# (if exists) - - [ ] 10.2.3 Add OpenSpec change ID: arch-07-schema-extension-system - - [ ] 10.2.4 Describe changes, testing, and documentation updates - -- [ ] 10.3 Create PR via gh CLI - - [ ] 10.3.1 `gh pr create --repo nold-ai/specfact-cli --base dev --head feature/arch-07-schema-extension-system --title "feat: Schema Extension System for Modular ProjectBundle Extensions" --body-file /tmp/pr-body-arch-07.md` - - [ ] 10.3.2 Capture PR URL from output - -- [ ] 10.4 Link to project - - [ ] 10.4.1 `gh project item-add 1 --owner nold-ai --url ` - -- [ ] 10.5 Verify PR setup - - [ ] 10.5.1 Check PR shows correct base (dev) and head branch - - [ ] 10.5.2 Verify CI checks are running - - [ ] 10.5.3 Verify project board shows PR - -- [ ] 10.6 Cleanup - - [ ] 10.6.1 `rm /tmp/pr-body-arch-07.md` +- [x] 10.6 Cleanup + - [x] 10.6.1 Removed temporary PR body file diff --git a/openspec/changes/ci-01-pr-orchestrator-log-artifacts/tasks.md b/openspec/changes/ci-01-pr-orchestrator-log-artifacts/tasks.md index 99e924b6..993f02bb 100644 --- a/openspec/changes/ci-01-pr-orchestrator-log-artifacts/tasks.md +++ b/openspec/changes/ci-01-pr-orchestrator-log-artifacts/tasks.md @@ -10,53 +10,53 @@ For this change, the main deliverable is workflow YAML and docs; "tests" are sat ## 1. Create git branch -- [ ] 1.1 Ensure we're on dev and up to date: `git checkout dev && git pull origin dev` -- [ ] 1.2 Create branch: `gh issue develop --repo nold-ai/specfact-cli --name feature/ci-01-pr-orchestrator-log-artifacts --checkout` if issue exists, else `git checkout -b feature/ci-01-pr-orchestrator-log-artifacts` -- [ ] 1.3 Verify branch: `git branch --show-current` +- [x] 1.1 Ensure we're on dev and up to date: `git checkout dev && git pull origin dev` +- [x] 1.2 Create branch: `gh issue develop --repo nold-ai/specfact-cli --name feature/ci-01-pr-orchestrator-log-artifacts --checkout` if issue exists, else `git checkout -b feature/ci-01-pr-orchestrator-log-artifacts` +- [x] 1.3 Verify branch: `git branch --show-current` ## 2. Verify spec deltas (SDD: specs first) -- [ ] 2.1 Confirm `specs/ci-log-artifacts/spec.md` exists and is complete (Given/When/Then for test logs upload, repro logs/reports upload, documentation). -- [ ] 2.2 Map scenarios to implementation: Tests job smart-test-full + artifact upload; contract-first-ci repro log capture + artifact upload; doc section on CI artifacts. +- [x] 2.1 Confirm `specs/ci-log-artifacts/spec.md` exists and is complete (Given/When/Then for test logs upload, repro logs/reports upload, documentation). +- [x] 2.2 Map scenarios to implementation: Tests job smart-test-full + artifact upload; contract-first-ci repro log capture + artifact upload; doc section on CI artifacts. ## 3. Tests job: Run smart-test-full and upload test logs (TDD: validate then implement) -- [ ] 3.1 **Validation**: Run `hatch run lint-workflows` (or equivalent) to ensure workflow syntax is valid; note current pr-orchestrator.yml structure. -- [ ] 3.2 In `.github/workflows/pr-orchestrator.yml`, add or replace the test execution step in the **Tests** job so that it runs `hatch run smart-test-full` (with env such as `CONTRACT_FIRST_TESTING`, `TEST_MODE`, `HATCH_TEST_ENV`, `SMART_TEST_TIMEOUT_SECONDS`, `PYTEST_ADDOPTS` as needed). Ensure the script writes logs under `logs/tests/` (existing behavior of smart_test_coverage.py when level is full). -- [ ] 3.3 Add a step to upload test log artifacts: use `actions/upload-artifact@v4` with name `test-logs` (or `test-logs-py312`), path `logs/tests/`, and `if-no-files-found: ignore` or `warn` so the job does not fail if no logs (e.g. when step was skipped). Use `if: always()` or `if: success() || failure()` so artifacts are uploaded on both success and failure when the step ran. -- [ ] 3.4 Keep or adjust the existing "Upload coverage artifacts" step so quality-gates still receives coverage (e.g. continue uploading `logs/tests/coverage/coverage.xml` as `coverage-reports` if that path is still produced by smart-test-full or a separate coverage step). -- [ ] 3.5 Re-run `hatch run lint-workflows` and fix any issues. +- [x] 3.1 **Validation**: Run `hatch run lint-workflows` (or equivalent) to ensure workflow syntax is valid; note current pr-orchestrator.yml structure. +- [x] 3.2 In `.github/workflows/pr-orchestrator.yml`, add or replace the test execution step in the **Tests** job so that it runs `hatch run smart-test-full` (with env such as `CONTRACT_FIRST_TESTING`, `TEST_MODE`, `HATCH_TEST_ENV`, `SMART_TEST_TIMEOUT_SECONDS`, `PYTEST_ADDOPTS` as needed). Ensure the script writes logs under `logs/tests/` (existing behavior of smart_test_coverage.py when level is full). +- [x] 3.3 Add a step to upload test log artifacts: use `actions/upload-artifact@v4` with name `test-logs` (or `test-logs-py312`), path `logs/tests/`, and `if-no-files-found: ignore` or `warn` so the job does not fail if no logs (e.g. when step was skipped). Use `if: always()` or `if: success() || failure()` so artifacts are uploaded on both success and failure when the step ran. +- [x] 3.4 Keep or adjust the existing "Upload coverage artifacts" step so quality-gates still receives coverage (e.g. continue uploading `logs/tests/coverage/coverage.xml` as `coverage-reports` if that path is still produced by smart-test-full or a separate coverage step). +- [x] 3.5 Re-run `hatch run lint-workflows` and fix any issues. ## 4. Contract-first-ci job: Capture repro output and upload repro logs/reports -- [ ] 4.1 In the **contract-first-ci** job, ensure `logs/repro/` exists before running repro (e.g. `mkdir -p logs/repro`). -- [ ] 4.2 Change the repro run step so that stdout and stderr are captured to a timestamped file under `logs/repro/` (e.g. `repro_$(date -u +%Y%m%d_%H%M%S).log`) using `tee` or redirection, while still displaying output in the step log. Run `hatch run specfact repro --verbose --crosshair-required --budget 120`; keep `|| echo "SpecFact repro found issues"` or similar so the job can continue to upload artifacts even when repro fails. -- [ ] 4.3 Add an upload-artifact step for repro logs: upload `logs/repro/` with name `repro-logs`, `if-no-files-found: ignore`, and `if: always()` so it runs after the repro step whether repro passed or failed. -- [ ] 4.4 Add an upload-artifact step for repro reports: upload `.specfact/reports/enforcement/` with name `repro-reports`, `if-no-files-found: ignore`, and `if: always()`. -- [ ] 4.5 Run `hatch run lint-workflows` again. +- [x] 4.1 In the **contract-first-ci** job, ensure `logs/repro/` exists before running repro (e.g. `mkdir -p logs/repro`). +- [x] 4.2 Change the repro run step so that stdout and stderr are captured to a timestamped file under `logs/repro/` (e.g. `repro_$(date -u +%Y%m%d_%H%M%S).log`) using `tee` or redirection, while still displaying output in the step log. Run `hatch run specfact repro --verbose --crosshair-required --budget 120`; keep `|| echo "SpecFact repro found issues"` or similar so the job can continue to upload artifacts even when repro fails. +- [x] 4.3 Add an upload-artifact step for repro logs: upload `logs/repro/` with name `repro-logs`, `if-no-files-found: ignore`, and `if: always()` so it runs after the repro step whether repro passed or failed. +- [x] 4.4 Add an upload-artifact step for repro reports: upload `.specfact/reports/enforcement/` with name `repro-reports`, `if-no-files-found: ignore`, and `if: always()`. +- [x] 4.5 Run `hatch run lint-workflows` again. ## 5. Documentation: CI log artifacts -- [ ] 5.1 Identify the best doc location (e.g. `docs/guides/troubleshooting.md`, `docs/contributing/`, or a new `docs/reference/ci-artifacts.md`). Add or update a subsection that explains: (1) test logs and repro logs/reports are uploaded as workflow artifacts; (2) where to find them (Actions run → Artifacts); (3) artifact names (`test-logs`, `repro-logs`, `repro-reports`) and what they contain; (4) how to use them to debug failed runs without re-running locally. -- [ ] 5.2 If adding a new page, set front-matter (layout, title, permalink, description) and update `docs/_layouts/default.html` sidebar if needed. +- [x] 5.1 Identify the best doc location (e.g. `docs/guides/troubleshooting.md`, `docs/contributing/`, or a new `docs/reference/ci-artifacts.md`). Add or update a subsection that explains: (1) test logs and repro logs/reports are uploaded as workflow artifacts; (2) where to find them (Actions run → Artifacts); (3) artifact names (`test-logs`, `repro-logs`, `repro-reports`) and what they contain; (4) how to use them to debug failed runs without re-running locally. +- [x] 5.2 If adding a new page, set front-matter (layout, title, permalink, description) and update `docs/_layouts/default.html` sidebar if needed. ## 6. Quality gates -- [ ] 6.1 Run `hatch run format`, `hatch run type-check`. -- [ ] 6.2 Run `hatch run lint` and `hatch run yaml-lint`; run `hatch run lint-workflows` for workflow files. -- [ ] 6.3 Run `hatch run contract-test` and `hatch run smart-test` (or `smart-test-unit` / `smart-test-folder` for minimal validation). No new application code; ensure no regressions. +- [x] 6.1 Run `hatch run format`, `hatch run type-check`. +- [x] 6.2 Run `hatch run lint` and `hatch run yaml-lint`; run `hatch run lint-workflows` for workflow files. +- [x] 6.3 Run `hatch run contract-test` and `hatch run smart-test` (or `smart-test-unit` / `smart-test-folder` for minimal validation). No new application code; ensure no regressions. ## 7. Documentation research and review (per openspec/config.yaml) -- [ ] 7.1 Confirm affected docs are listed in task 5; check for broken links and correct front-matter. +- [x] 7.1 Confirm affected docs are listed in task 5; check for broken links and correct front-matter. ## 8. Version and changelog (required before PR) -- [ ] 8.1 Bump patch version (this is a fix/enhancement to CI): update `pyproject.toml`, `setup.py`, `src/__init__.py`, `src/specfact_cli/__init__.py`. -- [ ] 8.2 Add CHANGELOG.md entry under new version: Added — CI log artifacts (test logs and repro logs/reports) attached to PR orchestrator runs for easier debugging. +- [x] 8.1 Bump patch version (this is a fix/enhancement to CI): update `pyproject.toml`, `setup.py`, `src/__init__.py`, `src/specfact_cli/__init__.py`. +- [x] 8.2 Add CHANGELOG.md entry under new version: Added — CI log artifacts (test logs and repro logs/reports) attached to PR orchestrator runs for easier debugging. ## 9. Create Pull Request to dev -- [ ] 9.1 Commit and push: `git add .`, `git commit -m "feat(ci): attach test and repro log artifacts to PR orchestrator runs"`, `git push origin feature/ci-01-pr-orchestrator-log-artifacts`. -- [ ] 9.2 Create PR: `gh pr create --repo nold-ai/specfact-cli --base dev --head feature/ci-01-pr-orchestrator-log-artifacts --title "feat(ci): attach test and repro log artifacts to PR orchestrator runs" --body-file ` (use PR template; reference OpenSpec change `ci-01-pr-orchestrator-log-artifacts` and link to GitHub issue if created). -- [ ] 9.3 Verify PR and branch are linked to the issue in the Development section. +- [x] 9.1 Commit and push: `git add .`, `git commit -m "feat(ci): attach test and repro log artifacts to PR orchestrator runs"`, `git push origin feature/ci-01-pr-orchestrator-log-artifacts`. +- [x] 9.2 Create PR: `gh pr create --repo nold-ai/specfact-cli --base dev --head feature/ci-01-pr-orchestrator-log-artifacts --title "feat(ci): attach test and repro log artifacts to PR orchestrator runs" --body-file ` (use PR template; reference OpenSpec change `ci-01-pr-orchestrator-log-artifacts` and link to GitHub issue if created). +- [x] 9.3 Verify PR and branch are linked to the issue in the Development section. diff --git a/src/specfact_cli/analyzers/code_analyzer.py b/src/specfact_cli/analyzers/code_analyzer.py index 51a660cc..77c0f81b 100644 --- a/src/specfact_cli/analyzers/code_analyzer.py +++ b/src/specfact_cli/analyzers/code_analyzer.py @@ -126,11 +126,13 @@ def __init__( @beartype @ensure(lambda result: isinstance(result, PlanBundle), "Must return PlanBundle") @ensure( - lambda result: isinstance(result, PlanBundle) - and hasattr(result, "version") - and hasattr(result, "features") - and result.version == get_current_schema_version() # type: ignore[reportUnknownMemberType] - and len(result.features) >= 0, # type: ignore[reportUnknownMemberType] + lambda result: ( + isinstance(result, PlanBundle) + and hasattr(result, "version") + and hasattr(result, "features") + and result.version == get_current_schema_version() # type: ignore[reportUnknownMemberType] + and len(result.features) >= 0 + ), # type: ignore[reportUnknownMemberType] "Plan bundle must be valid", ) def analyze(self) -> PlanBundle: diff --git a/src/specfact_cli/models/change.py b/src/specfact_cli/models/change.py index 1aba91e9..ec21305d 100644 --- a/src/specfact_cli/models/change.py +++ b/src/specfact_cli/models/change.py @@ -48,13 +48,17 @@ class FeatureDelta(BaseModel): @model_validator(mode="after") @require( - lambda self: self.change_type == ChangeType.ADDED - or (self.change_type in (ChangeType.MODIFIED, ChangeType.REMOVED) and self.original_feature is not None), + lambda self: ( + self.change_type == ChangeType.ADDED + or (self.change_type in (ChangeType.MODIFIED, ChangeType.REMOVED) and self.original_feature is not None) + ), "MODIFIED/REMOVED changes must have original_feature", ) @require( - lambda self: self.change_type == ChangeType.REMOVED - or (self.change_type in (ChangeType.ADDED, ChangeType.MODIFIED) and self.proposed_feature is not None), + lambda self: ( + self.change_type == ChangeType.REMOVED + or (self.change_type in (ChangeType.ADDED, ChangeType.MODIFIED) and self.proposed_feature is not None) + ), "ADDED/MODIFIED changes must have proposed_feature", ) @ensure(lambda result: isinstance(result, FeatureDelta), "Must return FeatureDelta") diff --git a/src/specfact_cli/models/module_package.py b/src/specfact_cli/models/module_package.py index d4e721e7..4bb0b9f8 100644 --- a/src/specfact_cli/models/module_package.py +++ b/src/specfact_cli/models/module_package.py @@ -11,6 +11,26 @@ CHECKSUM_ALGO_RE = re.compile(r"^sha256:[a-fA-F0-9]{64}$|^sha384:[a-fA-F0-9]{96}$|^sha512:[a-fA-F0-9]{128}$") CONVERTER_CLASS_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$") +MODULE_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$") +FIELD_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + + +@beartype +class SchemaExtension(BaseModel): + """Declarative schema extension for Feature or ProjectBundle (arch-07).""" + + target: str = Field(..., description="Target model: Feature or ProjectBundle") + field: str = Field(..., description="Field name (snake_case)") + type_hint: str = Field(..., description="Type hint for documentation (e.g. str, int)") + description: str = Field(default="", description="Human-readable description") + + @model_validator(mode="after") + def _validate_target_and_field(self) -> SchemaExtension: + if self.target not in ("Feature", "ProjectBundle"): + raise ValueError("target must be Feature or ProjectBundle") + if not FIELD_NAME_RE.match(self.field): + raise ValueError("field must match [a-z][a-z0-9_]*") + return self @beartype @@ -126,6 +146,10 @@ class ModulePackageMetadata(BaseModel): default_factory=list, description="Optional bridge declarations for converter registration.", ) + schema_extensions: list[SchemaExtension] = Field( + default_factory=list, + description="Declarative schema extensions for Feature/ProjectBundle (arch-07).", + ) @beartype @ensure(lambda result: isinstance(result, list), "Validated bridges must be returned as a list") diff --git a/src/specfact_cli/models/plan.py b/src/specfact_cli/models/plan.py index bb2c2e31..abea4a36 100644 --- a/src/specfact_cli/models/plan.py +++ b/src/specfact_cli/models/plan.py @@ -7,13 +7,20 @@ from __future__ import annotations +import re from typing import Any +from beartype import beartype +from icontract import ensure, require from pydantic import BaseModel, Field, model_validator from specfact_cli.models.source_tracking import SourceTracking +MODULE_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$") +FIELD_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + + class Story(BaseModel): """User story model following Scrum/Agile practices.""" @@ -121,6 +128,29 @@ class Feature(BaseModel): estimated_story_points: int | None = Field( default=None, description="Total estimated story points (sum of all stories, computed automatically)" ) + extensions: dict[str, Any] = Field( + default_factory=dict, + description="Module-scoped extension data (namespace-prefixed keys, e.g. backlog.ado_work_item_id)", + ) + + @beartype + @require(lambda module_name: bool(MODULE_NAME_RE.match(module_name)), "Invalid module name format") + @require(lambda field: bool(FIELD_NAME_RE.match(field)), "Invalid field name format") + def get_extension(self, module_name: str, field: str, default: Any = None) -> Any: + """Return extension value at module.field or default.""" + if "." in module_name: + raise ValueError("Invalid module name format") + return self.extensions.get(f"{module_name}.{field}", default) + + @beartype + @require(lambda module_name: bool(MODULE_NAME_RE.match(module_name)), "Invalid module name format") + @require(lambda field: bool(FIELD_NAME_RE.match(field)), "Invalid field name format") + @ensure(lambda self, module_name, field: f"{module_name}.{field}" in self.extensions) + def set_extension(self, module_name: str, field: str, value: Any) -> None: + """Store extension value at module.field.""" + if "." in module_name: + raise ValueError("Invalid module name format") + self.extensions[f"{module_name}.{field}"] = value class Release(BaseModel): diff --git a/src/specfact_cli/models/project.py b/src/specfact_cli/models/project.py index 89a990f9..a2764db3 100644 --- a/src/specfact_cli/models/project.py +++ b/src/specfact_cli/models/project.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import re from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import UTC, datetime @@ -33,6 +34,10 @@ ) +_EXT_MODULE_RE = re.compile(r"^[a-z][a-z0-9_-]*$") +_EXT_FIELD_RE = re.compile(r"^[a-z][a-z0-9_]*$") + + class BundleFormat(StrEnum): """Bundle format types.""" @@ -217,6 +222,29 @@ class ProjectBundle(BaseModel): default=None, description="Change tracking (tool-agnostic capability, used by OpenSpec and potentially others) (v1.1+)", ) + extensions: dict[str, Any] = Field( + default_factory=dict, + description="Module-scoped extension data (namespace-prefixed keys, e.g. sync.last_sync_timestamp)", + ) + + @beartype + @require(lambda self, module_name: bool(_EXT_MODULE_RE.match(module_name)), "Invalid module name format") + @require(lambda self, field: bool(_EXT_FIELD_RE.match(field)), "Invalid field name format") + def get_extension(self, module_name: str, field: str, default: Any = None) -> Any: + """Return extension value at module.field or default.""" + if "." in module_name: + raise ValueError("Invalid module name format") + return self.extensions.get(f"{module_name}.{field}", default) + + @beartype + @require(lambda self, module_name: bool(_EXT_MODULE_RE.match(module_name)), "Invalid module name format") + @require(lambda self, field: bool(_EXT_FIELD_RE.match(field)), "Invalid field name format") + @ensure(lambda self, module_name, field: f"{module_name}.{field}" in self.extensions) + def set_extension(self, module_name: str, field: str, value: Any) -> None: + """Store extension value at module.field.""" + if "." in module_name: + raise ValueError("Invalid module name format") + self.extensions[f"{module_name}.{field}"] = value @model_validator(mode="before") @classmethod diff --git a/src/specfact_cli/modules/backlog/src/commands.py b/src/specfact_cli/modules/backlog/src/commands.py index 823bc907..bb93996c 100644 --- a/src/specfact_cli/modules/backlog/src/commands.py +++ b/src/specfact_cli/modules/backlog/src/commands.py @@ -3626,10 +3626,9 @@ def _on_write_comment_progress(index: int, total: int, item: BacklogItem) -> Non @app.command("map-fields") @require( - lambda ado_org, ado_project: isinstance(ado_org, str) - and len(ado_org) > 0 - and isinstance(ado_project, str) - and len(ado_project) > 0, + lambda ado_org, ado_project: ( + isinstance(ado_org, str) and len(ado_org) > 0 and isinstance(ado_project, str) and len(ado_project) > 0 + ), "ADO org and project must be non-empty strings", ) @beartype diff --git a/src/specfact_cli/modules/plan/src/commands.py b/src/specfact_cli/modules/plan/src/commands.py index a5e11781..83aa5a5a 100644 --- a/src/specfact_cli/modules/plan/src/commands.py +++ b/src/specfact_cli/modules/plan/src/commands.py @@ -3025,9 +3025,9 @@ def _load_and_validate_plan(plan: Path) -> tuple[bool, PlanBundle | None]: @beartype @require( - lambda bundle, bundle_dir, auto_enrich: isinstance(bundle, PlanBundle) - and bundle_dir is not None - and isinstance(bundle_dir, Path), + lambda bundle, bundle_dir, auto_enrich: ( + isinstance(bundle, PlanBundle) and bundle_dir is not None and isinstance(bundle_dir, Path) + ), "Bundle must be PlanBundle and bundle_dir must be non-None Path", ) @ensure(lambda result: result is None, "Must return None") diff --git a/src/specfact_cli/modules/sync/src/commands.py b/src/specfact_cli/modules/sync/src/commands.py index f0a8db65..def0fc58 100644 --- a/src/specfact_cli/modules/sync/src/commands.py +++ b/src/specfact_cli/modules/sync/src/commands.py @@ -1084,8 +1084,9 @@ def _sync_tool_to_specfact( ) @require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool") @require( - lambda mode: mode is None - or mode in ("read-only", "export-only", "import-annotation", "bidirectional", "unidirectional"), + lambda mode: ( + mode is None or mode in ("read-only", "export-only", "import-annotation", "bidirectional", "unidirectional") + ), "Mode must be valid sync mode", ) @require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") diff --git a/src/specfact_cli/registry/extension_registry.py b/src/specfact_cli/registry/extension_registry.py new file mode 100644 index 00000000..83100564 --- /dev/null +++ b/src/specfact_cli/registry/extension_registry.py @@ -0,0 +1,59 @@ +""" +Global extension registry for schema extensions declared by modules (arch-07). + +Maps module name to list of SchemaExtension; enforces namespace collision detection at registration. +""" + +from __future__ import annotations + +from beartype import beartype + +from specfact_cli.models.module_package import SchemaExtension + + +def _check_collision( + module_name: str, + extensions: list[SchemaExtension], + registry: dict[str, list[SchemaExtension]], +) -> None: + """Raise ValueError if any (target, field) is already registered by another module.""" + for ext in extensions: + key = f"{module_name}.{ext.field}" + for existing_module, existing_exts in registry.items(): + if existing_module == module_name: + continue + for e in existing_exts: + if (e.target, e.field) == (ext.target, ext.field): + raise ValueError(f"Extension field collision: {key} already declared by module {existing_module}") + + +class ExtensionRegistry: + """Global registry of module-declared schema extensions (arch-07).""" + + _registry: dict[str, list[SchemaExtension]] + + def __init__(self) -> None: + self._registry = {} + + @beartype + def register(self, module_name: str, extensions: list[SchemaExtension]) -> None: + """Register schema extensions for a module. Raises ValueError on namespace collision.""" + _check_collision(module_name, extensions, self._registry) + self._registry.setdefault(module_name, []).extend(extensions) + + @beartype + def get_extensions(self, module_name: str) -> list[SchemaExtension]: + """Return list of schema extensions for the given module.""" + return list(self._registry.get(module_name, [])) + + @beartype + def list_all(self) -> dict[str, list[SchemaExtension]]: + """Return copy of full registry (module_name -> list of SchemaExtension).""" + return {k: list(v) for k, v in self._registry.items()} + + +def get_extension_registry() -> ExtensionRegistry: + """Return the global extension registry singleton.""" + if not hasattr(get_extension_registry, "_instance"): + get_extension_registry._instance = ExtensionRegistry() # type: ignore[attr-defined] + return get_extension_registry._instance # type: ignore[attr-defined] diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index 2f1780bf..73433c17 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -28,11 +28,13 @@ IntegrityInfo, ModulePackageMetadata, PublisherInfo, + SchemaExtension, ServiceBridgeMetadata, VersionedModuleDependency, VersionedPipDependency, ) from specfact_cli.registry.bridge_registry import BridgeRegistry, SchemaConverter +from specfact_cli.registry.extension_registry import get_extension_registry from specfact_cli.registry.metadata import CommandMetadata from specfact_cli.registry.module_installer import verify_module_artifact from specfact_cli.registry.module_state import find_dependents, read_modules_state @@ -229,6 +231,13 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack validated_service_bridges.append(ServiceBridgeMetadata.model_validate(bridge_entry)) except Exception: continue + validated_schema_extensions: list[SchemaExtension] = [] + for ext_entry in raw.get("schema_extensions", []) or []: + try: + if isinstance(ext_entry, dict): + validated_schema_extensions.append(SchemaExtension.model_validate(ext_entry)) + except Exception: + continue meta = ModulePackageMetadata( name=str(raw["name"]), version=str(raw.get("version", "0.1.0")), @@ -245,6 +254,7 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack module_dependencies_versioned=module_deps_versioned, pip_dependencies_versioned=pip_deps_versioned, service_bridges=validated_service_bridges, + schema_extensions=validated_schema_extensions, ) result.append((child, meta)) except Exception: @@ -823,6 +833,23 @@ def register_module_package_commands( else: logger.info("Module %s: Schema version %s (compatible)", meta.name, meta.schema_version) + if meta.schema_extensions: + try: + get_extension_registry().register(meta.name, meta.schema_extensions) + targets = sorted({e.target for e in meta.schema_extensions}) + logger.debug( + "Module %s registered %d schema extensions for %s", + meta.name, + len(meta.schema_extensions), + targets, + ) + except ValueError as exc: + logger.error( + "Module %s: Schema extension collision - %s (skipping extensions)", + meta.name, + exc, + ) + for bridge in meta.validate_service_bridges(): existing_owner = bridge_owner_map.get(bridge.id) if existing_owner: diff --git a/src/specfact_cli/telemetry.py b/src/specfact_cli/telemetry.py index 1fd78b65..aeb56b2e 100644 --- a/src/specfact_cli/telemetry.py +++ b/src/specfact_cli/telemetry.py @@ -131,8 +131,9 @@ def _read_config_file() -> dict[str, Any]: @beartype @require(lambda raw: raw is None or isinstance(raw, str), "Raw must be None or string") @ensure( - lambda result: isinstance(result, dict) - and all(isinstance(k, str) and isinstance(v, str) for k, v in result.items()), + lambda result: ( + isinstance(result, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in result.items()) + ), "Must return dict[str, str]", ) def _parse_headers(raw: str | None) -> dict[str, str]: @@ -175,13 +176,15 @@ class TelemetrySettings: @beartype @require(lambda cls: cls is TelemetrySettings, "Must be called on TelemetrySettings class") @ensure( - lambda result: isinstance(result, TelemetrySettings) - and isinstance(result.enabled, bool) - and (result.endpoint is None or isinstance(result.endpoint, str)) - and isinstance(result.headers, dict) - and isinstance(result.local_path, Path) - and isinstance(result.debug, bool) - and isinstance(result.opt_in_source, str), + lambda result: ( + isinstance(result, TelemetrySettings) + and isinstance(result.enabled, bool) + and (result.endpoint is None or isinstance(result.endpoint, str)) + and isinstance(result.headers, dict) + and isinstance(result.local_path, Path) + and isinstance(result.debug, bool) + and isinstance(result.opt_in_source, str) + ), "Must return valid TelemetrySettings instance", ) def from_env(cls) -> TelemetrySettings: @@ -291,11 +294,13 @@ def _fallback_local_log_path(cls) -> Path: "Settings must be None or TelemetrySettings", ) @ensure( - lambda self, result: hasattr(self, "_settings") - and hasattr(self, "_enabled") - and hasattr(self, "_session_id") - and isinstance(self._session_id, str) - and len(self._session_id) > 0, + lambda self, result: ( + hasattr(self, "_settings") + and hasattr(self, "_enabled") + and hasattr(self, "_session_id") + and isinstance(self._session_id, str) + and len(self._session_id) > 0 + ), "Must initialize all required instance attributes", ) def __init__(self, settings: object | None = None) -> None: diff --git a/src/specfact_cli/templates/registry.py b/src/specfact_cli/templates/registry.py index af23f88c..e47edf31 100644 --- a/src/specfact_cli/templates/registry.py +++ b/src/specfact_cli/templates/registry.py @@ -276,9 +276,11 @@ def resolve_template( priority_checks = [ # 1. provider+framework+persona (most specific) ( - lambda t: (provider and t.provider == provider) - and (framework and t.framework == framework) - and (persona and persona in t.personas), + lambda t: ( + (provider and t.provider == provider) + and (framework and t.framework == framework) + and (persona and persona in t.personas) + ), "provider+framework+persona", ), # 2. provider+framework diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 98c7fb33..a1a6199c 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -253,9 +253,11 @@ def process_template(content: str, description: str, format_type: Literal["md", @require(lambda repo_path: repo_path.is_dir(), "Repo path must be a directory") @require(lambda ide: ide in IDE_CONFIG, "IDE must be valid") @ensure( - lambda result: isinstance(result, tuple) - and len(result) == 2 - and (result[1] is None or (isinstance(result[1], Path) and result[1].exists())), + lambda result: ( + isinstance(result, tuple) + and len(result) == 2 + and (result[1] is None or (isinstance(result[1], Path) and result[1].exists())) + ), "Settings file path must exist if returned", ) def copy_templates_to_ide( diff --git a/src/specfact_cli/utils/terminal.py b/src/specfact_cli/utils/terminal.py index 61c2db5b..4d50d956 100644 --- a/src/specfact_cli/utils/terminal.py +++ b/src/specfact_cli/utils/terminal.py @@ -133,10 +133,9 @@ def get_console_config() -> dict[str, Any]: @beartype @ensure( - lambda result: isinstance(result, tuple) - and len(result) == 2 - and isinstance(result[0], tuple) - and isinstance(result[1], dict), + lambda result: ( + isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], tuple) and isinstance(result[1], dict) + ), "Must return tuple of (columns tuple, kwargs dict)", ) def get_progress_config() -> tuple[tuple[Any, ...], dict[str, Any]]: diff --git a/src/specfact_cli/validators/repro_checker.py b/src/specfact_cli/validators/repro_checker.py index efe13d6a..70d24450 100644 --- a/src/specfact_cli/validators/repro_checker.py +++ b/src/specfact_cli/validators/repro_checker.py @@ -850,8 +850,10 @@ def run_check( @ensure(lambda result: isinstance(result, ReproReport), "Must return ReproReport") @ensure(lambda result: result.total_checks >= 0, "Total checks must be non-negative") @ensure( - lambda result: result.total_checks - == result.passed_checks + result.failed_checks + result.timeout_checks + result.skipped_checks, + lambda result: ( + result.total_checks + == result.passed_checks + result.failed_checks + result.timeout_checks + result.skipped_checks + ), "Total checks must equal sum of all status types", ) def run_all_checks(self) -> ReproReport: diff --git a/src/specfact_cli/validators/schema.py b/src/specfact_cli/validators/schema.py index f35cc7d5..55800538 100644 --- a/src/specfact_cli/validators/schema.py +++ b/src/specfact_cli/validators/schema.py @@ -124,8 +124,10 @@ def validate_json_schema(self, data: dict, schema_name: str) -> ValidationReport @beartype @ensure( - lambda result: isinstance(result, ValidationReport) - or (isinstance(result, tuple) and len(result) == 3 and isinstance(result[0], bool)), + lambda result: ( + isinstance(result, ValidationReport) + or (isinstance(result, tuple) and len(result) == 3 and isinstance(result[0], bool)) + ), "Must return ValidationReport or tuple[bool, str | None, PlanBundle | None]", ) def validate_plan_bundle( @@ -174,8 +176,10 @@ def validate_plan_bundle( @beartype @ensure( - lambda result: isinstance(result, ValidationReport) - or (isinstance(result, tuple) and len(result) == 3 and isinstance(result[0], bool)), + lambda result: ( + isinstance(result, ValidationReport) + or (isinstance(result, tuple) and len(result) == 3 and isinstance(result[0], bool)) + ), "Must return ValidationReport or tuple[bool, str | None, Protocol | None]", ) def validate_protocol(protocol_or_path: Protocol | Path) -> ValidationReport | tuple[bool, str | None, Protocol | None]: diff --git a/tests/unit/models/test_module_package_metadata.py b/tests/unit/models/test_module_package_metadata.py index b6626dd1..5ca80c04 100644 --- a/tests/unit/models/test_module_package_metadata.py +++ b/tests/unit/models/test_module_package_metadata.py @@ -5,7 +5,11 @@ import pytest from pydantic import ValidationError -from specfact_cli.models.module_package import ModulePackageMetadata, ServiceBridgeMetadata +from specfact_cli.models.module_package import ( + ModulePackageMetadata, + SchemaExtension, + ServiceBridgeMetadata, +) def test_metadata_includes_schema_version() -> None: @@ -67,3 +71,42 @@ def test_service_bridge_converter_class_must_be_dotted_path() -> None: commands=["backlog"], service_bridges=[ServiceBridgeMetadata(id="ado", converter_class="InvalidClassPath")], ) + + +def test_manifest_parses_schema_extensions() -> None: + """Module-package manifest MAY include schema_extensions array (arch-07).""" + metadata = ModulePackageMetadata( + name="backlog", + commands=["backlog"], + schema_extensions=[ + SchemaExtension( + target="Feature", + field="ado_work_item_id", + type_hint="str", + description="Azure DevOps work item ID", + ), + ], + ) + assert len(metadata.schema_extensions) == 1 + assert metadata.schema_extensions[0].target == "Feature" + assert metadata.schema_extensions[0].field == "ado_work_item_id" + assert metadata.schema_extensions[0].type_hint == "str" + assert "Azure DevOps" in metadata.schema_extensions[0].description + + +def test_schema_extension_target_must_be_feature_or_project_bundle() -> None: + """SchemaExtension target SHALL be Feature or ProjectBundle.""" + with pytest.raises(ValidationError): + SchemaExtension( + target="Other", + field="x", + type_hint="str", + description="", + ) + + +def test_module_without_schema_extensions_remains_valid() -> None: + """Module without schema_extensions SHALL load successfully.""" + metadata = ModulePackageMetadata(name="backlog", commands=["backlog"]) + assert hasattr(metadata, "schema_extensions") + assert metadata.schema_extensions == [] diff --git a/tests/unit/models/test_schema_extensions.py b/tests/unit/models/test_schema_extensions.py new file mode 100644 index 00000000..5f660ae5 --- /dev/null +++ b/tests/unit/models/test_schema_extensions.py @@ -0,0 +1,140 @@ +""" +Unit tests for schema extension system (arch-07). + +Spec: schema-extension-system — extensions field and get_extension/set_extension on Feature and ProjectBundle. +""" + +from __future__ import annotations + +import json + +import pytest +import yaml + +from specfact_cli.models.plan import Feature, Product +from specfact_cli.models.project import ProjectBundle + + +class TestFeatureExtensions: + """Feature model extensions field and accessors (spec: schema-extension-system).""" + + def test_feature_includes_extensions_field(self) -> None: + """Feature model SHALL include extensions dict field defaulting to empty dict.""" + f = Feature(key="F-1", title="Test") + assert hasattr(f, "extensions") + assert f.extensions == {} + assert f.extensions is not None + + def test_feature_extensions_serialize_deserialize_yaml(self) -> None: + """extensions SHALL serialize/deserialize with YAML.""" + f = Feature(key="F-1", title="Test", extensions={"backlog.ado_id": "123"}) + dumped = yaml.safe_dump(f.model_dump()) + loaded = yaml.safe_load(dumped) + f2 = Feature.model_validate(loaded) + assert f2.extensions == {"backlog.ado_id": "123"} + + def test_feature_extensions_serialize_deserialize_json(self) -> None: + """extensions SHALL serialize/deserialize with JSON.""" + f = Feature(key="F-1", title="Test", extensions={"backlog.ado_id": "123"}) + dumped = json.dumps(f.model_dump()) + loaded = json.loads(dumped) + f2 = Feature.model_validate(loaded) + assert f2.extensions == {"backlog.ado_id": "123"} + + def test_feature_get_extension_returns_value(self) -> None: + """get_extension(module_name, field) SHALL return value at extensions['module.field'].""" + f = Feature(key="F-1", title="Test") + f.set_extension("backlog", "ado_work_item_id", "123456") + assert f.get_extension("backlog", "ado_work_item_id") == "123456" + + def test_feature_get_extension_missing_returns_default(self) -> None: + """get_extension with missing field SHALL return default.""" + f = Feature(key="F-1", title="Test") + assert f.get_extension("backlog", "missing_field", default="default_value") == "default_value" + assert "backlog.missing_field" not in f.extensions + + def test_feature_set_extension_stores_with_namespace_prefix(self) -> None: + """set_extension(module_name, field, value) SHALL store at extensions['module.field'].""" + f = Feature(key="F-1", title="Test") + f.set_extension("backlog", "ado_work_item_id", "123456") + assert f.extensions["backlog.ado_work_item_id"] == "123456" + + def test_feature_invalid_module_name_raises(self) -> None: + """Invalid module_name (e.g. contains dots) SHALL raise ValueError or contract violation.""" + f = Feature(key="F-1", title="Test") + with pytest.raises((ValueError, AssertionError), match=r"Invalid module name format|module name"): + f.set_extension("backlog.submodule", "field", "value") + + def test_feature_invalid_field_name_raises(self) -> None: + """Invalid field name format SHALL raise (contract or ValueError).""" + f = Feature(key="F-1", title="Test") + with pytest.raises((ValueError, AssertionError)): + f.set_extension("backlog", "invalid-field", "value") + + +class TestProjectBundleExtensions: + """ProjectBundle model extensions field and accessors.""" + + def _minimal_bundle(self) -> ProjectBundle: + from specfact_cli.models.project import BundleManifest, BundleVersions + + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + return ProjectBundle( + manifest=manifest, + bundle_name="test", + product=Product(themes=[], releases=[]), + ) + + def test_project_bundle_includes_extensions_field(self) -> None: + """ProjectBundle SHALL include extensions dict field defaulting to empty dict.""" + bundle = self._minimal_bundle() + assert hasattr(bundle, "extensions") + assert bundle.extensions == {} + assert bundle.extensions is not None + + def test_project_bundle_extensions_serialize_deserialize(self) -> None: + """extensions SHALL serialize/deserialize with YAML/JSON.""" + bundle = self._minimal_bundle() + bundle.set_extension("sync", "last_sync_timestamp", "2025-01-15T12:00:00Z") + dumped = bundle.model_dump(mode="json") + loaded = json.loads(json.dumps(dumped)) + bundle2 = ProjectBundle.model_validate(loaded) + assert bundle2.get_extension("sync", "last_sync_timestamp") == "2025-01-15T12:00:00Z" + + def test_project_bundle_get_extension_set_extension(self) -> None: + """get_extension/set_extension SHALL work on ProjectBundle.""" + bundle = self._minimal_bundle() + bundle.set_extension("sync", "last_sync_timestamp", "2025-01-15T12:00:00Z") + assert bundle.get_extension("sync", "last_sync_timestamp") == "2025-01-15T12:00:00Z" + assert bundle.get_extension("sync", "missing", default="def") == "def" + + +class TestBackwardCompatibility: + """Backward compatibility: bundles without extensions load successfully.""" + + def test_feature_without_extensions_loads(self) -> None: + """Feature from dict without 'extensions' key SHALL default to empty dict.""" + data = {"key": "F-1", "title": "Test"} + f = Feature.model_validate(data) + assert f.extensions == {} + + def test_bundle_operations_without_extensions(self) -> None: + """Core operations SHALL work when extensions is empty dict.""" + from specfact_cli.models.project import BundleManifest, BundleVersions + + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + bundle = ProjectBundle( + manifest=manifest, + bundle_name="test", + product=Product(themes=[], releases=[]), + ) + assert bundle.extensions == {} + assert bundle.get_feature("x") is None diff --git a/tests/unit/specfact_cli/registry/test_extension_registry.py b/tests/unit/specfact_cli/registry/test_extension_registry.py new file mode 100644 index 00000000..9fab49d7 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_extension_registry.py @@ -0,0 +1,74 @@ +""" +Unit tests for ExtensionRegistry (arch-07 schema extension system). + +Spec: schema-extension-system — namespace collision detection, registry populated at registration. +""" + +from __future__ import annotations + +import pytest + +from specfact_cli.models.module_package import SchemaExtension +from specfact_cli.registry.extension_registry import ExtensionRegistry + + +class TestExtensionRegistry: + """ExtensionRegistry register, collision detection, list_all.""" + + def test_register_extensions_from_module(self) -> None: + """Registry SHALL register extensions from a module.""" + registry = ExtensionRegistry() + exts = [ + SchemaExtension(target="Feature", field="ado_work_item_id", type_hint="str", description="ADO ID"), + ] + registry.register("backlog", exts) + assert registry.get_extensions("backlog") == exts + + def test_list_all_returns_module_to_extensions(self) -> None: + """list_all() SHALL return dict module_name -> list of SchemaExtension.""" + registry = ExtensionRegistry() + exts = [ + SchemaExtension(target="Feature", field="ado_id", type_hint="str", description="ADO work item ID"), + ] + registry.register("backlog", exts) + all_ = registry.list_all() + assert "backlog" in all_ + assert all_["backlog"] == exts + + def test_same_module_multiple_fields(self) -> None: + """Same module declaring multiple fields SHALL register successfully.""" + registry = ExtensionRegistry() + exts = [ + SchemaExtension(target="Feature", field="ado_work_item_id", type_hint="str", description="ADO ID"), + SchemaExtension(target="Feature", field="jira_issue_key", type_hint="str", description="Jira key"), + ] + registry.register("backlog", exts) + assert len(registry.get_extensions("backlog")) == 2 + + def test_different_modules_unique_namespaces(self) -> None: + """Different modules with unique namespaces SHALL both succeed.""" + registry = ExtensionRegistry() + registry.register( + "backlog", + [SchemaExtension(target="Feature", field="ado_work_item_id", type_hint="str", description="")], + ) + registry.register( + "sync", + [SchemaExtension(target="ProjectBundle", field="last_sync_timestamp", type_hint="str", description="")], + ) + assert len(registry.get_extensions("backlog")) == 1 + assert len(registry.get_extensions("sync")) == 1 + assert len(registry.list_all()) == 2 + + def test_collision_raises_or_logs(self) -> None: + """Duplicate extension field (same module.field) from different module SHALL be rejected.""" + registry = ExtensionRegistry() + registry.register( + "module_a", + [SchemaExtension(target="Feature", field="ado_work_item_id", type_hint="str", description="")], + ) + with pytest.raises(ValueError, match=r"collision|already declared"): + registry.register( + "module_b", + [SchemaExtension(target="Feature", field="ado_work_item_id", type_hint="str", description="")], + )