diff --git a/.github/workflows/docs-review.yml b/.github/workflows/docs-review.yml new file mode 100644 index 00000000..a77fd145 --- /dev/null +++ b/.github/workflows/docs-review.yml @@ -0,0 +1,62 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +# yamllint disable rule:line-length rule:truthy +name: Docs Review + +on: + pull_request: + branches: [main, dev] + paths: + - "**/*.md" + - "**/*.mdc" + - "docs/**" + - "tests/unit/docs/test_release_docs_parity.py" + - ".github/workflows/docs-review.yml" + push: + branches: [main, dev] + paths: + - "**/*.md" + - "**/*.mdc" + - "docs/**" + - "tests/unit/docs/test_release_docs_parity.py" + - ".github/workflows/docs-review.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + docs-review: + name: Docs Review + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install docs review dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest + + - name: Run docs review suite + run: | + mkdir -p logs/docs-review + DOCS_REVIEW_LOG="logs/docs-review/docs-review_$(date -u +%Y%m%d_%H%M%S).log" + python -m pytest tests/unit/docs/test_release_docs_parity.py -q 2>&1 | tee "$DOCS_REVIEW_LOG" + exit "${PIPESTATUS[0]:-$?}" + + - name: Upload docs review logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: docs-review-logs + path: logs/docs-review/ + if-no-files-found: ignore diff --git a/docs/adapters/backlog-adapter-patterns.md b/docs/adapters/backlog-adapter-patterns.md index 675ca5bc..2ad0ee73 100644 --- a/docs/adapters/backlog-adapter-patterns.md +++ b/docs/adapters/backlog-adapter-patterns.md @@ -493,4 +493,4 @@ When implementing new backlog adapters: - **[Azure DevOps Adapter Documentation](./azuredevops.md)** - Azure DevOps adapter reference - **[DevOps Adapter Integration Guide](../guides/devops-adapter-integration.md)** - Complete integration guide for GitHub and ADO - **[Validation Integration](../validation-integration.md)** - Validation with change proposals -- **[Bridge Adapter Interface](../bridge-adapter-interface.md)** - Base adapter interface +- **[Bridge Adapter Interface](../reference/architecture.md#required-adapter-interface)** - Base adapter interface diff --git a/docs/examples/integration-showcases/README.md b/docs/examples/integration-showcases/README.md index 5b062f0d..639a4fc9 100644 --- a/docs/examples/integration-showcases/README.md +++ b/docs/examples/integration-showcases/README.md @@ -39,7 +39,7 @@ This folder contains everything you need to understand and test SpecFact CLI int ### Setup Script -1. **[`setup-integration-tests.sh`](setup-integration-tests.sh)** 🚀 **AUTOMATED SETUP** +1. **[`setup-integration-tests.sh`](https://github.com/nold-ai/specfact-cli/blob/main/docs/examples/integration-showcases/setup-integration-tests.sh)** 🚀 **AUTOMATED SETUP** - **Purpose**: Automated script to create test cases for all examples - **Content**: Creates test directories, sample code, and configuration files @@ -59,7 +59,7 @@ This gives you a complete overview of what SpecFact can do with real examples. **Step 2**: Choose your path: -- **Want to test the examples?** → Use [`setup-integration-tests.sh`](setup-integration-tests.sh) then follow [`integration-showcases-testing-guide.md`](integration-showcases-testing-guide.md) +- **Want to test the examples?** → Use [`setup-integration-tests.sh`](https://github.com/nold-ai/specfact-cli/blob/main/docs/examples/integration-showcases/setup-integration-tests.sh) then follow [`integration-showcases-testing-guide.md`](integration-showcases-testing-guide.md) - **Just need quick commands?** → Check [`integration-showcases-quick-reference.md`](integration-showcases-quick-reference.md) diff --git a/docs/examples/quick-examples.md b/docs/examples/quick-examples.md index 4728db31..62a93de5 100644 --- a/docs/examples/quick-examples.md +++ b/docs/examples/quick-examples.md @@ -1,7 +1,9 @@ --- layout: default title: Quick Examples -permalink: /quick-examples/ +permalink: /examples/quick-examples/ +redirect_from: + - /quick-examples/ --- # Quick Examples diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 6dac2ea6..e9acfa1e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -541,4 +541,4 @@ hatch run format hatch run lint ``` -See [CONTRIBUTING.md](../../CONTRIBUTING.md) for detailed contribution guidelines. +See [CONTRIBUTING.md](https://github.com/nold-ai/specfact-cli/blob/main/CONTRIBUTING.md) for detailed contribution guidelines. diff --git a/docs/getting-started/tutorial-openspec-speckit.md b/docs/getting-started/tutorial-openspec-speckit.md index eb52c134..de2ee1bf 100644 --- a/docs/getting-started/tutorial-openspec-speckit.md +++ b/docs/getting-started/tutorial-openspec-speckit.md @@ -688,4 +688,4 @@ gh repo view your-org/your-repo Copyright © 2025-2026 Nold AI (Owner: Dominikus Nold) -**Trademarks**: All product names, logos, and brands mentioned in this documentation are the property of their respective owners. NOLD AI (NOLDAI) is a registered trademark (wordmark) at the European Union Intellectual Property Office (EUIPO). See [TRADEMARKS.md](../../TRADEMARKS.md) for more information. +**Trademarks**: All product names, logos, and brands mentioned in this documentation are the property of their respective owners. NOLD AI (NOLDAI) is a registered trademark (wordmark) at the European Union Intellectual Property Office (EUIPO). See [TRADEMARKS.md](/TRADEMARKS/) for more information. diff --git a/docs/guides/ai-ide-workflow.md b/docs/guides/ai-ide-workflow.md index c9012ab7..f142cc09 100644 --- a/docs/guides/ai-ide-workflow.md +++ b/docs/guides/ai-ide-workflow.md @@ -1,7 +1,9 @@ --- layout: default title: AI IDE Workflow Guide -permalink: /ai-ide-workflow/ +permalink: /guides/ai-ide-workflow/ +redirect_from: + - /ai-ide-workflow/ --- # AI IDE Workflow Guide diff --git a/docs/guides/brownfield-engineer.md b/docs/guides/brownfield-engineer.md index 0f34bca1..254bae99 100644 --- a/docs/guides/brownfield-engineer.md +++ b/docs/guides/brownfield-engineer.md @@ -1,7 +1,9 @@ --- layout: default title: Modernizing Legacy Code (Brownfield Engineer Guide) -permalink: /brownfield-engineer/ +permalink: /guides/brownfield-engineer/ +redirect_from: + - /brownfield-engineer/ --- # Guide for Legacy Modernization Engineers diff --git a/docs/guides/brownfield-journey.md b/docs/guides/brownfield-journey.md index 00c5465d..e87c7c42 100644 --- a/docs/guides/brownfield-journey.md +++ b/docs/guides/brownfield-journey.md @@ -1,7 +1,9 @@ --- layout: default title: Brownfield Modernization Journey -permalink: /brownfield-journey/ +permalink: /guides/brownfield-journey/ +redirect_from: + - /brownfield-journey/ --- # Brownfield Modernization Journey diff --git a/docs/guides/common-tasks.md b/docs/guides/common-tasks.md index de640cca..fced0c7d 100644 --- a/docs/guides/common-tasks.md +++ b/docs/guides/common-tasks.md @@ -1,7 +1,9 @@ --- layout: default title: Common Tasks Quick Reference -permalink: /common-tasks/ +permalink: /guides/common-tasks/ +redirect_from: + - /common-tasks/ --- # Common Tasks Quick Reference diff --git a/docs/guides/competitive-analysis.md b/docs/guides/competitive-analysis.md index fb2f7368..aedc513d 100644 --- a/docs/guides/competitive-analysis.md +++ b/docs/guides/competitive-analysis.md @@ -1,7 +1,9 @@ --- layout: default title: Competitive Analysis -permalink: /competitive-analysis/ +permalink: /guides/competitive-analysis/ +redirect_from: + - /competitive-analysis/ --- # What You Gain with SpecFact CLI diff --git a/docs/guides/copilot-mode.md b/docs/guides/copilot-mode.md index db545729..902631c0 100644 --- a/docs/guides/copilot-mode.md +++ b/docs/guides/copilot-mode.md @@ -1,7 +1,9 @@ --- layout: default title: Using CoPilot Mode -permalink: /copilot-mode/ +permalink: /guides/copilot-mode/ +redirect_from: + - /copilot-mode/ --- # Using CoPilot Mode diff --git a/docs/guides/ide-integration.md b/docs/guides/ide-integration.md index f2abb1f4..54ba292a 100644 --- a/docs/guides/ide-integration.md +++ b/docs/guides/ide-integration.md @@ -342,4 +342,4 @@ The `specfact init` command handles all conversions automatically. --- -**Trademarks**: All product names, logos, and brands mentioned in this guide are the property of their respective owners. NOLD AI (NOLDAI) is a registered trademark (wordmark) at the European Union Intellectual Property Office (EUIPO). See [TRADEMARKS.md](../../TRADEMARKS.md) for more information. +**Trademarks**: All product names, logos, and brands mentioned in this guide are the property of their respective owners. NOLD AI (NOLDAI) is a registered trademark (wordmark) at the European Union Intellectual Property Office (EUIPO). See [TRADEMARKS.md](/TRADEMARKS/) for more information. diff --git a/docs/guides/migration-guide.md b/docs/guides/migration-guide.md index c9c080e7..5a8f68e3 100644 --- a/docs/guides/migration-guide.md +++ b/docs/guides/migration-guide.md @@ -1,7 +1,9 @@ --- layout: default title: Migration Guide -permalink: /migration-guide/ +permalink: /guides/migration-guide/ +redirect_from: + - /migration-guide/ --- # Migration Guide diff --git a/docs/guides/team-collaboration-workflow.md b/docs/guides/team-collaboration-workflow.md index 91a78479..01a40813 100644 --- a/docs/guides/team-collaboration-workflow.md +++ b/docs/guides/team-collaboration-workflow.md @@ -1,7 +1,9 @@ --- layout: default title: Team Collaboration Workflow -permalink: /team-collaboration-workflow/ +permalink: /guides/team-collaboration-workflow/ +redirect_from: + - /team-collaboration-workflow/ --- # Team Collaboration Workflow diff --git a/docs/guides/testing-terminal-output.md b/docs/guides/testing-terminal-output.md index 21b7bfd5..10a59f87 100644 --- a/docs/guides/testing-terminal-output.md +++ b/docs/guides/testing-terminal-output.md @@ -1,7 +1,9 @@ --- layout: default title: Testing Terminal Output Modes -permalink: /testing-terminal-output/ +permalink: /guides/testing-terminal-output/ +redirect_from: + - /testing-terminal-output/ --- # Testing Terminal Output Modes diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index e1eb0614..e7dc04f7 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -1,7 +1,9 @@ --- layout: default title: Troubleshooting -permalink: /troubleshooting/ +permalink: /guides/troubleshooting/ +redirect_from: + - /troubleshooting/ --- # Troubleshooting diff --git a/docs/guides/use-cases.md b/docs/guides/use-cases.md index 4faed708..193b3289 100644 --- a/docs/guides/use-cases.md +++ b/docs/guides/use-cases.md @@ -1,7 +1,9 @@ --- layout: default title: Use Cases -permalink: /use-cases/ +permalink: /guides/use-cases/ +redirect_from: + - /use-cases/ --- # Use Cases diff --git a/docs/guides/ux-features.md b/docs/guides/ux-features.md index 86d50959..1c79a680 100644 --- a/docs/guides/ux-features.md +++ b/docs/guides/ux-features.md @@ -1,7 +1,9 @@ --- layout: default title: UX Features Guide -permalink: /ux-features/ +permalink: /guides/ux-features/ +redirect_from: + - /ux-features/ --- # UX Features Guide diff --git a/docs/openspec-opsx-migration.md b/docs/openspec-opsx-migration.md index 16dff6a6..0149eee4 100644 --- a/docs/openspec-opsx-migration.md +++ b/docs/openspec-opsx-migration.md @@ -20,7 +20,7 @@ SpecFact CLI has migrated to **OpenSpec OPSX** (fluid, action-based workflow). P - **Project context import**: When syncing OpenSpec project context, the bridge resolves `openspec/config.yaml` first; if absent, it uses `openspec/project.md`. No code changes required in repos that still have `project.md`. - **Detection**: OpenSpec is detected if `openspec/config.yaml`, `openspec/project.md`, or `openspec/specs/` exists. -- **Config format**: See [openspec/config.yaml](../openspec/config.yaml) in this repo for an example. The `context:` block is injected into planning; `rules:` apply per-artifact. +- **Config format**: See [openspec/config.yaml](https://github.com/nold-ai/specfact-cli/blob/main/openspec/config.yaml) in this repo for an example. The `context:` block is injected into planning; `rules:` apply per-artifact. ## References diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index da2ec213..dca80dec 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -1,7 +1,7 @@ --- layout: default title: Architecture -permalink: /architecture/ +permalink: /reference/architecture/ --- # Architecture diff --git a/docs/reference/debug-logging.md b/docs/reference/debug-logging.md index 0063a798..76dba83c 100644 --- a/docs/reference/debug-logging.md +++ b/docs/reference/debug-logging.md @@ -1,7 +1,9 @@ --- layout: default title: Debug Logging -permalink: /debug-logging/ +permalink: /reference/debug-logging/ +redirect_from: + - /debug-logging/ --- diff --git a/docs/reference/directory-structure.md b/docs/reference/directory-structure.md index 3673ddf7..20a7217c 100644 --- a/docs/reference/directory-structure.md +++ b/docs/reference/directory-structure.md @@ -1,7 +1,9 @@ --- layout: default title: SpecFact CLI Directory Structure -permalink: /directory-structure/ +permalink: /reference/directory-structure/ +redirect_from: + - /directory-structure/ --- # SpecFact CLI Directory Structure diff --git a/docs/reference/modes.md b/docs/reference/modes.md index 36a1528f..9414975a 100644 --- a/docs/reference/modes.md +++ b/docs/reference/modes.md @@ -1,7 +1,9 @@ --- layout: default title: Operational Modes -permalink: /modes/ +permalink: /reference/modes/ +redirect_from: + - /modes/ --- # Operational Modes diff --git a/docs/reference/parameter-standard.md b/docs/reference/parameter-standard.md index 4d2fad77..583715dd 100644 --- a/docs/reference/parameter-standard.md +++ b/docs/reference/parameter-standard.md @@ -237,9 +237,9 @@ def generate_contracts( ## 🔗 Related Documentation -- **[CLI Reorganization Implementation Plan](../../specfact-cli-internal/docs/internal/implementation/CLI_REORGANIZATION_IMPLEMENTATION_PLAN.md)** - Full reorganization plan -- **[Command Reference](./commands.md)** - Complete command reference -- **[Project Bundle Refactoring Plan](../../specfact-cli-internal/docs/internal/implementation/PROJECT_BUNDLE_REFACTORING_PLAN.md)** - Bundle parameter requirements +- **[Command Reference](./commands.md)** - Complete command reference and current parameter surfaces +- **[Directory Structure](./directory-structure.md)** - Repository and workspace parameter context +- **[Architecture](./architecture.md)** - Runtime and adapter architecture context for parameter design --- diff --git a/docs/reference/schema-versioning.md b/docs/reference/schema-versioning.md index 045af7d4..b3ee24ba 100644 --- a/docs/reference/schema-versioning.md +++ b/docs/reference/schema-versioning.md @@ -1,7 +1,9 @@ --- layout: default title: Schema Versioning -permalink: /schema-versioning/ +permalink: /reference/schema-versioning/ +redirect_from: + - /schema-versioning/ --- # Schema Versioning @@ -174,5 +176,5 @@ schema_metadata: ## Related Documentation - [Architecture - Change Tracking Models](../reference/architecture.md#change-tracking-models-v11-schema) - Technical details -- [Architecture - Bridge Adapter Interface](../reference/architecture.md#bridge-adapter-interface) - Adapter implementation guide +- [Architecture - Required Adapter Interface](../reference/architecture.md#required-adapter-interface) - Adapter implementation guide - [Directory Structure](directory-structure.md) - Bundle file organization diff --git a/docs/technical/README.md b/docs/technical/README.md index 4caf47c2..d3c11e3b 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -17,7 +17,7 @@ Technical documentation for contributors and developers working on SpecFact CLI. ### Maintenance Scripts -For maintenance scripts and developer utilities, see the [Contributing Guide](../../CONTRIBUTING.md#developer-tools) section on Developer Tools. This includes: +For maintenance scripts and developer utilities, see the [Contributing Guide](https://github.com/nold-ai/specfact-cli/blob/main/CONTRIBUTING.md#developer-tools) section on Developer Tools. This includes: - **Cleanup Acceptance Criteria Script** - Removes duplicate replacement instruction text from acceptance criteria - Other maintenance and development utilities in the `scripts/` directory diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 9600f69a..616f04a6 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -90,6 +90,7 @@ These are derived extensions of the same 2026-02-15 plan and are required to ope |--------|-------|---------------|----------|------------| | docs | 01 | docs-01-core-modules-docs-alignment | [#348](https://github.com/nold-ai/specfact-cli/issues/348) | module-migration-01 ✅; module-migration-02 ✅; module-migration-03 ✅; module-migration-05 ✅; module-migration-06/07 outputs inform residual cleanup wording | | docs | 03 | ✅ docs-03-command-syntax-parity (archived 2026-03-18) | pending | docs-01 ✅; docs-02 ✅ | +| docs | 04 | docs-04-docs-review-gate-and-link-integrity | pending | docs-03 ✅ | ### Marketplace (module distribution) diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/.openspec.yaml b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/.openspec.yaml new file mode 100644 index 00000000..d8b0ed03 --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-20 diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/CHANGE_VALIDATION.md b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/CHANGE_VALIDATION.md new file mode 100644 index 00000000..8285e27b --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/CHANGE_VALIDATION.md @@ -0,0 +1,5 @@ +# Change Validation + +- Timestamp: 2026-03-20T14:42:35+01:00 +- Command: `openspec validate docs-04-docs-review-gate-and-link-integrity --strict` +- Result: `Change 'docs-04-docs-review-gate-and-link-integrity' is valid` diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/TDD_EVIDENCE.md b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/TDD_EVIDENCE.md new file mode 100644 index 00000000..57c11211 --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/TDD_EVIDENCE.md @@ -0,0 +1,25 @@ +# TDD Evidence + +## Pre-Implementation Failing Run + +- Timestamp: 2026-03-20T14:31:44+01:00 +- Command: `hatch run pytest tests/unit/docs/test_release_docs_parity.py -q` +- Result: failed + +### Failure Summary + +- `test_navigation_links_resolve_to_published_docs_routes` failed because the first validator pass still treated Jekyll `{{ ... | relative_url }}` expressions and non-page assets such as `feed.xml` and `assets/main.css` as literal docs routes. +- `test_authored_internal_docs_links_resolve_to_published_docs_targets` failed because authored docs still mixed true published-route drift with links to non-published repo files and missing published targets. +- The failing run surfaced the underlying docs regressions that motivated this change, including sidebar routes such as `/reference/directory-structure/`, `/reference/architecture/`, and multiple `/guides/...` links that did not match the authored page permalinks. + +## Post-Implementation Passing Run + +- Timestamp: 2026-03-20T14:40:50+01:00 +- Command: `hatch run pytest tests/unit/docs/test_release_docs_parity.py -q` +- Result: passed + +### Verification Summary + +- The route-aware docs parity suite passed after normalizing guide/reference/example permalinks to the published section routes used by sidebar navigation. +- Broken authored links to missing repo-local files were replaced with valid published docs targets or GitHub source links where the target is intentionally not a published docs page. +- The dedicated `Docs Review` workflow now provides a fast mandatory-check path for docs-only changes without waiting for the full code-oriented PR orchestrator. diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/design.md b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/design.md new file mode 100644 index 00000000..6830659a --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/design.md @@ -0,0 +1,66 @@ +## Context + +The docs site is published with Jekyll front matter and explicit permalinks, but authored links currently mix file-relative paths, assumed `/reference/...` routes, and page-level permalinks that do not always align. The existing docs parity tests check a few content invariants plus a minimal front-matter presence check, while the PR orchestrator treats docs-only and Markdown-only changes as ignorable. That combination allows broken published routes to ship even when the authored source page still exists. + +The change needs both content repair and an enforcement path. The enforcement must stay lightweight enough for PRs, use repository-local information only, and be clear when a failure is caused by a missing page, a wrong permalink, or incomplete front matter. + +## Goals / Non-Goals + +**Goals:** + +- Detect broken internal docs links before merge. +- Detect navigation or landing-page links that point at unpublished routes. +- Require complete Jekyll front matter for pages that are part of published docs navigation and authored-link targets. +- Ensure docs-only PRs trigger a dedicated docs review workflow without waiting for the full code-oriented pipeline. + +**Non-Goals:** + +- Building the full Jekyll site in PR orchestration. +- Validating external websites beyond basic scheme/host filtering. +- Enforcing that every Markdown page in `docs/` appears in global navigation; only navigation-owned and authored-link targets are in scope. + +## Decisions + +### Decision: Reuse the existing docs parity test module as the docs review engine + +Extend `tests/unit/docs/test_release_docs_parity.py` with helpers that parse front matter, derive published routes, and validate internal authored links. This keeps the logic in the current docs-quality test surface, makes failures readable in pytest output, and avoids inventing a separate one-off script and assertion format. + +Alternative considered: a standalone Python script under `scripts/`. Rejected because it would duplicate test harness behavior and add one more place to maintain path and parsing logic. + +### Decision: Treat published-route resolution as the source of truth, not file paths + +The validator should compute the set of valid published routes from each docs page's explicit permalink or the site default, then compare authored links against that route map. This directly models the `docs.specfact.io` behavior and catches cases where a file exists but its published URL differs from the navigation link. + +Alternative considered: checking only that a target Markdown file exists on disk. Rejected because it misses the current failure mode where the file exists but the published URL is different. + +### Decision: Scope mandatory metadata checks to published docs pages and navigation-linked targets + +Require `layout`, `title`, and `permalink` for published docs pages that are reachable from docs navigation or internal authored links, and require a valid front-matter block for all other authored-link targets. This raises the bar where missing metadata hurts users while avoiding an unnecessarily broad rule for internal planning or auxiliary Markdown. + +Alternative considered: requiring all docs Markdown files to have the full metadata set. Rejected because some auxiliary files are intentionally not part of the published navigation experience. + +### Decision: Add a dedicated docs-review workflow for docs-only changes + +Add a separate `.github/workflows/docs-review.yml` workflow that runs the targeted docs parity suite for docs and Markdown changes. Keep the heavier PR orchestrator focused on code-oriented validation so docs-only changes get a fast required check without waking the full runtime test matrix. + +Alternative considered: adding a `docs-review` job inside `.github/workflows/pr-orchestrator.yml`. Rejected because docs-only PRs would still wait on the code-oriented orchestration workflow and the user explicitly wants a lighter mandatory check. + +## Risks / Trade-offs + +- [False positives from flexible Markdown links] → Limit validation to internal site-style links and normalize common relative-link forms before asserting. +- [Metadata requirements may surface many latent docs issues] → Start with navigation-owned and authored-link targets, then fix the discovered set in the same change. +- [Workflow duration increases for docs-only PRs] → Run only the targeted docs parity test file in the docs-review job. + +## Migration Plan + +1. Add the OpenSpec deltas and implement the stricter docs review tests. +2. Run the targeted docs parity suite and capture the failing evidence before fixing routes or workflow logic. +3. Correct broken docs permalinks and any missing navigation-linked metadata. +4. Add the dedicated `Docs Review` workflow for docs and Markdown changes. +5. Re-run the targeted docs parity suite and the affected workflow validation gates, then record passing evidence. + +Rollback strategy: revert the docs route corrections and dedicated docs-review workflow as a single change if the new validator proves too noisy; the repository will return to the prior lax behavior. + +## Open Questions + +- None at this stage; the implementation approach is straightforward and bounded to docs/test/workflow files. diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/proposal.md b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/proposal.md new file mode 100644 index 00000000..b4f920db --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/proposal.md @@ -0,0 +1,37 @@ +# Change: Repair Published Docs Links And Add A Docs Review Gate + +## Why + +Published docs currently contain broken links because authored navigation and landing-page links do not consistently match the pages' actual Jekyll permalinks. The PR orchestrator also ignores docs-only and Markdown-only changes, so link drift, missing front matter, and missing docs coverage can ship without any review gate. + +## What Changes + +- Audit the current published-doc navigation and landing-page links, then correct broken routes and any missing linked docs coverage in core docs. +- Add automated docs review validation that checks Jekyll front matter, published-route resolution, and authored internal links for docs pages. +- Add a dedicated docs review workflow so docs-only changes run fast validation without waiting for the full code-oriented PR orchestrator. +- Record and enforce a discoverability contract for navigation-owned docs pages so broken or missing linked pages fail fast in CI. + +## Capabilities + +### New Capabilities + +- `docs-review-gate`: validate published docs links, front matter, and navigation-owned page coverage during local and CI docs review. + +### Modified Capabilities + +- `documentation-alignment`: docs landing pages, sidebar links, and authored internal links must resolve to the actual published permalinks for the current docs site. + +## Impact + +- Affected docs: `docs/index.md`, `docs/_layouts/default.html`, `docs/reference/directory-structure.md`, and any additional docs pages found during link audit. +- Affected validation: `tests/unit/docs/test_release_docs_parity.py` and any new docs-review helpers or fixtures needed for route/link validation. +- Affected CI: `.github/workflows/docs-review.yml` provides the dedicated docs-only validation path and can be configured as a required GitHub check. +- User-facing impact: linked pages on `https://docs.specfact.io` resolve correctly, and future docs regressions fail in PRs before merge. + +## Source Tracking + + +- **GitHub Issue**: pending +- **Issue URL**: pending +- **Last Synced Status**: local-proposal +- **Sanitized**: true diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/specs/docs-review-gate/spec.md b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/specs/docs-review-gate/spec.md new file mode 100644 index 00000000..180f8c0e --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/specs/docs-review-gate/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Docs review validates published route integrity + +The docs review gate SHALL derive the published route for authored docs pages from Jekyll front matter and site defaults, and SHALL fail when an internal docs link points to a route that is not published by the current docs source tree. + +#### Scenario: Sidebar or landing page links point to an unpublished route + +- **WHEN** the docs review gate evaluates links from `docs/index.md` or `docs/_layouts/default.html` +- **THEN** every internal docs route resolves to exactly one published docs page +- **AND** the failure output identifies the authored source and the unresolved route when a link is broken + +#### Scenario: Authored markdown links drift from the published permalink + +- **WHEN** the docs review gate evaluates internal Markdown links inside published docs pages +- **THEN** links to docs pages resolve by published route rather than only by source-file existence +- **AND** a page with a mismatched permalink fails validation even if the Markdown file still exists on disk + +### Requirement: Docs review validates required front matter for published docs targets + +The docs review gate SHALL fail when a published docs page that is linked from navigation or another authored docs page is missing required front matter fields needed for publishing and navigation: `layout`, `title`, and `permalink`. + +#### Scenario: Linked docs page is missing required metadata + +- **WHEN** the docs review gate evaluates a navigation-linked or authored-link target page +- **THEN** the page must declare `layout`, `title`, and `permalink` in front matter +- **AND** the failure output identifies the page and the missing keys + +### Requirement: Docs-only pull requests run a dedicated docs review workflow + +A dedicated docs review workflow SHALL run the docs review gate for pull requests or pushes that change docs or Markdown content, even when no Python source files changed. + +#### Scenario: Docs-only change triggers docs review validation + +- **WHEN** a pull request changes only `docs/**` or Markdown files +- **THEN** the dedicated docs review workflow runs the targeted docs review suite +- **AND** docs validation does not wait for the full code-oriented PR orchestrator to complete diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/specs/documentation-alignment/spec.md b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/specs/documentation-alignment/spec.md new file mode 100644 index 00000000..70349612 --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/specs/documentation-alignment/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Navigation-owned docs links match published permalinks + +The docs landing page and sidebar navigation SHALL link to the actual published permalinks for their target pages, and SHALL NOT assume a section-prefixed route when the page publishes elsewhere. + +#### Scenario: Reader opens a navigation-linked reference page + +- **WHEN** a reader selects a reference or guide link from `docs/index.md` or `docs/_layouts/default.html` +- **THEN** the route resolves on `docs.specfact.io` +- **AND** the link target matches the page permalink declared in the authored docs source + +### Requirement: Broken published docs routes are corrected in authored source + +When docs review identifies a broken published route caused by authored permalink drift, the authored page or link source SHALL be corrected in the same remediation change so the published docs site remains internally consistent. + +#### Scenario: Existing docs page has a mismatched permalink + +- **WHEN** an authored docs page exists but the linked published route does not resolve because the page permalink differs +- **THEN** the remediation updates the authored permalink or the authored link source to restore route integrity +- **AND** the corrected route remains covered by docs review validation diff --git a/openspec/changes/docs-04-docs-review-gate-and-link-integrity/tasks.md b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/tasks.md new file mode 100644 index 00000000..1087e7fb --- /dev/null +++ b/openspec/changes/docs-04-docs-review-gate-and-link-integrity/tasks.md @@ -0,0 +1,21 @@ +## 1. Change Setup And Spec Deltas + +- [x] 1.1 Update `openspec/CHANGE_ORDER.md` with the new `docs-04-docs-review-gate-and-link-integrity` entry +- [x] 1.2 Add the `docs-review-gate` capability spec for published-route, front-matter, and docs-only CI validation +- [x] 1.3 Add the `documentation-alignment` delta covering navigation-owned permalink integrity and same-change remediation of broken routes + +## 2. Validation First + +- [x] 2.1 Extend `tests/unit/docs/test_release_docs_parity.py` to validate internal docs routes, linked-page metadata, and navigation-owned links +- [x] 2.2 Run the targeted docs parity suite and capture a failing result in `openspec/changes/docs-04-docs-review-gate-and-link-integrity/TDD_EVIDENCE.md` + +## 3. Remediation And Workflow Enforcement + +- [x] 3.1 Fix broken docs permalinks and any linked-page front-matter gaps discovered by the new validation +- [x] 3.2 Add `.github/workflows/docs-review.yml` so docs-only and Markdown-only changes run a dedicated docs-review workflow + +## 4. Verification And Delivery + +- [x] 4.1 Re-run the targeted docs parity suite and record the passing result in `openspec/changes/docs-04-docs-review-gate-and-link-integrity/TDD_EVIDENCE.md` +- [x] 4.2 Run `openspec validate docs-04-docs-review-gate-and-link-integrity --strict` and save the result to `CHANGE_VALIDATION.md` +- [x] 4.3 Run the affected repo quality gates for touched docs/test/workflow files diff --git a/tests/unit/docs/test_release_docs_parity.py b/tests/unit/docs/test_release_docs_parity.py index 12d59235..5bbaddef 100644 --- a/tests/unit/docs/test_release_docs_parity.py +++ b/tests/unit/docs/test_release_docs_parity.py @@ -1,13 +1,28 @@ from __future__ import annotations +import re from pathlib import Path +from urllib.parse import unquote, urlparse MODULES_DOCS_HOST = "modules.specfact.io" +DOCS_HOST = "docs.specfact.io" +MARKDOWN_LINK_RE = re.compile(r"(? Path: + return Path(__file__).resolve().parents[3] def _repo_file(path: str) -> Path: - return Path(__file__).resolve().parents[3] / path + return _repo_root() / path + + +def _docs_root() -> Path: + return _repo_root() / "docs" def _assert_mentions_modules_docs_site(content: str) -> None: @@ -17,6 +32,208 @@ def _assert_mentions_modules_docs_site(content: str) -> None: assert content[host_index + len(MODULES_DOCS_HOST)] == "/" +def _is_docs_markdown(path: Path) -> bool: + return path.suffix == ".md" and "_site" not in path.parts and "vendor" not in path.parts + + +def _iter_docs_markdown_paths() -> list[Path]: + return sorted(path.resolve() for path in _docs_root().rglob("*.md") if _is_docs_markdown(path)) + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _split_front_matter(text: str) -> tuple[dict[str, str], str]: + if not text.startswith("---\n"): + return {}, text + + lines = text.splitlines() + end_index = None + for index in range(1, len(lines)): + if lines[index].strip() == "---": + end_index = index + break + if end_index is None: + return {}, text + + metadata: dict[str, str] = {} + for raw_line in lines[1:end_index]: + if ":" not in raw_line: + continue + key, value = raw_line.split(":", 1) + metadata[key.strip()] = value.strip().strip('"').strip("'") + + body = "\n".join(lines[end_index + 1 :]) + return metadata, body + + +def _normalize_route(route: str) -> str: + cleaned = unquote(route.strip()) + if not cleaned: + return "/" + if not cleaned.startswith("/"): + cleaned = "/" + cleaned + cleaned = re.sub(r"/{2,}", "/", cleaned) + if cleaned != "/" and not cleaned.endswith("/"): + cleaned += "/" + return cleaned + + +def _published_route_for_path(path: Path, metadata: dict[str, str]) -> str: + permalink = metadata.get("permalink") + if permalink: + return _normalize_route(permalink) + return _normalize_route(f"/{path.stem}/") + + +def _build_published_docs_index() -> tuple[dict[str, Path], dict[Path, dict[str, str]], dict[Path, str]]: + route_to_path: dict[str, Path] = {} + path_to_metadata: dict[Path, dict[str, str]] = {} + path_to_route: dict[Path, str] = {} + + for path in _iter_docs_markdown_paths(): + metadata, _ = _split_front_matter(_read_text(path)) + route = _published_route_for_path(path, metadata) + route_to_path[route] = path + path_to_metadata[path] = metadata + path_to_route[path] = route + + return route_to_path, path_to_metadata, path_to_route + + +def _extract_links(source: Path, content: str) -> list[str]: + if source.suffix == ".html": + return HTML_HREF_RE.findall(content) + return MARKDOWN_LINK_RE.findall(content) + + +def _normalize_jekyll_relative_url(link: str) -> str: + match = JEKYLL_RELATIVE_URL_RE.fullmatch(link.strip()) + if match: + return match.group(1) + return link + + +def _is_published_docs_route_candidate(route: str) -> bool: + return route not in {"/assets/main.css/", "/feed.xml/"} + + +def _resolve_internal_docs_target( + source: Path, + raw_link: str, + route_to_path: dict[str, Path], + path_to_route: dict[Path, str], +) -> tuple[str | None, Path | None, str | None]: + stripped = _normalize_jekyll_relative_url(raw_link.strip()) + if not stripped or stripped.startswith("#"): + return None, None, None + + parsed = urlparse(stripped) + if parsed.scheme in {"mailto", "javascript", "tel"}: + return None, None, None + if parsed.scheme in {"http", "https"}: + if parsed.netloc != DOCS_HOST: + return None, None, None + route = _normalize_route(parsed.path or "/") + if not _is_published_docs_route_candidate(route): + return None, None, None + target = route_to_path.get(route) + if target is None: + return route, None, f"{source.relative_to(_repo_root())} -> {route}" + return route, target, None + if parsed.scheme: + return None, None, None + + target_value = unquote(parsed.path) + if not target_value: + return None, None, None + + if target_value.startswith("/"): + route = _normalize_route(target_value) + if not _is_published_docs_route_candidate(route): + return None, None, None + target = route_to_path.get(route) + if target is None: + return route, None, f"{source.relative_to(_repo_root())} -> {route}" + return route, target, None + + candidate = (source.parent / target_value).resolve() + if candidate.is_dir(): + readme_candidate = (candidate / "README.md").resolve() + if readme_candidate.is_file() and _is_docs_markdown(readme_candidate): + route = path_to_route.get(readme_candidate) + if route is None: + return None, None, f"{source.relative_to(_repo_root())} -> {target_value}" + return route, readme_candidate, None + return None, None, None + + if candidate.is_file() and _is_docs_markdown(candidate): + route = path_to_route.get(candidate) + if route is None: + return None, None, f"{source.relative_to(_repo_root())} -> {target_value}" + return route, candidate, None + + if not candidate.suffix: + markdown_candidate = candidate.with_suffix(".md") + if markdown_candidate.is_file() and _is_docs_markdown(markdown_candidate): + resolved_candidate = markdown_candidate.resolve() + route = path_to_route.get(resolved_candidate) + if route is None: + return None, None, f"{source.relative_to(_repo_root())} -> {target_value}" + return route, resolved_candidate, None + + route = _normalize_route(target_value) + if not _is_published_docs_route_candidate(route): + return None, None, None + target = route_to_path.get(route) + if target is None: + return route, None, f"{source.relative_to(_repo_root())} -> {target_value} (normalized: {route})" + return route, target, None + + +def _navigation_sources() -> list[Path]: + return [ + _repo_file("docs/index.md").resolve(), + _repo_file("docs/_layouts/default.html").resolve(), + ] + + +def _scan_navigation_targets() -> tuple[list[str], set[Path]]: + route_to_path, _, path_to_route = _build_published_docs_index() + failures: list[str] = [] + targets: set[Path] = set() + + for source in _navigation_sources(): + for link in _extract_links(source, _read_text(source)): + _, target, failure = _resolve_internal_docs_target(source, link, route_to_path, path_to_route) + if failure: + failures.append(failure) + if target is not None: + targets.add(target) + + return failures, targets + + +def _scan_authored_doc_link_failures() -> tuple[list[str], set[Path]]: + route_to_path, _, path_to_route = _build_published_docs_index() + failures: list[str] = [] + targets: set[Path] = set() + + for source in _iter_docs_markdown_paths(): + metadata, body = _split_front_matter(_read_text(source)) + if not metadata: + continue + for link in _extract_links(source, body): + _, target, failure = _resolve_internal_docs_target(source, link, route_to_path, path_to_route) + if failure: + failures.append(failure) + if target is not None: + targets.add(target) + + return failures, targets + + def test_changelog_has_single_0340_release_header() -> None: changelog = _repo_file("CHANGELOG.md").read_text(encoding="utf-8") assert changelog.count("## [0.34.0] - 2026-02-18") == 1 @@ -105,12 +322,12 @@ def _scan_authored_docs(pattern: str) -> list[tuple[str, int, str]]: - Any line where the pattern co-occurs with the word "removed" or "(removed)" """ hits: list[tuple[str, int, str]] = [] - repo_root = Path(__file__).resolve().parents[3] + repo_root = _repo_root() sources: list[Path] = [repo_root / "README.md"] docs_dir = repo_root / "docs" - for p in docs_dir.rglob("*.md"): - if "_site" not in p.parts and "vendor" not in p.parts: - sources.append(p) + for path in docs_dir.rglob("*.md"): + if "_site" not in path.parts and "vendor" not in path.parts: + sources.append(path) for src in sources: if not src.exists(): continue @@ -118,7 +335,6 @@ def _scan_authored_docs(pattern: str) -> list[tuple[str, int, str]]: if pattern not in line: continue stripped = line.strip() - # Skip code-block comment lines, but do not ignore Markdown headings. if stripped.startswith("#") and not stripped.startswith( ("# ", "## ", "### ", "#### ", "##### ", "###### ") ): @@ -133,7 +349,7 @@ def _scan_authored_docs(pattern: str) -> list[tuple[str, int, str]]: def _fmt_hits(hits: list[tuple[str, int, str]]) -> str: - return "\n".join(f" {p}:{n} {line}" for p, n, line in hits) + return "\n".join(f" {path}:{lineno} {line}" for path, lineno, line in hits) def test_removed_project_plan_syntax_absent_from_authored_docs() -> None: @@ -199,13 +415,38 @@ def test_current_backlog_subcommands_documented_in_commands_reference() -> None: def test_all_published_docs_markdown_files_have_jekyll_front_matter() -> None: - repo_root = Path(__file__).resolve().parents[3] - docs_root = repo_root / "docs" missing: list[str] = [] - for path in sorted(docs_root.rglob("*.md")): - if "_site" in path.parts or "vendor" in path.parts: - continue - first_line = path.read_text(encoding="utf-8").splitlines()[0] if path.read_text(encoding="utf-8") else "" + for path in _iter_docs_markdown_paths(): + first_line = _read_text(path).splitlines()[0] if _read_text(path) else "" if first_line != "---": - missing.append(str(path.relative_to(repo_root))) + missing.append(str(path.relative_to(_repo_root()))) assert not missing, "Docs files missing front matter:\n" + "\n".join(missing) + + +# --------------------------------------------------------------------------- +# docs-04-docs-review-gate-and-link-integrity +# --------------------------------------------------------------------------- + + +def test_navigation_links_resolve_to_published_docs_routes() -> None: + failures, _ = _scan_navigation_targets() + assert not failures, "Broken navigation docs links:\n" + "\n".join(sorted(failures)) + + +def test_authored_internal_docs_links_resolve_to_published_docs_targets() -> None: + failures, _ = _scan_authored_doc_link_failures() + assert not failures, "Broken authored docs links:\n" + "\n".join(sorted(failures)) + + +def test_navigation_link_targets_have_required_front_matter_keys() -> None: + _, targets = _scan_navigation_targets() + _, path_to_metadata, _ = _build_published_docs_index() + missing: list[str] = [] + + for target in sorted(targets): + metadata = path_to_metadata[target] + missing_keys = [key for key in REQUIRED_NAV_FRONT_MATTER_KEYS if not metadata.get(key)] + if missing_keys: + missing.append(f"{target.relative_to(_repo_root())}: missing {', '.join(missing_keys)}") + + assert not missing, "Navigation-linked docs missing required front matter keys:\n" + "\n".join(missing)