diff --git a/.github/workflows/sign-modules-on-approval.yml b/.github/workflows/sign-modules-on-approval.yml index 33f45915..f434d795 100644 --- a/.github/workflows/sign-modules-on-approval.yml +++ b/.github/workflows/sign-modules-on-approval.yml @@ -6,11 +6,12 @@ on: types: [submitted] concurrency: - group: sign-modules-on-approval-${{ github.event.pull_request.number }} + group: sign-modules-on-approval-${{ github.event.pull_request.number || github.event.number }} cancel-in-progress: true permissions: contents: write + pull-requests: read jobs: sign-modules: @@ -18,28 +19,17 @@ jobs: env: SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE }} - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref || github.head_ref }} steps: - name: Eligibility gate (required status check) id: gate + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [ "${{ github.event.review.state }}" != "approved" ]; then - echo "sign=false" >> "$GITHUB_OUTPUT" - echo "::notice::Skipping module signing: review state is not approved." - exit 0 - fi - author_association="${{ github.event.review.user.author_association }}" - case "$author_association" in - COLLABORATOR|MEMBER|OWNER) - ;; - *) - echo "sign=false" >> "$GITHUB_OUTPUT" - echo "::notice::Skipping module signing: reviewer association '${author_association}' is not trusted for signing." - exit 0 - ;; - esac + review_state="${{ github.event.review.state || '' }}" + author_association="${{ github.event.review.user.author_association || '' }}" base_ref="${{ github.event.pull_request.base.ref }}" if [ "$base_ref" != "dev" ] && [ "$base_ref" != "main" ]; then echo "sign=false" >> "$GITHUB_OUTPUT" @@ -53,8 +43,22 @@ jobs: echo "::notice::Skipping module signing: fork PR (head repo differs from target repo)." exit 0 fi + if [ "$review_state" != "approved" ]; then + echo "sign=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping module signing: review state is not approved." + exit 0 + fi + case "$author_association" in + COLLABORATOR|MEMBER|OWNER) + ;; + *) + echo "sign=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping module signing: reviewer association '${author_association}' is not trusted for signing." + exit 0 + ;; + esac echo "sign=true" >> "$GITHUB_OUTPUT" - echo "Eligible for module signing (approved by trusted reviewer, same-repo PR to dev or main)." + echo "Eligible for module signing (same-repo PR to dev or main with trusted approval state)." - name: Guard signing secrets if: steps.gate.outputs.sign == 'true' @@ -139,7 +143,7 @@ jobs: { echo "### Module signing (CI approval)" if [ "${GATE_SIGN}" != "true" ]; then - echo "Signing skipped (eligibility gate: not approved, wrong base branch, or fork PR)." + echo "Signing skipped (eligibility gate: no trusted approval, wrong base branch, or fork PR)." else echo "Manifests discovered under \`packages/\`: ${MANIFESTS_COUNT:-unknown}" if [ "${COMMIT_CHANGED}" = "true" ]; then diff --git a/.github/workflows/sign-modules.yml b/.github/workflows/sign-modules.yml index daa26d39..77ce0ead 100644 --- a/.github/workflows/sign-modules.yml +++ b/.github/workflows/sign-modules.yml @@ -70,11 +70,16 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Fetch workflow_dispatch comparison base if: github.event_name == 'workflow_dispatch' run: git fetch --no-tags origin "${{ github.event.inputs.base_branch }}" + - name: Fetch pull_request comparison base + if: github.event_name == 'pull_request' + run: git fetch --no-tags origin "${{ github.event.pull_request.base.ref }}" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -151,6 +156,39 @@ jobs: ) PY + - name: Auto-sign changed module manifests (same-repo PRs, non-bot actors) + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'github-actions[bot]' + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} + SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE }} + run: | + set -euo pipefail + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ]; then + echo "::error::Missing SPECFACT_MODULE_PRIVATE_SIGN_KEY. Configure the secret so same-repo PRs can auto-sign module manifests." + exit 1 + fi + MERGE_BASE="$(git merge-base HEAD "origin/${{ github.event.pull_request.base.ref }}")" + python scripts/sign-modules.py \ + --changed-only \ + --base-ref "$MERGE_BASE" \ + --bump-version patch \ + --payload-from-filesystem + + if [ -z "$(git status --porcelain -- packages/)" ]; then + echo "No manifest signing changes to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u -- packages/ + git commit -m "chore(modules): ci sign changed modules" + git push origin "HEAD:${PR_HEAD_REF}" + - name: Strict verify module manifests (push to dev/main) if: github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'main') run: | diff --git a/docs/bundles/project/import-migration.md b/docs/bundles/project/import-migration.md index 87e99fd1..c7ea379d 100644 --- a/docs/bundles/project/import-migration.md +++ b/docs/bundles/project/import-migration.md @@ -18,6 +18,7 @@ This guide covers advanced features and optimizations in the `code import` comma The `code import` command has been optimized for large codebases and includes several features to improve reliability, performance, and user experience: - **Progress Reporting**: Real-time progress bars for long-running operations +- **Default Ignore Policy**: Hidden and heavyweight artifact directories are pruned before traversal - **Feature Validation**: Automatic validation of existing features when resuming imports - **Early Save Checkpoint**: Features saved immediately after analysis to prevent data loss - **Performance Optimizations**: Pre-computed caches for 5-15x faster processing @@ -29,6 +30,26 @@ The `code import` command has been optimized for large codebases and includes se The import command now provides detailed progress reporting for all major operations: +### Discovery and ETA Semantics + +Import progress is now derived from the work discovered after ignore pruning, not from a fixed-duration promise. + +- Default traversal skips hidden and heavyweight directories such as `.git/`, `.specfact/`, `.venv/`, `venv/`, `node_modules/`, `build/`, `dist/`, and `__pycache__/`. +- Repo-local overrides can be added in `.specfact/.specfactignore`. +- When import encounters ignored artifact trees with more than 500 entries or repositories larger than 1,000 files, it emits warnings instead of promising that the run will finish in a specific number of minutes. +- Remaining time is based on processed-versus-discovered work at the current runtime rate, so estimates remain provisional for repos above those configured thresholds. + +### Repo-local Ignore Overrides + +Add extra ignore patterns to `.specfact/.specfactignore` when a repository contains generated or vendored trees outside the default exclusions: + +```text +custom_ignore/ +third_party/generated/** +``` + +Patterns are applied before traversal so ignored trees do not inflate scanned-file counts or ETA totals. + ### Feature Analysis Progress During the initial codebase analysis, you'll see: @@ -192,7 +213,7 @@ specfact code import my-project --repo . --revalidate-features For codebases with 1000+ features: 1. **Use partial analysis**: Start with `--entry-point` to analyze one module at a time -2. **Monitor progress**: Watch the progress bars to estimate completion time +2. **Monitor progress**: Treat remaining time as live guidance based on discovered work, not a fixed promise 3. **Use checkpoints**: Let the early save checkpoint work for you - don't worry about interruptions 4. **Re-validate periodically**: Use `--revalidate-features` after major code changes @@ -217,8 +238,9 @@ For codebases with 1000+ features: If source file linking is slow: -- **Check file count**: Large numbers of files (10,000+) will take longer +- **Check file count**: Large numbers of files (10,000+) will take longer even after default ignore pruning - **Monitor progress**: The progress bar shows current status +- **Review warnings**: Heavy-artifact warnings identify ignored trees that would otherwise dominate runtime - **Use entry points**: Limit scope with `--entry-point` for faster processing ### Validation Issues @@ -247,4 +269,4 @@ If import is interrupted: --- -**Happy importing!** 🚀 +**Happy importing!** 🚀 \ No newline at end of file diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index c8413cc3..46de4dc7 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -80,11 +80,29 @@ These changes are the modules-side runtime companions to split core governance a | validation | 02 | validation-02-full-chain-engine | [#171](https://github.com/nold-ai/specfact-cli-modules/issues/171) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); core counterpart `specfact-cli#241`; runtime inputs from `#164` and `#165`; policy semantics from `#158` | | docs + validation | 15 | docs-15-code-review-validation-guardrails | [#202](https://github.com/nold-ai/specfact-cli-modules/issues/202) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); Parent Epic: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162); no known blockers | +### Five-pillar governance and enterprise runtime companions + +These changes are the modules-side runtime companions to the five-pillar governance wave in `specfact-cli`. Core remains authoritative for schemas, scoring, resolution semantics, and shared report contracts; this repo owns the runnable bundle packages, manifests, and packaged tool integrations. + +| Module | Order | Change folder | GitHub # | Blocked by | +|--------|-------|---------------|----------|------------| +| telemetry + finops | 01 | finops-01-module-cost-outcome | [#223](https://github.com/nold-ai/specfact-cli-modules/issues/223) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#220](https://github.com/nold-ai/specfact-cli-modules/issues/220); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); paired core changes `telemetry-01-opentelemetry-default-on` and `finops-01-telemetry-and-outcomes` | +| knowledge | 02 | knowledge-01-module-memory-runtime | [#224](https://github.com/nold-ai/specfact-cli-modules/issues/224) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#221](https://github.com/nold-ai/specfact-cli-modules/issues/221); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); paired core change `knowledge-01-distillation-engine`; uses default markdown-graph runtime | +| review | 03 | review-resiliency-01-module | [#226](https://github.com/nold-ai/specfact-cli-modules/issues/226) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#217](https://github.com/nold-ai/specfact-cli-modules/issues/217); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); paired core change `review-resiliency-01-contracts`; optional evidence hooks depend on `knowledge-01-module-memory-runtime` | +| security | 03 | security-01-module-sast-sca-secret | [#227](https://github.com/nold-ai/specfact-cli-modules/issues/227) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#218](https://github.com/nold-ai/specfact-cli-modules/issues/218); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); paired core change `security-01-unified-findings-model`; shared policy semantics from `policy-02-packs-and-modes` | +| architecture | 03 | architecture-02-module-well-architected | [#230](https://github.com/nold-ai/specfact-cli-modules/issues/230) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#219](https://github.com/nold-ai/specfact-cli-modules/issues/219); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); paired core change `architecture-02-well-architected-review`; boundary rules align with `ALLOWED_IMPORTS.md` | +| security | 04 | security-02-module-license-compliance | [#228](https://github.com/nold-ai/specfact-cli-modules/issues/228) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#218](https://github.com/nold-ai/specfact-cli-modules/issues/218); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); `security-01-module-sast-sca-secret`; paired core changes `security-01-unified-findings-model` and shared policy semantics | +| security | 05 | security-03-module-pii-gdpr-eu | [#229](https://github.com/nold-ai/specfact-cli-modules/issues/229) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#218](https://github.com/nold-ai/specfact-cli-modules/issues/218); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); `security-01-module-sast-sca-secret`; paired core changes `security-01-unified-findings-model` and `security-02-eu-gdpr-baseline` | +| knowledge | 06 | knowledge-02-module-writeback | [#225](https://github.com/nold-ai/specfact-cli-modules/issues/225) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#221](https://github.com/nold-ai/specfact-cli-modules/issues/221); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); `knowledge-01-module-memory-runtime`; paired core change `knowledge-02-preflight-context-assembly` | +| enterprise | 09 | enterprise-01-module-policy-client | [#231](https://github.com/nold-ai/specfact-cli-modules/issues/231) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#222](https://github.com/nold-ai/specfact-cli-modules/issues/222); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); paired core change `enterprise-01-policy-resolution-extension`; depends on prior five-pillar runtime bundles being available for policy application targets | +| enterprise | 10 | enterprise-02-module-audit-client | [#232](https://github.com/nold-ai/specfact-cli-modules/issues/232) | Parent Epic: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216); Parent Feature: [#222](https://github.com/nold-ai/specfact-cli-modules/issues/222); core umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511); `enterprise-01-module-policy-client`; paired core change `enterprise-02-rbac-and-audit-trail` | + ### Code review and sidecar validation improvements | Module | Order | Change folder | GitHub # | Blocked by | |--------|-------|---------------|----------|------------| | code-review + codebase | 01 | code-review-bug-finding-and-sidecar-venv-fix | [#174](https://github.com/nold-ai/specfact-cli-modules/issues/174) | Parent Feature: [#175](https://github.com/nold-ai/specfact-cli-modules/issues/175); Epic: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162) | +| codebase + project-runtime | 02 | codebase-import-runtime-hardening | [#235](https://github.com/nold-ai/specfact-cli-modules/issues/235) | Parent Feature: [#234](https://github.com/nold-ai/specfact-cli-modules/issues/234); Epic: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162); no known blockers | ### Module trust chain and CI security @@ -108,4 +126,4 @@ These changes are the modules-side runtime companions to split core governance a | Module | Order | Change folder | GitHub # | Blocked by | |--------|-------|---------------|----------|------------| -| peer-deps | 01 | ✅ module-bundle-deps-auto-install (archived 2026-04-05) | [#135](https://github.com/nold-ai/specfact-cli-modules/issues/135) | — | +| peer-deps | 01 | ✅ module-bundle-deps-auto-install (archived 2026-04-05) | [#135](https://github.com/nold-ai/specfact-cli-modules/issues/135) | — | \ No newline at end of file diff --git a/openspec/changes/architecture-02-module-well-architected/.openspec.yaml b/openspec/changes/architecture-02-module-well-architected/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/architecture-02-module-well-architected/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/architecture-02-module-well-architected/design.md b/openspec/changes/architecture-02-module-well-architected/design.md new file mode 100644 index 00000000..d3e0b912 --- /dev/null +++ b/openspec/changes/architecture-02-module-well-architected/design.md @@ -0,0 +1,78 @@ +# Architecture Well-Architected Module Design + +## Context + +Architecture governance spans both shared contracts in `specfact-cli` and executable repository analysis in `specfact-cli-modules`. This change defines the module bundle that inspects dependency boundaries, interface changes, and ADR traceability while mapping results into the paired core architecture review model. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for `specfact-architecture`. +- Reuse repository boundary patterns such as `ALLOWED_IMPORTS.md` instead of inventing a second mechanism. +- Normalize architecture findings into the paired core report contract for downstream policy and evidence flows. +- Keep bundle dependencies explicit and optional by analyzer/provider. + +**Non-Goals:** + +- Reimplement the paired core architecture scoring or findings schema. +- Replace hand-written ADR content or lifecycle management. +- Introduce enterprise-only policy-server behavior in the first release. + +## Decisions + +### 1. Ship architecture review as a standalone official bundle + +- **Decision**: Create `packages/specfact-architecture/` with the `architecture` command and its own manifest/signing lifecycle. +- **Why**: Architecture review spans different analyzers, docs, and adoption concerns than existing bundles. +- **Alternative considered**: Extend `specfact-govern` or `specfact-code-review`. Rejected because it would blur ownership and bundle identity. + +#### Implementation Requirements + +Implementation PRs must include a complete `module-package.yaml` for the new specfact-architecture bundle and explicitly declare the adapter/manifest boundaries deferred by this decision. The package must define all required fields following the same pattern as specfact-govern/specfact-codebase: + +- `name`, `version`, `commands`, `tier`, `publisher` +- `bundle_dependencies`, `pip_dependencies` +- `core_compatibility` (e.g., ">=0.40.0,<1.0.0") that matches the paired specfact-cli change +- `integrity` + +Verification steps before merging: +- Confirm the `architecture-02-well-architected-review` entry exists and is stable in specfact-cli +- Verify registry integration by passing the existing `sign-modules-on-approval` and `publish-modules` workflows so manifests auto-sign and tarballs auto-publish + +### 2. Treat boundary policies as portable rule resources + +- **Decision**: Convert the `ALLOWED_IMPORTS.md` pattern into bundle-consumable rules/resources rather than reading only repository-local markdown ad hoc. The portable rule schema and enforcement code must preserve all three policy dimensions from ALLOWED_IMPORTS.md: (1) dual-mode prefix matching by adding a flag/field for exact+dot-prefix matching (preserving the `_is_allowed_prefix` semantics) so the rule parser and matcher honor it, (2) a MIGRATE-tier blocking marker or enforcement-level field that causes unconditional forbids for non-allowed imports in that tier, and (3) directional cross-bundle allowlists (explicit source→target allow entries) so bundle isolation rules are expressible. The rule parser/loader, matcher (where `_is_allowed_prefix` is referenced), and enforcement engine must read these fields and enforce them deterministically. +- **Why**: The bundle needs deterministic, reusable behavior across repositories while maintaining full enforcement fidelity from ALLOWED_IMPORTS.md. +- **Alternative considered**: Hard-code modules-repo-specific checks. Rejected because it would not generalize to user repositories. + +### 3. Separate graph extraction from policy evaluation + +- **Decision**: Analyzer adapters extract imports/interfaces/ADR references first, then evaluate those facts through rule resources mapped to the paired core findings model. +- **Why**: This keeps the bundle adaptable across languages/toolchains while preserving one reporting contract. +- **Alternative considered**: Bind each analyzer directly to final findings. Rejected because it makes provider changes harder and duplicates evaluation logic. + +## Risks / Trade-offs + +- **Risk**: Cross-language dependency graphs vary in precision. → **Mitigation**: define provider-specific fixtures and make unsupported ecosystems degrade gracefully. +- **Risk**: Rule translation from `ALLOWED_IMPORTS.md` may miss nuanced intent. → **Mitigation**: specify a canonical rule-resource format and document where human review remains required. +- **Risk**: Interface diff analysis may create noisy findings. → **Mitigation**: make breaking/non-breaking classification explicit in scenarios and tests. + +## Migration Plan + +1. Confirm the paired core architecture review contract (`architecture-02-well-architected-review`) is stable enough for module integration. **Meta-note**: This design can merge as a design artifact, but implementation PRs must verify the dependency checklist (presence of `architecture-02-well-architected-review` in `specfact-cli`, documented overlaps with `architecture-01-solution-layer`, and a stable paired core change) before any module integration or references are added. +2. Implement package structure, analyzer adapters, and rule-resource translation. +3. Publish docs, registry metadata, signatures, and compatibility range together. + +## Dependency Checklist + +**Implementation BLOCKER**: Module integration and any references to the contract named `architecture-02-well-architected-review` in the repository `specfact-cli` are disallowed until the paired `specfact-cli` change exists and is marked stable. Implementation PRs cannot proceed until the following conditions are met: + +- [ ] The `architecture-02-well-architected-review` artifact exists in `specfact-cli` with a stable contract +- [ ] Overlaps and boundaries with `architecture-01-solution-layer` are documented and resolved +- [ ] The paired core change is marked as stable and available for module integration + +## Open Questions + +- Which dependency graph providers should be first-class on day one for Python and JavaScript repositories? +- Should interface diff support start with one ecosystem or ship as a provider abstraction from the first implementation? \ No newline at end of file diff --git a/openspec/changes/architecture-02-module-well-architected/proposal.md b/openspec/changes/architecture-02-module-well-architected/proposal.md new file mode 100644 index 00000000..736f312b --- /dev/null +++ b/openspec/changes/architecture-02-module-well-architected/proposal.md @@ -0,0 +1,69 @@ +# Change: Architecture Well-Architected Module + +## Why + +The core repo can define architecture review contracts, but the modules repo still needs a runtime bundle that inspects boundaries, ADR coverage, import hygiene, and interface drift across real codebases. Without that bundle, the architecture pillar cannot move from guidance to executable review. + +## What Changes + +- **NEW**: Add a `specfact-architecture` bundle with an `architecture` command for boundary, interface, and well-architected review. +- **NEW**: Package analyzers and rule resources for dependency-graph checks, ADR traceability, and interface-diff evaluation. +- **NEW**: Encode the `ALLOWED_IMPORTS.md` pattern into reusable review rules so the modules repo and user repositories can share the same boundary-checking approach. +- **Introduce** report surfaces that map architecture findings into the paired core review contract and optionally emit evidence for the knowledge runtime. +- **EXTEND**: Reserve manifest, registry, docs, and signing work for a new official architecture bundle. + +### Adapter Contract + +The normalized boundary between analyzers and the core review contract defines how architecture findings flow from packaged analyzers into the core ReviewFinding model: + +**Mapping to ReviewFinding Fields:** + +- **category**: Set to `"architecture"` for all architecture analyzer findings +- **tool**: Set to the analyzer name (e.g., `"pylint"` for the pylint-runner expectation, or other analyzer names for dependency-graph, ADR traceability, interface-diff) +- **rule**: Set to the violation rule identifier from the analyzer (e.g., rule IDs from pylint or custom rule identifiers for boundary violations) +- **file**: Set to the repository-relative file path where the violation occurs +- **line**: Set to the 1-based line number or line range start where the violation occurs +- **message**: Set to a human-readable description of the architecture violation +- **severity**: Set to `"error"`, `"warning"`, or `"info"` based on the violation severity +- **fixable**: Optional boolean indicating whether the finding can be auto-fixed (default: false) + +**Supplemental Evidence:** + +- `evidence_refs` may be retained as supplemental references for additional context (stable file paths, line ranges, artifact identifiers), but primary location data must use the canonical `file` and `line` fields + +**Emission Semantics:** + +- **Emission Mode**: Synchronous emission (findings emitted immediately after analyzer completion) +- **Retry/Ordering Semantics**: Findings are emitted in deterministic analyzer execution order with no retry; failures in one analyzer do not block subsequent analyzers + +## Capabilities + +### New Capabilities + +- `architecture-well-architected-module`: Runtime bundle, analyzers, and normalized reporting for architecture and well-architected review. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-architecture/`, dependency-graph adapters, and boundary-rule resources. +- Affected docs: bundle overview and command-reference documentation for `architecture`. +- Dependencies: paired core change `architecture-02-well-architected-review`; existing repo guidance from `ALLOWED_IMPORTS.md`. +- Release impact: introduces a new signed official bundle and registry entry. +- **Core Compatibility**: The reserved manifest `architecture-02-well-architected-review` specifies `core_compatibility: ">=1.0.0 <2.0.0"` in `module-package.yaml` registry metadata, declaring the required core version range for the paired architecture review contracts. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#219](https://github.com/nold-ai/specfact-cli-modules/issues/219) +- **GitHub Issue**: [#230](https://github.com/nold-ai/specfact-cli-modules/issues/230) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/architecture-02-module-well-architected/specs/architecture-well-architected-module/spec.md b/openspec/changes/architecture-02-module-well-architected/specs/architecture-well-architected-module/spec.md new file mode 100644 index 00000000..3d56800b --- /dev/null +++ b/openspec/changes/architecture-02-module-well-architected/specs/architecture-well-architected-module/spec.md @@ -0,0 +1,30 @@ +# Architecture Well-Architected Module Specification + +## ADDED Requirements + +### Requirement: Architecture bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-architecture` that exposes the `architecture` command and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` for the paired core architecture review contracts. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the architecture bundle is discoverable as an official package with the `architecture` command and valid compatibility metadata + +### Requirement: Boundary and interface analysis uses the shared review contracts + +The bundle SHALL translate dependency-boundary, interface-diff, and ADR-traceability analysis into the paired core architecture findings/report contracts instead of defining bundle-local report schemas. + +#### Scenario: An import boundary violation is detected + +- **WHEN** the bundle evaluates repository dependency rules and finds a forbidden import crossing +- **THEN** it emits a normalized architecture finding with the violated boundary rule and a stable evidence reference + +### Requirement: Portable boundary rules support ALLOWED_IMPORTS patterns + +The bundle SHALL support portable rule resources derived from `ALLOWED_IMPORTS.md`-style policies so repository owners can encode architecture boundaries without writing provider-specific code. + +#### Scenario: Repository policy is expressed through allowed-import rules + +- **WHEN** the bundle loads repository boundary policy derived from an `ALLOWED_IMPORTS.md`-style source +- **THEN** it applies those rules during architecture review and classifies violations through the paired core findings contract diff --git a/openspec/changes/architecture-02-module-well-architected/tasks.md b/openspec/changes/architecture-02-module-well-architected/tasks.md new file mode 100644 index 00000000..c97a9f5c --- /dev/null +++ b/openspec/changes/architecture-02-module-well-architected/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/architecture-02-module-well-architected` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm paired core change `architecture-02-well-architected-review` is available and document the minimum required `core_compatibility`. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/architecture-well-architected-module/spec.md`. +- [ ] 2.2 Write failing tests for boundary-rule evaluation, interface-diff classification, and ADR traceability ingestion. +- [ ] 2.3 Write failing tests proving `ALLOWED_IMPORTS.md`-style rules can be translated into portable bundle resources. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/architecture-02-module-well-architected/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-architecture/` with manifest, Typer entrypoints, analyzer adapters, and rule resources. +- [ ] 3.2 Implement import-graph, interface-diff, and ADR-traceability adapters that normalize into the paired core architecture findings/report contracts. +- [ ] 3.3 Integrate repository boundary rules derived from `ALLOWED_IMPORTS.md` and package any provider-specific defaults needed for initial language support. +- [ ] 3.4 Update registry metadata, docs references, signing inputs, and any import allowlists required by the new bundle. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate architecture-02-module-well-architected --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link the paired core architecture change, and note any deferred analyzer providers as follow-up issues. diff --git a/openspec/changes/codebase-import-runtime-hardening/.openspec.yaml b/openspec/changes/codebase-import-runtime-hardening/.openspec.yaml new file mode 100644 index 00000000..c4036b7c --- /dev/null +++ b/openspec/changes/codebase-import-runtime-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-20 diff --git a/openspec/changes/codebase-import-runtime-hardening/TDD_EVIDENCE.md b/openspec/changes/codebase-import-runtime-hardening/TDD_EVIDENCE.md new file mode 100644 index 00000000..1e2322f8 --- /dev/null +++ b/openspec/changes/codebase-import-runtime-hardening/TDD_EVIDENCE.md @@ -0,0 +1,29 @@ +# TDD evidence — codebase-import-runtime-hardening + +## Timestamp + +2026-04-20 (worktree `cursor/codebase-import-runtime-hardening-98df`) + +## Failing-before tests + +- `pytest tests/unit/specfact_project/test_import_runtime_policy.py tests/unit/test_bundle_resource_payloads.py` + - Failing import-policy tests prove the shared traversal helper does not exist yet: `ModuleNotFoundError: No module named 'specfact_project.utils.import_path_policy'`. + - Failing analyzer-progress test proves `CodeAnalyzer.analyze()` still counts raw discovered files instead of the filtered analyzable set for Phase 3 progress totals. + - Failing bundle-resource tests prove required project runtime templates are not packaged under `packages/specfact-project/resources/templates/` and therefore are absent from the built artifact payload. + +## Passing-after tests + +- `pytest tests/unit/specfact_project/test_import_runtime_policy.py tests/unit/test_bundle_resource_payloads.py` + - `21 passed` after introducing the shared import traversal policy, filtered progress totals, packaged runtime templates, and runtime template-resolution coverage. + +## Publish pre-check + +- `python3 scripts/publish_module.py --bundle specfact-project` + - Passed after bumping `packages/specfact-project/module-package.yaml` to `0.41.4`. + +## GitHub tracking notes + +- Parent Feature created: `specfact-cli-modules#234` +- Change issue created: `specfact-cli-modules#235` +- Labels, issue types, and native parent/sub-issue links were applied successfully. +- ProjectV2 assignment could not be updated with the available repository token because GitHub returned `Resource not accessible by personal access token` for `addProjectV2ItemById`. diff --git a/openspec/changes/codebase-import-runtime-hardening/design.md b/openspec/changes/codebase-import-runtime-hardening/design.md new file mode 100644 index 00000000..e618da7d --- /dev/null +++ b/openspec/changes/codebase-import-runtime-hardening/design.md @@ -0,0 +1,93 @@ +# Design + +## Context + +`specfact code import` currently computes work by calling `Path.rglob()` in several separate phases and only filters many paths after discovery. That means virtual environments, build outputs, hidden tool directories, and other heavy trees still contribute traversal cost and often distort progress totals. In parallel, `specfact-project` generators load Jinja2 templates from `resources/templates`, but the referenced template files are absent from the bundle payload and the loader logic is less resilient than the prompt/resource patterns already used elsewhere in the repo. + +This change spans multiple runtime surfaces in `specfact-project` and affects the `specfact-codebase` import entrypoint behavior, so it benefits from an explicit design before implementation. + +## Goals / Non-Goals + +**Goals:** +- Define one reusable import traversal policy that can prune hidden and heavyweight directories before file enumeration. +- Ensure import progress and ETA reflect the filtered work set, not the raw repository size. +- Add repo-local `.specfact/.specfactignore` support that future commands can also reuse. +- Package and resolve required generator Jinja2 templates from the module artifact with regression tests. +- Surface large-artifact warnings without aborting the import. + +**Non-Goals:** +- Implement full `.gitignore` parsing or arbitrary nested ignore-file semantics. +- Redesign the entire AI analyzer pipeline beyond replacing the most expensive unpruned scans with the shared runtime policy. +- Introduce a new external dependency for ignore-file parsing if a lightweight in-repo parser is sufficient. +- Change the core CLI contract outside the module-owned bundle behavior in this repository. + +## Decisions + +### Decision 1: Introduce a shared import path policy helper + +Create a small shared helper in `specfact_project` that evaluates: +- built-in excluded directory names and artifact patterns, +- hidden dot-prefixed names by default, +- `.specfact/.specfactignore` patterns relative to the repo root, +- optional allow-list exceptions for explicitly targeted entry points. + +Why: +- Current ignore logic is duplicated and inconsistent across `_count_python_files`, `CodeAnalyzer.analyze`, relationship extraction, and AI context loading. +- A single helper allows pruning during traversal rather than post-filtering after `rglob()`. + +Alternative considered: +- Keep per-callsite substring filters and just add more names. Rejected because it still pays the full traversal cost and keeps totals inconsistent. + +### Decision 2: Switch the relevant scans to pruned traversal + +Replace raw `rglob()` use in the import runtime hot paths with a helper that walks directories while mutating `dirnames` to skip ignored trees early. + +Why: +- Early pruning addresses the primary performance problem directly. +- It gives one canonical file list that can drive counts, warnings, and ETA totals. + +Alternative considered: +- Keep `rglob()` and filter after discovery. Rejected because it does not solve the filesystem overhead that triggered the user report. + +### Decision 3: Derive ETA from live filtered work + +Progress reporting should use the filtered candidate count as the total work and update ETA from processed-versus-discovered progress. Large-artifact warnings should appear when ignored or scanned directories cross configurable thresholds so users understand why the import is slow. + +Why: +- Current totals include skipped files and make Rich's remaining-time estimate misleading. +- Warning users about heavyweight artifacts is more actionable than promising a fixed duration. + +Alternative considered: +- Only change the user-facing wording from "about 5 minutes." Rejected because runtime feedback would still remain low-signal on large repos. + +### Decision 4: Package generator templates as first-class bundle resources + +Add the required `.j2` files under `packages/specfact-project/resources/templates/` and resolve them via a helper that checks packaged resource roots first and development roots second. Mirror the resilience pattern already used in `persona_exporter.py`, but centralize it to avoid more one-off path math. + +Why: +- The runtime failure is caused first by missing files and second by fragile single-path resolution. +- Resource packaging is already a governed capability in this repo, so the fix should align with existing bundle payload tests and docs. + +Alternative considered: +- Embed the templates as inline Python strings. Rejected because it makes templates harder to maintain and diverges from the resource-payload pattern used elsewhere. + +## Risks / Trade-offs + +- [Ignore policy is too broad and skips legitimate source] -> Mitigation: support explicit entry-point anchoring, add focused tests for hidden but intentional source paths, and keep the built-in pattern set conservative. +- [Shared traversal helper changes multiple code paths at once] -> Mitigation: add targeted failing-first tests per phase and keep the helper API narrow. +- [Template packaging requires manifest/signature churn] -> Mitigation: treat manifest/resource updates as one release surface, then run signature and registry verification in the required gate order. +- [Warning thresholds create noisy output on medium repos] -> Mitigation: warn only for clearly unusual artifact sizes and keep messages advisory rather than repetitive. + +## Migration Plan + +1. Add OpenSpec spec deltas and tasks. +2. Write failing tests that prove ignored trees are pruned, ETA totals match filtered work, warnings surface for heavy artifacts, and required templates exist/resolution works. +3. Implement the shared traversal and template-resolution helpers, then update the import runtime and generators to use them. +4. Bump affected bundle versions, regenerate signed artifacts if required, update registry metadata, and document the new behavior. +5. Run the required quality gates and full SpecFact code review before merge. + +Rollback is low risk: revert the helper adoption and packaged resources together so traversal and runtime resource behavior return to the previous implementation. + +## Open Questions + +- None for implementation start; warning thresholds can be tuned in-code if test evidence shows the initial defaults are too noisy. \ No newline at end of file diff --git a/openspec/changes/codebase-import-runtime-hardening/proposal.md b/openspec/changes/codebase-import-runtime-hardening/proposal.md new file mode 100644 index 00000000..34312dbc --- /dev/null +++ b/openspec/changes/codebase-import-runtime-hardening/proposal.md @@ -0,0 +1,39 @@ +## Why + +`specfact code import` currently walks too much of a repository before it applies ignore rules, which causes it to scan virtual environments, build outputs, hidden tooling directories, and other heavyweight artifacts that users never intended to import. The same runtime area also ships incomplete resources: project-bundle generators reference Jinja2 templates that are not packaged with the module artifact, so installed workflows can fail even when the command surface is otherwise available. + +## What Changes + +- **NEW**: Add a shared codebase-import runtime policy that prunes hidden and heavyweight directories before traversal, supports a repo-local `.specfact/.specfactignore`, and emits targeted warnings when unusually large artifact trees are encountered. +- **NEW**: Replace fixed-duration import expectations with progress derived from discovered-versus-processed work so long-running imports report remaining time from live scan data instead of a hard-coded optimism bias. +- **EXTEND**: Align all import traversal phases (`count`, analyzer discovery, relationship extraction, and AI context loading) to the same ignore policy so progress totals and scanned files reflect the real implementation scope. +- **EXTEND**: Ship the project bundle's runtime Jinja2 templates as bundled resources, resolve them from packaged paths, and add tests that fail if required generator templates are missing from the module artifact. +- **EXTEND**: Update import documentation to explain the default ignore behavior, `.specfact/.specfactignore`, large-artifact warnings, and the new ETA semantics. + +## Capabilities + +### New Capabilities +- `codebase-import-runtime`: Default ignore policy, warning surfaces, and dynamic progress estimation for `specfact code import` runtime scans. + +### Modified Capabilities +- `bundle-packaged-resources`: Project-bundle runtime generator templates become required packaged resources alongside prompts and other module-owned assets. + +## Impact + +- Affected code: `packages/specfact-project/` import runtime, scanners, analyzers, generator resource resolution, and bundled resources; `packages/specfact-codebase/` command entry points where import behavior is surfaced. +- Affected tests: targeted import-runtime, generator-resource, and bundle-payload coverage under `tests/`. +- Affected docs: codebase/project bundle import docs on modules.specfact.io, especially `docs/bundles/project/import-migration.md` and related workflow guidance. +- Release impact: patch version bumps and signature/registry updates for module artifacts whose manifests or packaged resources change. + +--- + +## Source Tracking + + +- **Modules Epic**: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162) +- **Parent Feature**: [#234](https://github.com/nold-ai/specfact-cli-modules/issues/234) +- **GitHub Issue**: [#235](https://github.com/nold-ai/specfact-cli-modules/issues/235) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false diff --git a/openspec/changes/codebase-import-runtime-hardening/specs/bundle-packaged-resources/spec.md b/openspec/changes/codebase-import-runtime-hardening/specs/bundle-packaged-resources/spec.md new file mode 100644 index 00000000..ed37ea57 --- /dev/null +++ b/openspec/changes/codebase-import-runtime-hardening/specs/bundle-packaged-resources/spec.md @@ -0,0 +1,19 @@ +# Requirement Changes + +## MODIFIED Requirements + +### Requirement: Official bundles SHALL ship module-owned resource payloads + +Each official bundle package SHALL include the prompt templates and other non-code resources that are owned by that bundle's workflows or commands. Bundle-owned resources SHALL not depend on fallback storage under the core CLI repository. + +#### Scenario: Project runtime generators ship required Jinja2 templates + +- **WHEN** the project bundle is built, installed, or imported from an editable checkout +- **THEN** every generator template referenced by the project runtime exists under the bundle-owned packaged resource paths +- **AND** runtime template lookup resolves those packaged templates without depending on files from the core CLI repository + +#### Scenario: Missing generator templates fail bundle payload validation + +- **WHEN** a required project runtime template such as `protocol.yaml.j2` or `github-action.yml.j2` is absent from the package payload +- **THEN** bundle resource validation SHALL fail before release +- **AND** the failure message SHALL name the missing template path so the packaging defect can be corrected before publish \ No newline at end of file diff --git a/openspec/changes/codebase-import-runtime-hardening/specs/codebase-import-runtime/spec.md b/openspec/changes/codebase-import-runtime-hardening/specs/codebase-import-runtime/spec.md new file mode 100644 index 00000000..07efb010 --- /dev/null +++ b/openspec/changes/codebase-import-runtime-hardening/specs/codebase-import-runtime/spec.md @@ -0,0 +1,57 @@ +# Codebase Import Runtime + +## ADDED Requirements + +### Requirement: Import runtime SHALL prune hidden and heavyweight trees before discovery + +`specfact code import` SHALL apply one deterministic ignore policy before any repository traversal phase counts, discovers, or analyzes candidate files. The default policy SHALL exclude dot-prefixed directories, virtual environments, build outputs, dependency caches, and other heavyweight artifact roots unless the user explicitly targets a path inside them. + +The implementation MUST apply ignore policies in the following explicit order: + +1. First, load the deterministic default ignore policy +2. Then, read and merge `.specfact/.specfactignore` patterns +3. Finally, apply explicit user target paths (which override ignored paths) + +#### Scenario: Default traversal skips hidden and heavyweight directories + +- **WHEN** the user runs `specfact code import` against a repository root that contains directories such as `.git/`, `.specfact/`, `.venv/`, `venv/`, `node_modules/`, `build/`, `dist/`, or `__pycache__/` +- **THEN** import discovery SHALL prune those directories before recursive traversal +- **AND** those files SHALL NOT contribute to scanned-file counts, analyzer inputs, or relationship extraction inputs + +#### Scenario: Repo-local ignore file extends the default policy + +- **WHEN** the repository contains `.specfact/.specfactignore` with additional ignore patterns +- **THEN** `specfact code import` SHALL merge those patterns with the default ignore policy for every traversal phase +- **AND** matching files or directories SHALL be pruned before traversal rather than filtered only after discovery + +### Requirement: Import runtime SHALL surface large-artifact warnings + +The import runtime SHALL warn when repository traversal encounters unusually large ignored artifact trees or unexpectedly high file volumes that are likely to dominate wall-clock time on slow environments. + +#### Scenario: Encountering a heavy artifact tree emits a warning + +- **WHEN** import discovery encounters an ignored directory whose file count crosses the runtime's heavy-artifact threshold +- **THEN** the command SHALL emit a warning that names the directory or pattern class +- **AND** the warning SHALL explain that the tree was ignored to avoid inflated import duration + +#### Scenario: Encountering unusually large candidate file volume emits a warning + +- **WHEN** the import runtime discovers a candidate file volume that exceeds its large-repository warning threshold after pruning ignored trees +- **THEN** the command SHALL warn that repository size and environment overhead can materially extend import duration +- **AND** it SHALL avoid promising a fixed-duration expectation such as "about five minutes" + +### Requirement: Import progress SHALL use discovered-versus-processed work + +Long-running import phases SHALL derive percentage and remaining-time feedback from the amount of real work discovered after ignore pruning, not from optimistic static estimates or totals that include skipped files. + +#### Scenario: Analyzer progress total reflects analyzable files only + +- **WHEN** the analyzer computes its progress task for a repository import +- **THEN** the total work units SHALL equal the filtered set of analyzable files after ignore pruning +- **AND** progress completion SHALL be able to reach 100 percent without stalling below the total because skipped files were counted + +#### Scenario: Remaining time updates from live discovered work + +- **WHEN** the command has discovered part of the repository and has processed a subset of that discovered work +- **THEN** the remaining-time display SHALL be derived from processed-versus-discovered work at the current runtime rate +- **AND** any early estimate before full discovery SHALL be labeled as provisional rather than a fixed promise \ No newline at end of file diff --git a/openspec/changes/codebase-import-runtime-hardening/tasks.md b/openspec/changes/codebase-import-runtime-hardening/tasks.md new file mode 100644 index 00000000..eebebce4 --- /dev/null +++ b/openspec/changes/codebase-import-runtime-hardening/tasks.md @@ -0,0 +1,32 @@ +# Tasks: codebase-import-runtime-hardening + +## 1. GitHub readiness and change scaffolding + +- [ ] 1.1 Verify `.specfact/backlog/github_hierarchy_cache.md` is fresh, confirm the new change fits the `specfact code` epic, and create or sync the parent Feature plus change issue with labels, project assignment, blockers, and concurrency checks recorded. +- [ ] 1.2 Add `codebase-import-runtime-hardening` to `openspec/CHANGE_ORDER.md` under the appropriate pending section with its parent Feature and GitHub issue links. +- [ ] 1.3 Validate the new change artifacts with `openspec validate codebase-import-runtime-hardening --strict` before implementation begins. + +## 2. Spec-first failing tests + +- [ ] 2.1 Add focused tests for import traversal defaults and warnings, including dot-prefixed directories, virtual environments, build outputs, `.specfact/.specfactignore`, and progress totals used for ETA reporting. +- [ ] 2.2 Add focused tests for packaged runtime generator templates so missing `.j2` resources fail before code changes. +- [ ] 2.3 Run the targeted tests to capture failing-before evidence and record the commands, timestamps, and failures in `openspec/changes/codebase-import-runtime-hardening/TDD_EVIDENCE.md`. + +## 3. Import runtime hardening + +- [ ] 3.1 Introduce a shared ignore-policy helper for code import traversal that prunes hidden and heavyweight directories before discovery while honoring repo-local `.specfact/.specfactignore`. +- [ ] 3.2 Apply that policy consistently across file counting, analyzer discovery, relationship extraction, and AI context loading, and surface warnings when unusually large artifact trees are skipped. +- [ ] 3.3 Replace fixed/optimistic ETA messaging with progress derived from discovered and processed work so reported remaining time tracks live import execution. + +## 4. Runtime resource packaging and docs + +- [ ] 4.1 Add the required project-bundle Jinja2 templates to packaged resources and resolve them from robust packaged/development paths in the affected generators. +- [ ] 4.2 Extend bundle payload tests and any manifest/resource metadata needed so shipped resources remain signed and versioned correctly. +- [ ] 4.3 Update user-facing docs for import defaults, ignore overrides, large-artifact warnings, and runtime template packaging expectations. + +## 5. Passing evidence, quality gates, and review + +- [ ] 5.1 Re-run the targeted import/runtime and resource tests, then record passing evidence in `openspec/changes/codebase-import-runtime-hardening/TDD_EVIDENCE.md`. +- [ ] 5.2 Run the required quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, `hatch run contract-test`, the relevant `hatch run smart-test`, and the relevant `hatch run test`. +- [ ] 5.3 Run `hatch run specfact code review run --bug-hunt --json --out .specfact/code-review.json --scope full`, fix every finding on modified artifacts, rerun until the report passes, and record the command and timestamp in `TDD_EVIDENCE.md`. +- [ ] 5.4 Commit, push, and open or update the PR to `dev` after verification is green. \ No newline at end of file diff --git a/openspec/changes/enterprise-01-module-policy-client/.openspec.yaml b/openspec/changes/enterprise-01-module-policy-client/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/enterprise-01-module-policy-client/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/enterprise-01-module-policy-client/design.md b/openspec/changes/enterprise-01-module-policy-client/design.md new file mode 100644 index 00000000..32e70fb3 --- /dev/null +++ b/openspec/changes/enterprise-01-module-policy-client/design.md @@ -0,0 +1,55 @@ +# Context + +Enterprise policy push belongs to a client-side module in this repo because the server component is explicitly out of scope for the five-pillar OpenSpec wave. This bundle needs to fetch signed payloads, cache them locally, and expose inspection commands while preserving the product’s no-op behavior for users who are not connected to an enterprise endpoint. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for `specfact-enterprise-policy`. +- Verify and cache pushed policy payloads before they affect local resolution behavior. +- Keep enterprise policy support additive so unconfigured users continue to operate normally. +- Provide deterministic local metadata showing when and how policy layers were applied. + +**Non-Goals:** + +- Implement the enterprise policy server itself. +- Replace the paired core resolution-chain semantics. +- Require enterprise connectivity for base product behavior. + +## Decisions + +### 1. Ship policy sync as an enterprise-only bundle + +- **Decision**: Create `packages/specfact-enterprise-policy/` with dedicated sync/status commands. +- **Why**: Enterprise policy pull should remain optional and separately installable from the base platform. +- **Alternative considered**: Fold policy sync into `specfact-govern`. Rejected because it mixes enterprise transport concerns into a general governance bundle. + +### 2. Verify before apply + +- **Decision**: The bundle validates signed payload metadata before cached policies influence local resolution. +- **Why**: Enterprise policy push is only trustworthy if the client can reject tampered or stale payloads. +- **Alternative considered**: Apply first and verify lazily. Rejected because it weakens the core governance posture. + +### 3. No configured endpoint means explicit no-op behavior + +- **Decision**: When enterprise configuration is absent, the bundle surfaces that status clearly and does not alter local resolution behavior. +- **Why**: Free-tier and offline users must not experience degraded UX because enterprise support exists. +- **Alternative considered**: Treat missing configuration as an error. Rejected because it violates additive-tier semantics. + +## Risks / Trade-offs + +- **Risk**: Policy payload caching can drift from server state. → **Mitigation**: cache metadata must include freshness and signature details, and sync/status commands expose drift clearly. +- **Risk**: Misconfiguration could create confusing mixed-mode behavior. → **Mitigation**: explicit status surfaces distinguish active, stale, and no-op states. +- **Risk**: Transport/security details could change with the eventual server implementation. → **Mitigation**: keep the bundle contract focused on verified payload consumption, not server internals. + +## Migration Plan + +1. Confirm the paired core resolution-chain extension is stable enough for integration. +2. Implement package structure, transport/cache helpers, and sync/status commands. +3. Publish docs, registry metadata, signatures, and compatibility range together. + +## Open Questions + +- Which transport/auth mechanism should be assumed in the initial client contract without overcommitting to server internals? +- How much offline cache retention policy needs to be codified in the first release? diff --git a/openspec/changes/enterprise-01-module-policy-client/proposal.md b/openspec/changes/enterprise-01-module-policy-client/proposal.md new file mode 100644 index 00000000..f2652179 --- /dev/null +++ b/openspec/changes/enterprise-01-module-policy-client/proposal.md @@ -0,0 +1,44 @@ +# Change: Enterprise Policy Client Module + +## Why + +The enterprise add-on requires a client-side runtime that can fetch, verify, cache, and apply pushed policy layers without turning the base product into a server-dependent system. Without a dedicated policy-client bundle, the extended resolution chain remains unimplemented for enterprise users. + +## What Changes + +- **NEW**: Add a `specfact-enterprise-policy` bundle with commands for syncing and inspecting enterprise policy payloads. +- **NEW**: Package a signed policy-fetch and cache client that merges org-mandatory and team-advisory layers into the local resolution chain. +- **NEW**: Add graceful no-op behavior when no enterprise endpoint is configured so free-tier and offline users are unaffected. +- **NEW**: Define deterministic local cache and verification metadata for pushed policy payloads. +- **EXTEND**: Reserve manifest, registry, docs, and signing work for a first-party enterprise policy client bundle. + +## Capabilities + +### New Capabilities + +- `enterprise-policy-client`: Runtime bundle, signed policy sync, cache management, and inspection surfaces for enterprise policy layers. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-enterprise-policy/`, policy transport/cache helpers, and sync/status commands. +- Affected docs: bundle overview and command-reference documentation for enterprise policy sync. +- Dependencies: paired core change `enterprise-01-policy-resolution-extension`. +- Release impact: introduces a new signed official bundle and registry entry intended for enterprise deployments. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#222](https://github.com/nold-ai/specfact-cli-modules/issues/222) +- **GitHub Issue**: [#231](https://github.com/nold-ai/specfact-cli-modules/issues/231) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false diff --git a/openspec/changes/enterprise-01-module-policy-client/specs/enterprise-policy-client/spec.md b/openspec/changes/enterprise-01-module-policy-client/specs/enterprise-policy-client/spec.md new file mode 100644 index 00000000..faac9853 --- /dev/null +++ b/openspec/changes/enterprise-01-module-policy-client/specs/enterprise-policy-client/spec.md @@ -0,0 +1,30 @@ +# Enterprise Policy Client + +## ADDED Requirements + +### Requirement: Enterprise policy bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-enterprise-policy` that exposes enterprise policy sync commands and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` for the paired core enterprise policy contracts. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the enterprise policy bundle is discoverable as an official package with sync/status commands and valid compatibility metadata + +### Requirement: Policy payloads are verified before use + +The bundle SHALL verify payload integrity and freshness metadata before cached enterprise policy layers influence local resolution behavior. + +#### Scenario: A signed policy payload is fetched + +- **WHEN** the bundle downloads a policy payload from a configured enterprise endpoint +- **THEN** it validates the payload metadata before storing it in the local cache or marking the policy layer as active + +### Requirement: Missing enterprise configuration is a no-op state + +The bundle SHALL surface a clear no-op status when no enterprise endpoint is configured and SHALL not modify local policy resolution in that state. + +#### Scenario: No enterprise endpoint is configured + +- **WHEN** a user runs the enterprise policy status command without enterprise configuration +- **THEN** the bundle reports that enterprise policy sync is inactive and leaves local resolution behavior unchanged \ No newline at end of file diff --git a/openspec/changes/enterprise-01-module-policy-client/tasks.md b/openspec/changes/enterprise-01-module-policy-client/tasks.md new file mode 100644 index 00000000..8f80d72e --- /dev/null +++ b/openspec/changes/enterprise-01-module-policy-client/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/enterprise-01-module-policy-client` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm paired core change `enterprise-01-policy-resolution-extension` is available and document the minimum required `core_compatibility`. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/enterprise-policy-client/spec.md`. +- [ ] 2.2 Write failing tests for payload verification, cache freshness handling, and no-op behavior without enterprise configuration. +- [ ] 2.3 Write failing tests for sync/status command output and local policy-layer merge behavior. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/enterprise-01-module-policy-client/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-enterprise-policy/` with manifest, Typer entrypoints, transport/cache helpers, and verification utilities. +- [ ] 3.2 Implement signed payload fetch, verification, cache storage, and inspection commands that align with the paired core resolution-chain contracts. +- [ ] 3.3 Integrate no-op behavior for unconfigured environments plus deterministic local metadata for applied policy layers. +- [ ] 3.4 Update registry metadata, docs references, signing inputs, and any import allowlists required by the new bundle. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate enterprise-01-module-policy-client --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link the paired core enterprise change, and note any deferred transport/auth decisions as follow-up issues. diff --git a/openspec/changes/enterprise-02-module-audit-client/.openspec.yaml b/openspec/changes/enterprise-02-module-audit-client/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/enterprise-02-module-audit-client/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/enterprise-02-module-audit-client/design.md b/openspec/changes/enterprise-02-module-audit-client/design.md new file mode 100644 index 00000000..3651b000 --- /dev/null +++ b/openspec/changes/enterprise-02-module-audit-client/design.md @@ -0,0 +1,57 @@ +# Design + +## Context + +The five-pillar enterprise design keeps server contracts out of scope for this wave, but it still needs a client-side audit bundle that can prepare, queue, sign, and inspect governance events. This module must interoperate with the paired core audit schema and with the enterprise policy client while remaining safe for disconnected or partially configured environments. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for `specfact-enterprise-audit`. +- Emit signed, privacy-aware audit events aligned with the paired core schema. +- Support local queueing and retry/inspection behavior for events that cannot be delivered immediately. +- Keep event metadata structured enough for later reconciliation and drift analytics. + +**Non-Goals:** + +- Implement the enterprise audit service or server-side storage. +- Replace the paired core audit-event schema or RBAC semantics. +- Require immediate network delivery for every governance action. + +## Decisions + +### 1. Ship audit emission as its own enterprise bundle + +- **Decision**: Create `packages/specfact-enterprise-audit/` with dedicated emission and inspection commands. +- **Why**: Audit transport, buffering, and event privacy are separate concerns from policy sync or base governance logic. +- **Alternative considered**: Combine audit behavior with the enterprise policy bundle. Rejected because it couples two independently deployable surfaces. + +### 2. Queue first, deliver later when necessary + +- **Decision**: The bundle records deterministic local queue metadata for events that cannot be delivered immediately. +- **Why**: Enterprise governance actions must not be lost because a remote endpoint is temporarily unavailable. +- **Alternative considered**: Fail the originating action whenever the audit backend is unavailable. Rejected because it creates brittle runtime behavior for disconnected workflows. + +### 3. Keep payloads privacy-aware by design + +- **Decision**: Audit payloads include structured event metadata and references, not raw sensitive content. +- **Why**: Audit surfaces must preserve accountability without leaking governed data. +- **Alternative considered**: Store raw payload snapshots for debugging. Rejected because it conflicts with the security/privacy posture of the broader platform. + +## Risks / Trade-offs + +- **Risk**: Local queue growth may become operationally noisy. → **Mitigation**: provide inspection commands and deterministic receipt/retention metadata. +- **Risk**: Delivery semantics may evolve with the future server implementation. → **Mitigation**: keep this change focused on client-side event preparation and queueing contracts. +- **Risk**: Overly sparse payloads could weaken audit usefulness. → **Mitigation**: preserve event type, actor role, target references, and source rule/policy identifiers in the schema mapping. + +## Migration Plan + +1. Confirm the paired core audit/RBAC contracts are stable enough for module integration. +2. Implement package structure, signing helpers, queue storage, and inspection commands. +3. Publish docs, registry metadata, signatures, and compatibility range together. + +## Open Questions + +- Which queue storage format best balances resilience and inspectability for the initial release? +- How should retry cadence and dead-letter behavior be surfaced in the first client contract? \ No newline at end of file diff --git a/openspec/changes/enterprise-02-module-audit-client/proposal.md b/openspec/changes/enterprise-02-module-audit-client/proposal.md new file mode 100644 index 00000000..5238cae5 --- /dev/null +++ b/openspec/changes/enterprise-02-module-audit-client/proposal.md @@ -0,0 +1,44 @@ +# Change: Enterprise Audit Client Module + +## Why + +The enterprise add-on needs a client-side runtime that emits signed audit events for rule promotion, policy sync, approvals, and related governance actions. Without a dedicated audit bundle, enterprise RBAC and audit-trail contracts cannot be exercised by the modules-side runtime. + +## What Changes + +- **NEW**: Add a `specfact-enterprise-audit` bundle with commands and helpers for audit event emission and queue inspection. +- **NEW**: Package signed audit-event preparation, buffering, and retry behavior compatible with the paired core audit schema. +- **NEW**: Add privacy-aware handling so audit payloads carry event metadata without leaking restricted content. +- **NEW**: Define deterministic local queue/receipt metadata for events awaiting delivery or reconciliation. +- **EXTEND**: Reserve manifest, registry, docs, and signing work for a first-party enterprise audit bundle. + +## Capabilities + +### New Capabilities + +- `enterprise-audit-client`: Runtime bundle, signed audit event emission, and local queue/inspection surfaces for enterprise governance actions. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-enterprise-audit/`, signing/queue helpers, and audit inspection commands. +- Affected docs: bundle overview and command-reference documentation for enterprise audit workflows. +- Dependencies: paired core change `enterprise-02-rbac-and-audit-trail`; related sequencing from `enterprise-01-module-policy-client`. +- Release impact: introduces a new signed official bundle and registry entry intended for enterprise deployments. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#222](https://github.com/nold-ai/specfact-cli-modules/issues/222) +- **GitHub Issue**: [#232](https://github.com/nold-ai/specfact-cli-modules/issues/232) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false diff --git a/openspec/changes/enterprise-02-module-audit-client/specs/enterprise-audit-client/spec.md b/openspec/changes/enterprise-02-module-audit-client/specs/enterprise-audit-client/spec.md new file mode 100644 index 00000000..ecb95986 --- /dev/null +++ b/openspec/changes/enterprise-02-module-audit-client/specs/enterprise-audit-client/spec.md @@ -0,0 +1,30 @@ +# Enterprise Audit Client Module Specification + +## ADDED Requirements + +### Requirement: Enterprise audit bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-enterprise-audit` that exposes audit-related commands and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` entries. **Note**: The paired core change `enterprise-02-rbac-and-audit-trail` (enterprise RBAC, audit trail, and audit-event contracts) does not currently exist; implementation is blocked until the paired core change is proposed and merged into `specfact-cli`. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the enterprise audit bundle is discoverable as an official package with audit commands and valid compatibility metadata + +### Requirement: Audit events are signed and privacy-aware + +The bundle SHALL serialize governance actions into the audit-event schema defined by `enterprise-02-rbac-and-audit-trail`, sign the event payloads, and avoid storing raw restricted content in emitted or queued events. + +#### Scenario: A governance action triggers audit emission + +- **WHEN** the bundle prepares an audit event for a governed action such as rule promotion or approval +- **THEN** it emits a signed, privacy-aware event payload aligned with `enterprise-02-rbac-and-audit-trail` + +### Requirement: Audit delivery supports local queue inspection + +The bundle SHALL persist deterministic local queue metadata for audit events that are not yet delivered and SHALL expose inspection or retry commands for those queued events. + +#### Scenario: Event delivery is temporarily unavailable + +- **WHEN** an audit event cannot be delivered immediately +- **THEN** the bundle records the event in a local queue with deterministic receipt metadata and makes it visible to inspection or retry commands \ No newline at end of file diff --git a/openspec/changes/enterprise-02-module-audit-client/tasks.md b/openspec/changes/enterprise-02-module-audit-client/tasks.md new file mode 100644 index 00000000..dfdd2c66 --- /dev/null +++ b/openspec/changes/enterprise-02-module-audit-client/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/enterprise-02-module-audit-client` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm paired core change `enterprise-02-rbac-and-audit-trail` and upstream sequencing from `enterprise-01-module-policy-client` are available and document the minimum required `core_compatibility`. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/enterprise-audit-client/spec.md`. +- [ ] 2.2 Write failing tests for audit payload signing, privacy-aware serialization, local queue persistence, and retry/inspection behavior. +- [ ] 2.3 Write failing tests for governance-action mappings into the paired core audit schema. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/enterprise-02-module-audit-client/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-enterprise-audit/` with manifest, Typer entrypoints, queue helpers, and signing utilities. +- [ ] 3.2 Implement audit event preparation and queueing aligned with the paired core audit contracts. +- [ ] 3.3 Add inspection/retry commands plus deterministic local receipt metadata without requiring immediate network delivery. +- [ ] 3.4 Update registry metadata, docs references, signing inputs, and any import allowlists required by the new bundle. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate enterprise-02-module-audit-client --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link paired enterprise changes, and note any deferred delivery/reconciliation behavior as follow-up issues. diff --git a/openspec/changes/finops-01-module-cost-outcome/.openspec.yaml b/openspec/changes/finops-01-module-cost-outcome/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/finops-01-module-cost-outcome/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/finops-01-module-cost-outcome/design.md b/openspec/changes/finops-01-module-cost-outcome/design.md new file mode 100644 index 00000000..576afc82 --- /dev/null +++ b/openspec/changes/finops-01-module-cost-outcome/design.md @@ -0,0 +1,55 @@ +# Context + +FinOps reporting depends on core-owned schemas and budget semantics, but the modules repo must package the runtime collectors and classifiers that convert actual session data into evidence. This bundle has to work for both networked provider APIs and local/offline workflows without turning the feature into a cloud-only surface. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for `specfact-finops`. +- Collect provider token/cost metadata and normalize it into the paired core FinOps evidence contract. +- Classify outcomes using workflow signals that the rest of the ecosystem can consume deterministically. +- Keep offline and air-gapped workflows supported through explicit local fallback behavior. + +**Non-Goals:** + +- Implement org-level approval workflows; those extend through paired core enterprise changes. +- Replace the paired core telemetry schema or budget gate logic. +- Require every provider to expose identical billing APIs in the first release. + +## Decisions + +### 1. Make provider collectors pluggable behind one bundle + +- **Decision**: Ship `specfact-finops` as one bundle with provider-specific collector adapters and a shared normalization layer. +- **Why**: Users need one command surface even when providers differ behind the scenes. +- **Alternative considered**: One bundle per provider. Rejected because it fragments the FinOps workflow and complicates outcome rollups. + +### 2. Keep outcome classification explicit and deterministic + +- **Decision**: The bundle derives outcomes from explicit workflow signals and shared enums rather than heuristic free-form summaries. +- **Why**: Distillation and budget analytics depend on stable outcome categories. +- **Alternative considered**: LLM-generated outcome labels. Rejected because it undermines determinism for the core evidence schema. + +### 3. Support offline-safe collection paths + +- **Decision**: When provider billing APIs are unavailable, the bundle can still emit evidence using local token accounting and explicit `source` metadata. +- **Why**: The platform’s local-first posture should not make FinOps unavailable to offline users. +- **Alternative considered**: Require remote billing APIs for every report. Rejected because it excludes local and air-gapped workflows. + +## Risks / Trade-offs + +- **Risk**: Billing APIs differ and may lag behind model usage events. → **Mitigation**: preserve source metadata and test adapters independently from normalization logic. +- **Risk**: Outcome classification may miss workflow nuance. → **Mitigation**: keep enums explicit and allow later paired-core extensions instead of overfitting this first change. +- **Risk**: Offline fallbacks may be mistaken for authoritative billed cost. → **Mitigation**: require evidence metadata to distinguish estimated vs provider-reported cost. + +## Migration Plan + +1. Confirm paired core telemetry/FinOps contracts are stable enough for integration. +2. Implement package structure, collectors, and classifier adapters with fixture-based tests; ship a truthful registry entry, `module-package.yaml`, signing metadata, and docs parity with `modules.specfact.io`; tests SHALL assert `module-package.yaml` exists and validates required adapter-boundary fields alongside collectors and classifier adapters. +3. Publish documentation to the canonical modules site (`modules.specfact.io`) with docs parity alongside registry metadata, `module-package.yaml`, signatures, compatibility ranges, and adapter-boundary declarations before release. + +## Open Questions + +- Which provider APIs should be first-class in the initial release? +- What is the minimum viable workflow signal set for reliable outcome classification on day one? diff --git a/openspec/changes/finops-01-module-cost-outcome/proposal.md b/openspec/changes/finops-01-module-cost-outcome/proposal.md new file mode 100644 index 00000000..6766d62d --- /dev/null +++ b/openspec/changes/finops-01-module-cost-outcome/proposal.md @@ -0,0 +1,44 @@ +# Change: FinOps Cost And Outcome Module + +## Why + +The core repo can define session evidence and budget contracts, but users still need a runtime bundle that collects cost and token data, classifies outcomes, and writes reusable evidence. Without a module-side implementation, the FinOps pillar cannot measure whether the platform is actually improving efficiency over time. + +## What Changes + +- **NEW**: Add a `specfact-finops` bundle with a `finops` command surface for collection, classification, and reporting. +- **NEW**: Package cost collectors for supported LLM providers plus a local-friendly fallback path for air-gapped or inference-only environments. +- **NEW**: Add an outcome classifier that maps session telemetry and downstream workflow results into the paired core outcome taxonomy. +- **NEW**: Emit evidence files compatible with the paired core FinOps schema and the knowledge distillation loop. +- **EXTEND**: Reserve manifest, registry, docs, and signing work for a first-party FinOps bundle. + +## Capabilities + +### New Capabilities + +- `finops-cost-outcome-module`: Runtime bundle, collectors, classifiers, and reporting surfaces for FinOps evidence generation. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-finops/`, provider adapters, and evidence/report helpers. +- Affected docs: bundle overview and command-reference documentation for `finops`. +- Dependencies: paired core changes `telemetry-01-opentelemetry-default-on` and `finops-01-telemetry-and-outcomes`. +- Release impact: introduces a new signed official bundle and registry entry. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#220](https://github.com/nold-ai/specfact-cli-modules/issues/220) +- **GitHub Issue**: [#223](https://github.com/nold-ai/specfact-cli-modules/issues/223) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false diff --git a/openspec/changes/finops-01-module-cost-outcome/specs/finops-cost-outcome-module/spec.md b/openspec/changes/finops-01-module-cost-outcome/specs/finops-cost-outcome-module/spec.md new file mode 100644 index 00000000..de6cb10d --- /dev/null +++ b/openspec/changes/finops-01-module-cost-outcome/specs/finops-cost-outcome-module/spec.md @@ -0,0 +1,30 @@ +# FinOps Cost and Outcome Module Specification + +## ADDED Requirements + +### Requirement: FinOps bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-finops` that exposes the `finops` command and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` for the paired core FinOps evidence contract from `finops-01-telemetry-and-outcomes`. The bundle manifest SHALL explicitly list `telemetry-01-opentelemetry-default-on` as a sequencing prerequisite in the manifest's `core_compatibility` or dependency metadata fields, as documented in `openspec/CHANGE_ORDER.md`. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the FinOps bundle is discoverable as an official package with the `finops` command and valid compatibility metadata + +### Requirement: Collectors emit normalized FinOps evidence + +The bundle SHALL gather provider-reported or local fallback token/cost data and emit evidence files through the FinOps evidence contract from `finops-01-telemetry-and-outcomes` rather than a bundle-local schema. + +#### Scenario: Provider cost data is available + +- **WHEN** the bundle receives provider token and billing data for a session +- **THEN** it writes normalized FinOps evidence with cost, token, source, and outcome fields compatible with the paired core schema + +### Requirement: Outcome classification is deterministic + +The bundle SHALL classify sessions into the outcome enum published with `finops-01-telemetry-and-outcomes` using explicit workflow signals and SHALL not require LLM-generated free-form summaries for outcome selection. + +#### Scenario: A session leads to rule promotion + +- **WHEN** the bundle observes workflow signals showing a completed distillation cycle that promoted a rule +- **THEN** it records the session outcome as `rule-updated` in the normalized FinOps evidence \ No newline at end of file diff --git a/openspec/changes/finops-01-module-cost-outcome/tasks.md b/openspec/changes/finops-01-module-cost-outcome/tasks.md new file mode 100644 index 00000000..1d05db6d --- /dev/null +++ b/openspec/changes/finops-01-module-cost-outcome/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/finops-01-module-cost-outcome` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm paired core changes `telemetry-01-opentelemetry-default-on` and `finops-01-telemetry-and-outcomes` are available and document the minimum required `core_compatibility`. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/finops-cost-outcome-module/spec.md`. +- [ ] 2.2 Write failing tests for provider cost normalization, local fallback accounting, and evidence-file generation. +- [ ] 2.3 Write failing tests for deterministic outcome classification and report rendering. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/finops-01-module-cost-outcome/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-finops/` with manifest, Typer entrypoints, provider adapters, and reporting helpers. +- [ ] 3.2 Implement collectors that normalize cost/token data into the paired core FinOps evidence/report contracts. +- [ ] 3.3 Implement deterministic outcome classification plus evidence write paths compatible with downstream knowledge and enterprise analytics. +- [ ] 3.4 Update registry metadata, docs references, and any import allowlists required by the new bundle. Note: signature artifacts are validated by CI gates and produced by publish automation; do not manually commit signature files. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate finops-01-module-cost-outcome --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link paired telemetry/FinOps changes, and note any deferred provider integrations as follow-up issues. \ No newline at end of file diff --git a/openspec/changes/knowledge-01-module-memory-runtime/.openspec.yaml b/openspec/changes/knowledge-01-module-memory-runtime/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/knowledge-01-module-memory-runtime/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/knowledge-01-module-memory-runtime/design.md b/openspec/changes/knowledge-01-module-memory-runtime/design.md new file mode 100644 index 00000000..e7e932af --- /dev/null +++ b/openspec/changes/knowledge-01-module-memory-runtime/design.md @@ -0,0 +1,55 @@ +# Context + +Knowledge distillation needs a module-side runtime that gives users a default, local-first place to store evidence, learnings, and rules. The paired core change defines the schema and protocol, but the modules repo owns the package, command surface, and filesystem behavior that make the feature usable without enterprise infrastructure or vector databases. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for `specfact-knowledge`. +- Ship the markdown-graph backend as the default runtime implementation of the paired core `MemoryBackend` protocol. +- Define local filesystem layout and gitignore expectations for evidence, learnings, rules, and graph metadata. +- Keep optional vector-store or remote adapters out of the correctness-critical path. + +**Non-Goals:** + +- Replace the paired core knowledge schema or promotion policy. +- Require Chroma or any embedding/vector backend for the default workflow. +- Implement writeback into instruction surfaces; that belongs in `knowledge-02-module-writeback`. + +## Decisions + +### 1. Make markdown-graph the reference runtime + +- **Decision**: `specfact-knowledge` ships the markdown-graph backend as the default implementation and first-class command surface. +- **Why**: The platform’s local-first posture requires a zero-config backend that works in git. +- **Alternative considered**: Make a vector database the default. Rejected because it adds operational complexity to the base workflow. + +### 2. Separate memory storage from writeback automation + +- **Decision**: This bundle owns storage, search, and distillation entrypoints, while writeback into prompts/docs remains a separate bundle. +- **Why**: The runtime should stay useful even when users do not want automation rewriting instruction surfaces. +- **Alternative considered**: Combine runtime and writeback in one bundle. Rejected because it couples storage to optional automation. + +### 3. Treat `.specfact/memory/` as a structured contract + +- **Decision**: The bundle defines deterministic subdirectories and file naming for evidence, learnings, rules, and graph metadata. +- **Why**: Repeatable distillation and review depend on predictable layout. +- **Alternative considered**: Allow arbitrary user-defined layouts in the initial release. Rejected because it weakens interoperability with the paired core tooling. + +## Risks / Trade-offs + +- **Risk**: Large repositories could generate a noisy local memory tree. → **Mitigation**: define gitignore defaults and command filters early. +- **Risk**: Search quality without embeddings may be limited. → **Mitigation**: keep tag/keyword search deterministic now and leave richer retrieval to future optional adapters. +- **Risk**: Users may expect automatic writeback after distillation. → **Mitigation**: document the split between runtime storage and the follow-on writeback bundle. + +## Migration Plan + +1. Confirm the paired core knowledge schema and protocol are stable enough for runtime integration. **Note**: The `MemoryBackend` protocol must either be published in `specfact-cli` or added to the paired core change scope. This design document must explicitly point to the published spec (or pairing ticket) with the required stability guarantee before module implementation. +2. Implement package structure, markdown-graph backend, and CLI entrypoints. +3. Publish docs, registry metadata, signatures, and compatibility range together. + +## Open Questions + +- Which graph metadata files belong under version control vs ignore defaults in the first release? +- Should search support only tags and keywords initially, or also relationship traversal commands? \ No newline at end of file diff --git a/openspec/changes/knowledge-01-module-memory-runtime/proposal.md b/openspec/changes/knowledge-01-module-memory-runtime/proposal.md new file mode 100644 index 00000000..d3a61dbc --- /dev/null +++ b/openspec/changes/knowledge-01-module-memory-runtime/proposal.md @@ -0,0 +1,44 @@ +# Change: Knowledge Memory Runtime Module + +## Why + +The core repo can define knowledge schemas and memory protocols, but the modules repo needs a concrete runtime bundle that owns the filesystem layout, command surfaces, and default backend behavior. Without a bundle-side implementation, the distillation loop cannot actually collect evidence, search memory, or promote learnings in day-to-day workflows. + +## What Changes + +- **NEW**: Add a `specfact-knowledge` bundle with `memory` subcommands for logging, searching, promoting, and distilling. +- Package the markdown-graph backend as the default runtime implementation for evidence, learnings, and rules under `.specfact/memory/`. +- Define gitignore, repo-layout, and file-lifecycle expectations for evidence, learnings, rules, and generated graph metadata. +- Add search and status surfaces that expose the paired core `MemoryBackend` behavior without requiring a vector store. +- **EXTEND**: Reserve manifest, registry, docs, and signing work for a first-party knowledge bundle. + +## Capabilities + +### New Capabilities + +- `knowledge-memory-runtime`: Runtime bundle, command surfaces, and default markdown-graph backend for SpecFact memory workflows. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-knowledge/`, filesystem backend helpers, and command resources. +- Affected docs: bundle overview and command-reference documentation for `memory`. +- Dependencies: paired core change `knowledge-01-distillation-engine`. **Note**: The paired core change folder `openspec/changes/knowledge-01-distillation-engine` must be created and merged into `specfact-cli` before proceeding with module implementation. +- Release impact: introduces a new signed official bundle and registry entry. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#221](https://github.com/nold-ai/specfact-cli-modules/issues/221) +- **GitHub Issue**: [#224](https://github.com/nold-ai/specfact-cli-modules/issues/224) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/knowledge-01-module-memory-runtime/specs/knowledge-memory-runtime/spec.md b/openspec/changes/knowledge-01-module-memory-runtime/specs/knowledge-memory-runtime/spec.md new file mode 100644 index 00000000..2487275d --- /dev/null +++ b/openspec/changes/knowledge-01-module-memory-runtime/specs/knowledge-memory-runtime/spec.md @@ -0,0 +1,30 @@ +# Knowledge Memory Runtime + +## ADDED Requirements + +### Requirement: Knowledge bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-knowledge` that exposes memory-related commands and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` for the paired core knowledge contracts. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the knowledge bundle is discoverable as an official package with memory commands and valid compatibility metadata + +### Requirement: Markdown-graph is the default runtime backend + +The bundle SHALL implement the paired core `MemoryBackend` protocol using a markdown-graph backend rooted at `.specfact/memory/` and SHALL not require a vector store for correctness. + +#### Scenario: A repository initializes the memory runtime + +- **WHEN** a user runs the knowledge bundle in a repository with no existing memory layout +- **THEN** the bundle creates the expected `.specfact/memory/` structure and uses the markdown-graph backend as the default runtime + +### Requirement: Memory commands manage evidence, learnings, and rules deterministically + +The bundle SHALL expose commands that add, search, promote, and inspect memory content using the paired core schemas and deterministic filesystem behavior. + +#### Scenario: Evidence is recorded from a review workflow + +- **WHEN** a workflow records a new evidence item through the knowledge bundle +- **THEN** the bundle stores it using the paired core schema in the expected memory directory and makes it available to subsequent search/status commands \ No newline at end of file diff --git a/openspec/changes/knowledge-01-module-memory-runtime/tasks.md b/openspec/changes/knowledge-01-module-memory-runtime/tasks.md new file mode 100644 index 00000000..d6ed656a --- /dev/null +++ b/openspec/changes/knowledge-01-module-memory-runtime/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/knowledge-01-module-memory-runtime` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 On the paired `nold-ai/specfact-cli` branch that will ship with this module, verify `openspec/changes/knowledge-01-distillation-engine` exists (or record the authoritative renamed folder if upstream differs), then document the minimum required `core_compatibility` and cross-link the umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) in `TDD_EVIDENCE.md` during bootstrap. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/knowledge-memory-runtime/spec.md`. +- [ ] 2.2 Write failing tests for filesystem layout creation, markdown-graph indexing, search behavior, and command registration. +- [ ] 2.3 Write failing tests for evidence, learning, and rule file lifecycle handling under `.specfact/memory/`. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/knowledge-01-module-memory-runtime/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-knowledge/` with manifest, Typer entrypoints, filesystem backend helpers, and default resources. +- [ ] 3.2 Implement the markdown-graph backend and CLI commands that satisfy the paired core `MemoryBackend` expectations. +- [ ] 3.3 Add deterministic repo-layout, gitignore, search, and status behavior for memory content without introducing a required vector store. +- [ ] 3.4 Coordinate registry, signing, docs, and allowlists in one delivery slice: add the bundle to the official modules registry with truthful `bundle_dependencies`; publish or refresh the public `knowledge` command docs on `modules.specfact.io`; produce a signed bundle artifact with a version bump and updated signature verification; update import allowlists for `packages/specfact-knowledge/` dependencies; and update `module-package.yaml` plus signing metadata so registry, signing inputs, docs, and allowlists stay aligned with the migration plan. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate knowledge-01-module-memory-runtime --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link the paired core knowledge change, and note any deferred optional adapters as follow-up issues. diff --git a/openspec/changes/knowledge-02-module-writeback/.openspec.yaml b/openspec/changes/knowledge-02-module-writeback/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/knowledge-02-module-writeback/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/knowledge-02-module-writeback/design.md b/openspec/changes/knowledge-02-module-writeback/design.md new file mode 100644 index 00000000..ac4c55a6 --- /dev/null +++ b/openspec/changes/knowledge-02-module-writeback/design.md @@ -0,0 +1,57 @@ +# Design: knowledge-02-module-writeback + +## Context + +The knowledge runtime stores and distills evidence, but teams also need a controlled way to project approved rules into repo-visible instruction surfaces. This change defines a separate bundle that keeps writeback opt-in, previewable, and traceable while depending on the core preflight/change-assembly work and the base knowledge runtime. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for `specfact-knowledge-writeback`. +- Keep writeback targets explicit, previewable, and reversible at the file level. +- Reuse approved memory rules from the base knowledge runtime instead of duplicating storage logic. +- Record deterministic metadata describing which rules were projected into which targets. + +**Non-Goals:** + +- Replace the base memory runtime or distillation engine. +- Automatically mutate instruction surfaces without preview or user selection. +- Introduce repo-hosted secrets or external service dependencies as a requirement. + +## Decisions + +### 1. Separate writeback from storage/runtime + +- **Decision**: Ship writeback automation as its own bundle, not inside `specfact-knowledge`. +- **Why**: Many users want memory storage without automatic prompt or docs regeneration. +- **Alternative considered**: Put writeback inside the base knowledge bundle. Rejected because it couples storage to optional automation. + +### 2. Require previewable target adapters + +- **Decision**: Every writeback target must support preview or draft generation before any file mutation or comment-post preparation. +- **Why**: Prompt and instruction surfaces are high-sensitivity files and need reviewable changes. +- **Alternative considered**: Direct in-place regeneration by default. Rejected because it violates the repo’s safety posture. + +### 3. Track writeback through deterministic manifests + +- **Decision**: The bundle emits metadata describing source rules, target type, target path, and generation timestamps for each writeback attempt. +- **Why**: Later drift analysis and review need to know how instruction surfaces were derived. +- **Alternative considered**: Rely only on git diffs. Rejected because it loses structured lineage data. + +## Risks / Trade-offs + +- **Risk**: Writeback templates may oversimplify nuanced rules. → **Mitigation**: preserve source rule references in output manifests and previews. +- **Risk**: File-target coverage could grow too broad too early. → **Mitigation**: start with named adapters and document that new targets require explicit approval. +- **Risk**: Users may assume writeback implies deployment or remote sync. → **Mitigation**: keep outputs local and draft-oriented in the initial release. + +## Migration Plan + +1. Confirm paired core and base knowledge runtime changes are stable enough for integration. +2. Implement package structure, target adapters, and preview/draft flows. +3. Publish docs, registry metadata, signatures, and compatibility range together. + +## Open Questions + +- Which writeback target should be considered the canonical first-class path in the initial release? +- Should CodeRabbit output stop at draft comment bodies, or also generate machine-readable metadata for later posting? \ No newline at end of file diff --git a/openspec/changes/knowledge-02-module-writeback/proposal.md b/openspec/changes/knowledge-02-module-writeback/proposal.md new file mode 100644 index 00000000..54660d44 --- /dev/null +++ b/openspec/changes/knowledge-02-module-writeback/proposal.md @@ -0,0 +1,52 @@ +# Change: Knowledge Writeback Module + +## Why + +Distilled rules only create leverage if they are written back into the instruction surfaces that guide future AI and review workflows. A dedicated writeback bundle lets users opt into regenerating prompt files and drafting memory-sharing comments without forcing those behaviors into the base knowledge runtime. + +## What Changes + +- **NEW**: Add a `specfact-knowledge-writeback` bundle with commands that regenerate selected instruction surfaces from approved memory rules. +- **NEW**: Package adapters for `BUGBOT.md`, `.github/copilot-instructions.md`, and CodeRabbit memory-comment drafts as opt-in writeback targets. +- **NEW**: Require preview/dry-run output and explicit destination targeting so writeback remains reviewable and safe for repo owners. +- **NEW**: Normalize selected rule metadata into deterministic writeback manifests that can be audited later. +- **EXTEND**: Reserve manifest, registry, docs, and signing work for a first-party writeback bundle. + +### Writeback Interface + +The adapter boundary between writeback commands and the core memory runtime defines how approved rules are projected into instruction surfaces: + +- **Adapter Method Signature**: Each writeback adapter implements a standard method accepting rule identifiers, destination path, and dry-run flag +- **Manifest Schema Fields**: `ruleIds` (list of rule identifiers), `ruleVersions` (semantic versions or commit hashes), `inputDigest` (hash of source rules), `outputDigest` (hash of generated output), `timestamp` (ISO 8601 generation time), `adapterId` (writeback target type), `destination` (file path or endpoint), `previewHash` (preview content hash for verification), `dryRunFlag` (boolean indicating preview-only mode) + +## Capabilities + +### New Capabilities + +- `knowledge-writeback`: Runtime bundle, previewable writeback targets, and deterministic output manifests for memory-driven instruction updates. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-knowledge-writeback/`, target adapters, and preview/output helpers. +- Affected docs: bundle overview and command-reference documentation for writeback commands. +- Dependencies: paired core change `knowledge-02-preflight-context-assembly`; module dependency `knowledge-01-module-memory-runtime`. +- Release impact: introduces a new signed official bundle and registry entry. +- **Reserved Manifest Metadata**: The bundle declares `bundle_dependencies: ["knowledge-01-module-memory-runtime"]` with semver range `">=1.0.0 <2.0.0"`, and `core_compatibility: ">=1.0.0 <2.0.0"` for the paired core writeback contracts in `module-package.yaml`. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#221](https://github.com/nold-ai/specfact-cli-modules/issues/221) +- **GitHub Issue**: [#225](https://github.com/nold-ai/specfact-cli-modules/issues/225) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/knowledge-02-module-writeback/specs/knowledge-writeback/spec.md b/openspec/changes/knowledge-02-module-writeback/specs/knowledge-writeback/spec.md new file mode 100644 index 00000000..16be6629 --- /dev/null +++ b/openspec/changes/knowledge-02-module-writeback/specs/knowledge-writeback/spec.md @@ -0,0 +1,30 @@ +# Knowledge Writeback Module Specification + +## ADDED Requirements + +### Requirement: Writeback bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-knowledge-writeback` that exposes writeback commands and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` for the paired core knowledge contracts. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the writeback bundle is discoverable as an official package with writeback commands and valid compatibility metadata + +### Requirement: Every writeback target supports preview before mutation + +The bundle SHALL generate previews or drafts for every supported writeback target before applying any file mutation or external-comment payload preparation. + +#### Scenario: A user selects a file target for writeback + +- **WHEN** the user requests writeback into an instruction file +- **THEN** the bundle first produces a preview showing the generated changes and source rule references before any file is updated + +### Requirement: Writeback emits deterministic lineage metadata + +The bundle SHALL record deterministic metadata that identifies source rules, target type, target destination, and generation timestamps for each writeback operation. Generation timestamps MUST be normalized to a caller-provided deterministic run timestamp (for example `run_start_time`) when supplied; if absent, timestamps MUST be the writeback tool’s own run-start instant. Every recorded timestamp MUST use UTC in ISO-8601 with second precision (`YYYY-MM-DDThh:mm:ssZ`) without fractional seconds or local offsets, and any timestamps supplied in inputs MUST be normalized to that exact format and timezone before persistence so manifests remain reproducible. + +#### Scenario: A writeback draft is generated + +- **WHEN** the bundle produces a draft for a configured target +- **THEN** it also writes a deterministic output manifest describing which approved rules were projected into that target diff --git a/openspec/changes/knowledge-02-module-writeback/tasks.md b/openspec/changes/knowledge-02-module-writeback/tasks.md new file mode 100644 index 00000000..199a7573 --- /dev/null +++ b/openspec/changes/knowledge-02-module-writeback/tasks.md @@ -0,0 +1,28 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/knowledge-02-module-writeback` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm paired core change `knowledge-02-preflight-context-assembly` and module dependency `knowledge-01-module-memory-runtime` are available and document the minimum required `core_compatibility`. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. +- [ ] 1.4 Refresh the GitHub hierarchy cache (`python scripts/sync_github_hierarchy_cache.py`) and verify parent/child plus blocker metadata matches the authoritative GitHub graph before execution starts. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/knowledge-writeback/spec.md`. +- [ ] 2.2 Write failing tests for preview generation, deterministic output manifests, and target-adapter selection. +- [ ] 2.3 Write failing tests for file-target safety behavior and draft CodeRabbit output generation. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/knowledge-02-module-writeback/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-knowledge-writeback/` with manifest, Typer entrypoints, target adapters, and preview helpers. +- [ ] 3.2 Implement adapters for the first approved writeback targets using rules sourced from `specfact-knowledge`. +- [ ] 3.3 Add deterministic output-manifest generation plus preview/dry-run behavior for every target. +- [ ] 3.4 Update registry metadata, docs references, signing inputs, and any import allowlists required by the new bundle. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate knowledge-02-module-writeback --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link paired knowledge changes, and note any deferred target adapters as follow-up issues. diff --git a/openspec/changes/marketplace-07-pr-auto-sign-updates/.openspec.yaml b/openspec/changes/marketplace-07-pr-auto-sign-updates/.openspec.yaml new file mode 100644 index 00000000..4b8c565f --- /dev/null +++ b/openspec/changes/marketplace-07-pr-auto-sign-updates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-21 diff --git a/openspec/changes/marketplace-07-pr-auto-sign-updates/TDD_EVIDENCE.md b/openspec/changes/marketplace-07-pr-auto-sign-updates/TDD_EVIDENCE.md new file mode 100644 index 00000000..29d61b64 --- /dev/null +++ b/openspec/changes/marketplace-07-pr-auto-sign-updates/TDD_EVIDENCE.md @@ -0,0 +1,77 @@ +# TDD Evidence: marketplace-07-pr-auto-sign-updates + +## Failing Before + +Date: 2026-04-21 + +Command: + +```bash +python3 -m pytest tests/unit/workflows/test_sign_modules_hardening.py tests/unit/workflows/test_sign_modules_on_approval.py -q +``` + +Result: + +```text +collected 16 items + +tests/unit/workflows/test_sign_modules_hardening.py ....FF.... +tests/unit/workflows/test_sign_modules_on_approval.py F..... + +FAILED tests/unit/workflows/test_sign_modules_hardening.py::test_sign_modules_hardening_auto_signs_same_repo_pull_requests +FAILED tests/unit/workflows/test_sign_modules_hardening.py::test_sign_modules_hardening_checks_out_pr_head_for_pr_events +FAILED tests/unit/workflows/test_sign_modules_on_approval.py::test_sign_modules_on_approval_trigger_and_job_filter +``` + +Interpretation: + +- `sign-modules.yml` did not yet auto-sign same-repo PR updates. +- `sign-modules.yml` did not yet check out the PR head SHA for pull-request remediation. +- `sign-modules-on-approval.yml` still had a `pull_request` trigger instead of approval-only + triggering. + +## Passing After + +Date: 2026-04-21 + +Command: + +```bash +python3 -m pytest tests/unit/workflows/test_sign_modules_hardening.py tests/unit/workflows/test_sign_modules_on_approval.py -q +``` + +Result: + +```text +collected 16 items + +tests/unit/workflows/test_sign_modules_hardening.py .......... +tests/unit/workflows/test_sign_modules_on_approval.py ...... + +16 passed in 0.33s +``` + +Additional verification: + +```bash +hatch run yaml-lint .github/workflows/sign-modules.yml .github/workflows/sign-modules-on-approval.yml +openspec validate marketplace-07-pr-auto-sign-updates --strict +hatch run specfact code review run --bug-hunt --json --out .specfact/code-review.json \ + .github/workflows/sign-modules.yml \ + .github/workflows/sign-modules-on-approval.yml \ + tests/unit/workflows/test_sign_modules_hardening.py \ + tests/unit/workflows/test_sign_modules_on_approval.py \ + openspec/changes/marketplace-07-pr-auto-sign-updates/proposal.md \ + openspec/changes/marketplace-07-pr-auto-sign-updates/design.md \ + openspec/changes/marketplace-07-pr-auto-sign-updates/tasks.md \ + openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-integration/spec.md \ + openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-module-signing-on-approval/spec.md \ + openspec/specs/ci-integration/spec.md \ + openspec/specs/ci-module-signing-on-approval/spec.md +``` + +Observed outcomes: + +- `yaml-lint`: passed (`Validated 6 manifests and registry/index.json`) +- `openspec validate marketplace-07-pr-auto-sign-updates --strict`: passed +- SpecFact code review: `Review completed with no findings.` diff --git a/openspec/changes/marketplace-07-pr-auto-sign-updates/design.md b/openspec/changes/marketplace-07-pr-auto-sign-updates/design.md new file mode 100644 index 00000000..c53d1085 --- /dev/null +++ b/openspec/changes/marketplace-07-pr-auto-sign-updates/design.md @@ -0,0 +1,77 @@ +# Design: Auto-Sign Module Manifest Updates On Every PR Sync + +## Context + +The repository already has two related workflows: + +1. `sign-modules.yml` verifies module manifest integrity on `pull_request`, `push`, and + `workflow_dispatch`. It also auto-signs pushes to protected `dev` and `main`, but it does not + auto-sign PR branches. +2. `sign-modules-on-approval.yml` signs PR branches after trusted approval. Its recent + `pull_request` path made later approved `synchronize` events eligible for signing, which created + overlap and a self-trigger risk. + +The requested behavior is broader than approval-time remediation: every same-repo PR update against +`dev` or `main` should repair checksums and signatures automatically so repeated review-fix pushes +stay mergeable without manual signing. + +## Goals + +- Auto-sign changed manifests on every same-repo PR update to `dev` or `main`. +- Keep fork PRs read-only. +- Avoid self-triggered signing loops from bot commits. +- Preserve existing push-to-`dev`/`main` signing behavior. + +## Non-Goals + +- Signing fork PRs. +- Changing publish or registry workflows. +- Removing approval-time signing as a workflow entry point if it can remain a harmless backstop. + +## Decisions + +### Decision 1: PR remediation belongs in `sign-modules.yml` + +`sign-modules.yml` already runs on every relevant `pull_request` event. It is the correct place to +repair manifest integrity before verification because the same workflow can sign and then verify the +updated checkout in one run. + +Implementation shape: + +- checkout the PR head SHA for `pull_request` events +- sign changed manifests for same-repo PRs +- commit and push directly back to `github.event.pull_request.head.ref` +- run the existing verify step against the now-updated checkout + +### Decision 2: Skip bot-authored self-sign commits + +The PR remediation step must skip events triggered by the workflow's own commit. The guard is based +on the bot actor and the fixed commit message: + +- actor: `github-actions[bot]` +- commit message: `chore(modules): ci sign changed modules` + +That preserves repeated human review-fix pushes while preventing infinite self-sign loops. + +### Decision 3: `sign-modules-on-approval.yml` returns to review-only triggering + +Once PR-update remediation is handled in `sign-modules.yml`, the approval workflow no longer needs a +`pull_request` trigger. Keeping only `pull_request_review` avoids duplicate responsibility and keeps +the approval workflow aligned with its name. + +## Risks / Trade-offs + +- **Risk: detached checkout cannot push the PR branch** + Mitigation: check out `github.event.pull_request.head.sha` and push with + `git push origin "HEAD:${PR_HEAD_REF}"`. +- **Risk: stale verification after the signing push** + Mitigation: sign before verify in the same job so the working tree already contains the updated + manifests when verification runs. +- **Risk: duplicate signing between workflows** + Mitigation: remove the `pull_request` trigger from `sign-modules-on-approval.yml`. + +## Verification Plan + +- Add workflow-structure tests for PR auto-sign steps and approval-only triggers. +- Capture a failing-before test run for the new expectations. +- Re-run focused workflow tests and YAML lint after implementation. diff --git a/openspec/changes/marketplace-07-pr-auto-sign-updates/proposal.md b/openspec/changes/marketplace-07-pr-auto-sign-updates/proposal.md new file mode 100644 index 00000000..e1b9e5ac --- /dev/null +++ b/openspec/changes/marketplace-07-pr-auto-sign-updates/proposal.md @@ -0,0 +1,45 @@ +# Change: Auto-Sign Module Manifest Updates On Every PR Sync + +## Why + +`specfact-cli-modules` currently remediates manifest checksum and signature drift on pull requests +only after trusted approval via `sign-modules-on-approval.yml`. That leaves same-repo PRs exposed +to checksum mismatch failures on every earlier review-fix push, because `sign-modules.yml` verifies +PRs on `pull_request` events but does not repair them. + +This is a poor fit for iterative code review where a PR can receive many `synchronize` pushes +before the final approval. Review agents and humans should not need a local signing key or a fresh +approval just to repair manifest integrity after each update. + +## What Changes + +- **MODIFY**: `.github/workflows/sign-modules.yml` to auto-sign changed same-repo PR heads for + every `pull_request` event targeting `dev` or `main`, then verify the updated manifests in the + same run. +- **MODIFY**: `.github/workflows/sign-modules-on-approval.yml` to remain approval-only so PR-update + remediation lives in one workflow and does not overlap. +- **MODIFY**: Workflow tests under `tests/unit/workflows/` to cover same-repo PR remediation, + bot-loop avoidance, and approval-only triggering. +- **MODIFY**: OpenSpec CI signing specs so the new PR-update remediation behavior is explicit. + +## Capabilities + +### Modified Capabilities + +- `ci-integration`: same-repo PRs targeting `dev` or `main` auto-repair changed + `packages/*/module-package.yaml` manifests on every update before verification completes. +- `ci-module-signing-on-approval`: approval-triggered signing remains available as an approval + workflow, but PR-update remediation no longer depends on approval state. + +## Impact + +- **Affected workflows**: `.github/workflows/sign-modules.yml`, + `.github/workflows/sign-modules-on-approval.yml` +- **Affected tests**: `tests/unit/workflows/test_sign_modules_hardening.py`, + `tests/unit/workflows/test_sign_modules_on_approval.py` +- **Affected specs**: `openspec/specs/ci-integration/spec.md`, + `openspec/specs/ci-module-signing-on-approval/spec.md` +- **GitHub secrets used**: `SPECFACT_MODULE_PRIVATE_SIGN_KEY`, + `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- **Repository scope**: same-repo PR branches only; fork PRs remain out of scope because the + default `GITHUB_TOKEN` cannot push to contributor forks diff --git a/openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-integration/spec.md b/openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-integration/spec.md new file mode 100644 index 00000000..26702b5c --- /dev/null +++ b/openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-integration/spec.md @@ -0,0 +1,54 @@ +# ci-integration Delta Specification (specfact-cli-modules) + +## MODIFIED Requirements + +### Requirement: pr-orchestrator skips signature requirement for dev-targeting events + +The `verify-module-signatures` job in `pr-orchestrator.yml` SHALL NOT enforce `--require-signature` +for pull requests or pushes targeting `dev`; it SHALL enforce `--require-signature` only for +`main`-targeting events. + +#### Scenario: Feature-to-dev PR with unsigned package manifests + +- **WHEN** a pull request targets `dev` +- **AND** the PR contains package manifest changes with checksum-only integrity blocks +- **THEN** the CI signing workflow SHALL repair changed same-repo + `packages/*/module-package.yaml` manifests on the PR branch before verification completes +- **AND** the `verify-module-signatures` job SHALL pass without `--require-signature` +- **AND** all downstream jobs (`quality`, `contract-tests`, etc.) SHALL not be blocked + +#### Scenario: Same-repo PR update re-signs manifests after review fixes + +- **WHEN** a same-repo pull request targets `dev` or `main` +- **AND** a later `pull_request` `synchronize` event adds review-fix commits that change module + payloads +- **THEN** the CI signing workflow SHALL re-sign the changed manifests on the PR head branch +- **AND** SHALL commit `chore(modules): ci sign changed modules` only when the manifests changed +- **AND** the workflow SHALL skip this remediation for its own bot-authored signing commit + +#### Scenario: Fork PR remains verify-only + +- **WHEN** a pull request targets `dev` or `main` +- **AND** the head branch lives in a fork +- **THEN** the CI signing workflow SHALL NOT attempt to push repaired manifests to that branch +- **AND** verification SHALL remain read-only + +#### Scenario: Dev-to-main PR with unsigned manifests (before approval) + +- **WHEN** a pull request targets `main` +- **AND** one or more `packages/*/module-package.yaml` files lack a valid signature +- **THEN** the `verify-module-signatures` job SHALL fail with `--require-signature` +- **AND** the PR SHALL be blocked from merging + +#### Scenario: Dev-to-main PR after CI signing commit + +- **WHEN** a pull request targets `main` +- **AND** the CI signing workflow has committed signed manifests to the PR branch +- **THEN** the `verify-module-signatures` job SHALL pass with `--require-signature` +- **AND** the PR SHALL be unblocked (subject to other required checks) + +#### Scenario: Push to main post-merge + +- **WHEN** a commit is pushed to `main` (post-merge) +- **THEN** the `verify-module-signatures` job SHALL run with `--require-signature` +- **AND** fail if any `packages/*/module-package.yaml` lacks a valid signature diff --git a/openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-module-signing-on-approval/spec.md b/openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-module-signing-on-approval/spec.md new file mode 100644 index 00000000..fd4ed5b7 --- /dev/null +++ b/openspec/changes/marketplace-07-pr-auto-sign-updates/specs/ci-module-signing-on-approval/spec.md @@ -0,0 +1,71 @@ +# ci-module-signing-on-approval Delta Specification (specfact-cli-modules) + +## MODIFIED Requirements + +### Requirement: Sign packages manifests on PR approval + +The system SHALL automatically sign changed `packages/*/module-package.yaml` manifests using CI +secrets when a same-repo pull request targeting `dev` or `main` receives a trusted approval review, +and SHALL commit the signed manifests back to the PR branch. + +#### Scenario: PR to dev approved with package module changes + +- **WHEN** a pull request targeting `dev` is approved by a trusted reviewer +- **AND** the PR contains changes to one or more files under `packages/` +- **THEN** the CI signing workflow SHALL discover all `packages/*/module-package.yaml` manifests + whose payload changed on the PR branch since the merge-base with `origin/dev` +- **AND** SHALL sign them using `SPECFACT_MODULE_PRIVATE_SIGN_KEY` and + `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- **AND** SHALL commit the updated manifests back to the PR branch + +#### Scenario: PR to main approved with package module changes + +- **WHEN** a pull request targeting `main` is approved by a trusted reviewer +- **AND** the PR contains changes to one or more files under `packages/` +- **THEN** the CI signing workflow SHALL sign all changed manifests relative to the merge-base + between the PR head and `origin/main` +- **AND** SHALL commit the signed manifests back to the PR branch before merge + +#### Scenario: PR approved with no package changes + +- **WHEN** a pull request is approved +- **AND** no files under `packages/` have changed relative to the base branch +- **THEN** the CI signing workflow SHALL exit cleanly with no commit + +#### Scenario: Approval workflow does not trigger on PR synchronize events + +- **WHEN** a same-repo pull request already has a trusted approval +- **AND** the PR later receives a `pull_request` `synchronize` event +- **THEN** `sign-modules-on-approval.yml` SHALL NOT be triggered by that event +- **AND** PR-update signing SHALL remain the responsibility of the general PR signing workflow + +#### Scenario: Missing signing secret + +- **WHEN** the signing workflow triggers on approval +- **AND** `SPECFACT_MODULE_PRIVATE_SIGN_KEY` is empty or unset, or `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` is empty or unset +- **THEN** the workflow SHALL fail before checkout/signing with a clear error naming which secret(s) are missing +- **AND** SHALL NOT commit partial changes + +#### Scenario: Fork PR is out of scope for automated signing + +- **WHEN** a pull request targets `dev` or `main` but the head branch lives in a fork + (`head.repo` differs from the base repository) +- **THEN** the signing workflow SHALL NOT run + +### Requirement: Manifest discovery covers packages directory + +The signing workflow SHALL discover module manifests from the `packages/` directory tree, not only +from `src/specfact_cli/modules/` or `modules/` (which do not exist in this repository). + +#### Scenario: Sign only changed packages manifests + +- **WHEN** the signing workflow runs with changes across multiple packages +- **AND** only a subset of packages have payload changes +- **THEN** only the changed `packages/*/module-package.yaml` files SHALL be signed and committed +- **AND** unchanged package manifests SHALL NOT be modified + +#### Scenario: Sign workflow produces idempotent output + +- **WHEN** the signing workflow runs twice on the same package payload +- **THEN** the resulting `integrity:` block SHALL be byte-for-byte identical +- **AND** the second run SHALL produce no git diff and SHALL skip the commit diff --git a/openspec/changes/marketplace-07-pr-auto-sign-updates/tasks.md b/openspec/changes/marketplace-07-pr-auto-sign-updates/tasks.md new file mode 100644 index 00000000..0912c62b --- /dev/null +++ b/openspec/changes/marketplace-07-pr-auto-sign-updates/tasks.md @@ -0,0 +1,29 @@ +# Tasks: marketplace-07-pr-auto-sign-updates + +## 1. Specs and failing evidence + +- [x] 1.1 Update the CI signing specs to require same-repo PR auto-sign remediation on every + `pull_request` update to `dev` or `main`, and to scope `sign-modules-on-approval.yml` back to + approval-only triggering. +- [x] 1.2 Add workflow tests for PR auto-sign remediation in + `tests/unit/workflows/test_sign_modules_hardening.py` and trigger expectations in + `tests/unit/workflows/test_sign_modules_on_approval.py`. +- [x] 1.3 Run the focused workflow tests and record the failing-before output in + `openspec/changes/marketplace-07-pr-auto-sign-updates/TDD_EVIDENCE.md`. + +## 2. Workflow implementation + +- [x] 2.1 Update `.github/workflows/sign-modules.yml` so same-repo `pull_request` events targeting + `dev` or `main` auto-sign changed manifests with CI secrets, commit + `chore(modules): ci sign changed modules`, and push back to the PR head branch. +- [x] 2.2 Ensure the PR auto-sign path skips fork PRs and self-triggered bot commits while still + allowing later human `synchronize` pushes to be re-signed automatically. +- [x] 2.3 Update `.github/workflows/sign-modules-on-approval.yml` so it triggers only on + `pull_request_review`. + +## 3. Verification and evidence + +- [x] 3.1 Re-run the focused workflow tests after the workflow edits and confirm they pass. +- [x] 3.2 Run `hatch run yaml-lint` on the touched workflow files. +- [x] 3.3 Record the passing-after evidence in + `openspec/changes/marketplace-07-pr-auto-sign-updates/TDD_EVIDENCE.md`. diff --git a/openspec/changes/review-resiliency-01-module/.openspec.yaml b/openspec/changes/review-resiliency-01-module/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/review-resiliency-01-module/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/review-resiliency-01-module/design.md b/openspec/changes/review-resiliency-01-module/design.md new file mode 100644 index 00000000..248856d3 --- /dev/null +++ b/openspec/changes/review-resiliency-01-module/design.md @@ -0,0 +1,57 @@ +# Review Resiliency Module Design + +## Context + +`specfact-cli` owns the resiliency finding model, scorer, and CLI contracts, but `specfact-cli-modules` owns the executable bundle that packages third-party analyzers and rule resources. This change needs a new bundle instead of extending `specfact-code-review` so resiliency policy can evolve independently, ship its own dependencies, and remain optional for users who only want source-quality review. + +## Goals / Non-Goals + +**Goals:** + +- Define the package layout, manifest fields, and command ownership for `specfact-review-resiliency`. +- Keep the bundle local-first by default while still allowing explicit opt-in probes for higher-cost runtime checks. +- Reuse the paired core review contracts so downstream evidence, reporting, and future policy gates stay uniform. +- Make bundle dependencies and registry/signing expectations explicit before implementation starts. + +**Non-Goals:** + +- Implement the core scoring model or findings schema; those remain in `specfact-cli`. +- Add always-on chaos or load testing against live systems. +- Introduce mandatory enterprise connectivity or telemetry requirements. + +## Decisions + +### 1. Ship resiliency as a standalone official bundle + +- **Decision**: Create `packages/specfact-review-resiliency/` with its own `module-package.yaml`, versioning, and signing lifecycle. +- **Why**: Resiliency checks have different dependencies, rollout cadence, and operator expectations than static code review. A standalone bundle keeps install footprint optional and avoids overloading `specfact-code-review`. +- **Alternative considered**: Extend `specfact-code-review`. Rejected because it would conflate source hygiene with runtime robustness and would force unrelated dependencies onto all code-review users. + +### 2. Separate static rule packs from optional active probes + +- **Decision**: Make static analyzers and deterministic rule packs the default path, and gate active probe adapters behind explicit flags and profile settings. +- **Why**: The repo’s offline-first discipline and CI safety expectations do not permit surprise load generation or environment mutation. +- **Alternative considered**: Enable probe execution automatically when configuration is present. Rejected because it increases blast radius and complicates repeatable CI. + +### 3. Depend on core contracts, not duplicate schemas + +- **Decision**: Bundle code imports the paired core resiliency findings/report models from `specfact_cli`, and the manifest raises `core_compatibility` when those contracts change. +- **Why**: Shared scoring, rendering, and downstream knowledge ingestion belong in one canonical place. +- **Alternative considered**: Define bundle-local pydantic models. Rejected because it invites drift across core and module surfaces. + +## Risks / Trade-offs + +- **Risk**: Probe tooling can introduce flaky tests or platform-specific behavior. → **Mitigation**: keep probes opt-in and specify deterministic fixture-based tests as the default contract. +- **Risk**: A new bundle increases registry and signing overhead. → **Mitigation**: make manifest, registry, and version-bump tasks explicit in the implementation checklist. +- **Risk**: Rule overlap with `specfact-code-review` could confuse users. → **Mitigation**: document category boundaries and keep resiliency findings scoped to runtime robustness concerns. + +## Migration Plan + +1. Land paired core change `review-resiliency-01-contracts` so bundle development targets a stable findings contract. +2. Implement the new package and command surface in a dedicated worktree. +3. Add registry metadata, docs, signing inputs, and compatibility declarations together before publishing. + +## Open Questions + +- Which optional probe runner provides the best cross-platform baseline with minimal dependency weight? +- Should retry/idempotency rules ship only as Semgrep-style resources, or also expose AST-native Python checks in the first release? diff --git a/openspec/changes/review-resiliency-01-module/proposal.md b/openspec/changes/review-resiliency-01-module/proposal.md new file mode 100644 index 00000000..197e0896 --- /dev/null +++ b/openspec/changes/review-resiliency-01-module/proposal.md @@ -0,0 +1,75 @@ +# Change: Review Resiliency Module + +## Why + +The core repo can define resiliency contracts and scoring, but SpecFact still lacks a modules-side bundle that actually runs resiliency-oriented checks against repositories. Without a dedicated bundle, operational-scalability review remains a paper capability instead of a runnable part of the platform. + +## What Changes + +- **NEW**: Add a `specfact-review-resiliency` bundle scaffold under `packages/` with `review-resiliency` as its primary command. +- **NEW**: Package deterministic resiliency rule packs for retry, timeout, idempotency, backpressure, and graceful-degradation checks that map into the paired core findings contract. +- **OPTIONAL**: Add optional probe adapters for lightweight load-profile validation behind explicit opt-in flags so default review runs stay local-first and safe for CI. +- **NOTE**: Emit findings in the shared review report shape and, when the knowledge bundle is installed, forward evidence metadata to the memory runtime without making that integration mandatory. +- **EXTEND**: Document the new bundle on modules docs and declare manifest, registry, and bundle-dependency expectations for later implementation. + +### Adapter Contract + +The adapter boundary between resiliency analyzers and the core review contract defines how resiliency findings flow into the core ReviewFinding model: + +**Mapping to ReviewFinding Fields:** + +- **category**: Set to an existing allowed enum value from the `ReviewFinding` type's `category` field (e.g., `"clean_code"` for general resiliency patterns). New categories must be proposed via a core-spec change rather than in this module +- **severity**: Set to `"error"`, `"warning"`, or `"info"` based on the violation severity +- **tool**: Set to the resiliency analyzer name (e.g., `"resiliency-analyzer"` or specific tool name) +- **rule**: Set to the stable rule identifier in format `resiliency--` (e.g., `resiliency-retry-001`) +- **file**: Set to the repository-relative file path where the violation occurs +- **line**: Set to the 1-based line number or line range start +- **message**: Set to a human-readable description of the resiliency finding +- **fixable**: Optional boolean indicating whether the finding can be auto-fixed (default: false) + +**Field Mapping from Custom Schema to ReviewFinding:** + +- `id` → `rule` (or use as tool-specific identifier in extended metadata) +- `title` → Incorporated into `message` as the primary human-readable text +- `description` → Incorporated into `message` as detailed explanation +- `location` → Mapped to `file` and `line` fields +- `timestamp` → Can be preserved as metadata but is not a core ReviewFinding field +- `confidence` → Can be preserved as metadata but is not a core ReviewFinding field +- `evidence_refs` → Can be retained as supplemental references but primary location uses `file`/`line` + +**Severity Enum Values:** + +- Use canonical severity values: `"error"`, `"warning"`, `"info"` +- Map previous severity ranges to these values as needed + +## Capabilities + +### New Capabilities + +- `review-resiliency-module`: Runtime bundle, command surfaces, packaged rule sets, and evidence adapters for resiliency review. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-review-resiliency/`, bundle loader wiring, and rule resources under `resources/`. +- Affected docs: bundle overview and command-reference pages on modules.specfact.io for resiliency review. +- Dependencies: paired core change `review-resiliency-01-contracts`; optional knowledge integration with `knowledge-01-module-memory-runtime`. +- Release impact: introduces a new signed official bundle with a new `module-package.yaml` and registry entry. +- **Core Compatibility and Dependencies**: The bundle declares `core_compatibility: ">=1.0.0 <2.0.0"` for `review-resiliency-01-contracts` in `module-package.yaml`. Optional integration with `knowledge-01-module-memory-runtime` is declared under `bundle_dependencies` with a feature flag key `knowledge_integration_enabled: false` (default off), allowing evidence forwarding when the knowledge bundle is installed and the flag is explicitly enabled. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#217](https://github.com/nold-ai/specfact-cli-modules/issues/217) +- **GitHub Issue**: [#226](https://github.com/nold-ai/specfact-cli-modules/issues/226) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/review-resiliency-01-module/specs/review-resiliency-module/spec.md b/openspec/changes/review-resiliency-01-module/specs/review-resiliency-module/spec.md new file mode 100644 index 00000000..7c8071a5 --- /dev/null +++ b/openspec/changes/review-resiliency-01-module/specs/review-resiliency-module/spec.md @@ -0,0 +1,30 @@ +# Review Resiliency Module Specification + +## ADDED Requirements + +### Requirement: Resiliency review bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-review-resiliency` that exposes the `review-resiliency` command, declares any required `bundle_dependencies`, and advertises a truthful `core_compatibility` range scoped to the paired core OpenSpec change `review-resiliency-01-contracts` (resiliency finding, scorer, and report contracts shipped from `specfact-cli`). + +#### Scenario: Bundle is installed for review execution + +- **WHEN** SpecFact loads installed module manifests +- **THEN** the resiliency bundle is discoverable as an official package with the `review-resiliency` command and valid compatibility metadata + +### Requirement: Resiliency findings map to the shared review contracts + +The bundle SHALL translate static rule-pack results and any explicitly enabled probe outputs into the resiliency finding and report models defined by `review-resiliency-01-contracts` (imported from the `specfact_cli` package paths published with that change) without defining a competing schema in the modules repository. + +#### Scenario: Static checks produce resiliency findings + +- **WHEN** the bundle detects retry, timeout, idempotency, or graceful-degradation violations +- **THEN** it emits findings categorized for the shared core report contract with stable rule identifiers and deterministic evidence references + +### Requirement: Active probes are explicit opt-in behavior + +The bundle SHALL keep load-profile or probe-style checks disabled by default and SHALL require explicit command flags or profile configuration before executing any active runtime probe. + +#### Scenario: Probe flags are absent + +- **WHEN** a user runs the default resiliency review command without enabling probes +- **THEN** the bundle performs only offline-safe static analysis and reports that active probes were skipped by design diff --git a/openspec/changes/review-resiliency-01-module/tasks.md b/openspec/changes/review-resiliency-01-module/tasks.md new file mode 100644 index 00000000..928bef54 --- /dev/null +++ b/openspec/changes/review-resiliency-01-module/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/review-resiliency-01-module` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 On the paired `nold-ai/specfact-cli` branch that will ship with this bundle, verify `openspec/changes/review-resiliency-01-contracts` exists (or capture the authoritative renamed folder), document the minimum required `core_compatibility`, and update `openspec/CHANGE_ORDER.md` plus section 3.2 references if the upstream change id differs; record the discovered import paths for the resiliency finding/report models in `TDD_EVIDENCE.md` before coding. +- [ ] 1.3 Before implementation begins, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/review-resiliency-module/spec.md` with command, rule-pack, and evidence scenarios. +- [ ] 2.2 Write failing tests for bundle manifest loading, command registration, and findings-to-report mapping. +- [ ] 2.3 Write failing tests for static resiliency rule execution and opt-in probe gating behavior. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/review-resiliency-01-module/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-review-resiliency/` with `module-package.yaml`, Typer entrypoints, and packaged rule resources. +- [ ] 3.2 Implement adapters that translate analyzer output into the paired core resiliency findings/report contracts. +- [ ] 3.3 Add optional probe adapters behind explicit flags/profile configuration without changing default offline-safe behavior. +- [ ] 3.4 Update registry metadata, signing inputs, docs references, and any import allowlists required by the new bundle. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and broader affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate review-resiliency-01-module --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link the paired core change, and note any deferred probe integrations as follow-up issues. diff --git a/openspec/changes/security-01-module-sast-sca-secret/.openspec.yaml b/openspec/changes/security-01-module-sast-sca-secret/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/security-01-module-sast-sca-secret/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/security-01-module-sast-sca-secret/design.md b/openspec/changes/security-01-module-sast-sca-secret/design.md new file mode 100644 index 00000000..75191037 --- /dev/null +++ b/openspec/changes/security-01-module-sast-sca-secret/design.md @@ -0,0 +1,64 @@ +# Context + +Security analysis in the modules repo must package third-party scanners while deferring canonical findings, severity scoring, and policy interpretation to the core repo. This change establishes the bundle boundary for the broadest security surface: source-code analysis, dependency risk, SBOM generation, and secret detection. The bundle has to remain composable with license and privacy modules instead of becoming a catch-all for every security-adjacent concern. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for a new `specfact-security` bundle. +- Normalize multiple analyzer outputs into the shared core security finding model. +- Keep policy-mode interpretation aligned with existing policy/profile work rather than inventing bundle-local severity logic. +- Make registry, manifest, and signing consequences explicit before implementation. + +**Non-Goals:** + +- Implement GDPR-specific privacy detection; that belongs in `security-03-module-pii-gdpr-eu`. +- Implement SPDX-focused license policy evaluation; that belongs in `security-02-module-license-compliance`. +- Replace the paired core security findings contract. + +## Decisions + +### Command Surface + +The bundle exposes a single `security` command that orchestrates all four scanner classes (SAST, SCA, SBOM, secret scanning). Users can enable/disable specific scanner classes via command flags or profile configuration, but all scanners map to the unified command surface rather than separate per-scanner commands. + +### 1. Keep broad security orchestration in one bundle + +- **Decision**: SAST, SCA, SBOM, and secret scanning ship together in `specfact-security`. +- **Why**: These scanners are typically invoked in one review pass and share the same normalized findings pipeline. +- **Alternative considered**: Separate bundles for each scanner class. Rejected because it would fragment the default security workflow and complicate findings aggregation. + +### 2. Normalize tool output immediately at the adapter boundary + +- **Decision**: Each scanner adapter translates tool-native fields into the core findings schema before results reach reporting or evidence code. +- **Why**: Downstream code should consume one consistent contract regardless of scanner source. +- **Alternative considered**: Preserve raw tool payloads through the pipeline. Rejected because it pushes schema drift into every caller. + +### 3. Reuse policy-mode semantics from shared governance work + +- **Decision**: The bundle reads advisory/mixed/hard enforcement intent from existing profile and policy mechanisms and only decides which scanners to run plus how to surface failures. +- **Why**: Policy meaning already has a single source of truth in the ecosystem. +- **Alternative considered**: Bundle-local enforcement flags with independent semantics. Rejected because it would drift from policy packs and enterprise governance. + +## Risks / Trade-offs + +- **Risk**: Third-party scanner version drift could destabilize output normalization. → **Mitigation**: define fixture-based adapter tests and pin expected mappings in spec scenarios. +- **Risk**: A single broad bundle may feel heavy for users who only want one scanner class. → **Mitigation**: keep command flags/profile settings selective while retaining a unified bundle identity. +- **Risk**: Overlap with license/privacy modules could blur product boundaries. → **Mitigation**: document explicit scope lines and keep separate bundles for SPDX and GDPR/PII concerns. + +## Migration Plan + +1. Land the paired core findings model so adapters target a stable schema. +2. Implement the new package and adapters with fixture-based tests. +3. Add registry entry, docs, signatures, and compatibility range as one release unit. + +**Core Compatibility Details**: +- **Version Constraint Format**: `core_compatibility: ">=1.0.0 <2.0.0"` in `module-package.yaml` +- **Initial Core Contract Target Versions**: `security-01-unified-findings-model` (required), `policy-02-packs-and-modes` (required) +- **Bundle Dependencies**: `policy-02-packs-and-modes` should be declared as a module-level bundle dependency (not core_compatibility) if it ships as a separate bundle; if it remains a core contract, it belongs in `core_compatibility` + +## Open Questions + +- Which dependency-risk backend should be the default on day one: Grype, OSV, or both behind provider switches? +- Should SBOM generation be mandatory for every security run or enabled only when dependency analysis is requested? \ No newline at end of file diff --git a/openspec/changes/security-01-module-sast-sca-secret/proposal.md b/openspec/changes/security-01-module-sast-sca-secret/proposal.md new file mode 100644 index 00000000..6c3ec2b8 --- /dev/null +++ b/openspec/changes/security-01-module-sast-sca-secret/proposal.md @@ -0,0 +1,44 @@ +# Change: Security Module For SAST, SCA, And Secrets + +## Why + +The core repo can standardize security findings, but users still need a runnable bundle that orchestrates SAST, dependency, SBOM, and secret scans in one place. Without a dedicated module, the unified security model cannot produce real enforcement or evidence in everyday workflows. + +## What Changes + +- **NEW**: Add a `specfact-security` bundle under `packages/` with a `security` command for SAST, SCA, SBOM, and secret scanning. +- **NEW**: Package adapters for Semgrep, Syft, Grype or OSV-style dependency analysis, and secret scanning so their outputs normalize into the paired core finding model. +- **NEW**: Add profile-aware execution modes that honor advisory, mixed, and hard policy modes without duplicating policy semantics in the bundle. +- **NEW**: Define stable output surfaces for markdown and JSON security reports and optional evidence handoff to the knowledge runtime. +- **EXTEND**: Reserve manifest, registry, docs, and signing work required for a new official security bundle. + +## Capabilities + +### New Capabilities + +- `security-sast-sca-secret-module`: Runtime bundle, analyzer orchestration, and normalized findings for SAST, SCA, SBOM, and secret scanning. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-security/`, bundled scanner adapters, and resource/configuration assets. +- Affected docs: new bundle overview plus command-reference documentation for `security`. +- Dependencies: paired core change `security-01-unified-findings-model`; related policy semantics from `policy-02-packs-and-modes`. +- Release impact: introduces a new official bundle with signing, registry, and compatibility declarations. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#218](https://github.com/nold-ai/specfact-cli-modules/issues/218) +- **GitHub Issue**: [#227](https://github.com/nold-ai/specfact-cli-modules/issues/227) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false diff --git a/openspec/changes/security-01-module-sast-sca-secret/specs/security-sast-sca-secret-module/spec.md b/openspec/changes/security-01-module-sast-sca-secret/specs/security-sast-sca-secret-module/spec.md new file mode 100644 index 00000000..ef814d94 --- /dev/null +++ b/openspec/changes/security-01-module-sast-sca-secret/specs/security-sast-sca-secret-module/spec.md @@ -0,0 +1,30 @@ +# Security SAST SCA Secret Module Specification + +## ADDED Requirements + +### Requirement: Security bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-security` that exposes the `security` command and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` entries. **Note**: If `security-01-unified-findings-model` is not an accepted core contract in `specfact-cli`, this requirement must be updated. For `policy-02-packs-and-modes`: if it ships as a separate module bundle, it should be declared under `bundle_dependencies` (not `core_compatibility`); if it remains a core contract, it belongs in `core_compatibility`. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the security bundle is discoverable as an official package with the `security` command and valid compatibility metadata + +### Requirement: Scanner adapters normalize into the shared findings model + +The bundle SHALL translate SAST, SCA, SBOM, and secret-scan outputs into the security finding and report contracts from `security-01-unified-findings-model` before results reach reporting, evidence, or policy-mode handling governed by `policy-02-packs-and-modes`. + +#### Scenario: Multiple scanner classes produce results + +- **WHEN** the bundle runs configured SAST, dependency, SBOM, and secret scanners +- **THEN** it emits one normalized findings stream that preserves category-specific metadata while using the shared core schema + +### Requirement: Policy modes influence security execution + +The bundle SHALL honor advisory, mixed, and hard security enforcement modes from shared policy/profile configuration when deciding exit behavior and user-facing report status. + +#### Scenario: Hard mode receives a blocking finding + +- **WHEN** a scan in hard mode returns a finding that `policy-02-packs-and-modes` classifies as blocking under the shared findings model from `security-01-unified-findings-model` +- **THEN** the bundle surfaces the failure with the normalized report contract and a non-success command outcome \ No newline at end of file diff --git a/openspec/changes/security-01-module-sast-sca-secret/tasks.md b/openspec/changes/security-01-module-sast-sca-secret/tasks.md new file mode 100644 index 00000000..291c8033 --- /dev/null +++ b/openspec/changes/security-01-module-sast-sca-secret/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/security-01-module-sast-sca-secret` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Verify that both referenced core changes (`security-01-unified-findings-model` and `policy-02-packs-and-modes`) exist and are merged in `specfact-cli` before binding core compatibility. Document the minimum required `core_compatibility` versions in `TDD_EVIDENCE.md` and record any sequencing notes. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/security-sast-sca-secret-module/spec.md`. +- [ ] 2.2 Write failing adapter tests for Semgrep, dependency-risk, SBOM, and secret-scan result normalization. +- [ ] 2.3 Write failing tests for command-mode behavior across advisory, mixed, and hard policy modes. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/security-01-module-sast-sca-secret/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-security/` with manifest, Typer entrypoints, and scanner/resource wiring. +- [ ] 3.2 Implement adapters that normalize scanner output into the paired core security findings/report contracts. +- [ ] 3.3 Integrate profile-aware execution controls, JSON/markdown reporting, and optional knowledge evidence handoff. +- [ ] 3.4 Update registry metadata, docs references, signing inputs, and any import allowlists required by the new bundle. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate security-01-module-sast-sca-secret --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link paired security changes, and call out any deferred scanner providers as follow-up issues. \ No newline at end of file diff --git a/openspec/changes/security-02-module-license-compliance/.openspec.yaml b/openspec/changes/security-02-module-license-compliance/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/security-02-module-license-compliance/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/security-02-module-license-compliance/design.md b/openspec/changes/security-02-module-license-compliance/design.md new file mode 100644 index 00000000..5d55217e --- /dev/null +++ b/openspec/changes/security-02-module-license-compliance/design.md @@ -0,0 +1,57 @@ +# Design: security-02-module-license-compliance + +## Context + +License governance sits at the boundary of supply-chain security and legal compliance, but the runtime bundle needs to stay narrow enough that organizations can adopt it independently. This change defines the modules-side package that consumes SBOM data, evaluates SPDX license identifiers against policy, and reports findings through the shared core contracts. + +## Goals / Non-Goals + +**Goals:** + +- Define the bundle structure and command ownership for `specfact-license-compliance`. +- Normalize SBOM/license evidence into the shared core findings model. +- Keep license exceptions and policy semantics aligned with existing policy/profile infrastructure. +- Make room for interoperability with the broader security bundle without forcing runtime consolidation. + +**Non-Goals:** + +- Replace the general-purpose security bundle for SAST, SCA, or secret scanning. +- Implement legal workflow tooling outside SpecFact’s findings/report surfaces. +- Introduce enterprise-only policy resolution in the bundle itself. + +## Decisions + +### 1. Ship license compliance as a sibling bundle + +- **Decision**: Create a standalone `specfact-license-compliance` bundle instead of folding SPDX checks into `specfact-security`. +- **Why**: License policy often has different adoption, ownership, and exception flows than scanner-driven security review. +- **Alternative considered**: Add license scanning to the security bundle. Rejected because it would entangle release cadence and user scope. + +### 2. Consume SBOM inputs rather than redefining package inventory + +- **Decision**: The bundle can generate or ingest SBOM data, but its contract starts at normalized SBOM/license records instead of owning repository inventory logic. +- **Why**: SBOM generation backends may evolve independently while the bundle’s stable value is policy evaluation. +- **Alternative considered**: Make the bundle responsible for all dependency discovery semantics. Rejected because it duplicates security-bundle concerns. + +### 3. Express license state through the core findings contract + +- **Decision**: License allow/deny/exception outcomes map into the paired core findings model and report surfaces. +- **Why**: Downstream policy, evidence, and future enterprise reporting should not special-case license output. +- **Alternative considered**: Emit a separate license-only schema. Rejected because it fragments governance surfaces. + +## Risks / Trade-offs + +- **Risk**: SPDX normalization differences across SBOM tools may create inconsistent results. → **Mitigation**: define adapter fixtures and canonical SPDX mapping tables in tests. +- **Risk**: Users may expect the security bundle alone to cover license policy. → **Mitigation**: document bundle boundaries and integration points clearly. +- **Risk**: Exception handling can drift from core policy semantics. → **Mitigation**: rely on shared policy-pack interpretation instead of bundle-local exception logic. + +## Migration Plan + +1. Confirm paired core security/policy contracts are stable enough for integration. +2. Implement package structure, SBOM ingestion, and license evaluation adapters. +3. Publish manifest, registry, docs, and signatures together once compatibility is validated. + +## Open Questions + +- Should the first release generate SBOMs itself, consume shared SBOM artifacts, or support both modes from day one? +- Which SPDX edge cases need explicit normalization rules in the initial release? \ No newline at end of file diff --git a/openspec/changes/security-02-module-license-compliance/proposal.md b/openspec/changes/security-02-module-license-compliance/proposal.md new file mode 100644 index 00000000..1fcde13b --- /dev/null +++ b/openspec/changes/security-02-module-license-compliance/proposal.md @@ -0,0 +1,68 @@ +# Change: License Compliance Module + +## Why + +License governance needs a focused runtime bundle that evaluates SBOM and SPDX license data against policy without burying those concerns inside a broader scanner module. A dedicated package keeps licensing auditable, policy-driven, and independently deployable for users who need supply-chain governance without the full security stack. + +## What Changes + +- **NEW**: Add a `specfact-license-compliance` bundle with a `license` command focused on SBOM and SPDX license evaluation. +- **INCLUDES**: Package SBOM ingestion and SPDX normalization adapters that feed the paired core security/license findings model. +- **ENABLES**: Add policy-aware handling for allowed, denied, and exception-based license states so reports can surface explicit remediation guidance. +- **NEW**: Define bundle manifest, registry, docs, and signing expectations for a first-party official license-compliance package. +- **EXTEND**: Reserve compatibility hooks so this bundle can share generated SBOM data with the broader security bundle without forcing a single runtime package. + +### Adapter Contract + +The adapter boundary between SBOM/license analyzers and the core security/license findings model specifies how license evaluation flows into the normalized findings contract: + +- **Core Findings Schema Fields** (inlined from the unified findings model for license findings): + - `id` (string): Unique stable identifier for the finding + - `type` (string): Finding type (see SPDX-to-Normalized Finding Mapping Rules below for allowed values) + - `severity` (string): Severity level; allowed values: `"high"`, `"medium"`, `"low"`, `"info"` + - `description` (string): Human-readable description of the finding + - `source` (string): Originating tool or analyzer name + - `timestamp` (ISO 8601 string): UTC timestamp when the finding was generated + - `evidence_refs` (list of objects, optional): Supplemental references with stable file paths, line ranges, or artifact identifiers +- **SPDX-to-Normalized Finding Mapping Rules**: + - Denied SPDX identifier → `severity: "high"`, `type: "license-violation"` + - Allowed SPDX identifier → `severity: "info"`, `type: "license-compliant"` + - Exception-based allow → `severity: "medium"`, `type: "license-exception-applied"` + - Unknown/missing license → `severity: "medium"`, `type: "license-unknown"` + +## Capabilities + +### New Capabilities + +- `license-compliance-module`: Runtime bundle, command surface, and SPDX/SBOM evaluation flow for license governance. + +### Modified Capabilities + +_None. + +### Inter-Bundle Compatibility + +- **Core Compatibility Version Range**: `core_compatibility: ">=1.0.0,<2.0.0"` in `module-package.yaml` +- **Inter-Bundle Hook Interface**: The bundle can optionally emit SBOM artifacts to a shared location (e.g., `.specfact/sbom/`) that the `security` bundle can consume, enabling SBOM reuse without runtime coupling +- **Bundle Dependencies Declaration**: If shared policy semantics from `policy-02-packs-and-modes` are required, declare it under `bundle_dependencies` (not `core_compatibility`) + +## Impact + +- Affected code: future `packages/specfact-license-compliance/`, SBOM ingestion helpers, and policy mapping resources. +- Affected docs: bundle overview and command-reference documentation for `license`. +- Dependencies: This proposal inlines the findings schema (previously referenced from `security-01-unified-findings-model`) and depends on the shared policy-pack semantics from `policy-02-packs-and-modes`. +- Release impact: introduces a new signed official bundle and registry entry. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#218](https://github.com/nold-ai/specfact-cli-modules/issues/218) +- **GitHub Issue**: [#228](https://github.com/nold-ai/specfact-cli-modules/issues/228) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/security-02-module-license-compliance/specs/license-compliance-module/spec.md b/openspec/changes/security-02-module-license-compliance/specs/license-compliance-module/spec.md new file mode 100644 index 00000000..4f352b41 --- /dev/null +++ b/openspec/changes/security-02-module-license-compliance/specs/license-compliance-module/spec.md @@ -0,0 +1,30 @@ +# License Compliance Module Specification + +## ADDED Requirements + +### Requirement: License compliance bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-license-compliance` that exposes the `license` command and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` for `security-01-unified-findings-model` (shared security findings/report contracts reused for license findings). **Note**: Confirm the core change `security-01-unified-findings-model` status in `specfact-cli` and add appropriate notes about `core_compatibility` version requirements before implementation. + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the license-compliance bundle is discoverable as an official package with the `license` command and valid compatibility metadata + +### Requirement: SPDX and SBOM evaluation uses the shared findings contract + +The bundle SHALL ingest normalized SBOM/license records and emit allow, deny, or exception findings through the findings/report contracts from `security-01-unified-findings-model` instead of a bundle-local reporting schema. + +#### Scenario: A denied license is detected + +- **WHEN** the bundle evaluates SBOM records against configured policy and encounters a denied SPDX identifier +- **THEN** it emits a normalized license finding with remediation guidance and a policy-aligned severity + +### Requirement: Policy modes control license command outcomes + +The bundle SHALL honor advisory, mixed, and hard policy modes when determining command status and user-facing report outcomes for license findings. + +#### Scenario: Advisory mode encounters a denied license + +- **WHEN** policy mode is advisory and evaluation returns a denied license +- **THEN** the bundle reports the finding without converting the overall command outcome into a blocking failure \ No newline at end of file diff --git a/openspec/changes/security-02-module-license-compliance/tasks.md b/openspec/changes/security-02-module-license-compliance/tasks.md new file mode 100644 index 00000000..d5df794c --- /dev/null +++ b/openspec/changes/security-02-module-license-compliance/tasks.md @@ -0,0 +1,27 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/security-02-module-license-compliance` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm paired core changes `security-01-unified-findings-model` and `security-02-eu-gdpr-baseline` are available, document the minimum required `core_compatibility` against both, and capture any sequencing assumptions in `TDD_EVIDENCE.md`. +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/license-compliance-module/spec.md`. +- [ ] 2.2 Write failing tests for SBOM ingestion, SPDX normalization, and policy evaluation outcomes. +- [ ] 2.3 Write failing tests for exception handling and command exit/report behavior across policy modes. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/security-02-module-license-compliance/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-license-compliance/` with manifest, Typer entrypoints, and bundled policy resources. +- [ ] 3.2 Implement SBOM ingestion and SPDX normalization adapters that emit the findings/report contracts from `security-01-unified-findings-model`. +- [ ] 3.3 Integrate policy-mode handling, remediation messaging, and optional SBOM sharing hooks for adjacent security workflows. +- [ ] 3.4 Update registry metadata, docs references, signing inputs, and any import allowlists required by the new bundle. + +## 4. Verification and delivery + +- [ ] 4.1 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.3 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.4 Run `openspec validate security-02-module-license-compliance --strict`. +- [ ] 4.5 Open the modules PR to `dev`, cross-link paired security changes, and note any deferred SBOM-provider work as follow-up issues. diff --git a/openspec/changes/security-03-module-pii-gdpr-eu/.openspec.yaml b/openspec/changes/security-03-module-pii-gdpr-eu/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/security-03-module-pii-gdpr-eu/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/security-03-module-pii-gdpr-eu/design.md b/openspec/changes/security-03-module-pii-gdpr-eu/design.md new file mode 100644 index 00000000..98ac1504 --- /dev/null +++ b/openspec/changes/security-03-module-pii-gdpr-eu/design.md @@ -0,0 +1,55 @@ +## Context + +Privacy and GDPR governance need specialized detection, redaction, and region-aware policy handling that would be unwieldy inside a general security bundle. This change defines the modules-side package that runs privacy-focused detectors, evaluates EU/GDPR rule packs, and emits safe normalized findings through the paired core contracts. + +## Goals / Non-Goals + +**Goals:** + +- Define the package structure and command ownership for `specfact-pii-gdpr`. +- Normalize PII/GDPR detections into the paired core findings model. +- Ensure evidence and reports stay safe to store by supporting redaction-aware output handling. +- Keep GDPR/EU rule selection aligned with shared policy-pack semantics and the paired core baseline. + +**Non-Goals:** + +- Replace the broader security bundle for SAST, SCA, SBOM, or secrets. +- Implement enterprise server-side policy management in the bundle. +- Guarantee legal sufficiency outside the scope of the explicitly codified rule packs. + +## Decisions + +### 1. Ship privacy/GDPR checks as a dedicated bundle + +- **Decision**: Create `packages/specfact-pii-gdpr/` with its own manifest and `privacy` command. +- **Why**: Privacy review needs different detectors, output-handling constraints, and ownership expectations than generic security scanning. +- **Alternative considered**: Fold privacy into `specfact-security`. Rejected because it would mix redaction-sensitive workflows into a broader operational surface. + +### 2. Make redaction a first-class adapter responsibility + +- **Decision**: Detector adapters return normalized findings plus redaction-safe evidence references, not raw PII payloads. +- **Why**: The modules repo must not create a privacy tool that leaks the very data it flags. +- **Alternative considered**: Preserve raw samples for debugging. Rejected because it conflicts with the GDPR/minimization posture described in the plan. + +### 3. Bind EU/GDPR behavior to shared rule packs + +- **Decision**: GDPR articles, lawful-basis checks, and region/residency assertions come from the paired core baseline plus shared policy-pack selection, not bundle-local hard-coded mode flags. +- **Why**: Privacy governance needs one source of truth for what counts as compliant. +- **Alternative considered**: Bundle-local configuration only. Rejected because it would drift from core policy semantics and enterprise extensions. + +## Risks / Trade-offs + +- **Risk**: Detector precision may vary across languages and file formats. → **Mitigation**: specify deterministic fixtures and safe false-positive handling in tests. +- **Risk**: Over-redaction may reduce remediation usefulness. → **Mitigation**: preserve structured evidence references and field/type classifications while suppressing raw values. +- **Risk**: Users may misread the bundle as a full legal compliance product. → **Mitigation**: document explicit boundaries and keep requirements tied to codified rule packs. + +## Migration Plan + +1. Confirm the paired core privacy/GDPR contracts are stable enough for integration. +2. Implement the new package, detector adapters, and redaction-safe reporting flow. +3. Add docs, registry metadata, signatures, and compatibility ranges together for release. + +## Open Questions + +- Which detector backend gives the best default balance between offline operation and acceptable false-positive rates? +- Should residency checks operate only on explicit configuration/metadata inputs in the first release, or also inspect provider defaults? diff --git a/openspec/changes/security-03-module-pii-gdpr-eu/proposal.md b/openspec/changes/security-03-module-pii-gdpr-eu/proposal.md new file mode 100644 index 00000000..ffb42044 --- /dev/null +++ b/openspec/changes/security-03-module-pii-gdpr-eu/proposal.md @@ -0,0 +1,45 @@ +# Change: Privacy And GDPR EU Module + +## Why + +The five-pillar plan requires a dedicated runtime surface for PII detection, GDPR-oriented checks, and EU data-governance rules. Without a specialized module, privacy findings stay theoretical and cannot participate in the same review, evidence, and policy workflows as the other governance pillars. + +## What Changes + +- **NEW**: Add a `specfact-pii-gdpr` bundle with a `privacy` command focused on PII detection and GDPR/EU rule execution. +- **NEW**: Package detectors and rule resources that classify prompt/log/data findings into the paired core privacy and GDPR finding categories. +- **NEW**: Add EU-specific residency and lawful-basis checks that can be enabled through the shared policy-pack mechanism rather than ad hoc flags. +- **NEW**: Define redaction-safe evidence and reporting surfaces so privacy reviews can feed downstream knowledge/enterprise flows without leaking sensitive payloads. +- **EXTEND**: Reserve manifest, registry, docs, and signing work for a first-party privacy bundle. + +## Capabilities + +### New Capabilities + +- `privacy-gdpr-module`: Runtime bundle, command surface, detectors, and redaction-safe reporting for PII and GDPR/EU governance. + +### Modified Capabilities + +_None._ + +## Impact + +- Affected code: future `packages/specfact-pii-gdpr/`, detector adapters, and GDPR rule resources. +- Affected docs: bundle overview and command-reference documentation for `privacy`. +- Dependencies: paired core changes `security-01-unified-findings-model` and `security-02-eu-gdpr-baseline`. +- **Blocked-by (policy-pack core)**: shared policy-pack semantics from `policy-02-packs-and-modes` (see umbrella [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511)) must land before this module can rely on policy-driven GDPR/EU packs instead of ad hoc flags. +- Release impact: introduces a new signed official bundle and registry entry. + +--- + +## Source Tracking + + +- **Core umbrella (specfact-cli)**: [specfact-cli#511](https://github.com/nold-ai/specfact-cli/issues/511) +- **Modules Epic**: [#216](https://github.com/nold-ai/specfact-cli-modules/issues/216) +- **Parent Feature**: [#218](https://github.com/nold-ai/specfact-cli-modules/issues/218) +- **GitHub Issue**: [#229](https://github.com/nold-ai/specfact-cli-modules/issues/229) +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: proposed +- **Sanitized**: false diff --git a/openspec/changes/security-03-module-pii-gdpr-eu/specs/privacy-gdpr-module/spec.md b/openspec/changes/security-03-module-pii-gdpr-eu/specs/privacy-gdpr-module/spec.md new file mode 100644 index 00000000..523493a7 --- /dev/null +++ b/openspec/changes/security-03-module-pii-gdpr-eu/specs/privacy-gdpr-module/spec.md @@ -0,0 +1,30 @@ +# Privacy and GDPR Module Specification + +## ADDED Requirements + +### Requirement: Privacy bundle registration + +The modules repository SHALL provide a signed official bundle named `nold-ai/specfact-pii-gdpr` that exposes the `privacy` command and declares truthful bundle dependencies, pip dependencies, and `core_compatibility` entries for `security-01-unified-findings-model` and `security-02-eu-gdpr-baseline` (shared privacy/GDPR findings and EU baseline contracts). + +#### Scenario: Bundle manifest is loaded + +- **WHEN** SpecFact reads installed module manifests +- **THEN** the privacy bundle is discoverable as an official package with the `privacy` command and valid compatibility metadata + +### Requirement: PII and GDPR detections use normalized safe findings + +The bundle SHALL translate detector output into the privacy and GDPR finding/report contracts from `security-01-unified-findings-model` and `security-02-eu-gdpr-baseline` and SHALL store only redaction-safe evidence references rather than raw sensitive payloads. + +#### Scenario: PII is detected in a prompt or log artifact + +- **WHEN** the bundle identifies a configured PII type in analyzed content +- **THEN** it emits a normalized privacy finding that records the PII class and evidence reference without persisting the raw sensitive value + +### Requirement: EU and GDPR rule packs affect privacy command outcomes + +The bundle SHALL honor the EU/GDPR baseline from `security-02-eu-gdpr-baseline` and the shared policy/profile modes from `policy-02-packs-and-modes` when determining privacy command status and remediation messaging. + +#### Scenario: EU residency policy is violated in hard mode + +- **WHEN** the bundle evaluates configured residency data and finds a violation while policy mode is hard +- **THEN** it reports a normalized GDPR finding and returns a blocking command outcome diff --git a/openspec/changes/security-03-module-pii-gdpr-eu/tasks.md b/openspec/changes/security-03-module-pii-gdpr-eu/tasks.md new file mode 100644 index 00000000..02e262f2 --- /dev/null +++ b/openspec/changes/security-03-module-pii-gdpr-eu/tasks.md @@ -0,0 +1,30 @@ +# 1. Branch and dependency guardrails + +- [ ] 1.1 Create `chore/security-03-module-pii-gdpr-eu` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm paired core changes `security-01-unified-findings-model` and `security-02-eu-gdpr-baseline` are available and document the minimum required `core_compatibility` (to be recorded in the module manifest). +- [ ] 1.3 Before implementation, create or sync public GitHub tracking metadata for this change, including parent linkage, labels, project assignment, blockers, blocked-by relationships, and `in progress` concurrency checks. + +## 2. Spec and failing-test preparation + +- [ ] 2.1 Finalize `specs/privacy-gdpr-module/spec.md`. +- [ ] 2.2 Write failing tests for PII detection normalization, GDPR article/lawful-basis mapping, and redaction-safe evidence handling. +- [ ] 2.3 Write failing tests for EU residency checks and command behavior across policy modes. +- [ ] 2.4 Capture failing-first evidence in `openspec/changes/security-03-module-pii-gdpr-eu/TDD_EVIDENCE.md`. + +## 3. Bundle implementation + +- [ ] 3.1 Scaffold `packages/specfact-pii-gdpr/` with manifest, Typer entrypoints, detector adapters, and bundled rule resources. +- [ ] 3.2 Update `packages/specfact-pii-gdpr/module-package.yaml` with the minimum required `core_compatibility` value documented in task 1.2. +- [ ] 3.3 Implement normalization into the paired core privacy/GDPR findings/report contracts with redaction-safe evidence references. +- [ ] 3.4 Integrate shared policy/profile handling for lawful-basis and residency checks plus optional knowledge evidence hooks. +- [ ] 3.5 Validate bundled rule resources: add a pre-publish validation that confirms all templates/resources in packages/specfact-pii-gdpr/ are included in the package, fail the release if any are missing, and include the missing resource path in the error message. +- [ ] 3.6 Update registry metadata, docs references, and any import allowlists required by the new bundle. Note: signature artifacts are produced by publish automation and validated by CI gates; do not manually commit signature files. + +## 4. Verification and delivery + +- [ ] 4.1 Verify compatibility with `nold-ai/specfact-cli` whenever changing shared privacy/GDPR findings or report contracts by running and reviewing the paired public artifacts/tests in specfact-cli and confirming any required adapter changes or test updates before PR handoff. +- [ ] 4.2 Re-run targeted tests and affected package coverage; record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.3 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, then `hatch run contract-test` and relevant `hatch run smart-test` / `hatch run test`. +- [ ] 4.4 Run `hatch run specfact code review run --json --out .specfact/code-review.json --scope full`, remediate every finding, and record the command plus timestamp in `TDD_EVIDENCE.md`. +- [ ] 4.5 Run `openspec validate security-03-module-pii-gdpr-eu --strict`. +- [ ] 4.6 Open the modules PR to `dev`, cross-link paired privacy/security changes, and note any deferred detector providers as follow-up issues. \ No newline at end of file diff --git a/openspec/specs/ci-integration/spec.md b/openspec/specs/ci-integration/spec.md index f1109782..91984337 100644 --- a/openspec/specs/ci-integration/spec.md +++ b/openspec/specs/ci-integration/spec.md @@ -13,9 +13,27 @@ for pull requests or pushes targeting `dev`; it SHALL enforce `--require-signatu - **WHEN** a pull request targets `dev` - **AND** the PR contains package manifest changes with checksum-only integrity blocks -- **THEN** the `verify-module-signatures` job SHALL pass without `--require-signature` +- **THEN** the CI signing workflow SHALL repair changed same-repo + `packages/*/module-package.yaml` manifests on the PR branch before verification completes +- **AND** the `verify-module-signatures` job SHALL pass without `--require-signature` - **AND** all downstream jobs (`quality`, `contract-tests`, etc.) SHALL not be blocked +#### Scenario: Same-repo PR update re-signs manifests after review fixes + +- **WHEN** a same-repo pull request targets `dev` or `main` +- **AND** a later `pull_request` `synchronize` event adds review-fix commits that change module + payloads +- **THEN** the CI signing workflow SHALL re-sign the changed manifests on the PR head branch +- **AND** SHALL commit `chore(modules): ci sign changed modules` only when the manifests changed +- **AND** the workflow SHALL skip this remediation for its own bot-authored signing commit + +#### Scenario: Fork PR remains verify-only + +- **WHEN** a pull request targets `dev` or `main` +- **AND** the head branch lives in a fork +- **THEN** the CI signing workflow SHALL NOT attempt to push repaired manifests to that branch +- **AND** verification SHALL remain read-only + #### Scenario: Dev-to-main PR with unsigned manifests (before approval) - **WHEN** a pull request targets `main` @@ -52,4 +70,3 @@ The repository pre-commit hook that runs `verify-modules-signature.py` SHALL app - **WHEN** a developer commits on branch `main` - **AND** any `packages/*/module-package.yaml` lacks a valid signature under `--require-signature` - **THEN** the pre-commit signature hook SHALL fail - diff --git a/openspec/specs/ci-module-signing-on-approval/spec.md b/openspec/specs/ci-module-signing-on-approval/spec.md index 1448dc9e..aa8509e5 100644 --- a/openspec/specs/ci-module-signing-on-approval/spec.md +++ b/openspec/specs/ci-module-signing-on-approval/spec.md @@ -6,8 +6,8 @@ TBD - created by archiving change marketplace-06-ci-module-signing. Update Purpo ### Requirement: Sign packages manifests on PR approval The system SHALL automatically sign changed `packages/*/module-package.yaml` manifests using CI -secrets when a pull request targeting `dev` or `main` is approved, and SHALL commit the signed -manifests back to the PR branch. +secrets when a same-repo pull request targeting `dev` or `main` receives a trusted approval review, +and SHALL commit the signed manifests back to the PR branch. #### Scenario: PR to dev approved with package module changes @@ -34,6 +34,13 @@ manifests back to the PR branch. - **AND** no files under `packages/` have changed relative to the base branch - **THEN** the CI signing workflow SHALL exit cleanly with no commit +#### Scenario: Approval workflow does not trigger on PR synchronize events + +- **WHEN** a same-repo pull request already has a trusted approval +- **AND** the PR later receives a `pull_request` `synchronize` event +- **THEN** `sign-modules-on-approval.yml` SHALL NOT be triggered by that event +- **AND** PR-update signing SHALL remain the responsibility of the general PR signing workflow + #### Scenario: Missing signing secret - **WHEN** the signing workflow triggers on approval @@ -65,4 +72,3 @@ from `src/specfact_cli/modules/` or `modules/` (which do not exist in this repos - **WHEN** the signing workflow runs twice on the same package payload - **THEN** the resulting `integrity:` block SHALL be byte-for-byte identical - **AND** the second run SHALL produce no git diff and SHALL skip the commit - diff --git a/packages/specfact-code-review/module-package.yaml b/packages/specfact-code-review/module-package.yaml index 48e91a00..2f5114f0 100644 --- a/packages/specfact-code-review/module-package.yaml +++ b/packages/specfact-code-review/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-code-review -version: 0.47.13 +version: 0.47.14 commands: - code tier: official @@ -23,5 +23,5 @@ description: Official SpecFact code review bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:5345c5f7d453481edc033ebb64f5b38d3d8ebdcd8a8c6ae1fcd879d5136dc919 - signature: UyRN+72IHFSxnU1woW5XJwuBRd1eZQrgKT4XODi2LAhI7G/RgY2SRCrde1fugYuE9Rc4Wx8kNMdjzah01DoPAA== + checksum: sha256:9feec110298126ea77f58789dc4c7ba61ff9bfdcaee91b580afd57b9178903ae + signature: kIHPg05QU/PSTuGYnn1a2JtXooCCWx1gGZ4bmjoJqOjRQpso/tD2xaApAjYOwCEpXXpRceaBMa+PC3DrniKrCA== diff --git a/packages/specfact-code-review/src/specfact_code_review/run/findings.py b/packages/specfact-code-review/src/specfact_code_review/run/findings.py index ccaa9672..dea9c5cc 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/findings.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/findings.py @@ -31,6 +31,39 @@ FAIL = "FAIL" +class EvidenceRef(BaseModel): + """Structured representation of supplemental evidence reference.""" + + path: str | None = Field(default=None, description="Stable file path reference.") + start_line: int | None = Field(default=None, ge=1, description="Start line number (1-based).") + end_line: int | None = Field(default=None, ge=1, description="End line number (1-based).") + artifact_id: str | None = Field(default=None, description="Artifact identifier.") + description: str | None = Field(default=None, description="Description of the evidence.") + + @field_validator("path", "artifact_id", "description") + @classmethod + def _validate_non_empty_if_present(cls, value: str | None) -> str | None: + if value is not None and not value.strip(): + raise ValueError("value must not be empty if provided") + return value + + @model_validator(mode="after") + def _validate_invariants(self) -> EvidenceRef: + # At least one locator must be present + if self.path is None and self.artifact_id is None and self.start_line is None: + raise ValueError("at least one locator (path, artifact_id, or start_line) must be provided") + + # If end_line is provided, start_line must be provided + if self.end_line is not None and self.start_line is None: + raise ValueError("start_line must be provided if end_line is present") + + # If both start_line and end_line are provided, end_line >= start_line + if self.start_line is not None and self.end_line is not None and self.end_line < self.start_line: + raise ValueError("end_line must be greater than or equal to start_line") + + return self + + class ReviewFinding(BaseModel): """Structured representation of a code-review finding.""" @@ -56,6 +89,10 @@ class ReviewFinding(BaseModel): line: int = Field(..., ge=1, description="1-based source line number.") message: str = Field(..., description="User-facing finding message.") fixable: bool = Field(default=False, description="Whether the finding can be automatically fixed.") + evidence_refs: list[EvidenceRef] | None = Field( + default=None, + description="Optional supplemental references with stable file paths, line ranges, or artifact identifiers.", + ) @field_validator("tool", "rule", "file", "message") @classmethod diff --git a/packages/specfact-project/module-package.yaml b/packages/specfact-project/module-package.yaml index 5ce109ae..0f2994a5 100644 --- a/packages/specfact-project/module-package.yaml +++ b/packages/specfact-project/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-project -version: 0.41.3 +version: 0.41.7 commands: - project tier: official @@ -27,5 +27,5 @@ description: Official SpecFact project bundle package. category: project bundle_group_command: project integrity: - checksum: sha256:96e0b0266d79064beaeada8dfa3a0c2decf80038c640a422200be9bc5d51a0db - signature: J05ibXhduI10YnFVlNiEZik8S+VHsTbpU1FkWEYYeb+LKS4d74+f7AdWMgAz8DKNUwSrlBkjfqvLpoOh65XhBQ== + checksum: sha256:7c2e1a87b345c6344f602a533f072b40f87437ab6c7d6b1851364a5f0f2d5405 + signature: 2MZlG91YNoOvlEjqEoDlKQOidX2SVhIQ/DbV51igr8HZbt4TZPK4/UH9OdUhi73XjqQVcW8BmjyqxZ21oKxFCg== diff --git a/packages/specfact-project/resources/templates/github-action.yml.j2 b/packages/specfact-project/resources/templates/github-action.yml.j2 new file mode 100644 index 00000000..0096049b --- /dev/null +++ b/packages/specfact-project/resources/templates/github-action.yml.j2 @@ -0,0 +1,27 @@ +name: SpecFact Validation + +on: + pull_request: + push: + branches: + - main + - dev + +jobs: + specfact: + runs-on: ubuntu-latest + timeout-minutes: {{ budget }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "{{ python_version }}" + + - name: Install SpecFact + run: pip install specfact-cli + + - name: Run validation + run: specfact validate --repo . --name "{{ repo_name }}" diff --git a/packages/specfact-project/resources/templates/persona/default.md.j2 b/packages/specfact-project/resources/templates/persona/default.md.j2 new file mode 100644 index 00000000..41a97f4e --- /dev/null +++ b/packages/specfact-project/resources/templates/persona/default.md.j2 @@ -0,0 +1,59 @@ +# {{ persona_name | replace("-", " ") | title }} + +_Generated from bundle `{{ bundle_name }}` on {{ created_at }}._ + +{% if idea %} +## Idea + +### Title +{{ idea.title }} + +{% if idea.narrative %} +### Narrative +{{ idea.narrative }} + +{% endif %} +{% endif %} +{% if business %} +## Business + +{# Intentional machine-readable JSON embedding for downstream tooling #} +{{ business | tojson(indent=2) }} + +{% endif %} +{% if product %} +## Product + +{# Intentional machine-readable JSON embedding for downstream tooling #} +{{ product | tojson(indent=2) }} + +{% endif %} +{% if features %} +## Features + +{% for feature_key, feature in features.items() %} +### {{ feature.title or feature_key }} + +{% if feature.outcomes %} +#### Outcomes +{% for outcome in feature.outcomes %} +- {{ outcome }} +{% endfor %} + +{% endif %} +{% if feature.acceptance %} +#### Acceptance +{% for item in feature.acceptance %} +- {{ item }} +{% endfor %} + +{% endif %} +{% if feature.constraints %} +#### Constraints +{% for item in feature.constraints %} +- {{ item }} +{% endfor %} + +{% endif %} +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/packages/specfact-project/resources/templates/protocol.yaml.j2 b/packages/specfact-project/resources/templates/protocol.yaml.j2 new file mode 100644 index 00000000..b29e0230 --- /dev/null +++ b/packages/specfact-project/resources/templates/protocol.yaml.j2 @@ -0,0 +1,20 @@ +states: +{% for state in states %} + - {{ state | tojson }} +{% endfor %} +start: {{ start | tojson }} +transitions: +{% for transition in transitions %} + - from_state: {{ transition.from_state | tojson }} + on_event: {{ transition.on_event | tojson }} + to_state: {{ transition.to_state | tojson }} +{% if transition.guard %} + guard: {{ transition.guard | tojson }} +{% endif %} +{% endfor %} +{% if guards %} +guards: +{% for guard_name, guard_expr in guards.items() %} + {{ guard_name | tojson }}: {{ guard_expr | tojson }} +{% endfor %} +{% endif %} diff --git a/packages/specfact-project/src/specfact_project/generators/plan_generator.py b/packages/specfact-project/src/specfact_project/generators/plan_generator.py index 93971c02..d4571515 100644 --- a/packages/specfact-project/src/specfact_project/generators/plan_generator.py +++ b/packages/specfact-project/src/specfact_project/generators/plan_generator.py @@ -10,6 +10,8 @@ from specfact_cli.models.plan import PlanBundle from specfact_cli.utils.structured_io import StructuredFormat, dump_structured_file, dumps_structured_data +from specfact_project.generators.template_paths import resolve_templates_dir + class PlanGenerator: """ @@ -27,8 +29,7 @@ def __init__(self, templates_dir: Path | None = None) -> None: templates_dir: Directory containing Jinja2 templates (default: resources/templates) """ if templates_dir is None: - # Default to resources/templates relative to project root - templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates" + templates_dir = resolve_templates_dir() self.templates_dir = Path(templates_dir) self.env = Environment( diff --git a/packages/specfact-project/src/specfact_project/generators/protocol_generator.py b/packages/specfact-project/src/specfact_project/generators/protocol_generator.py index 41c12a70..8144fe3e 100644 --- a/packages/specfact-project/src/specfact_project/generators/protocol_generator.py +++ b/packages/specfact-project/src/specfact_project/generators/protocol_generator.py @@ -9,6 +9,8 @@ from jinja2 import Environment, FileSystemLoader from specfact_cli.models.protocol import Protocol +from specfact_project.generators.template_paths import resolve_project_templates_dir + class ProtocolGenerator: """ @@ -26,8 +28,7 @@ def __init__(self, templates_dir: Path | None = None) -> None: templates_dir: Directory containing Jinja2 templates (default: resources/templates) """ if templates_dir is None: - # Default to resources/templates relative to project root - templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates" + templates_dir = resolve_project_templates_dir() self.templates_dir = Path(templates_dir) self.env = Environment( diff --git a/packages/specfact-project/src/specfact_project/generators/template_paths.py b/packages/specfact-project/src/specfact_project/generators/template_paths.py new file mode 100644 index 00000000..26110b52 --- /dev/null +++ b/packages/specfact-project/src/specfact_project/generators/template_paths.py @@ -0,0 +1,76 @@ +"""Shared helpers for resolving packaged project-bundle Jinja templates.""" + +from __future__ import annotations + +from pathlib import Path + +from beartype import beartype +from icontract import ensure, require + + +def _candidate_template_roots() -> tuple[Path, ...]: + package_root = Path(__file__).parent.parent + return ( + package_root / "resources" / "templates", + package_root.parent.parent / "resources" / "templates", + ) + + +def _target(root: Path, subdir: str | None) -> Path: + return root / subdir if subdir else root + + +def _matches_required_glob(path: Path, required_glob: str | None) -> bool: + return path.exists() and (required_glob is None or any(path.glob(required_glob))) + + +@beartype +@require( + lambda templates_dir: templates_dir is None or isinstance(templates_dir, Path), "templates_dir must be Path or None" +) +@ensure(lambda result: isinstance(result, Path), "must resolve to a Path") +def resolve_project_templates_dir( + templates_dir: Path | None = None, + *, + subdir: str | None = None, + required_glob: str | None = None, +) -> Path: + """Return the preferred project-bundle templates directory.""" + if templates_dir is not None: + return Path(templates_dir) + + roots = _candidate_template_roots() + for root in roots: + candidate = _target(root, subdir) + if _matches_required_glob(candidate, required_glob): + return candidate + + checked = ", ".join(str(_target(r, subdir)) for r in roots) + raise RuntimeError( + f"No packaged template root matched required_glob={required_glob!r}; " + f"candidates (after subdir={subdir!r}): {checked}" + ) + + +@beartype +@require( + lambda templates_dir: templates_dir is None or isinstance(templates_dir, Path), "templates_dir must be Path or None" +) +@ensure(lambda result: isinstance(result, Path), "must resolve to a Path") +def resolve_templates_dir( + templates_dir: Path | None = None, + subdir: str | None = None, + required_glob: str | None = None, +) -> Path: + """Backward-compatible alias for project template resolution.""" + return resolve_project_templates_dir(templates_dir=templates_dir, subdir=subdir, required_glob=required_glob) + + +@beartype +@require( + lambda templates_dir: templates_dir is None or isinstance(templates_dir, Path), "templates_dir must be Path or None" +) +@ensure(lambda result: isinstance(result, Path), "must resolve to a Path") +def resolve_runtime_template_dir(templates_dir: Path | None = None) -> Path: + """Backward-compatible alias for runtime template resolution.""" + return resolve_project_templates_dir(templates_dir=templates_dir) diff --git a/packages/specfact-project/src/specfact_project/generators/workflow_generator.py b/packages/specfact-project/src/specfact_project/generators/workflow_generator.py index 5f3287b1..337aeccc 100644 --- a/packages/specfact-project/src/specfact_project/generators/workflow_generator.py +++ b/packages/specfact-project/src/specfact_project/generators/workflow_generator.py @@ -10,6 +10,8 @@ from icontract import ensure, require from jinja2 import Environment, FileSystemLoader +from specfact_project.generators.template_paths import resolve_templates_dir + class WorkflowGenerator: """ @@ -26,11 +28,7 @@ def __init__(self, templates_dir: Path | None = None) -> None: Args: templates_dir: Directory containing Jinja2 templates (default: resources/templates) """ - if templates_dir is None: - # Default to resources/templates relative to project root - templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates" - - self.templates_dir = Path(templates_dir) + self.templates_dir = resolve_templates_dir(templates_dir) self.env = Environment( loader=FileSystemLoader(self.templates_dir), trim_blocks=True, diff --git a/packages/specfact-project/src/specfact_project/import_cmd/commands.py b/packages/specfact-project/src/specfact_project/import_cmd/commands.py index 70945dba..10e23f39 100644 --- a/packages/specfact-project/src/specfact_project/import_cmd/commands.py +++ b/packages/specfact-project/src/specfact_project/import_cmd/commands.py @@ -10,6 +10,7 @@ import multiprocessing import os +import sys import time from pathlib import Path from typing import TYPE_CHECKING, Any @@ -2499,6 +2500,10 @@ def from_code( from specfact_cli.modes import get_router from specfact_cli.utils.structure import SpecFactStructure + from specfact_project import import_runtime_patches as _import_runtime_patches + + _import_runtime_patches.apply_import_runtime_patches(commands_module=sys.modules[__name__]) + if is_debug_mode(): debug_log_operation( "command", diff --git a/packages/specfact-project/src/specfact_project/import_runtime_patches.py b/packages/specfact-project/src/specfact_project/import_runtime_patches.py new file mode 100644 index 00000000..fdb04be9 --- /dev/null +++ b/packages/specfact-project/src/specfact_project/import_runtime_patches.py @@ -0,0 +1,265 @@ +"""Runtime patch helpers for code-import discovery policy.""" + +from __future__ import annotations + +import importlib +import inspect +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +from beartype import beartype +from icontract import ensure, require + +from specfact_project.utils.import_path_policy import ImportDiscoveryResult, discover_code_files + + +_PATCH_APPLIED = False +_PATCH_APPLY_LOCK = threading.RLock() +_CONSOLE_PRINT_PATCH_LOCK = threading.RLock() +_rglob_patch_lock = threading.RLock() + + +def _pattern_to_extension(pattern: str) -> str | None: + if pattern.startswith("*.") and all(ch not in pattern[2:] for ch in "*?[]/\\"): + return pattern[1:] + return None + + +def _build_discovery( + repo_root: Path, + *, + entry_point: Path | None = None, + include_tests: bool = True, +) -> ImportDiscoveryResult: + return discover_code_files(repo_root, extensions={".py"}, entry_point=entry_point, include_tests=include_tests) + + +@dataclass(slots=True) +class _AnalyzeCodebaseArgs: + repo: Path + entry_point: Path | None + bundle: str + confidence: float + key_format: str + routing_result: Any + incremental_callback: Any | None + + +@dataclass(slots=True) +class _RelationshipArgs: + repo: Path + entry_point: Path | None + bundle_dir: Path + incremental_changes: dict[str, bool] | None + plan_bundle: Any + should_regenerate_relationships: bool + should_regenerate_graph: bool + include_tests: bool + + +@contextmanager +def _patched_rglob( + repo_root: Path, + *, + entry_point: Path | None = None, + include_tests: bool = True, + max_files: int | None = None, +) -> Iterator[None]: + resolved_repo_root = repo_root.resolve() + resolved_entry_point = entry_point.resolve() if entry_point else None + + with _rglob_patch_lock: + original_rglob = Path.rglob + + def patched(self: Path, pattern: str): # type: ignore[override] + extension = _pattern_to_extension(pattern) + if extension is None: + return original_rglob(self, pattern) + + resolved_self = self.resolve() + if resolved_self not in (resolved_repo_root, resolved_entry_point): + return original_rglob(self, pattern) + + scoped_entry_point = None if resolved_self == resolved_repo_root else resolved_self + discovery = discover_code_files( + resolved_repo_root, + extensions={extension}, + entry_point=scoped_entry_point, + include_tests=include_tests, + max_files=max_files, + ) + return iter(discovery.files) + + Path.rglob = patched # type: ignore[assignment] + try: + yield + finally: + Path.rglob = original_rglob # type: ignore[assignment] + + +@contextmanager +def _patched_console_print(commands_module: Any) -> Iterator[None]: + with _CONSOLE_PRINT_PATCH_LOCK: + original_print = commands_module.console.print + + def patched(*args: Any, **kwargs: Any) -> None: + if args and isinstance(args[0], str) and "typically takes 2-5 minutes" in args[0]: + return + original_print(*args, **kwargs) + + commands_module.console.print = patched + try: + yield + finally: + commands_module.console.print = original_print + + +def _emit_runtime_warnings(commands_module: Any, discovery: ImportDiscoveryResult) -> None: + for warning in discovery.warnings: + commands_module.console.print(f"[yellow]⚠ {warning}[/yellow]") + message = ( + f"[yellow]⏱️ Provisional import estimate based on discovered work: about {discovery.provisional_eta}[/yellow]" + if discovery.provisional_eta + else "\n[yellow]⏱️ Import duration depends on discovered source files and repository artifacts; " + "remaining time updates as work is processed.[/yellow]" + ) + commands_module.console.print(message) + + +def _patch_code_analyzer(code_analyzer: Any) -> None: + original_analyze = code_analyzer.CodeAnalyzer.analyze + + def patched_analyze(self: Any): + with _patched_rglob(self.repo_path, entry_point=self.entry_point): + return original_analyze(self) + + code_analyzer.CodeAnalyzer.analyze = patched_analyze + + +def _patch_count_python_files(commands: Any) -> None: + def patched_count_python_files(repo: Path) -> int: + return len(_build_discovery(repo).files) + + commands._count_python_files = patched_count_python_files + + +def _run_patched_analyze_codebase(original_analyze_codebase: Any, commands: Any, args: _AnalyzeCodebaseArgs): + discovery = _build_discovery(args.repo, entry_point=args.entry_point) + _emit_runtime_warnings(commands, discovery) + with _patched_console_print(commands), _patched_rglob(args.repo, entry_point=args.entry_point): + return original_analyze_codebase( + args.repo, + args.entry_point, + args.bundle, + args.confidence, + args.key_format, + args.routing_result, + args.incremental_callback, + ) + + +def _build_analyze_args_from_mapping(arguments: dict[str, Any]) -> _AnalyzeCodebaseArgs: + return _AnalyzeCodebaseArgs( + repo=arguments["repo"], + entry_point=arguments["entry_point"], + bundle=arguments["bundle"], + confidence=arguments["confidence"], + key_format=arguments["key_format"], + routing_result=arguments["routing_result"], + incremental_callback=arguments.get("incremental_callback"), + ) + + +def _install_patch(target: Any, attr_name: str, runner: Any, args_builder: Any, context: Any) -> None: + original = getattr(target, attr_name) + signature = inspect.signature(original) + + def patched(*args: Any, **kwargs: Any) -> Any: + bound = signature.bind_partial(*args, **kwargs) + built = args_builder(dict(bound.arguments)) + if kwargs: + built = replace(built, **kwargs) + return runner(original, context, built) + + setattr(target, attr_name, patched) + + +def _run_patched_extract_relationships(original_extract_relationships: Any, commands: Any, args: _RelationshipArgs): + discovery = _build_discovery(args.repo, entry_point=args.entry_point, include_tests=args.include_tests) + for warning in discovery.warnings: + commands.console.print(f"[yellow]⚠ {warning}[/yellow]") + with _patched_rglob(args.repo, entry_point=args.entry_point, include_tests=args.include_tests): + return original_extract_relationships( + args.repo, + args.entry_point, + args.bundle_dir, + args.incremental_changes, + args.plan_bundle, + args.should_regenerate_relationships, + args.should_regenerate_graph, + args.include_tests, + ) + + +def _build_relationship_args_from_mapping(arguments: dict[str, Any]) -> _RelationshipArgs: + return _RelationshipArgs( + repo=arguments["repo"], + entry_point=arguments["entry_point"], + bundle_dir=arguments["bundle_dir"], + incremental_changes=arguments["incremental_changes"], + plan_bundle=arguments["plan_bundle"], + should_regenerate_relationships=arguments["should_regenerate_relationships"], + should_regenerate_graph=arguments["should_regenerate_graph"], + include_tests=bool(arguments.get("include_tests", False)), + ) + + +def _patch_load_codebase_context(analyze_agent: Any) -> None: + original_load_codebase_context = analyze_agent.AnalyzeAgent._load_codebase_context + + def patched_load_codebase_context(self: Any, repo_path: Path) -> dict[str, Any]: + with _patched_rglob(repo_path, max_files=100): + return original_load_codebase_context(self, repo_path) + + analyze_agent.AnalyzeAgent._load_codebase_context = patched_load_codebase_context + + +@beartype +@require(lambda: True, "Patch application has no runtime preconditions") +@ensure(lambda: _PATCH_APPLIED is True, "Patches must be marked applied after execution") +def apply_import_runtime_patches(*, commands_module: Any | None = None) -> None: + """Patch import-runtime entrypoints without modifying legacy high-complexity files.""" + global _PATCH_APPLIED + with _PATCH_APPLY_LOCK: + if _PATCH_APPLIED: + return + + from specfact_project.agents import analyze_agent + from specfact_project.analyzers import code_analyzer + + commands = commands_module + if commands is None: + commands = importlib.import_module("specfact_project.import_cmd.commands") + + _patch_code_analyzer(code_analyzer) + _patch_count_python_files(commands) + _install_patch( + commands, + "_analyze_codebase", + _run_patched_analyze_codebase, + _build_analyze_args_from_mapping, + commands, + ) + _install_patch( + commands, + "_extract_relationships_and_graph", + _run_patched_extract_relationships, + _build_relationship_args_from_mapping, + commands, + ) + _patch_load_codebase_context(analyze_agent) + _PATCH_APPLIED = True diff --git a/packages/specfact-project/src/specfact_project/sync/commands.py b/packages/specfact-project/src/specfact_project/sync/commands.py index 6433b544..b233096c 100644 --- a/packages/specfact-project/src/specfact_project/sync/commands.py +++ b/packages/specfact-project/src/specfact_project/sync/commands.py @@ -748,6 +748,10 @@ def sync_repository( if target is None: target = resolved_repo / ".specfact" + from specfact_project import import_runtime_patches as _import_runtime_patches + + _import_runtime_patches.apply_import_runtime_patches() + sync = RepositorySync(resolved_repo, target, confidence_threshold=confidence) if watch: diff --git a/packages/specfact-project/src/specfact_project/utils/import_path_policy.py b/packages/specfact-project/src/specfact_project/utils/import_path_policy.py new file mode 100644 index 00000000..df6b5e24 --- /dev/null +++ b/packages/specfact-project/src/specfact_project/utils/import_path_policy.py @@ -0,0 +1,475 @@ +"""Shared ignore and discovery policy for code import runtime scans.""" + +from __future__ import annotations + +import os +from collections.abc import Iterable +from dataclasses import dataclass +from fnmatch import fnmatch +from pathlib import Path +from typing import Any, cast + +from beartype import beartype +from icontract import ensure, require + + +DEFAULT_IGNORED_DIR_NAMES = { + ".git", + ".hg", + ".idea", + ".mypy_cache", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".specfact", + ".svn", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "env", + "htmlcov", + "node_modules", + "site-packages", + "venv", +} +DEFAULT_IGNORED_FILE_NAMES = {".DS_Store"} +DEFAULT_IGNORED_FILE_SUFFIXES = {".pyc", ".pyo"} +DEFAULT_IGNORED_SEGMENTS = (".eggs",) +_DEFAULT_SPECFACTIGNORE = Path(".specfact") / ".specfactignore" +_DEFAULT_HEAVY_IGNORED_ENTRY_THRESHOLD = 500 +_DEFAULT_LARGE_REPO_FILE_THRESHOLD = 1000 +_MAX_IGNORED_ENTRY_COUNT_SAMPLE = 100 + + +@dataclass(slots=True) +class ImportDiscoveryResult: + """Filtered discovery result with warning metadata.""" + + files: list[Path] + warnings: list[str] + ignored_counts: dict[str, int] + provisional_eta: str | None = None + + +@dataclass(slots=True, frozen=True) +class ImportDiscoveryOptions: + """Configuration for runtime discovery scans.""" + + extensions: frozenset[str] + entry_point: Path | None = None + include_tests: bool = True + ignore_patterns: tuple[str, ...] = () + allow_hidden: bool = False + heavy_ignored_entry_threshold: int = _DEFAULT_HEAVY_IGNORED_ENTRY_THRESHOLD + large_repo_file_threshold: int = _DEFAULT_LARGE_REPO_FILE_THRESHOLD + max_files: int | None = None + + +@dataclass(slots=True, frozen=True) +class _DiscoveryWalkContext: + repo_root: Path + patterns: tuple[str, ...] + allow_hidden: bool + explicit_roots: frozenset[Path] + normalized_extensions: frozenset[str] + include_tests: bool + ignored_entry_sample_cap: int + + +def _contains_any_part(parts: tuple[str, ...], candidates: frozenset[str] | tuple[str, ...]) -> bool: + return any(part in candidates for part in parts) + + +def _resolve_entry_point(repo_root: Path, entry_point: Path | None) -> Path: + resolved_repo_root = repo_root.resolve() + if entry_point is None: + return resolved_repo_root + + resolved_entry_point = ( + entry_point.resolve() if entry_point.is_absolute() else (resolved_repo_root / entry_point).resolve() + ) + try: + resolved_entry_point.relative_to(resolved_repo_root) + except ValueError as exc: + raise ValueError(f"Entry point must resolve within repository: {resolved_entry_point}") from exc + return resolved_entry_point + + +def _normalize_glob_pattern(pattern: str) -> str: + normalized = pattern.strip() + if not normalized: + return "" + while normalized.startswith("./"): + normalized = normalized[2:] + while normalized.startswith("/"): + normalized = normalized[1:] + return normalized + + +def _as_tuple(patterns: Iterable[str]) -> tuple[str, ...]: + return tuple(patterns) + + +def _merged_ignore_patterns(repo_root: Path, ignore_patterns: tuple[str, ...]) -> tuple[str, ...]: + return _as_tuple(load_specfactignore_patterns(repo_root)) + ignore_patterns + + +def _normalize_extensions(extensions: Iterable[str]) -> frozenset[str]: + return frozenset(ext if ext.startswith(".") else f".{ext}" for ext in extensions) + + +def _relative_path(path: Path, repo_root: Path) -> Path | None: + try: + return path.relative_to(repo_root) + except ValueError: + return None + + +def _explicit_root_set(explicit_roots: Iterable[Path]) -> set[Path]: + return {root.resolve() for root in explicit_roots if root.exists()} + + +def _relative_to_explicit_root(path: Path, explicit_roots: Iterable[Path]) -> Path | None: + resolved_path = path.resolve() + for root in explicit_roots: + try: + return resolved_path.relative_to(root) + except ValueError: + continue + return None + + +def _contains_ignored_dir(parts: tuple[str, ...]) -> bool: + return _contains_any_part(parts, frozenset(DEFAULT_IGNORED_DIR_NAMES)) + + +def _contains_ignored_segment(parts: tuple[str, ...]) -> bool: + return _contains_any_part(parts, DEFAULT_IGNORED_SEGMENTS) + + +def _is_hidden_path(parts: tuple[str, ...], allow_hidden: bool) -> bool: + if allow_hidden: + return False + return any(part.startswith(".") for part in parts if part not in {".", ".."}) + + +def _is_ignored_file(path: Path) -> bool: + return path.is_file() and (path.name in DEFAULT_IGNORED_FILE_NAMES or path.suffix in DEFAULT_IGNORED_FILE_SUFFIXES) + + +def _has_glob_metacharacters(pattern: str) -> bool: + return any(char in pattern for char in "*?[]") + + +def _matches_dir_pattern(rel_posix: str, pattern: str) -> bool: + dir_pattern = pattern.rstrip("/") + return rel_posix == dir_pattern or rel_posix.startswith(f"{dir_pattern}/") + + +def _matches_simple_pattern(rel_posix: str, pattern: str) -> bool: + return not _has_glob_metacharacters(pattern) and (rel_posix == pattern or rel_posix.startswith(f"{pattern}/")) + + +def _matches_ignore_pattern(relative_path: Path, patterns: Iterable[str]) -> bool: + rel_posix = relative_path.as_posix() + rel_with_sep = f"{rel_posix}/" + for pattern in patterns: + cleaned = _normalize_glob_pattern(pattern) + if not cleaned: + continue + if cleaned.endswith("/") and _matches_dir_pattern(rel_posix, cleaned): + return True + if fnmatch(rel_posix, cleaned) or fnmatch(rel_with_sep, f"{cleaned}/"): + return True + if _matches_simple_pattern(rel_posix, cleaned): + return True + return False + + +@beartype +@require(lambda repo_root: repo_root.is_dir(), "repo_root must exist") +@ensure(lambda result: isinstance(result, list), "Must return list") +def load_specfactignore_patterns(repo_root: Path, ignore_file: Path | None = None) -> list[str]: + """Load repo-local ignore patterns from `.specfact/.specfactignore`.""" + + path = ignore_file or (repo_root / _DEFAULT_SPECFACTIGNORE) + if not path.exists() or path.is_dir(): + return [] + + patterns: list[str] = [] + for raw_line in path.read_text(encoding="utf-8").splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + normalized = _normalize_glob_pattern(stripped) + if normalized: + patterns.append(normalized) + return patterns + + +@beartype +@require(lambda repo_root: repo_root.is_dir(), "repo_root must exist") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def should_ignore_path( + path: Path, + repo_root: Path, + *, + ignore_patterns: Iterable[str] = (), + allow_hidden: bool = False, + explicit_roots: Iterable[Path] = (), +) -> bool: + """Return True when a file or directory should be excluded from import scans.""" + + relative_path = _relative_path(path, repo_root) + if relative_path is None: + return True + + explicit_root_set = _explicit_root_set(explicit_roots) + explicit_relative = _relative_to_explicit_root(path, explicit_root_set) + structural_path = explicit_relative if explicit_relative is not None else relative_path + parts = tuple(structural_path.parts) + ignored_by_structure = ( + _contains_ignored_dir(parts) + or _contains_ignored_segment(parts) + or _is_hidden_path(parts, allow_hidden) + or _is_ignored_file(path) + ) + if ignored_by_structure: + return True + return _matches_ignore_pattern(relative_path, ignore_patterns) + + +@beartype +@require(lambda repo_root: repo_root.is_dir(), "repo_root must exist") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def should_skip_path( + path: Path, + repo_root: Path, + *, + ignore_patterns: Iterable[str] = (), + allow_hidden: bool = False, + explicit_roots: Iterable[Path] = (), +) -> bool: + """Backward-compatible alias for callers adopting the shared import policy.""" + + return should_ignore_path( + path, + repo_root, + ignore_patterns=ignore_patterns, + allow_hidden=allow_hidden, + explicit_roots=explicit_roots, + ) + + +def _sample_directory_entries(path: Path, sample_cap: int) -> int: + count = 0 + try: + with os.scandir(path) as entries: + for count, _ in enumerate(entries, start=1): + if count >= sample_cap: + return count + except OSError: + return 1 + return count + + +def _count_ignored_entries(path: Path, sample_cap: int) -> int: + if not path.exists() or not path.is_dir(): + return 1 + count = _sample_directory_entries(path, sample_cap) + return max(count, 1) + + +def _record_ignored_count(ignored_counts: dict[str, int], name: str, count: int) -> None: + ignored_counts[name] = ignored_counts.get(name, 0) + count + + +def _filter_dirnames( + current_path: Path, + dirnames: list[str], + context: _DiscoveryWalkContext, + ignored_counts: dict[str, int], +) -> list[str]: + kept_dirnames: list[str] = [] + for dirname in dirnames: + child = current_path / dirname + if should_ignore_path( + child, + context.repo_root, + ignore_patterns=context.patterns, + allow_hidden=context.allow_hidden, + explicit_roots=context.explicit_roots, + ): + _record_ignored_count( + ignored_counts, dirname, _count_ignored_entries(child, context.ignored_entry_sample_cap) + ) + continue + kept_dirnames.append(dirname) + return kept_dirnames + + +def _should_include_file(file_path: Path, context: _DiscoveryWalkContext) -> bool: + if file_path.suffix not in context.normalized_extensions: + return False + if should_ignore_path( + file_path, + context.repo_root, + ignore_patterns=context.patterns, + allow_hidden=context.allow_hidden, + explicit_roots=context.explicit_roots, + ): + return False + return context.include_tests or not _is_test_path(file_path) + + +def _build_warnings( + files: list[Path], + ignored_counts: dict[str, int], + options: ImportDiscoveryOptions, +) -> list[str]: + warnings: list[str] = [] + for ignored_name, count in sorted(ignored_counts.items()): + if count >= options.heavy_ignored_entry_threshold: + warnings.append( + f"Ignored heavy artifact tree '{ignored_name}' ({count} top-level entries sampled) " + "to avoid inflated import duration." + ) + if len(files) >= options.large_repo_file_threshold: + warnings.append( + "Repository size may materially extend import duration; remaining time is derived from live processed work." + ) + return warnings + + +def _build_provisional_eta(files: list[Path]) -> str | None: + count = len(files) + if count == 0: + return None + if count < 50: + return "less than a minute" + minutes = max(1, count // 200) + return f"{minutes} minute(s)" + + +def _finalize_discovery( + files: list[Path], + ignored_counts: dict[str, int], + options: ImportDiscoveryOptions, +) -> ImportDiscoveryResult: + files.sort() + warnings = _build_warnings(files, ignored_counts, options) + return ImportDiscoveryResult( + files=files, + warnings=warnings, + ignored_counts=ignored_counts, + provisional_eta=_build_provisional_eta(files), + ) + + +def _process_candidate_file( + file_path: Path, + *, + context: _DiscoveryWalkContext, + ignored_counts: dict[str, int], + files: list[Path], + filename: str, +) -> bool: + if not _should_include_file(file_path, context): + if file_path.suffix in context.normalized_extensions: + _record_ignored_count(ignored_counts, filename, 1) + return False + files.append(file_path) + return True + + +@beartype +@require(lambda repo_root: repo_root.is_dir(), "repo_root must exist") +@ensure(lambda result: isinstance(result, ImportDiscoveryResult), "Must return ImportDiscoveryResult") +def _discover_code_files_with_options(repo_root: Path, options: ImportDiscoveryOptions) -> ImportDiscoveryResult: + """Discover source files while pruning ignored directories before traversal.""" + + resolved_repo_root = repo_root.resolve() + root = _resolve_entry_point(resolved_repo_root, options.entry_point).resolve() + patterns = _merged_ignore_patterns(resolved_repo_root, options.ignore_patterns) + context = _DiscoveryWalkContext( + repo_root=resolved_repo_root, + patterns=patterns, + allow_hidden=options.allow_hidden, + explicit_roots=frozenset(_explicit_root_set((root,) if options.entry_point is not None else ())), + normalized_extensions=_normalize_extensions(options.extensions), + include_tests=options.include_tests, + ignored_entry_sample_cap=max(_MAX_IGNORED_ENTRY_COUNT_SAMPLE, options.heavy_ignored_entry_threshold), + ) + + files: list[Path] = [] + ignored_counts: dict[str, int] = {} + + if root.is_file(): + included = _process_candidate_file( + root, + context=context, + ignored_counts=ignored_counts, + files=files, + filename=root.name, + ) + if included and options.max_files is not None and len(files) >= options.max_files: + return _finalize_discovery(files, ignored_counts, options) + return _finalize_discovery(files, ignored_counts, options) + + for current_dir, dirnames, filenames in os.walk(root): + current_path = Path(current_dir) + dirnames[:] = _filter_dirnames(current_path, dirnames, context, ignored_counts) + + for filename in filenames: + file_path = current_path / filename + included = _process_candidate_file( + file_path, + context=context, + ignored_counts=ignored_counts, + files=files, + filename=filename, + ) + if included and options.max_files is not None and len(files) >= options.max_files: + return _finalize_discovery(files, ignored_counts, options) + + return _finalize_discovery(files, ignored_counts, options) + + +@beartype +@require(lambda repo_root: repo_root.is_dir(), "repo_root must exist") +@ensure(lambda result: isinstance(result, ImportDiscoveryResult), "Must return ImportDiscoveryResult") +def discover_code_files( + repo_root: Path, + options: ImportDiscoveryOptions | None = None, + **kwargs: Any, +) -> ImportDiscoveryResult: + """Discover source files while pruning ignored directories before traversal.""" + if options is None: + options = ImportDiscoveryOptions( + extensions=_normalize_extensions(cast(Iterable[str], kwargs.pop("extensions", ()))), + entry_point=cast(Path | None, kwargs.pop("entry_point", None)), + include_tests=bool(kwargs.pop("include_tests", True)), + ignore_patterns=_as_tuple(cast(Iterable[str], kwargs.pop("ignore_patterns", ()) or ())), + allow_hidden=bool(kwargs.pop("allow_hidden", False)), + heavy_ignored_entry_threshold=int( + kwargs.pop("heavy_ignored_entry_threshold", _DEFAULT_HEAVY_IGNORED_ENTRY_THRESHOLD) + ), + large_repo_file_threshold=int(kwargs.pop("large_repo_file_threshold", _DEFAULT_LARGE_REPO_FILE_THRESHOLD)), + max_files=cast(int | None, kwargs.pop("max_files", None)), + ) + elif kwargs: + unexpected = ", ".join(sorted(str(key) for key in kwargs)) + raise TypeError(f"Unexpected discovery keyword overrides with options object: {unexpected}") + return _discover_code_files_with_options( + repo_root, + options, + ) + + +def _is_test_path(file_path: Path) -> bool: + name = file_path.name + if name.startswith("test_") or name.endswith("_test.py"): + return True + return any(part in {"test", "tests", "spec"} for part in file_path.parts) diff --git a/registry/index.json b/registry/index.json index 017606a3..e2d6f1ad 100644 --- a/registry/index.json +++ b/registry/index.json @@ -2,9 +2,9 @@ "modules": [ { "id": "nold-ai/specfact-project", - "latest_version": "0.41.3", - "download_url": "modules/specfact-project-0.41.3.tar.gz", - "checksum_sha256": "a3df973c103e0708bef7a6ad23ead9b45e3354ba2ecb878f4d64e753e163a817", + "latest_version": "0.41.6", + "download_url": "modules/specfact-project-0.41.6.tar.gz", + "checksum_sha256": "b5b576146ead024dbc8a00dfda782fd988dbd59b358286d39d38e4b58b3a7edd", "tier": "official", "publisher": { "name": "nold-ai", diff --git a/registry/modules/specfact-project-0.41.4.tar.gz b/registry/modules/specfact-project-0.41.4.tar.gz new file mode 100644 index 00000000..d5bf89c3 Binary files /dev/null and b/registry/modules/specfact-project-0.41.4.tar.gz differ diff --git a/registry/modules/specfact-project-0.41.4.tar.gz.sha256 b/registry/modules/specfact-project-0.41.4.tar.gz.sha256 new file mode 100644 index 00000000..a0237a2a --- /dev/null +++ b/registry/modules/specfact-project-0.41.4.tar.gz.sha256 @@ -0,0 +1 @@ +4ee3816bdf65040e31f72d3af1a5337860dcc227e43a8cd9d319bb186fcb993d diff --git a/registry/modules/specfact-project-0.41.5.tar.gz b/registry/modules/specfact-project-0.41.5.tar.gz new file mode 100644 index 00000000..6a8bd1f9 Binary files /dev/null and b/registry/modules/specfact-project-0.41.5.tar.gz differ diff --git a/registry/modules/specfact-project-0.41.5.tar.gz.sha256 b/registry/modules/specfact-project-0.41.5.tar.gz.sha256 new file mode 100644 index 00000000..3f6b73bb --- /dev/null +++ b/registry/modules/specfact-project-0.41.5.tar.gz.sha256 @@ -0,0 +1 @@ +5654625bacfecc658231c96baad2e45a6e83f01d6a66de7d125eed1dce518257 diff --git a/registry/modules/specfact-project-0.41.6.tar.gz b/registry/modules/specfact-project-0.41.6.tar.gz new file mode 100644 index 00000000..7e08d784 Binary files /dev/null and b/registry/modules/specfact-project-0.41.6.tar.gz differ diff --git a/registry/modules/specfact-project-0.41.6.tar.gz.sha256 b/registry/modules/specfact-project-0.41.6.tar.gz.sha256 new file mode 100644 index 00000000..4d4c3008 --- /dev/null +++ b/registry/modules/specfact-project-0.41.6.tar.gz.sha256 @@ -0,0 +1 @@ +b5b576146ead024dbc8a00dfda782fd988dbd59b358286d39d38e4b58b3a7edd diff --git a/registry/signatures/specfact-project-0.41.5.tar.sig b/registry/signatures/specfact-project-0.41.5.tar.sig new file mode 100644 index 00000000..d767adce --- /dev/null +++ b/registry/signatures/specfact-project-0.41.5.tar.sig @@ -0,0 +1 @@ +bWI6WmMqUTcG57fDckZCooxJzBw7xBL0XIeS4EYZ9a4krx5vCFzfkL4gKVplyOvv4wkS7OFipj6JYdsldJuGAw== diff --git a/registry/signatures/specfact-project-0.41.6.tar.sig b/registry/signatures/specfact-project-0.41.6.tar.sig new file mode 100644 index 00000000..77d9c258 --- /dev/null +++ b/registry/signatures/specfact-project-0.41.6.tar.sig @@ -0,0 +1 @@ +zLv/3RUOq9HhLVqvfVSfXmGcKK3pN4op605XSDkdXtcxqiavaIjmPcO4ei8mcYueay9smndrO7HkSBB5z6i7Dg== diff --git a/scripts/ensure_github_hierarchy_subissues.py b/scripts/ensure_github_hierarchy_subissues.py new file mode 100644 index 00000000..9427b6c7 --- /dev/null +++ b/scripts/ensure_github_hierarchy_subissues.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Ensure GitHub Epic/Feature sub-issue edges via GraphQL ``addSubIssue``. + +``createIssue`` with ``parentIssueId`` often creates the same links, but this +script matches the explicit ``addSubIssue`` workflow used for specfact-cli +hierarchy setup: it only calls ``addSubIssue`` when the child is not already +listed on the parent's ``subIssues`` connection. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from typing import Any + + +_GH_GRAPHQL_TIMEOUT_SEC = 120 + +# Five-pillar modules wave (2026-04): epic #216 -> features -> user stories. +DEFAULT_EDGES: tuple[tuple[int, int], ...] = ( + (216, 217), + (216, 218), + (216, 219), + (216, 220), + (216, 221), + (216, 222), + (217, 226), + (218, 227), + (218, 228), + (218, 229), + (219, 230), + (220, 223), + (221, 224), + (221, 225), + (222, 231), + (222, 232), +) + + +def _gh_graphql(*, query: str, variables: dict[str, Any], timeout: int = _GH_GRAPHQL_TIMEOUT_SEC) -> dict[str, Any]: + args = ["gh", "api", "graphql", "-f", f"query={query}"] + for key, value in variables.items(): + if value is None: + continue + if isinstance(value, list): + for item in value: + args.extend(["-f", f"{key}[]={item}"]) + else: + if isinstance(value, str): + args.extend(["-f", f"{key}={value}"]) + else: + args.extend(["-F", f"{key}={value}"]) + try: + proc = subprocess.run( + args, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"gh graphql timed out after {timeout}s (context: gh api graphql); " + f"stdout={exc.stdout!r}; stderr={exc.stderr!r}" + ) from exc + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "gh graphql failed") + payload = json.loads(proc.stdout) + if not isinstance(payload, dict): + raise RuntimeError(f"gh graphql returned non-dict payload: {proc.stdout}") + if "errors" in payload: + raise RuntimeError(json.dumps(payload["errors"], indent=2)) + data = payload.get("data") + if data is None: + raise RuntimeError(f"gh graphql returned no data field: {proc.stdout}") + return data + + +def _issue_node_id(*, owner: str, name: str, number: int) -> str: + q = """ + query($owner:String!,$name:String!,$n:Int!){ + repository(owner:$owner,name:$name){issue(number:$n){id}} + } + """.strip() + data = _gh_graphql(query=q, variables={"owner": owner, "name": name, "n": number}) + node = data.get("repository", {}).get("issue") + if not isinstance(node, dict) or not node.get("id"): + raise RuntimeError(f"missing issue node id for #{number}") + return str(node["id"]) + + +def _subissue_numbers(*, owner: str, name: str, parent_number: int) -> set[int]: + q = """ + query($owner:String!,$name:String!,$n:Int!,$after:String){ + repository(owner:$owner,name:$name){ + issue(number:$n){ + subIssues(first:100, after:$after){ + pageInfo{hasNextPage,endCursor} + nodes{number} + } + } + } + } + """.strip() + collected: set[int] = set() + after: str | None = None + while True: + variables: dict[str, Any] = {"owner": owner, "name": name, "n": parent_number, "after": after} + data = _gh_graphql(query=q, variables=variables) + issue = data.get("repository", {}).get("issue") + if not isinstance(issue, dict): + raise RuntimeError(f"missing issue #{parent_number}") + sub = issue.get("subIssues") + if not isinstance(sub, dict): + raise RuntimeError(f"missing subIssues for issue #{parent_number}") + raw_nodes = sub.get("nodes", []) + if not isinstance(raw_nodes, list): + raise RuntimeError(f"subIssues nodes is not a list for issue #{parent_number}") + for node in raw_nodes: + if isinstance(node, dict) and node.get("number") is not None: + collected.add(int(node["number"])) + page_info = sub.get("pageInfo") + if not isinstance(page_info, dict) or not page_info.get("hasNextPage"): + break + cursor = page_info.get("endCursor") + if not isinstance(cursor, str) or not cursor: + raise RuntimeError(f"subIssues pagination missing endCursor for issue #{parent_number}") + after = cursor + return collected + + +def _add_sub_issue(*, parent_id: str, child_id: str) -> None: + m = """ + mutation($issueId:ID!,$subIssueId:ID!){ + addSubIssue(input:{issueId:$issueId,subIssueId:$subIssueId,replaceParent:true}){ + issue{number} + subIssue{number} + } + } + """.strip() + _gh_graphql(query=m, variables={"issueId": parent_id, "subIssueId": child_id}) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-owner", default="nold-ai") + parser.add_argument("--repo-name", default="specfact-cli-modules") + parser.add_argument("--dry-run", action="store_true", help="Print actions only") + args = parser.parse_args(argv) + + owner, name = args.repo_owner, args.repo_name + added = 0 + skipped = 0 + would_add = 0 + + # Simple in-memory caches + subissue_cache: dict[int, set[int]] = {} + node_id_cache: dict[int, str] = {} + + for parent_num, child_num in DEFAULT_EDGES: + # Check cache for sub-issue numbers + if parent_num not in subissue_cache: + subissue_cache[parent_num] = _subissue_numbers(owner=owner, name=name, parent_number=parent_num) + existing = subissue_cache[parent_num] + + if child_num in existing: + skipped += 1 + continue + if args.dry_run: + sys.stdout.write(f"[dry-run] would addSubIssue #{child_num} under #{parent_num}\n") + would_add += 1 + continue + + # Check cache for node IDs + if parent_num not in node_id_cache: + node_id_cache[parent_num] = _issue_node_id(owner=owner, name=name, number=parent_num) + parent_id = node_id_cache[parent_num] + + if child_num not in node_id_cache: + node_id_cache[child_num] = _issue_node_id(owner=owner, name=name, number=child_num) + child_id = node_id_cache[child_num] + + _add_sub_issue(parent_id=parent_id, child_id=child_id) + sys.stdout.write(f"addSubIssue #{child_num} -> parent #{parent_num}\n") + added += 1 + + if args.dry_run: + sys.stdout.write(f"Done (dry-run): {would_add} would add, {skipped} already linked (repo {owner}/{name}).\n") + else: + sys.stdout.write(f"Done: {added} added, {skipped} already linked (repo {owner}/{name}).\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (RuntimeError, OSError, json.JSONDecodeError) as exc: + sys.stderr.write(f"{exc}\n") + raise SystemExit(1) from exc diff --git a/tests/unit/specfact_project/test_import_runtime_policy.py b/tests/unit/specfact_project/test_import_runtime_policy.py new file mode 100644 index 00000000..4abf2c99 --- /dev/null +++ b/tests/unit/specfact_project/test_import_runtime_policy.py @@ -0,0 +1,301 @@ +"""Tests for shared code-import path discovery and progress accounting.""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from specfact_project.analyzers.code_analyzer import CodeAnalyzer + + +def _apply_import_runtime_patches() -> None: + """Match production import/sync commands: discovery policy patches are not applied at package import.""" + import specfact_project.import_runtime_patches as _import_runtime_patches + + _import_runtime_patches.apply_import_runtime_patches() + + +_apply_import_runtime_patches() + + +def _discover_code_files(*args, **kwargs): + module = importlib.import_module("specfact_project.utils.import_path_policy") + return module.discover_code_files(*args, **kwargs) + + +def test_discover_code_files_prunes_default_ignored_dirs_and_specfactignore(tmp_path: Path) -> None: + """Discovery should exclude heavyweight defaults and repo-local ignore patterns before traversal.""" + (tmp_path / "src").mkdir() + (tmp_path / "src" / "real.py").write_text("class RealFeature:\n pass\n", encoding="utf-8") + + ignored_paths = [ + tmp_path / ".venv" / "lib" / "site-packages" / "noise.py", + tmp_path / ".hidden" / "skip.py", + tmp_path / "build" / "generated.py", + tmp_path / ".specfact" / "cache" / "ignored.py", + tmp_path / "custom_ignore" / "custom.py", + ] + for path in ignored_paths: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("class Noise:\n pass\n", encoding="utf-8") + + ignore_file = tmp_path / ".specfact" / ".specfactignore" + ignore_file.parent.mkdir(parents=True, exist_ok=True) + ignore_file.write_text("custom_ignore/\n", encoding="utf-8") + + result = _discover_code_files(tmp_path, extensions={".py"}) + + assert [path.relative_to(tmp_path).as_posix() for path in result.files] == ["src/real.py"] + from specfact_project.import_cmd import commands as _import_commands + + assert _import_commands._count_python_files(tmp_path) == 1 + + +def test_discover_code_files_entry_point_file_inside_ignored_tree_is_included(tmp_path: Path) -> None: + """Explicit file entry_point under a default-ignored tree should still be discovered.""" + target = tmp_path / ".venv" / "lib" / "site-packages" / "target.py" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("class Scoped:\n pass\n", encoding="utf-8") + (tmp_path / "src" / "other.py").parent.mkdir(parents=True, exist_ok=True) + (tmp_path / "src" / "other.py").write_text("class Other:\n pass\n", encoding="utf-8") + + result = _discover_code_files(tmp_path, extensions={".py"}, entry_point=Path(".venv/lib/site-packages/target.py")) + + assert [path.relative_to(tmp_path).as_posix() for path in result.files] == [".venv/lib/site-packages/target.py"] + + +def test_discover_code_files_entry_point_still_applies_default_ignores(tmp_path: Path) -> None: + """Scoped discovery must not traverse default ignored dirs under the entry point.""" + (tmp_path / "app" / "main.py").parent.mkdir(parents=True, exist_ok=True) + (tmp_path / "app" / "main.py").write_text("class App:\n pass\n", encoding="utf-8") + noise = tmp_path / "app" / "node_modules" / "pkg" / "noise.py" + noise.parent.mkdir(parents=True, exist_ok=True) + noise.write_text("class Noise:\n pass\n", encoding="utf-8") + + result = _discover_code_files(tmp_path, extensions={".py"}, entry_point=Path("app")) + + assert [path.relative_to(tmp_path).as_posix() for path in result.files] == ["app/main.py"] + + +def test_discover_code_files_entry_point_directory_inside_ignored_tree_is_included(tmp_path: Path) -> None: + """Explicit directory entry_point under an ignored tree should include descendants relative to that root.""" + target = tmp_path / ".venv" / "lib" / "site-packages" / "pkg" + target.mkdir(parents=True, exist_ok=True) + (target / "kept.py").write_text("class Kept:\n pass\n", encoding="utf-8") + (target / "nested").mkdir() + (target / "nested" / "also_kept.py").write_text("class Nested:\n pass\n", encoding="utf-8") + (target / "node_modules" / "ghost.py").parent.mkdir(parents=True, exist_ok=True) + (target / "node_modules" / "ghost.py").write_text("class Ghost:\n pass\n", encoding="utf-8") + + result = _discover_code_files(tmp_path, extensions={".py"}, entry_point=Path(".venv/lib/site-packages/pkg")) + + assert [path.relative_to(tmp_path).as_posix() for path in result.files] == [ + ".venv/lib/site-packages/pkg/kept.py", + ".venv/lib/site-packages/pkg/nested/also_kept.py", + ] + + +def test_discover_code_files_rejects_entry_point_outside_repo(tmp_path: Path) -> None: + """Absolute entry points outside the repo should be rejected before traversal begins.""" + outside_root = tmp_path.parent / f"{tmp_path.name}-outside" + outside_root.mkdir() + + with pytest.raises(ValueError, match="within repository"): + _discover_code_files(tmp_path, extensions={".py"}, entry_point=outside_root) + + +def test_install_patch_forwards_keyword_arguments() -> None: + """Patched callables must preserve normal Python binding rules for positional overrides.""" + import specfact_project.import_runtime_patches as patches + + @dataclass + class _Bundle: + a: int + b: str + + calls: list[tuple[Any, ...]] = [] + + def runner(orig: Any, context: Any, args: _Bundle) -> str: + calls.append((orig, context, args)) + return orig(args.a, args.b) + + class Target: + def method(self, a: int, b: str) -> str: + return f"{a}{b}" + + target = Target() + patches._install_patch(target, "method", runner, lambda mapping: _Bundle(a=mapping["a"], b=mapping["b"]), "ctx") + + assert target.method(1, "y") == "1y" + assert len(calls) == 1 + assert calls[0][2].a == 1 and calls[0][2].b == "y" + + +def test_install_patch_accepts_kwargs_only() -> None: + """Patched callables must also support kwargs-only invocation.""" + import specfact_project.import_runtime_patches as patches + + @dataclass + class _Bundle: + a: int + b: str + + calls: list[tuple[Any, ...]] = [] + + def runner(orig: Any, context: Any, args: _Bundle) -> str: + calls.append((orig, context, args)) + return orig(args.a, args.b) + + class Target: + @staticmethod + def method(a: int, b: str) -> str: + return f"{a}{b}" + + target = Target() + patches._install_patch(target, "method", runner, lambda mapping: _Bundle(a=mapping["a"], b=mapping["b"]), "ctx") + + assert target.method(a=2, b="q") == "2q" + assert len(calls) == 1 + assert calls[0][2].a == 2 and calls[0][2].b == "q" + + +def test_discover_code_files_warns_for_heavy_ignored_and_large_repo(tmp_path: Path) -> None: + """Discovery should warn when ignored artifact trees or filtered repo size look unusually large.""" + for idx in range(3): + path = tmp_path / "src" / f"feature_{idx}.py" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"class Feature{idx}:\n pass\n", encoding="utf-8") + + heavy_root = tmp_path / "node_modules" + for idx in range(3): + subtree = heavy_root / f"pkg_{idx}" / "nested.py" + subtree.parent.mkdir(parents=True, exist_ok=True) + subtree.write_text("class NodeModuleNoise:\n pass\n", encoding="utf-8") + + result = _discover_code_files( + tmp_path, + extensions={".py"}, + heavy_ignored_entry_threshold=2, + large_repo_file_threshold=2, + ) + + assert len(result.files) == 3 + assert any("node_modules" in warning for warning in result.warnings) + assert any("Repository size may materially extend import duration" in warning for warning in result.warnings) + + +def test_discover_code_files_warns_for_default_heavy_threshold(tmp_path: Path) -> None: + """Default heavy-artifact thresholds should remain reachable for a large ignored tree.""" + (tmp_path / "src").mkdir() + (tmp_path / "src" / "real.py").write_text("class RealFeature:\n pass\n", encoding="utf-8") + + heavy_root = tmp_path / "node_modules" + for idx in range(500): + package_dir = heavy_root / f"pkg_{idx}" + package_dir.mkdir(parents=True, exist_ok=True) + + result = _discover_code_files(tmp_path, extensions={".py"}) + + assert [path.relative_to(tmp_path).as_posix() for path in result.files] == ["src/real.py"] + assert any("node_modules" in warning for warning in result.warnings) + + +def test_should_ignore_path_short_circuits_ignored_file_check( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Structural ignore checks should avoid file stats once a path is already ignored.""" + module = importlib.import_module("specfact_project.utils.import_path_policy") + ignored_file_checked = False + + def fake_is_ignored_file(path: Path) -> bool: + nonlocal ignored_file_checked + ignored_file_checked = True + return False + + monkeypatch.setattr(module, "_is_ignored_file", fake_is_ignored_file) + + ignored = module.should_ignore_path(tmp_path / ".venv" / "lib", tmp_path) + + assert ignored is True + assert ignored_file_checked is False + + +def test_build_discovery_forwards_include_tests(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Runtime patch discovery helper should preserve the caller's include-tests setting.""" + import specfact_project.import_runtime_patches as patches + + captured: dict[str, Any] = {} + + def fake_discover_code_files(repo_root: Path, **kwargs: Any) -> Any: + captured["repo_root"] = repo_root + captured["kwargs"] = kwargs + return patches.ImportDiscoveryResult(files=[], warnings=[], ignored_counts={}) + + monkeypatch.setattr(patches, "discover_code_files", fake_discover_code_files) + + patches._build_discovery(tmp_path, entry_point=Path("src"), include_tests=False) + + assert captured["repo_root"] == tmp_path + assert captured["kwargs"]["entry_point"] == Path("src") + assert captured["kwargs"]["include_tests"] is False + + +class _CapturingProgress: + """Tiny Progress stub to capture task totals without rendering Rich output.""" + + last_instance: _CapturingProgress | None = None + + def __init__(self, *args, **kwargs) -> None: + _ = args, kwargs + self.tasks: list[SimpleNamespace] = [] + _CapturingProgress.last_instance = self + + def __enter__(self) -> _CapturingProgress: + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + _ = exc_type, exc, tb + return False + + def add_task(self, description: str, total: int | None = None): + task = SimpleNamespace(description=description, initial_description=description, total=total, completed=0) + self.tasks.append(task) + return len(self.tasks) - 1 + + def update(self, task_id: int, **kwargs) -> None: + task = self.tasks[task_id] + for key, value in kwargs.items(): + setattr(task, key, value) + + def remove_task(self, task_id: int) -> None: + _ = task_id + + +def test_code_analyzer_progress_total_uses_filtered_files(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Phase 3 progress total should match analyzable files, not raw discovered files.""" + (tmp_path / "src" / "real.py").parent.mkdir(parents=True, exist_ok=True) + (tmp_path / "src" / "real.py").write_text("class RealFeature:\n pass\n", encoding="utf-8") + (tmp_path / ".venv" / "lib" / "site-packages").mkdir(parents=True, exist_ok=True) + (tmp_path / ".venv" / "lib" / "site-packages" / "ghost.py").write_text( + "class GhostFeature:\n pass\n", + encoding="utf-8", + ) + + monkeypatch.setattr("specfact_project.analyzers.code_analyzer.Progress", _CapturingProgress) + monkeypatch.setattr(CodeAnalyzer, "_analyze_commit_history", lambda self: None) + monkeypatch.setattr(CodeAnalyzer, "_enhance_features_with_dependencies", lambda self: None) + monkeypatch.setattr(CodeAnalyzer, "_extract_technology_stack_from_dependencies", lambda self: []) + + analyzer = CodeAnalyzer(tmp_path, confidence_threshold=0.0) + analyzer.analyze() + + progress = _CapturingProgress.last_instance + assert progress is not None + phase3 = next(task for task in progress.tasks if "Phase 3" in task.initial_description) + assert phase3.total == 1 diff --git a/tests/unit/test_bundle_resource_payloads.py b/tests/unit/test_bundle_resource_payloads.py index a42a339c..f80dfa25 100644 --- a/tests/unit/test_bundle_resource_payloads.py +++ b/tests/unit/test_bundle_resource_payloads.py @@ -9,6 +9,7 @@ import pytest import yaml +from specfact_cli.models.protocol import Protocol, Transition from specfact_cli.utils import ide_setup from tests.unit._script_test_utils import load_module_from_path @@ -52,6 +53,12 @@ "github_custom.yaml", ) +_PROJECT_RUNTIME_TEMPLATES = ( + "protocol.yaml.j2", + "github-action.yml.j2", +) +_PROJECT_PERSONA_TEMPLATES = ("default.md.j2",) + _IGNORED_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "logs"} _IGNORED_SUFFIXES = {".pyc", ".pyo"} @@ -128,6 +135,59 @@ def test_module_package_layout_matches_init_ide_resource_contract() -> None: assert (codebase / "resources" / "prompts" / "specfact.01-import.md").is_file() +def test_project_bundle_packages_runtime_generator_templates() -> None: + module_root = REPO_ROOT / "packages" / "specfact-project" + for name in _PROJECT_RUNTIME_TEMPLATES: + path = module_root / "resources" / "templates" / name + assert path.is_file(), f"missing project runtime template {path}" + for name in _PROJECT_PERSONA_TEMPLATES: + path = module_root / "resources" / "templates" / "persona" / name + assert path.is_file(), f"missing project persona template {path}" + + +def test_project_runtime_templates_resolve_at_runtime(tmp_path: Path) -> None: + from specfact_project.generators.persona_exporter import PersonaExporter + from specfact_project.generators.protocol_generator import ProtocolGenerator + from specfact_project.generators.workflow_generator import WorkflowGenerator + + protocol = Protocol( + states=["draft", "qa:review"], + start="draft", + transitions=[ + Transition( + from_state="draft", + on_event="complete:now", + to_state="qa:review", + guard="ready `#1`", + ) + ], + ) + + protocol_output = tmp_path / "protocol.yaml" + ProtocolGenerator().generate(protocol, protocol_output) + protocol_data = yaml.safe_load(protocol_output.read_text(encoding="utf-8")) + assert protocol_data["states"] == protocol.states + assert protocol_data["start"] == protocol.start + assert len(protocol_data["transitions"]) == 1 + tr = protocol_data["transitions"][0] + assert tr["from_state"] == protocol.transitions[0].from_state + assert tr["on_event"] == protocol.transitions[0].on_event + assert tr["to_state"] == protocol.transitions[0].to_state + assert tr["guard"] == protocol.transitions[0].guard + + workflow_output = tmp_path / "specfact-gate.yml" + WorkflowGenerator().generate_github_action(workflow_output, repo_name="example/project") + workflow_data = yaml.safe_load(workflow_output.read_text(encoding="utf-8")) + validate_step = next( + step for step in workflow_data["jobs"]["specfact"]["steps"] if step.get("name") == "Run validation" + ) + assert "example/project" in validate_step["run"] + + exporter = PersonaExporter() + assert exporter.templates_dir.name == "persona" + assert (exporter.templates_dir / "default.md.j2").is_file() + + def test_code_review_bundle_packages_clean_code_policy_pack_manifest() -> None: module_root = REPO_ROOT / "packages" / "specfact-code-review" roots = ( @@ -181,6 +241,17 @@ def test_code_review_artifact_contains_policy_pack_payload(tmp_path: Path) -> No assert "specfact-code-review/resources/policy-packs/specfact/clean-code-principles.yaml" in archive.getnames() +def test_project_artifact_contains_runtime_generator_templates(tmp_path: Path) -> None: + artifact = _build_bundle_artifact("specfact-project", tmp_path) + with tarfile.open(artifact, "r:gz") as archive: + names = set(archive.getnames()) + + for name in _PROJECT_RUNTIME_TEMPLATES: + assert f"specfact-project/resources/templates/{name}" in names + for name in _PROJECT_PERSONA_TEMPLATES: + assert f"specfact-project/resources/templates/persona/{name}" in names + + def test_core_prompt_discovery_finds_installed_backlog_bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: modules_root = tmp_path / "modules" installed_bundle = modules_root / "specfact-backlog" diff --git a/tests/unit/workflows/test_sign_modules_hardening.py b/tests/unit/workflows/test_sign_modules_hardening.py index da2128f6..0af60541 100644 --- a/tests/unit/workflows/test_sign_modules_hardening.py +++ b/tests/unit/workflows/test_sign_modules_hardening.py @@ -103,6 +103,25 @@ def test_sign_modules_hardening_auto_signs_on_push_non_bot() -> None: assert 'git push origin "HEAD:refs/heads/${SIGN_BRANCH}"' in workflow +def test_sign_modules_hardening_auto_signs_same_repo_pull_requests() -> None: + workflow = _workflow_text() + for needle in ( + "github.event_name == 'pull_request'", + "github.event.pull_request.head.repo.full_name == github.repository", + "github.actor != 'github-actions[bot]'", + "PR_HEAD_REF", + 'git commit -m "chore(modules): ci sign changed modules"', + 'git push origin "HEAD:${PR_HEAD_REF}"', + ): + assert needle in workflow + + +def test_sign_modules_hardening_checks_out_pr_head_for_pr_events() -> None: + workflow = _workflow_text() + assert "github.event.pull_request.head.sha" in workflow + assert "github.sha" in workflow + + @pytest.mark.parametrize( "needles", ( diff --git a/tests/unit/workflows/test_sign_modules_on_approval.py b/tests/unit/workflows/test_sign_modules_on_approval.py index 7adc180a..ec0c39c8 100644 --- a/tests/unit/workflows/test_sign_modules_on_approval.py +++ b/tests/unit/workflows/test_sign_modules_on_approval.py @@ -45,11 +45,12 @@ def _step_by_field(steps: list[Any], field: str, value: str) -> dict[str, Any]: raise AssertionError(f"No workflow step with {field}={value!r}") -def _assert_pull_request_review_submitted(doc: dict[Any, Any]) -> None: +def _assert_review_only_trigger(doc: dict[Any, Any]) -> None: on = _workflow_on_section(doc) pr_review = on["pull_request_review"] assert isinstance(pr_review, dict) - assert pr_review["types"] == ["submitted"] + assert set(pr_review["types"]) == {"submitted"} + assert "pull_request" not in on def _assert_sign_job_has_no_top_level_if(doc: dict[Any, Any]) -> None: @@ -85,16 +86,17 @@ def _assert_concurrency_and_permissions(doc: dict[Any, Any]) -> None: conc = doc["concurrency"] assert isinstance(conc, dict) assert conc["cancel-in-progress"] is True - assert "${{ github.event.pull_request.number }}" in conc["group"] + assert "github.event.pull_request.number" in conc["group"] perms = doc["permissions"] assert isinstance(perms, dict) assert perms["contents"] == "write" + assert perms["pull-requests"] == "read" def test_sign_modules_on_approval_trigger_and_job_filter() -> None: doc = _parsed_workflow() - _assert_pull_request_review_submitted(doc) + _assert_review_only_trigger(doc) _assert_sign_job_has_no_top_level_if(doc) _assert_eligibility_gate_step(doc) _assert_concurrency_and_permissions(doc) @@ -106,6 +108,7 @@ def test_sign_modules_on_approval_checkout_and_python() -> None: assert "github.event.pull_request.head.sha" in workflow assert "PR_HEAD_REF:" in workflow assert "PR_BASE_REF:" in workflow + assert "GH_TOKEN: ${{ github.token }}" in workflow assert 'python-version: "3.12"' in workflow or "python-version: '3.12'" in workflow