diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d27d57e..485a2cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ All notable changes to this project will be documented in this file. --- +## [0.43.3] - 2026-03-30 + +### Fixed + +- First-contact docs contract hardening: + - strengthened README / `docs/index.md` / `CONTRIBUTING.md` alignment tests + - restored explicit clickable modules-docs landing link validation + - hardened docs parity checks against filtered Jekyll `site.*` tokens and safer URL-host assertions +- Contract robustness for utility helpers under symbolic execution: + - `src/specfact_cli/utils/optional_deps.py` now fails closed on invalid import targets + - `src/specfact_cli/utils/acceptance_criteria.py` now rejects pathological control-character inputs + without regex exceptions + - `src/specfact_cli/utils/enrichment_parser.py` now uses safe regex helpers/guards so + `hatch run contract-test` passes CrossHair exploration for enrichment parsing paths +- OpenSpec/docs review remediation: + - wrapped overlong proposal bullets and corrected list spacing in active change artifacts + - added cross-repo first-contact traceability guidance for the core and modules docs split + +### Changed + +- Tests: + - added utility regression tests for invalid package names, pathological acceptance criteria, and + control-character enrichment blocks + - converted docs entrypoint file presence checks from import-time assertions to a module-scoped + skip fixture for clearer test behavior in partial environments + +--- + ## [0.43.2] - 2026-03-29 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65ed0a91..d8d5bcd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,6 +193,33 @@ SpecFact CLI uses **Spec-Driven Development (SDD)** via [OpenSpec](./openspec/) - Test documentation examples - **Update OpenSpec specs**: When implementing features, ensure [`openspec/specs/`](./openspec/specs/) reflects the new behavior +### Entry-Point Messaging Hierarchy + +The repository README, `docs/index.md`, and other first-contact surfaces must preserve the same +first-contact story. + +When editing those surfaces, make sure a new visitor can quickly answer: +- **What is SpecFact?** +- **Why does it exist?** +- **Why should I use it?** +- **What do I get?** +- **How do I get started?** + +Keep the hierarchy in this order: +1. Product identity +2. Why it exists +3. User value +4. How to get started +5. Deeper topology and cross-site handoff + +For first-contact pages, define SpecFact as the validation and alignment layer for software delivery +and present “keep backlog, specs, tests, and code in sync” as the user-visible outcome of that +positioning. + +GitHub-facing repo metadata must reinforce the same story. Keep the repository description, topics, +and other above-the-fold cues aligned with the README hero so visitors see the same product +identity before and after opening the repository. + ### Documentation Structure - `README.md`: Project overview and quick start diff --git a/README.md b/README.md index aa7cd631..8b4653e9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # SpecFact CLI -> **The "swiss knife" CLI that turns any codebase into a clear, safe, and shippable workflow.** -> Keep backlog, specs, tests, and code in sync so changes made by people or AI copilots do not break production. -> Works for brand-new projects and long-lived codebases - even if you are new to coding. +> **SpecFact is the validation and alignment layer for software delivery.** +> It adds the missing validation layer that keeps backlog intent, specifications, tests, and code +> from drifting apart across AI-assisted coding, brownfield systems, and governed delivery. +> Use it to move fast without losing rigor. **No API keys required. Works offline. Zero vendor lock-in.** @@ -23,21 +24,54 @@ invoke SpecFact as part of a command chain. --- -## Documentation Topology +## What is SpecFact? -`docs.specfact.io` is the canonical docs entry point for SpecFact. +SpecFact is the validation and alignment layer for software delivery. -- Core CLI/runtime/platform documentation remains owned by `specfact-cli`. -- Module-specific deep docs are canonically owned by `specfact-cli-modules`. -- The live modules docs site is currently published at `https://modules.specfact.io/`. +It is a local CLI that helps you keep the intent behind a change aligned from +backlog or idea through specifications, implementation, and checks. The “Swiss-knife CLI” metaphor +fits because SpecFact gives you a set of focused tools for specific delivery problems, not a vague +bag of features. -Use this repository's docs for the overall SpecFact workflow, CLI runtime lifecycle, module registry, trust model, and command-group topology. -Use the modules docs site for bundle-specific deep dives, adapter details, workflow tutorials, and module-authoring guidance. -In short, module-specific deep docs are canonically owned by `specfact-cli-modules`. +In practice, SpecFact helps you: +- add guardrails to AI-assisted and fast-moving greenfield work +- reverse-engineer large brownfield codebases into trustworthy structured understanding +- reduce the “I wanted X but got Y” drift between backlog, spec, and implementation +- move from local rigor toward team and enterprise policy enforcement ---- +## Why does it exist? + +Modern delivery drifts in predictable ways: +- AI-generated quick wins often lack the validation layer needed for mid- and long-term reliability +- brownfield systems often have missing or drifted specs, so teams need to reverse-engineer reality +- backlog intent gets reinterpreted into something else before it reaches code +- teams working with different skill levels, opinions, and AI IDE setups need consistent review and + policy enforcement + +SpecFact exists to reduce that drift. It helps teams understand what is really there, express what +should happen more accurately, and validate that the result still matches the original intent. + +## Why should I use it? + +Use SpecFact if you want one of these outcomes: +- ship AI-assisted changes faster without accepting fragile “looks fine to me” quality +- understand a legacy or unfamiliar codebase before changing it +- hand brownfield insight into OpenSpec, Spec-Kit, or other spec-first workflows +- keep backlog expectations, specifications, and implementation from silently diverging +- enforce shared rules consistently across developers and CI/CD -## Start Here (60 seconds) +## What do I get? + +With SpecFact, you get: +- a deterministic local CLI instead of another opaque SaaS dependency +- a validation layer around fast-moving implementation work +- codebase analysis and sidecar flows for brownfield understanding +- stronger backlog/spec/code alignment for real delivery workflows +- a path from individual rigor to organization-level policy enforcement + +## How do I get started? + +### Start Here (5 minutes) ### Install @@ -49,38 +83,104 @@ uvx specfact-cli@latest pip install -U specfact-cli ``` -### Bootstrap and IDE Setup +### Bootstrap ```bash -# First run: install official bundles +# Recommended first run specfact init --profile solo-developer - -# Alternative bundle selection -specfact init --install backlog,codebase -specfact init --install all - -# IDE prompt/template setup -specfact init ide -specfact init ide --ide cursor -specfact init ide --ide vscode ``` -`specfact init ide` discovers prompt resources from installed workflow modules and exports them to your IDE. If module prompt payloads are not installed yet, the CLI uses packaged fallback resources. - -### Run Your First Flow +### Get First Value ```bash -# Analyze an existing codebase +# Analyze a codebase you care about specfact code import my-project --repo . -# Snapshot current project state +# Snapshot the project state for follow-up workflows specfact project snapshot --bundle my-project -# Validate external code without modifying source +# Validate external code without modifying the target repo specfact code validate sidecar init my-project /path/to/repo specfact code validate sidecar run my-project /path/to/repo ``` +That path gives you a concrete first win: SpecFact understands your project context and gives you a +validated starting point instead of jumping straight into blind change work. + +### AI IDE Setup + +```bash +specfact init ide +specfact init ide --ide cursor +specfact init ide --ide vscode +``` + +`specfact init ide` discovers prompt resources from installed workflow modules and exports them to +your IDE. If module prompt payloads are not installed yet, the CLI uses packaged fallback resources. + +## Choose Your Path + +### Greenfield and AI-assisted delivery + +Use SpecFact as the validation layer around fast-moving implementation work. + +Start with: +- `specfact init --profile solo-developer` +- `specfact code validate sidecar init /path/to/repo` +- `specfact code validate sidecar run /path/to/repo` + +### Brownfield and reverse engineering + +Use SpecFact to understand an existing system before you change it, then hand that understanding +into spec-first tools such as OpenSpec or Spec-Kit. + +Start with: +- `specfact code import my-project --repo .` +- `specfact project snapshot --bundle my-project` +- `specfact code validate sidecar init my-project /path/to/repo` +- `specfact code validate sidecar run my-project /path/to/repo` + +### Backlog to code alignment + +Use SpecFact when the problem is not only code quality, but drift between expectations and delivery. +Backlog commands require a backlog-enabled profile or installed backlog bundle before the workflow +commands are available. + +Start with: +- `specfact init --profile backlog-team` +- `specfact backlog ceremony standup ...` +- `specfact backlog ceremony refinement ...` +- `specfact backlog verify-readiness --bundle ` + +### Team and policy enforcement + +Use SpecFact when multiple developers and AI IDEs need consistent checks and review behavior. + +Start with: +- `specfact backlog verify-readiness --bundle ` +- `specfact govern ...` +- CI validation flows that keep the same rules active outside local development + +## How do I get started if I want more? + +**Next steps** + +- **[Core CLI docs](docs/index.md)** for the core runtime, bootstrap, validation, and command topology +- **[Reference: command topology](docs/reference/commands.md)** for grouped command surfaces +- **[Canonical modules docs site](https://modules.specfact.io/)** for bundle-deep workflows + +## Documentation Topology + +`docs.specfact.io` is the canonical starting point for SpecFact. + +- Core CLI/runtime/platform documentation remains owned by `specfact-cli`. +- Module-specific deep docs are canonically owned by `specfact-cli-modules`. +- The live modules docs site is published at `https://modules.specfact.io/`. + +Use this repository's docs for the overall product story, runtime lifecycle, command topology, +trust model, and getting-started flow. Use the modules docs site when you want deeper workflow, +adapter, and module-authoring guidance. + ### Migration Note (Flat Commands Removed) As of `0.40.0`, flat root commands are removed. Use grouped commands: @@ -120,12 +220,6 @@ For GitHub, replace adapter/org/project with: /specfact.01-import my-project --repo . ``` -**Next steps** - -- **[Core CLI docs](docs/index.md)** -- **[Reference: command topology](docs/reference/commands.md)** -- **[Canonical modules docs site](https://modules.specfact.io/)** - --- ## Who It Is For diff --git a/docs/README.md b/docs/README.md index 8770f112..f0bd4b8c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,10 +8,16 @@ description: High-level index for the SpecFact core CLI docs and canonical modul # SpecFact CLI Documentation This repository owns the **core CLI** documentation set for SpecFact. -It explains the overall process of using SpecFact CLI, the platform runtime, and how official modules integrate into the grouped command surface. +Use it as the canonical starting point when a user still needs orientation around what SpecFact is, +why it exists, what value it provides, and how to get started. -For **module-specific deep functionality**, use the canonical modules docs site at `https://modules.specfact.io/`. -The canonical modules docs site owns the detailed guides for bundle workflows, adapters, and module authoring. +SpecFact is the validation and alignment layer for software delivery. The core docs explain the +product story, runtime lifecycle, bootstrap path, and the handoff into deeper module-owned +workflows. + +For **module-specific deep functionality**, use the canonical modules docs site at +`https://modules.specfact.io/`. The canonical modules docs site owns the detailed guides for bundle +workflows, adapters, and module authoring. ## Core Docs Scope @@ -35,6 +41,10 @@ Use the canonical modules docs site for: The canonical modules docs site is currently published at `https://modules.specfact.io/`. This docs set keeps release-line overview and handoff content for bundle workflows while the canonical modules docs site carries the deep bundle-specific guidance. +If a modules page is ever used as a first-contact surface, it must explain that `modules.specfact.io` +is the deeper workflow layer and direct un-oriented users back to `docs.specfact.io` for the core +product story and fast-start path. + ## Cross-site contract - [Documentation URL contract (core and modules)](reference/documentation-url-contract.md) — linking rules vs `modules.specfact.io` diff --git a/docs/index.md b/docs/index.md index 0895533e..9a46c7e7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- layout: default title: SpecFact CLI Documentation -description: Core CLI docs for runtime lifecycle, command topology, and official module integration. +description: SpecFact is the validation and alignment layer for software delivery. Start here for the core CLI story, first steps, and the handoff into module-deep workflows. permalink: / keywords: [specfact, core-cli, runtime, module-system, architecture] audience: [solo, team, enterprise] @@ -17,57 +17,101 @@ exempt_reason: "" # SpecFact CLI Documentation -SpecFact CLI is a contract-first Python CLI that keeps backlogs, specs, tests, and code in sync. This site covers the core platform - runtime, lifecycle, command topology, and architecture. +SpecFact is the validation and alignment layer for software delivery. -SpecFact CLI does **not** include built-in AI. It pairs deterministic CLI commands with slash-command prompts in your chosen IDE. +This site is the canonical starting point for the core CLI story: what SpecFact is, why it exists, +what value you get from it, how to get started, and when to move into deeper bundle-owned workflows. -For module-specific workflows (backlog, governance, adapters), see [modules.specfact.io](https://modules.specfact.io/). - -Use the shared portal navigation to move between **Docs Home**, **Core CLI**, and **Modules** without changing interaction patterns. +SpecFact does **not** include built-in AI. It pairs deterministic CLI commands with your chosen IDE +and copilot so fast-moving work has a stronger validation and alignment layer around it. --- -## Find Your Path +## What is SpecFact? + +SpecFact helps you keep backlog intent, specifications, implementation, and validation from drifting +apart. + +It is especially useful when: +- AI-assisted or “vibe-coded” work needs more rigor +- brownfield systems need trustworthy reverse-engineered understanding +- teams want to avoid the “I wanted X but got Y” delivery failure +- organizations need a path toward stronger shared policy enforcement + +## Why does it exist? + +Software delivery drifts in stages. Expectations change as they move from backlog language to +specification, from specification to implementation, and from implementation to review. SpecFact +exists to reduce that drift by giving you deterministic tooling for analysis, validation, and +alignment. + +## Why should I use it? + +Use SpecFact when you want faster delivery without losing validation, stronger brownfield +understanding before making changes, and less drift between backlog intent, specifications, and the +code that actually lands. + +## What do I get? + +With SpecFact, you get: +- deterministic local tooling instead of opaque cloud dependence +- a validation layer around AI-assisted delivery +- codebase analysis and sidecar validation for brownfield work +- stronger backlog/spec/code alignment +- a clean handoff from core runtime docs into module-deep workflows on `modules.specfact.io` + +## How to get started + +1. **[Installation](/getting-started/installation/)** - Install SpecFact CLI +2. **[5-Minute Quickstart](/getting-started/quickstart/)** - Get first value quickly +3. **[specfact init](/core-cli/init/)** - Bootstrap the core runtime and your local setup +4. **[Bootstrap Checklist](/module-system/bootstrap-checklist/)** - Verify bundle readiness + +If you are new to SpecFact, start here before jumping into module-deep workflows. + +## Choose Your Path
-

New User

-

Start with the core install and bootstrap path before adding workflow bundles.

+

Greenfield & AI-assisted delivery

+

Use SpecFact as the validation layer around fast-moving implementation work.

-

Team Lead

-

Set up shared runtime conventions, IDE flows, and team-level operating guidance.

+

Brownfield and reverse engineering

+

Use SpecFact to understand an existing system and then hand insight into spec-first workflows.

-

Platform Owner

-

Use the architecture and registry references to operate SpecFact as shared platform infrastructure.

+

Backlog to code alignment

+

Use SpecFact when the main problem is drift between expectations, specs, and implementation.

-

Module Operator

-

Manage installed bundles from core docs, then hand off to modules docs for bundle-owned workflows.

+

Team and policy enforcement

+

Use core runtime, governance, and shared workflow conventions to scale rigor across teams.

@@ -83,11 +127,14 @@ The `specfact-cli` package provides the stable platform surface: Installed modules mount workflows under `project`, `backlog`, `code`, `spec`, and `govern`. -## Get Started +## Modules Documentation -1. **[Installation](/getting-started/installation/)** - Install SpecFact CLI -2. **[5-Minute Quickstart](/getting-started/quickstart/)** - First analysis in under 5 minutes -3. **[Bootstrap Checklist](/module-system/bootstrap-checklist/)** - Verify modules are installed +`docs.specfact.io` is the default starting point. Move to the modules site when you need deeper +bundle-specific workflows, adapters, and authoring guidance. + +- **[Modules Docs Home](https://modules.specfact.io/)** - Backlog, project, spec, govern +- **[Module Development](https://modules.specfact.io/authoring/module-development/)** - Build your own modules +- **[Publishing Modules](https://modules.specfact.io/authoring/publishing-modules/)** - Publish to marketplace ## Module System @@ -123,11 +170,3 @@ Installed modules mount workflows under `project`, `backlog`, `code`, `spec`, an - **[Migration Guide](/migration/migration-guide/)** - Version upgrade guidance - **[CLI Reorganization](/migration/migration-cli-reorganization/)** - Command surface changes - **[OpenSpec Migration](/migration/openspec-migration/)** - OPSX workflow migration - -## Modules Documentation - -For in-depth module workflows, visit the canonical modules site: - -- **[Modules Docs Home](https://modules.specfact.io/)** - Backlog, project, spec, govern -- **[Module Development](https://modules.specfact.io/authoring/module-development/)** - Build your own modules -- **[Publishing Modules](https://modules.specfact.io/authoring/publishing-modules/)** - Publish to marketplace diff --git a/docs/reference/documentation-url-contract.md b/docs/reference/documentation-url-contract.md index fbc82bf7..64f24863 100644 --- a/docs/reference/documentation-url-contract.md +++ b/docs/reference/documentation-url-contract.md @@ -32,6 +32,16 @@ The **authoritative** URL and ownership rules for **both** documentation sites a 2. **Handoff pages** (see OpenSpec `docs-07-core-handoff-conversion`) must point to the **modules canonical URL** for each topic, with a short summary and prerequisites on core. 3. **Internal core links** must continue to resolve on `docs.specfact.io` per published `permalink` (docs review gate / parity tests). +## First-contact handoff contract + +- `docs.specfact.io` is the default starting point for first-time users. +- `modules.specfact.io` is the deeper workflow and bundle documentation layer. +- Core docs must explain what extra value the modules docs provide before linking users there. +- Modules landing pages must send un-oriented users back to the core docs when they still need the + product story, first-run guidance, or overall navigation context. +- Both sites must preserve the same product identity: SpecFact as the validation and alignment layer + for software delivery. + ## Repositories | Concern | Repository | diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index ff7670d8..30d61f21 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -118,6 +118,7 @@ The 2026-03-22 clean-code plan adds one new cross-repo change pair and re-sequen | docs | 07 | docs-07-core-handoff-conversion | [#439](https://github.com/nold-ai/specfact-cli/issues/439) | docs-05-core-site-ia-restructure; modules-repo/docs-06-modules-site-ia-restructure | | docs | 08 | docs-12-docs-validation-ci | [#440](https://github.com/nold-ai/specfact-cli/issues/440) | docs-05-core-site-ia-restructure; docs-07-core-handoff-conversion; modules-repo/docs-06 through docs-10 | | docs | 09 | docs-13-core-nav-search-theme-roles | [#458](https://github.com/nold-ai/specfact-cli/issues/458) | docs-05-core-site-ia-restructure; docs-07-core-handoff-conversion; docs-12-docs-validation-ci; modules-repo/docs-13-nav-search-theme-roles (design parity only, no content ownership coupling) | +| docs | 10 | docs-14-first-contact-story-and-onboarding (in progress) | [#466](https://github.com/nold-ai/specfact-cli/issues/466) | docs-05-core-site-ia-restructure ✅; docs-07-core-handoff-conversion ✅; docs-12-docs-validation-ci ✅; docs-13-core-nav-search-theme-roles ✅; Parent Feature: [#356](https://github.com/nold-ai/specfact-cli/issues/356) | ### Docs refactoring plan addendum (2026-03-23) diff --git a/openspec/changes/ci-02-trustworthy-green-checks/design.md b/openspec/changes/ci-02-trustworthy-green-checks/design.md index f74c8efa..9473d97e 100644 --- a/openspec/changes/ci-02-trustworthy-green-checks/design.md +++ b/openspec/changes/ci-02-trustworthy-green-checks/design.md @@ -44,7 +44,21 @@ Changes under `.github/workflows/**` must trigger mandatory CI validation for: This closes the current gap where workflow correctness depends too heavily on local tooling or bot analysis. -### 4. Local-vs-CI parity +### 4. Required-check reporting semantics + +Checks marked as required in branch protection must report a success or failure status on every PR +head commit. They must not depend on workflow-level `paths` or `paths-ignore` filters that suppress +the entire workflow for some commits while GitHub still expects the check name on the new SHA. + +If a required validation is out of scope for a given change, the workflow should still trigger and +the job should exit quickly with a clear success message such as “no relevant changes detected” +rather than failing to emit any status. + +Related workflows must also normalize the emitted check names. Branch protection should not depend on +two subtly different names for the same logical gate, such as case differences between orchestrator +jobs and dedicated workflows. + +### 5. Local-vs-CI parity The supported pre-commit installation path must expose the same core enforcement semantics as CI for: @@ -55,7 +69,7 @@ The supported pre-commit installation path must expose the same core enforcement The exact implementation can either expand `.pre-commit-config.yaml` or ensure the repo-supported setup always installs the smart-check wrapper as the authoritative hook path. -### 5. Review automation coverage +### 6. Review automation coverage CodeRabbit should not silently treat `dev` and `main` differently for automatic review coverage when both are active PR targets. The change only standardizes review coverage and expectations; it does not by itself turn CodeRabbit findings into a merge blocker. diff --git a/openspec/changes/ci-02-trustworthy-green-checks/proposal.md b/openspec/changes/ci-02-trustworthy-green-checks/proposal.md index 6dba65c8..e550a6fc 100644 --- a/openspec/changes/ci-02-trustworthy-green-checks/proposal.md +++ b/openspec/changes/ci-02-trustworthy-green-checks/proposal.md @@ -11,10 +11,15 @@ If maintainers cannot trust that "green" means the required checks really passed - **EXTEND** `.github/workflows/pr-orchestrator.yml` so required jobs fail on required tool failures instead of swallowing them behind warn-only shell patterns. - **NEW** gate taxonomy and naming rules for CI jobs so advisory jobs are explicitly named and never masquerade as hard merge gates. - **EXTEND** release PR validation semantics for `dev -> main` so test skipping is only allowed when commit parity is provable; otherwise release PRs must re-run the required validation set. +- **EXTEND** required-check triggering semantics so branch-protection checks always report on the + latest PR head commit, even when a change is out of scope for the underlying validation, instead + of being skipped entirely by workflow-level path filters. - **EXTEND** workflow/static validation so `.github/workflows/**` changes always run mandatory workflow lint and shell validation in CI, not only via local tooling or bot review. - **ALIGN** local pre-commit enforcement with the repository smart-check path so contributors who install the supported hooks get the same core gating semantics that CI expects. - **EXTEND** AI review coverage so PRs targeting `main` receive the same automatic review posture as PRs targeting `dev` for the configured CodeRabbit review surface. -- **REMEDIATE** repo review findings in archived doc-frontmatter OpenSpec artifacts, docs, changelog entries, and helper tests so archived/main specs are publishable and markdown/config review findings are actually cleared rather than deferred. +- **REMEDIATE** repo review findings in archived doc-frontmatter OpenSpec artifacts, docs, + changelog entries, and helper tests so archived/main specs are publishable and markdown/config + review findings are actually cleared rather than deferred. - **HARDEN** code-review report handling and doc-frontmatter validation diagnostics so malformed review JSON and frontmatter parse failures surface actionable errors instead of being silently downgraded. ## Capabilities @@ -31,6 +36,7 @@ If maintainers cannot trust that "green" means the required checks really passed ## Impact - **Affected CI**: `.github/workflows/pr-orchestrator.yml`, `.github/workflows/pre-merge-check.yml`, and any newly added workflow-lint workflow or required job wiring. +- **Affected status policy**: branch-protection required-check selection and workflow/job naming consistency for checks emitted by `.github/workflows/pr-orchestrator.yml` and `.github/workflows/sign-modules.yml`. - **Affected local tooling**: `.pre-commit-config.yaml`, `scripts/pre-commit-smart-checks.sh`, and associated developer setup/docs. - **Affected review automation**: `.coderabbit.yaml` target-branch scope and review expectations for `dev` and `main`. - **Affected docs/spec artifacts**: archived `doc-frontmatter-schema` artifacts, main OpenSpec specs, `CONTRIBUTING.md`, `docs/contributing/docs-sync.md`, and `CHANGELOG.md`. diff --git a/openspec/changes/ci-02-trustworthy-green-checks/specs/trustworthy-green-checks/spec.md b/openspec/changes/ci-02-trustworthy-green-checks/specs/trustworthy-green-checks/spec.md index 6fb1d802..fc72b678 100644 --- a/openspec/changes/ci-02-trustworthy-green-checks/specs/trustworthy-green-checks/spec.md +++ b/openspec/changes/ci-02-trustworthy-green-checks/specs/trustworthy-green-checks/spec.md @@ -41,6 +41,34 @@ Changes under `.github/workflows/**` SHALL trigger mandatory CI validation for w - **THEN** CI runs mandatory workflow validation for those changes - **AND** a workflow-lint failure blocks the required check from reporting success +### Requirement: Required branch-protection checks always report on PR head commits + +Any check selected as a required branch-protection gate SHALL emit a success or failure status for +every new pull-request head commit. Required checks SHALL NOT disappear for docs-only or otherwise +out-of-scope follow-up commits because the entire workflow was skipped by top-level `paths` or +`paths-ignore` filtering. + +#### Scenario: Docs-only follow-up push updates an existing pull request + +- **WHEN** a pull request receives a new head commit that only changes docs, markdown, or other + files outside a required validation's relevance scope +- **AND** branch protection still expects the required validation check on the new head SHA +- **THEN** the workflow still triggers and emits a status for that required check +- **AND** the job may exit early with a clear no-op success message +- **AND** GitHub does not leave the PR waiting on a missing required check status + +### Requirement: Required checks use canonical names across workflows + +When a logical required gate is emitted by more than one workflow or has related dedicated and +orchestrated forms, the repository SHALL standardize on one canonical emitted check name for branch +protection and documentation. + +#### Scenario: Signature validation exists in both orchestrator and dedicated workflow form + +- **WHEN** the repository exposes module-signature validation through multiple workflow entry points +- **THEN** the emitted required check names use one canonical spelling/casing per logical gate +- **AND** branch protection guidance does not depend on subtly different names that can drift apart + ### Requirement: Supported local pre-commit installation matches core CI gate semantics The repository-supported pre-commit installation path SHALL enforce the same core gate semantics that CI relies on for changed files, rather than leaving stronger checks only in an optional wrapper unknown to standard contributors. diff --git a/openspec/changes/ci-02-trustworthy-green-checks/tasks.md b/openspec/changes/ci-02-trustworthy-green-checks/tasks.md index 6f23773a..9c97a32f 100644 --- a/openspec/changes/ci-02-trustworthy-green-checks/tasks.md +++ b/openspec/changes/ci-02-trustworthy-green-checks/tasks.md @@ -31,17 +31,20 @@ Per `openspec/config.yaml`, tests before code for any behavior-changing task. Or - [ ] 3.1 Add or update workflow/unit tests that prove required jobs fail when underlying tools fail and that advisory jobs are explicitly marked as advisory. - [ ] 3.2 Add or update tests for `dev -> main` skip semantics so follow-up commits invalidate unsafe fast-path assumptions. -- [ ] 3.3 Add or update tests for pre-commit parity or supported-hook installation behavior. -- [ ] 3.4 Add or update tests for review JSON failure handling and doc-frontmatter helper expectations. -- [ ] 3.4 Run the new/updated tests before implementation and capture failing evidence in `TDD_EVIDENCE.md`. +- [ ] 3.3 Add or update tests/spec assertions that required checks still report on docs-only or otherwise out-of-scope PR commits instead of disappearing behind workflow-level path filters. +- [ ] 3.4 Add or update tests for pre-commit parity or supported-hook installation behavior. +- [ ] 3.5 Add or update tests for review JSON failure handling and doc-frontmatter helper expectations. +- [ ] 3.6 Run the new/updated tests before implementation and capture failing evidence in `TDD_EVIDENCE.md`. ## 4. Implementation: CI hardening - [ ] 4.1 Remove failure-swallowing patterns from required jobs in `pr-orchestrator.yml`. - [ ] 4.2 Rename or isolate remaining advisory jobs so their non-blocking status is explicit in job names and logs. - [ ] 4.3 Add mandatory workflow validation in CI for `.github/workflows/**` changes. -- [ ] 4.4 Rework `dev -> main` skip logic so only provably safe parity skips are allowed; otherwise run the required validation set. -- [ ] 4.5 Keep docs-only validation behavior explicit and compatible with docs-review workflow ownership. +- [ ] 4.4 Rework required-check triggers so required branch-protection checks always emit a status on every PR head commit, including docs-only follow-up pushes. +- [ ] 4.5 Normalize overlapping check/job names between orchestrator and dedicated workflows so branch protection targets a single canonical name per gate. +- [ ] 4.6 Rework `dev -> main` skip logic so only provably safe parity skips are allowed; otherwise run the required validation set. +- [ ] 4.7 Keep docs-only validation behavior explicit and compatible with docs-review workflow ownership. ## 5. Implementation: local and review parity diff --git a/openspec/changes/code-review-zero-findings/specs/dogfood-self-review/spec.md b/openspec/changes/code-review-zero-findings/specs/dogfood-self-review/spec.md index 5718ee6d..f9d663b7 100644 --- a/openspec/changes/code-review-zero-findings/specs/dogfood-self-review/spec.md +++ b/openspec/changes/code-review-zero-findings/specs/dogfood-self-review/spec.md @@ -73,6 +73,12 @@ Every public function (non-underscore-prefixed) in `src/specfact_cli/` SHALL hav - **THEN** the precondition checks a domain-meaningful invariant (e.g., path exists, non-empty string, valid enum) - **AND** the precondition is NOT a trivial `lambda x: x is not None` that merely restates the type +#### Scenario: Utility contract exploration handles pathological strings gracefully +- **WHEN** CrossHair or unit tests exercise utility helpers with pathological string inputs such as + control characters or malformed package names +- **THEN** the helpers SHALL return a safe fallback value instead of raising unexpected exceptions +- **AND** `hatch run contract-test` SHALL not report uncaught exceptions for those utility paths + ### Requirement: Complexity budget — no function exceeds CC15 No function in `src/specfact_cli/`, `scripts/`, or `tools/` SHALL have cyclomatic complexity >=16, as measured by radon. diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/.openspec.yaml b/openspec/changes/docs-14-first-contact-story-and-onboarding/.openspec.yaml new file mode 100644 index 00000000..fbf76661 --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-30 diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/TDD_EVIDENCE.md b/openspec/changes/docs-14-first-contact-story-and-onboarding/TDD_EVIDENCE.md new file mode 100644 index 00000000..40472792 --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/TDD_EVIDENCE.md @@ -0,0 +1,60 @@ +# TDD Evidence for docs-14-first-contact-story-and-onboarding + +## Pre-Implementation Test Failure (Expected) + +### Test Run: 2026-03-30 - First-contact story contract + +**Command:** + +```bash +cd /home/dom/git/nold-ai/specfact-cli-worktrees/feature/docs-14-first-contact-story-and-onboarding +python3 -m pytest tests/unit/docs/test_first_contact_story.py -q +``` + +**Result:** ✅ 5 tests failed as expected before the README/docs/contributor rewrite. + +**Failure summary:** + +- `README.md` did not yet define SpecFact as the validation and alignment layer. +- The README still placed documentation topology before the primary start path. +- The README did not yet provide explicit “choose your path” outcome routing. +- `docs/index.md` did not yet mirror the new first-contact story structure. +- `CONTRIBUTING.md` did not yet document the first-contact hierarchy and required questions. + +**Status:** ✅ Failing-first evidence captured before implementation. + +## Post-Implementation Passing Validation + +### Test Run: 2026-03-30 - First-contact story and docs handoff + +**Commands:** + +```bash +cd /home/dom/git/nold-ai/specfact-cli-worktrees/feature/docs-14-first-contact-story-and-onboarding +hatch env create +hatch run format +hatch run type-check +hatch run lint +hatch run contract-test +hatch test --cover -v +python3 -m pytest tests/unit/docs/test_first_contact_story.py tests/unit/test_core_docs_site_contract.py tests/unit/docs/test_release_docs_parity.py -q -k 'first_contact_story or core_landing_page_marks_core_repo_as_canonical_owner or readme_and_docs_index_define_core_and_modules_split' +hatch run yaml-lint +openspec validate docs-14-first-contact-story-and-onboarding --strict +``` + +**Result:** ✅ The full documented quality-gate sequence passed for this change. `hatch run type-check` +completed with `0 errors` and existing repo-wide test warnings outside this change scope; `hatch run +lint`, `hatch run contract-test`, and `hatch test --cover -v` all completed successfully. + +**Passing summary:** + +- `README.md` now answers the five first-contact questions in order and leads with the validation and + alignment story. +- `docs/index.md` mirrors the same story and keeps the core-to-modules handoff explicit. +- `docs/README.md` and `docs/reference/documentation-url-contract.md` now document the intended + `docs.specfact.io` to `modules.specfact.io` onboarding split. +- `CONTRIBUTING.md` now records the entry-point messaging hierarchy and repo-metadata alignment rule. +- The audited `hatch run type-check` gate was executed and recorded for this change; warnings came + from pre-existing repo-wide test typing debt rather than the touched first-contact files. +- The remaining local quality gates required by the checklist (`lint`, `contract-test`, and full + covered tests) were executed and passed before the follow-up review fixes were finalized. diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/design.md b/openspec/changes/docs-14-first-contact-story-and-onboarding/design.md new file mode 100644 index 00000000..4db2b2d1 --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/design.md @@ -0,0 +1,167 @@ +## Context + +SpecFact already has multiple credible first-contact surfaces: the GitHub repository landing, +`README.md`, `docs.specfact.io`, and `modules.specfact.io`. The current problem is not lack of +information. It is narrative fragmentation. The product story shifts too quickly from value to +topology, modules, migration notes, and audience variants before a newcomer can decide whether +SpecFact is relevant. + +The sharper product thesis coming out of the discovery work is: +- SpecFact is the validation and alignment layer for software delivery. +- In greenfield and AI-assisted work, it adds the missing rigor layer that keeps fast generation + from becoming unstable delivery. +- In brownfield work, it reverse-engineers trustworthy understanding and feeds structured insight + into spec-first workflows instead of competing with them. +- Across backlog, specs, and implementation, it reduces the “I wanted X but got Y” failure mode. +- At enterprise scale, it creates a path from local CLI review to centrally managed policy + enforcement across developers, AI IDEs, and CI/CD. + +The current docs architecture is also cross-repository: `specfact-cli` owns the core runtime and +canonical top-level docs, while `specfact-cli-modules` owns module-deep workflow docs. That split is +correct, but the handoff currently feels like internal structure rather than intentional onboarding. + +Stakeholders are: +- first-time visitors deciding whether to try SpecFact +- returning users who need a fast path to the right docs surface +- maintainers who need a stable messaging hierarchy that does not drift + +## Goals / Non-Goals + +**Goals:** +- establish one canonical product story for all first-contact surfaces +- make the validation-and-alignment USP explicit instead of implied +- answer the core user questions consistently: + - what is SpecFact? + - why does it exist? + - why should I use it? + - what do I get? + - how do I start? +- define a single fast-start path before branching into persona- or workflow-specific guidance +- differentiate greenfield validation value from brownfield reverse-engineering value without + diluting the core identity +- clarify the core-docs versus modules-docs handoff without requiring topology knowledge first +- include GitHub repo metadata expectations so the product story starts before README scroll depth + +**Non-Goals:** +- redesign the entire information architecture of all docs +- rewrite all deep reference pages or module-specific guides +- change CLI runtime behavior or command ownership +- introduce new hosting infrastructure or search systems + +## Decisions + +### Decision: Treat first-contact as a single product surface + +The repository landing, root README, `docs/index.md`, and modules homepage will be treated as one +coordinated onboarding surface rather than independent copy islands. + +Why: +- users evaluate the product across those touchpoints, not file-by-file +- a split message creates hesitation even when each page is individually “good” + +Alternative considered: +- improve each page independently without a shared message hierarchy + - rejected because it tends to recreate drift and different answers to “what is SpecFact?” + +### Decision: Lead with one primary identity sentence and one fast-start path + +Each first-contact surface will lead with: +- one identity statement +- one primary value proposition +- one short “start here now” path + +Why: +- visitors need a fast go/no-go decision before they want product topology +- a single path reduces overwhelm and increases trial intent + +Alternative considered: +- preserve multiple equal onboarding paths near the top + - rejected because the current problem is over-choice and diluted focus + +### Decision: Define the product as a validation-and-alignment layer, not a feature bucket + +The canonical story will define SpecFact first as the validation and alignment layer for software +delivery, with “keep backlog, specs, tests, and code in sync” presented as the observable outcome. + +Why: +- “keep in sync” is true but too generic on its own +- validation plus alignment explains the value for AI-assisted coding, brownfield analysis, and + enterprise governance in one frame + +Alternative considered: +- define the product primarily by the Swiss-knife metaphor or by enumerating command families + - rejected because metaphor alone is not enough and capability lists obscure the USP + +### Decision: Separate headline from proof points + +The top-level story will answer “what is SpecFact?” in plain language. Supporting details such as +greenfield/brownfield support, SDD/TDD/contracts, AI-copilot compatibility, reverse-engineering +handoff into spec-first tools, and module extensibility will remain as proof points rather than +headline overload. + +Why: +- those details are strengths, but they are secondary to basic product comprehension + +Alternative considered: +- keep the current capability-dense hero + - rejected because it communicates breadth before clarity + +### Decision: Make cross-site ownership explicit but delayed + +Core docs will explain that `docs.specfact.io` is the default starting point and +`modules.specfact.io` is the deeper workflow/bundle layer. The modules site will explicitly route +newcomers back to core docs if they are not yet oriented. + +Why: +- the repo split is an implementation detail until the user is ready for deeper workflows + +Alternative considered: +- merge all explanations into the README hero + - rejected because it front-loads topology before value + +### Decision: Define a reusable question-answer framework + +The change will encode the required first-contact questions and expected answers so future updates +can be reviewed against a concrete standard. + +Why: +- without a framework, copy regresses toward “everything SpecFact can do” +- this creates an auditable quality bar for README/docs/repo metadata changes + +## Risks / Trade-offs + +- [Risk] Stronger positioning may deprioritize some secondary capabilities above the fold. + → Mitigation: keep those capabilities in proof sections and “choose your path” cards lower on the page. + +- [Risk] The enterprise policy-management direction could overshadow current solo/team usefulness. + → Mitigation: position enterprise policy as the scale-up path, not the prerequisite reason to adopt. + +- [Risk] Cross-repo docs handoff changes may need mirrored implementation in `specfact-cli-modules`. + → Mitigation: define the contract here and call out the modules-side follow-up explicitly in tasks. + +- [Risk] Maintainers may disagree on the strongest primary message. + → Mitigation: make the first-contact questions explicit and use them as review criteria rather than taste alone. + +- [Risk] GitHub repo metadata changes are partly outside code review flow. + → Mitigation: document the target description/topics/tagline in the change so maintainers can apply them consistently. + +## Migration Plan + +1. Define the first-contact story and onboarding requirements in OpenSpec. +2. Rewrite README hero and first-run sections around the validation-and-alignment hierarchy. +3. Update `docs/index.md` and adjacent landing copy to mirror the same hierarchy. +4. Define the required modules-docs handoff copy and implementation note for the modules repo, + especially around brownfield reverse-engineering and bundle-deep workflow ownership. +5. Update contributor/docs guidance so future edits preserve the same structure. +6. Capture before/after evidence from the affected pages and verify markdown/docs gates. + +Rollback is straightforward: revert the docs and metadata copy changes if the new positioning proves +confusing or materially worsens navigation metrics/feedback. + +## Open Questions + +- Should the canonical identity sentence explicitly include “Swiss-knife CLI” in every surface, or + should that phrase remain the README/repo-facing metaphor while docs use a plainer version? +- Which GitHub topics/tags best reinforce discoverability without overfitting to internal jargon? +- How much modules-site wording can be changed in this repo versus requiring a paired change in + `specfact-cli-modules`? diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/proposal.md b/openspec/changes/docs-14-first-contact-story-and-onboarding/proposal.md new file mode 100644 index 00000000..183846b6 --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/proposal.md @@ -0,0 +1,90 @@ +# Change: First-contact story and onboarding overhaul across repo and docs entry points + +## Why + +SpecFact looks credible and powerful to first-time visitors, but the current first-contact surfaces +still make users absorb topology, module ownership, and multiple personas before they can answer the +most important go/no-go questions: what SpecFact is, why it exists, why they should care, what they +get, and how to start immediately. + +The sharper product truth is that SpecFact is the validation and alignment layer for software +delivery in the age of AI-assisted coding: it reduces drift between backlog intent, specification, +implementation, tests, and policy. That matters in four concrete situations: +- AI-assisted or “vibe-coded” greenfield work needs a validation layer so fast wins do not become + fragile long-term liabilities. +- Brownfield systems need reverse-engineered understanding and structured handoff into spec-first + tools such as OpenSpec or Spec-Kit. +- Teams need protection against the “I wanted X but got Y” drift that starts in backlog language and + grows through specification and implementation. +- Larger organizations need a path from local CLI rigor to centrally managed policy enforcement + across developers, AI IDEs, and CI/CD. + +If the README, GitHub repo metadata, core docs homepage, and modules docs homepage do not tell that +story with a fast path to first value, users may respect the project but still hesitate to try it. +This change sharpens those entry points so the project feels both mature and compelling on first +contact. + +## What Changes + +- **OVERHAUL** the root `README.md` so it leads with a single product story centered on validation + and alignment, a clear value proposition, a fast first-run path, and segmented “choose your path” + guidance for different users. +- **REFRAME** the core docs landing pages (`docs/index.md` and related navigation/landing copy) so + they behave like onboarding entry points rather than internal documentation indexes, while making + the “why now” case for AI-assisted and brownfield delivery. +- **ALIGN** the modules docs homepage handoff so it clearly explains what belongs on + `docs.specfact.io` versus `modules.specfact.io` without forcing newcomers to learn repository + topology before they understand the product, and so brownfield/spec-first handoff value is + explicit. +- **IMPROVE** GitHub repository first-contact metadata, including repository description, topics/tags, + and any repo-facing intro copy that influences the landing impression before README scroll depth. +- **ANSWER** the key first-contact questions consistently across all central entry points: + - What is SpecFact? + - Why does it exist? + - Why should I use it? + - What do I get from it? + - How do I get started? +- **DEFINE** a repeatable messaging hierarchy and story framework so future docs/homepage changes do + not drift back into capability sprawl or topology-first wording. +- **POSITION** the future enterprise policy-management path clearly enough to strengthen trust and + seriousness, without making the product sound enterprise-only today. + +## Capabilities + +### New Capabilities + +- `first-contact-story`: defines the canonical product story and messaging hierarchy for the repo + homepage, docs homepage, and cross-site handoff surfaces, centered on SpecFact as the validation + and alignment layer for software delivery. +- `entrypoint-onboarding`: defines the required first-run onboarding path and “choose your path” + navigation across README, core docs, and modules docs entry points for greenfield, brownfield, + and backlog-to-code workflows. + +### Modified Capabilities + +- `documentation-alignment`: documentation landing and handoff requirements must align with the new + first-contact story, repo/docs/modules ownership framing, and cross-site discoverability rules. + +## Impact + +- **Affected repo entry points**: `README.md`, GitHub repo description/topics guidance, badges, and + above-the-fold intro copy. +- **Affected core docs**: `docs/index.md`, any shared landing-page copy, and sidebar/top-nav wording + that shapes the first visit to `https://docs.specfact.io/`. +- **Affected modules docs coordination**: homepage copy and cross-site handoff expectations for + `https://modules.specfact.io/` (with implementation split across the owning repo where needed). +- **Affected docs policy**: contributor guidance must reflect the canonical message hierarchy and the + required first-contact questions. +- **User-facing impact**: higher clarity, faster orientation, stronger trial intent, and more + coherent positioning for both new and returning users, especially around AI-assisted delivery, + brownfield modernization, and end-to-end alignment value. + +## Source Tracking + + +- **GitHub Issue**: #466 +- **Issue URL**: https://github.com/nold-ai/specfact-cli/issues/466 +- **Parent Feature**: #356 +- **Parent Feature URL**: https://github.com/nold-ai/specfact-cli/issues/356 +- **Last Synced Status**: open +- **Sanitized**: true diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/documentation-alignment/spec.md b/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/documentation-alignment/spec.md new file mode 100644 index 00000000..36cfbc1c --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/documentation-alignment/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Entry-point messaging hierarchy is documented + +Contributor-facing documentation SHALL define the required messaging hierarchy for first-contact +surfaces so README and homepage edits preserve the same structure over time. + +#### Scenario: Contributor updates an entry-point page + +- **WHEN** a contributor edits `README.md`, `docs/index.md`, or other designated entry-point copy +- **THEN** the guidance SHALL require them to preserve the ordering of: + - product identity + - why it exists + - user value + - how to start + - deeper topology and branching guidance +- **AND** the guidance SHALL define validation/alignment as the product core, with “keep backlog, + specs, tests, and code in sync” expressed as the user-visible result + +### Requirement: Cross-site handoff copy stays aligned + +Documentation alignment rules SHALL require core-docs and modules-docs entry points to describe the +same ownership split and onboarding handoff. + +#### Scenario: Contributor edits core or modules landing copy + +- **WHEN** a contributor updates landing-page copy that references `docs.specfact.io` or + `modules.specfact.io` +- **THEN** the wording SHALL preserve the same explanation of what belongs to the core docs versus + the modules docs +- **AND** cross-site links SHALL direct users to the intended next step rather than only the raw site + URL + +### Requirement: First-contact copy encodes the key user questions + +Contributor guidance SHALL require entry-point copy to answer the key first-contact questions +explicitly enough that maintainers can review the page against them. + +#### Scenario: Maintainer reviews a rewritten entry-point page + +- **WHEN** a maintainer reviews changes to an entry-point page +- **THEN** they SHALL be able to verify that the page clearly answers: + - what SpecFact is + - why it exists + - why a user should use it + - what the user gets + - how the user gets started +- **AND** the page SHALL not bury those answers underneath topology or implementation details diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/entrypoint-onboarding/spec.md b/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/entrypoint-onboarding/spec.md new file mode 100644 index 00000000..542f3fa8 --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/entrypoint-onboarding/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: One primary fast-start path + +The central entry points SHALL provide one primary “start here now” path before branching into more +specialized persona or workflow guidance. + +#### Scenario: User wants to try SpecFact immediately + +- **WHEN** a first-time visitor reaches the primary getting-started section +- **THEN** the page SHALL provide one recommended install-and-first-run path +- **AND** that path SHALL appear before alternative personas, workflow branches, or topology details +- **AND** the path SHALL make the first value explicit rather than only listing commands + +### Requirement: Choose-your-path guidance follows the first-run path + +After the primary fast-start path, entry points SHALL route users into the most relevant next step +for their intent. + +#### Scenario: User needs the right next path + +- **WHEN** the user completes or reviews the first-run path +- **THEN** the entry point SHALL offer clear next-step options for at least: + - greenfield or AI-assisted development that needs stronger validation + - brownfield or legacy code understanding and reverse-engineering + - backlog/spec/code alignment workflows +- **AND** each option SHALL describe the user outcome, not just the internal command group + +### Requirement: Brownfield path explains the spec-first handoff + +Brownfield onboarding SHALL explain that SpecFact helps extract trustworthy understanding from +existing systems and feed that understanding into spec-first workflows. + +#### Scenario: User evaluates the brownfield path + +- **WHEN** a user reads the brownfield or existing-codebase onboarding path +- **THEN** the path SHALL explain that SpecFact analyzes the codebase and sidecar context to produce + structured insight +- **AND** it SHALL explain that this insight can be handed into spec-first tools such as OpenSpec or + Spec-Kit to create accurate specs and reduce drift + +### Requirement: Core-versus-modules handoff is explicit + +The entry points SHALL explain that `docs.specfact.io` is the default starting point and +`modules.specfact.io` is the deeper module- and workflow-specific documentation surface. + +#### Scenario: New user lands on modules docs + +- **WHEN** a first-time visitor reaches `modules.specfact.io` +- **THEN** the page SHALL explain that module docs are the deeper workflow layer +- **AND** it SHALL direct users back to the core docs if they still need orientation or the initial + getting-started flow +- **AND** it SHALL clarify that bundle-deep workflows build on the same validation/alignment story + +#### Scenario: Core docs hand off to module-deep guidance + +- **WHEN** a user outgrows the core landing guidance and needs workflow- or bundle-specific help +- **THEN** the core docs SHALL provide a clear, explicit handoff to `modules.specfact.io` +- **AND** the handoff SHALL explain what extra value the modules docs provide diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/first-contact-story/spec.md b/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/first-contact-story/spec.md new file mode 100644 index 00000000..558ca757 --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/specs/first-contact-story/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: Canonical first-contact product story + +The repository and documentation entry points SHALL present one canonical product story that answers +the first-contact questions in a consistent order: + +- what SpecFact is +- why it exists +- why a user should care +- what value the user gets +- how to start + +The canonical answer to “what is SpecFact?” SHALL define it as a validation and alignment layer for +software delivery, not merely as a collection of commands or integrations. + +#### Scenario: User reads the README hero + +- **WHEN** a first-time visitor lands on `README.md` +- **THEN** the page SHALL answer “what is SpecFact?” in one concise identity statement +- **AND** the answer SHALL appear before topology, module ownership, or migration detail +- **AND** the identity statement SHALL make validation/alignment central and present “keep things in + sync” as the outcome rather than the only definition + +#### Scenario: User compares repo and docs entry points + +- **WHEN** a user reads the repo README and the core docs homepage +- **THEN** both entry points SHALL describe the same core product identity +- **AND** they SHALL not give conflicting first impressions about whether SpecFact is primarily a CLI, + a module platform, an AI tool, or a backlog tool + +### Requirement: First-contact story explains the four product pressures + +The first-contact story SHALL explain why SpecFact exists by grounding it in the main delivery +pressures it addresses. + +#### Scenario: User asks why SpecFact exists + +- **WHEN** a first-time visitor reads the “why” section of the README or core docs landing page +- **THEN** the page SHALL explain that SpecFact addresses: + + - AI-assisted or vibe-coded changes that need stronger validation + - brownfield systems that need reverse-engineered understanding + - backlog/spec/code drift that causes “I wanted X but got Y” + - team and enterprise policy inconsistency across developers and CI +- **AND** the wording SHALL present those pressures as reasons the product exists, not as an + unstructured feature list + +### Requirement: Headline and proof-point separation + +First-contact surfaces SHALL keep the primary identity statement separate from supporting proof +points such as greenfield/brownfield support, SDD/TDD/contracts, AI-copilot compatibility, +reverse-engineering support, and module extensibility. + +#### Scenario: User scans the first screen + +- **WHEN** a user scans the first screen of the README or docs homepage +- **THEN** the primary message SHALL fit in a short headline/subheadline structure +- **AND** secondary capability claims SHALL appear as proof points rather than headline overload + +### Requirement: Future enterprise direction reinforces seriousness without narrowing adoption + +First-contact messaging SHALL describe centralized policy management as a scale-up path for teams and +enterprises without implying that SpecFact only makes sense for large organizations. + +#### Scenario: User scans enterprise and governance messaging + +- **WHEN** a visitor reads first-contact copy that mentions policy enforcement or future account/back-end support +- **THEN** the copy SHALL present that capability as an extension of the same validation/alignment story +- **AND** it SHALL preserve the message that solo developers and smaller teams can adopt SpecFact immediately + +### Requirement: Repo metadata reinforces the same story + +GitHub-facing repository metadata SHALL reinforce the same first-contact story used in the README and +docs landing pages. + +#### Scenario: User sees the repository before opening the README + +- **WHEN** a visitor sees the repository description, topics, badges, and other above-the-fold repo + cues +- **THEN** those cues SHALL reinforce the same core identity as the README hero +- **AND** they SHALL not emphasize internal topology ahead of user value + +Cross-repo traceability note: `modules.specfact.io` and the +`nold-ai/specfact-cli-modules` `docs/index.md` SHALL either present the same first-contact story or +provide an explicit handoff to the core docs. See `documentation-alignment/spec.md` for ownership +and cross-site wording guidance. diff --git a/openspec/changes/docs-14-first-contact-story-and-onboarding/tasks.md b/openspec/changes/docs-14-first-contact-story-and-onboarding/tasks.md new file mode 100644 index 00000000..04c39dcf --- /dev/null +++ b/openspec/changes/docs-14-first-contact-story-and-onboarding/tasks.md @@ -0,0 +1,88 @@ +# Tasks: docs-14-first-contact-story-and-onboarding + +## TDD / SDD order (enforced) + +Per `openspec/config.yaml`, specs come first, tests/evidence come second, and implementation comes +last. Messaging and docs changes still require explicit before/after validation and captured +evidence in `TDD_EVIDENCE.md`. + +--- + +## 1. Create git worktree for this change + +- [x] 1.1 Fetch latest and create a worktree with a new branch from `origin/dev`. +- [x] 1.2 Change into the worktree and run `hatch env create`. +- [x] 1.3 Verify the branch name and working directory match `docs-14-first-contact-story-and-onboarding`. +- [x] 1.4 Run `hatch run smart-test-status` from inside the worktree. +- [x] 1.5 Run `hatch run contract-test-status` from inside the worktree. + +## 2. Research and message contract + +- [x] 2.1 Review the current first-contact surfaces: GitHub repo landing, `README.md`, `docs/index.md`, + and the current modules-site homepage/handoff copy. +- [x] 2.2 Capture the current answers to: + - [x] 2.2.1 What is SpecFact? + - [x] 2.2.2 Why does it exist? + - [x] 2.2.3 Why should I use it? + - [x] 2.2.4 What do I get from it? + - [x] 2.2.5 How do I get started? +- [x] 2.3 Define the canonical story hierarchy and the one recommended fast-start path before editing + implementation files. +- [x] 2.4 Lock the sharper USP in writing: + - [x] 2.4.1 SpecFact as the validation and alignment layer for software delivery + - [x] 2.4.2 AI-assisted greenfield validation as one entry value path + - [x] 2.4.3 Brownfield reverse-engineering into spec-first workflows as another value path + - [x] 2.4.4 backlog-to-code drift reduction as the end-to-end business value + - [x] 2.4.5 enterprise policy management as the scale-up story, not the only audience + +## 3. Test-first / evidence-first preparation + +- [x] 3.1 Add or update docs validation checks, snapshot-style assertions, or reviewable evidence that + prove the new messaging hierarchy and first-run path are present. +- [x] 3.2 Record the pre-implementation state in `TDD_EVIDENCE.md`, including the current README/docs + wording and any failing or missing validation checks. + +## 4. Implementation: GitHub and README entry point + +- [x] 4.1 Rewrite the top of `README.md` around the canonical identity statement, value proposition, + and one fast-start path. +- [x] 4.2 Add explicit “choose your path” guidance after the primary getting-started flow. +- [x] 4.3 Document the intended GitHub repository description/topics/tagline updates in the repo-owned + source so maintainers can apply the same story above the fold. +- [x] 4.4 Ensure the README answers the five first-contact questions explicitly and in order. + +## 5. Implementation: Core docs and modules handoff + +- [x] 5.1 Update `docs/index.md` and any adjacent landing/navigation copy so `docs.specfact.io` + mirrors the same story and onboarding order as the README. +- [x] 5.2 Add or update the core-docs handoff to `modules.specfact.io` so it explains why and when a + user should move to module-deep docs. +- [x] 5.3 Define the required modules-homepage wording/contract for the paired `specfact-cli-modules` + implementation so the modules site routes un-oriented users back to core docs. +- [x] 5.4 Make the brownfield/spec-first handoff explicit in core and modules onboarding copy. + +## 6. Implementation: Alignment and contributor guidance + +- [x] 6.1 Update contributor-facing guidance so future entry-point edits preserve the same messaging + hierarchy. +- [x] 6.2 Ensure cross-site ownership wording remains consistent with the current core-versus-modules + documentation contract. + +## 7. Validation and quality gates + +- [x] 7.1 `hatch run format` +- [x] 7.2 `hatch run type-check` +- [x] 7.3 `hatch run lint` +- [x] 7.4 `hatch run contract-test` +- [x] 7.5 `hatch test --cover -v` +- [x] 7.6 `hatch run yaml-lint` +- [x] 7.7 Run the targeted docs/tests/review checks added for this change. +- [x] 7.8 Update `TDD_EVIDENCE.md` with post-implementation passing evidence and before/after entry-point comparisons. +- [x] 7.9 Run `openspec validate docs-14-first-contact-story-and-onboarding --strict`. + +## 8. Delivery + +- [x] 8.1 Update `openspec/CHANGE_ORDER.md` with implementation status when work begins/lands. +- [x] 8.2 Stage and commit with a Conventional Commit message. +- [x] 8.3 Push the feature branch and open a PR to `dev`. +- [ ] 8.4 After merge to `dev`, remove the worktree and delete the feature branch locally/remotely. diff --git a/pyproject.toml b/pyproject.toml index 1117c278..d5fa9c78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.43.2" +version = "0.43.3" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 525f4944..d9ca5c03 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.43.2", + version="0.43.3", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 9678e8e8..711efdfa 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.43.2" +__version__ = "0.43.3" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index bbbe0769..6f918cd3 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -45,6 +45,6 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.43.2" +__version__ = "0.43.3" __all__ = ["__version__"] diff --git a/src/specfact_cli/utils/acceptance_criteria.py b/src/specfact_cli/utils/acceptance_criteria.py index 8c74051b..d90fc689 100644 --- a/src/specfact_cli/utils/acceptance_criteria.py +++ b/src/specfact_cli/utils/acceptance_criteria.py @@ -30,9 +30,18 @@ ) +def _contains_disallowed_control_chars(text: str) -> bool: + """Return whether text contains control characters beyond normal whitespace.""" + + return any(ord(char) < 32 and char not in "\n\r\t" for char in text) + + def _code_pattern_match_is_meaningful(pattern: str, acceptance: str) -> bool: """Return True if regex matches are not only common English words.""" - matches = re.findall(pattern, acceptance, re.IGNORECASE) + try: + matches = re.findall(pattern, acceptance, re.IGNORECASE) + except re.error: + return False if isinstance(matches, list): actual = [m for m in matches if isinstance(m, str) and m.lower() not in _COMMON_WORD_TOKENS] else: @@ -61,6 +70,8 @@ def is_simplified_format_criteria(acceptance: str) -> bool: Returns: True if criteria use the simplified format, False otherwise """ + if _contains_disallowed_control_chars(acceptance): + return False acceptance_lower = acceptance.lower() # Pattern: "Must verify ... works correctly (see contract examples)" @@ -73,7 +84,13 @@ def is_simplified_format_criteria(acceptance: str) -> bool: r"check.*works\s+correctly.*\(see\s+contract", ] - return any(re.search(pattern, acceptance_lower) for pattern in simplified_patterns) + for pattern in simplified_patterns: + try: + if re.search(pattern, acceptance_lower): + return True + except re.error: + return False + return False @beartype @@ -97,6 +114,8 @@ def is_code_specific_criteria(acceptance: str) -> bool: Returns: True if criteria are code-specific, False if vague/generic """ + if _contains_disallowed_control_chars(acceptance): + return False acceptance_lower = acceptance.lower() # FIRST: Check for generic placeholders that indicate non-code-specific @@ -123,8 +142,12 @@ def is_code_specific_criteria(acceptance: str) -> bool: r"\bis\s+complete\b", r"\bis\s+ready\b", ] - if any(re.search(pattern, acceptance_lower) for pattern in vague_patterns): - return False # Not code-specific, should be enriched + for pattern in vague_patterns: + try: + if re.search(pattern, acceptance_lower): + return False # Not code-specific, should be enriched + except re.error: + return False # THIRD: Check for code-specific indicators code_specific_patterns = [ @@ -161,7 +184,11 @@ def is_code_specific_criteria(acceptance: str) -> bool: ] for pattern in code_specific_patterns: - if re.search(pattern, acceptance, re.IGNORECASE) and _code_pattern_match_is_meaningful(pattern, acceptance): + try: + matched = re.search(pattern, acceptance, re.IGNORECASE) + except re.error: + return False + if matched and _code_pattern_match_is_meaningful(pattern, acceptance): return True return False diff --git a/src/specfact_cli/utils/enrichment_parser.py b/src/specfact_cli/utils/enrichment_parser.py index 1c6b5e40..eb6301a1 100644 --- a/src/specfact_cli/utils/enrichment_parser.py +++ b/src/specfact_cli/utils/enrichment_parser.py @@ -18,6 +18,33 @@ from specfact_cli.models.plan import Feature, PlanBundle, Story +_NO_REGEX_FLAGS = 0 + + +def _contains_disallowed_control_chars(text: str) -> bool: + """Return whether text contains control characters beyond normal whitespace.""" + + return any(ord(char) < 32 and char not in "\n\r\t" for char in text) + + +def _safe_search(pattern: str, text: str, flags: int = _NO_REGEX_FLAGS) -> re.Match[str] | None: + """Return a regex search result, or None when regex evaluation fails.""" + + try: + return re.search(pattern, text, flags) + except re.error: + return None + + +def _safe_findall(pattern: str, text: str, flags: int = _NO_REGEX_FLAGS) -> list[str] | list[tuple[str, ...]]: + """Return regex matches, or an empty list when regex evaluation fails.""" + + try: + return re.findall(pattern, text, flags) + except re.error: + return [] + + def _story_from_dict_with_key(story_data: dict[str, Any], key: str) -> Story: return Story( key=key, @@ -131,17 +158,17 @@ def add_business_context(self, category: str, items: list[str]) -> None: def _extract_feature_title(feature_text: str) -> str: """Extract title from bold text or number-prefixed bold text.""" - title_match = re.search(r"^\*\*([^*]+)\*\*", feature_text, re.MULTILINE) + title_match = _safe_search(r"^\*\*([^*]+)\*\*", feature_text, re.MULTILINE) if not title_match: - title_match = re.search(r"^\d+\.\s*\*\*([^*]+)\*\*", feature_text, re.MULTILINE) + title_match = _safe_search(r"^\d+\.\s*\*\*([^*]+)\*\*", feature_text, re.MULTILINE) return title_match.group(1).strip() if title_match else "" def _extract_feature_key(feature_text: str, title: str) -> str: """Extract or generate a feature key from the text.""" - key_match = re.search(r"\(Key:\s*([A-Z0-9_-]+)\)", feature_text, re.IGNORECASE) + key_match = _safe_search(r"\(Key:\s*([A-Z0-9_-]+)\)", feature_text, re.IGNORECASE) if not key_match: - key_match = re.search(r"(?:key|Key):\s*([A-Z0-9_-]+)", feature_text, re.IGNORECASE) + key_match = _safe_search(r"(?:key|Key):\s*([A-Z0-9_-]+)", feature_text, re.IGNORECASE) if key_match: return key_match.group(1) if title: @@ -152,7 +179,7 @@ def _extract_feature_key(feature_text: str, title: str) -> str: def _extract_feature_outcomes(feature_text: str) -> list[str]: """Extract outcomes and business reason/value from feature text.""" outcomes: list[str] = [] - outcomes_match = re.search( + outcomes_match = _safe_search( r"(?:outcomes?|Outcomes?):\s*(.+?)(?:\n\s*(?:stories?|Stories?):|\Z)", feature_text, re.IGNORECASE | re.DOTALL, @@ -163,7 +190,7 @@ def _extract_feature_outcomes(feature_text: str) -> list[str]: o.strip() for o in re.split(r"\n|,", outcomes_text) if o.strip() and not o.strip().startswith("- Stories:") ] - reason_match = re.search( + reason_match = _safe_search( r"(?:reason|Reason|Business value):\s*(.+?)(?:\n(?:stories?|Stories?)|$)", feature_text, re.IGNORECASE | re.DOTALL, @@ -177,11 +204,11 @@ def _extract_feature_outcomes(feature_text: str) -> list[str]: def _extract_story_title(story_text: str) -> str: """Extract story title from bold text, a title field, or the first line.""" - title_match = re.search(r"^\*\*([^*]+)\*\*", story_text, re.MULTILINE) + title_match = _safe_search(r"^\*\*([^*]+)\*\*", story_text, re.MULTILINE) if title_match: return title_match.group(1).strip() - title_kw = re.search(r"(?:title|Title):\s*(.+?)(?:\n|$)", story_text, re.IGNORECASE) + title_kw = _safe_search(r"(?:title|Title):\s*(.+?)(?:\n|$)", story_text, re.IGNORECASE) if title_kw: return title_kw.group(1).strip() @@ -210,7 +237,7 @@ def _bullet_acceptance_lines(story_text: str, title: str) -> list[str]: def _extract_story_acceptance(story_text: str, title: str) -> list[str]: """Extract acceptance criteria from a story block.""" - acceptance_match = re.search( + acceptance_match = _safe_search( r"(?:acceptance(?:\s+criteria)?|criteria):\s*(.+?)(?:\n(?:tasks?|Tasks?|story\s+points?|Story\s+points?)|$)", story_text, re.IGNORECASE | re.DOTALL, @@ -228,12 +255,12 @@ def _extract_story_acceptance(story_text: str, title: str) -> list[str]: def _extract_story_points(story_text: str) -> tuple[float | int | None, float | int | None]: """Extract story points and value points from a story block.""" - points_match = re.search( + points_match = _safe_search( r"(?:story\s+points?|points?)\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", story_text, re.IGNORECASE, ) - value_points_match = re.search( + value_points_match = _safe_search( r"(?:value\s+points?|value)\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", story_text, re.IGNORECASE, @@ -273,9 +300,13 @@ def parse(self, report_path: Path | str) -> EnrichmentReport: FileNotFoundError: If report file doesn't exist ValueError: If report_path is empty or invalid """ - report_path = Path(report_path) - if not str(report_path).strip(): + report_path_str = str(report_path) + if not report_path_str.strip(): raise ValueError("Report path cannot be empty") + if _contains_disallowed_control_chars(report_path_str): + return EnrichmentReport() + + report_path = Path(report_path_str) if not report_path.exists(): raise FileNotFoundError(f"Enrichment report not found: {report_path}") if report_path.is_dir(): @@ -300,9 +331,11 @@ def parse(self, report_path: Path | str) -> EnrichmentReport: @require(lambda report: isinstance(report, EnrichmentReport), "Report must be EnrichmentReport") def _parse_missing_features(self, content: str, report: EnrichmentReport) -> None: """Parse missing features section from enrichment report.""" + if _contains_disallowed_control_chars(content): + return # Look for "Missing Features" or "Missing features" section pattern = r"##\s*(?:Missing\s+)?Features?\s*(?:\(.*?\))?\s*\n(.*?)(?=##|\Z)" - match = re.search(pattern, content, re.IGNORECASE | re.DOTALL) + match = _safe_search(pattern, content, re.IGNORECASE | re.DOTALL) if not match: return @@ -312,9 +345,11 @@ def _parse_missing_features(self, content: str, report: EnrichmentReport) -> Non # Stop at next feature (numbered item at start of line, optionally followed by bold text) # This avoids stopping at story numbers which are indented feature_pattern = r"(?:^|\n)(?:\d+\.|\*|\-)\s*(.+?)(?=\n(?:^\d+\.\s*\*\*|^\d+\.\s+[A-Z]|\*|\-|\Z))" - features = re.findall(feature_pattern, section, re.MULTILINE | re.DOTALL) + features = _safe_findall(feature_pattern, section, re.MULTILINE | re.DOTALL) for feature_text in features: + if not isinstance(feature_text, str): + continue feature = self._parse_feature_block(feature_text) if feature: report.add_missing_feature(feature) @@ -324,6 +359,8 @@ def _parse_missing_features(self, content: str, report: EnrichmentReport) -> Non @ensure(lambda result: result is None or isinstance(result, dict), "Must return None or dict") def _parse_feature_block(self, feature_text: str) -> dict[str, Any] | None: """Parse a single feature block from enrichment report.""" + if _contains_disallowed_control_chars(feature_text): + return None feature: dict[str, Any] = { "key": "", "title": "", @@ -335,18 +372,18 @@ def _parse_feature_block(self, feature_text: str) -> dict[str, Any] | None: feature["title"] = _extract_feature_title(feature_text) feature["key"] = _extract_feature_key(feature_text, feature["title"]) if not feature["title"]: - title_kw = re.search(r"(?:title|Title):\s*(.+?)(?:\n|$)", feature_text, re.IGNORECASE) + title_kw = _safe_search(r"(?:title|Title):\s*(.+?)(?:\n|$)", feature_text, re.IGNORECASE) if title_kw: feature["title"] = title_kw.group(1).strip() - confidence_match = re.search(r"(?:confidence|Confidence):\s*([0-9.]+)", feature_text, re.IGNORECASE) + confidence_match = _safe_search(r"(?:confidence|Confidence):\s*([0-9.]+)", feature_text, re.IGNORECASE) if confidence_match: with suppress(ValueError): feature["confidence"] = float(confidence_match.group(1)) feature["outcomes"] = _extract_feature_outcomes(feature_text) - stories_match = re.search( + stories_match = _safe_search( r"(?:stories?|Stories?):\s*(.+?)(?=\n\d+\.\s*\*\*|\n##|\Z)", feature_text, re.IGNORECASE | re.DOTALL ) if stories_match: @@ -363,6 +400,8 @@ def _parse_feature_block(self, feature_text: str) -> dict[str, Any] | None: @ensure(lambda result: isinstance(result, list), "Must return list of story dicts") def _parse_stories_from_text(self, stories_text: str, feature_key: str) -> list[dict[str, Any]]: """Parse stories from enrichment report text.""" + if _contains_disallowed_control_chars(stories_text): + return [] stories: list[dict[str, Any]] = [] # Extract individual stories (numbered, bulleted, or sub-headers) @@ -370,14 +409,16 @@ def _parse_stories_from_text(self, stories_text: str, feature_key: str) -> list[ # Handle indented stories (common in nested lists) # Match numbered stories with optional indentation: " 1. Story title" or "1. Story title" story_pattern = r"(?:^|\n)(?:\s*)(?:\d+\.)\s*(.+?)(?=\n(?:\s*)(?:\d+\.)|\Z)" - story_matches = re.findall(story_pattern, stories_text, re.MULTILINE | re.DOTALL) + story_matches = _safe_findall(story_pattern, stories_text, re.MULTILINE | re.DOTALL) # If no matches with numbered pattern, try bulleted pattern if not story_matches: story_pattern = r"(?:^|\n)(?:\s*)(?:\*|\-)\s*(.+?)(?=\n(?:\s*)(?:\*|\-|\d+\.)|\Z)" - story_matches = re.findall(story_pattern, stories_text, re.MULTILINE | re.DOTALL) + story_matches = _safe_findall(story_pattern, stories_text, re.MULTILINE | re.DOTALL) for idx, story_text in enumerate(story_matches, start=1): + if not isinstance(story_text, str): + continue story = self._parse_story_block(story_text, feature_key, idx) if story: stories.append(story) @@ -393,6 +434,8 @@ def _parse_stories_from_text(self, stories_text: str, feature_key: str) -> list[ @ensure(lambda result: result is None or isinstance(result, dict), "Must return None or story dict") def _parse_story_block(self, story_text: str, feature_key: str, story_number: int) -> dict[str, Any] | None: """Parse a single story block from enrichment report.""" + if _contains_disallowed_control_chars(story_text): + return None story: dict[str, Any] = { "key": "", "title": "", @@ -412,7 +455,7 @@ def _parse_story_block(self, story_text: str, feature_key: str, story_number: in story["title"] = _extract_story_title(story_text) story["acceptance"] = _extract_story_acceptance(story_text, story.get("title", "")) - tasks_match = re.search( + tasks_match = _safe_search( r"(?:tasks?|Tasks?):\s*(.+?)(?:\n(?:points?|Points?|$))", story_text, re.IGNORECASE | re.DOTALL ) if tasks_match: @@ -429,9 +472,11 @@ def _parse_story_block(self, story_text: str, feature_key: str, story_number: in @require(lambda report: isinstance(report, EnrichmentReport), "Report must be EnrichmentReport") def _parse_confidence_adjustments(self, content: str, report: EnrichmentReport) -> None: """Parse confidence adjustments section from enrichment report.""" + if _contains_disallowed_control_chars(content): + return # Look for "Confidence Adjustments" or "Confidence adjustments" section pattern = r"##\s*Confidence\s+Adjustments?\s*\n(.*?)(?=##|\Z)" - match = re.search(pattern, content, re.IGNORECASE | re.DOTALL) + match = _safe_search(pattern, content, re.IGNORECASE | re.DOTALL) if not match: return @@ -439,7 +484,7 @@ def _parse_confidence_adjustments(self, content: str, report: EnrichmentReport) # Extract adjustments (format: "FEATURE-KEY → 0.95" or "FEATURE-KEY: 0.95") adjustment_pattern = r"([A-Z0-9_-]+)\s*(?:→|:)\s*([0-9.]+)" - adjustments = re.findall(adjustment_pattern, section, re.IGNORECASE) + adjustments = _safe_findall(adjustment_pattern, section, re.IGNORECASE) for feature_key, confidence_str in adjustments: try: @@ -454,15 +499,17 @@ def _parse_confidence_adjustments(self, content: str, report: EnrichmentReport) @require(lambda report: isinstance(report, EnrichmentReport), "Report must be EnrichmentReport") def _parse_business_context(self, content: str, report: EnrichmentReport) -> None: """Parse business context section from enrichment report.""" + if _contains_disallowed_control_chars(content): + return pattern = r"##\s*Business\s+Context\s*\n(.*?)(?=##|\Z)" - match = re.search(pattern, content, re.IGNORECASE | re.DOTALL) + match = _safe_search(pattern, content, re.IGNORECASE | re.DOTALL) if not match: return section = match.group(1) def _add_list_from_heading(regex: str, category: str) -> None: - m = re.search(regex, section, re.IGNORECASE | re.DOTALL) + m = _safe_search(regex, section, re.IGNORECASE | re.DOTALL) if not m: return text = m.group(1) diff --git a/src/specfact_cli/utils/optional_deps.py b/src/specfact_cli/utils/optional_deps.py index d3fe3cef..eae71ec9 100644 --- a/src/specfact_cli/utils/optional_deps.py +++ b/src/specfact_cli/utils/optional_deps.py @@ -16,6 +16,12 @@ from icontract import ensure, require +def _is_importable_package_name(package_name: str) -> bool: + """Return whether the package name is a valid import target.""" + + return bool(package_name) and all(part.isidentifier() for part in package_name.split(".")) + + def _resolve_cli_tool_executable(tool_name: str) -> str | None: tool_path = shutil.which(tool_name) if tool_path is not None: @@ -105,10 +111,12 @@ def check_python_package_available(package_name: str) -> bool: Returns: True if package can be imported, False otherwise """ + if not _is_importable_package_name(package_name): + return False try: __import__(package_name) return True - except ImportError: + except (ImportError, TypeError, ValueError): return False diff --git a/tests/unit/docs/test_first_contact_story.py b/tests/unit/docs/test_first_contact_story.py new file mode 100644 index 00000000..2dab1870 --- /dev/null +++ b/tests/unit/docs/test_first_contact_story.py @@ -0,0 +1,206 @@ +"""Validate first-contact messaging across the core repo entry points. + +These tests ensure the README, docs landing page, and contributor guidance all +present the same canonical product story and onboarding order. +""" + +import re +from pathlib import Path +from urllib.parse import urlparse + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +README = REPO_ROOT / "README.md" +DOCS_INDEX = REPO_ROOT / "docs" / "index.md" +CONTRIBUTING = REPO_ROOT / "CONTRIBUTING.md" + +ABSOLUTE_URL_RE = re.compile(r"https?://[^\s)>'\"`]+") + + +@pytest.fixture(scope="module", autouse=True) +def _require_entrypoint_files() -> None: + """Skip the module if the docs entrypoint files are not present.""" + + if not REPO_ROOT.exists(): + pytest.skip(f"Repository root missing: expected at {REPO_ROOT}", allow_module_level=True) + if not README.is_file(): + pytest.skip(f"README.md missing: expected at {README}", allow_module_level=True) + if not DOCS_INDEX.is_file(): + pytest.skip(f"docs/index.md missing: expected at {DOCS_INDEX}", allow_module_level=True) + if not CONTRIBUTING.is_file(): + pytest.skip(f"CONTRIBUTING.md missing: expected at {CONTRIBUTING}", allow_module_level=True) + + +def _read(path: Path) -> str: + """Return the UTF-8 text contents of a repository file. + + Args: + path: Path to the file to read. + + Returns: + File contents as a string. + """ + + return path.read_text(encoding="utf-8") + + +def _assert_question_order(content: str, questions: list[str], surface: str) -> None: + """Assert that the first-contact questions appear in increasing order. + + Args: + content: Text content to inspect. + questions: Ordered question strings that must appear in sequence. + surface: Human-readable name of the file or surface being inspected. + """ + + indices: list[int] = [] + for question in questions: + index = content.find(question) + assert index != -1, f"Missing question {question!r} in {surface}" + indices.append(index) + + assert indices == sorted(indices), f"Questions are out of order in {surface}: {questions}" + + +def _assert_contains_url_host(content: str, host: str, surface: str) -> None: + """Assert that a surface contains at least one absolute URL for the expected host. + + Args: + content: Text content to inspect. + host: Expected URL host name. + surface: Human-readable name of the file or surface being inspected. + """ + + found_hosts = {urlparse(match.group(0).rstrip(".,;:")).netloc for match in ABSOLUTE_URL_RE.finditer(content)} + assert host in found_hosts, f"Missing URL host {host!r} in {surface}; found hosts: {sorted(found_hosts)}" + + +def _assert_contains_any_phrase(content: str, phrases: tuple[str, ...], message: str) -> None: + """Assert that at least one of the candidate phrases appears in the content.""" + + lowered = content.lower() + assert any(phrase in lowered for phrase in phrases), message + + +def test_readme_leads_with_validation_and_alignment_story() -> None: + readme = _read(README) + readme_lower = readme.lower() + questions = [ + "What is SpecFact?", + "Why does it exist?", + "Why should I use it?", + "What do I get?", + "How do I get started?", + ] + + assert "validation and alignment layer" in readme + _assert_question_order(readme, questions, "README.md") + _assert_contains_any_phrase( + readme_lower, + ("ai-assisted", "vibe-coded", "ai-generated"), + "README.md must explain AI-assisted validation pressure in the why-story.", + ) + _assert_contains_any_phrase( + readme_lower, + ("brownfield", "reverse-engineer", "reverse-engineered"), + "README.md must explain the brownfield reverse-engineering pressure.", + ) + _assert_contains_any_phrase( + readme_lower, + ("i wanted x but got y", "backlog/spec/code drift", "drift between backlog"), + "README.md must explain backlog/spec/code drift as a reason SpecFact exists.", + ) + _assert_contains_any_phrase( + readme_lower, + ("policy enforcement", "enterprise policy", "ci/cd", "shared rules"), + "README.md must explain team and enterprise policy consistency pressure.", + ) + + +def test_readme_prioritizes_fast_start_over_docs_topology() -> None: + readme = _read(README) + + start_match = re.search(r"^#+\s*Start Here", readme, re.MULTILINE) + topology_match = re.search(r"^#+\s*Documentation Topology", readme, re.MULTILINE) + assert start_match is not None, "Missing Start Here heading in README.md" + assert topology_match is not None, "Missing Documentation Topology heading in README.md" + start_idx = start_match.start() + topology_idx = topology_match.start() + assert start_idx < topology_idx + + +def test_readme_routes_users_by_outcome() -> None: + readme = _read(README) + readme_lower = readme.lower() + + assert "## Choose Your Path" in readme + assert "Greenfield and AI-assisted delivery" in readme + assert "Brownfield and reverse engineering" in readme + assert "Backlog to code alignment" in readme + _assert_contains_any_phrase( + readme_lower, + ("govern", "policy enforcement", "team and policy enforcement"), + "README.md must route users toward team and enterprise policy enforcement outcomes.", + ) + + +def test_docs_index_matches_first_contact_story() -> None: + docs_index = _read(DOCS_INDEX) + docs_index_lower = docs_index.lower() + questions = [ + "What is SpecFact?", + "Why does it exist?", + "Why should I use it?", + "What do I get?", + "How to get started", + ] + + assert "validation and alignment layer" in docs_index + _assert_question_order(docs_index, questions, "docs/index.md") + _assert_contains_url_host(docs_index, "modules.specfact.io", "docs/index.md") + _assert_contains_any_phrase( + docs_index_lower, + ("brownfield", "legacy code", "existing systems"), + "docs/index.md must describe the brownfield path.", + ) + _assert_contains_any_phrase( + docs_index_lower, + ("spec-first", "openspec", "spec-kit"), + "docs/index.md must explain the spec-first handoff for brownfield workflows.", + ) + _assert_contains_any_phrase( + docs_index_lower, + ("default starting point", "start here before jumping", "start here before"), + "docs/index.md must orient users that core docs are the default starting point.", + ) + _assert_contains_any_phrase( + docs_index_lower, + ("ai-assisted", "vibe-coded", "validation layer"), + "docs/index.md must explain AI-assisted validation pressure in the why-story.", + ) + _assert_contains_any_phrase( + docs_index_lower, + ("i wanted x but got y", "backlog language", "backlog/spec/code"), + "docs/index.md must explain backlog/spec/code drift as a reason SpecFact exists.", + ) + _assert_contains_any_phrase( + docs_index_lower, + ("policy enforcement", "organizations need a path", "developers, ai ides, and ci/cd"), + "docs/index.md must explain team and enterprise policy consistency pressure.", + ) + + +def test_contributing_guidance_mentions_entrypoint_story_hierarchy() -> None: + contributing = _read(CONTRIBUTING) + questions = [ + "What is SpecFact?", + "Why does it exist?", + "Why should I use it?", + "What do I get?", + "How do I get started?", + ] + + assert "first-contact" in contributing + _assert_question_order(contributing, questions, "CONTRIBUTING.md") diff --git a/tests/unit/docs/test_release_docs_parity.py b/tests/unit/docs/test_release_docs_parity.py index 90f14ce0..698979dd 100644 --- a/tests/unit/docs/test_release_docs_parity.py +++ b/tests/unit/docs/test_release_docs_parity.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from urllib.parse import unquote, urlparse +from urllib.parse import ParseResult, unquote, urlparse import yaml @@ -27,11 +27,13 @@ def _docs_root() -> Path: return _repo_root() / "docs" +def _extract_absolute_urls(content: str) -> list[str]: + return re.findall(r"https://[^\s)>'\"`]+", content) + + def _assert_mentions_modules_docs_site(content: str) -> None: - host_index = content.find(MODULES_DOCS_HOST) - assert host_index != -1 - assert content[max(0, host_index - 8) : host_index] == "https://" - assert content[host_index + len(MODULES_DOCS_HOST)] == "/" + urls = _extract_absolute_urls(content) + assert any(urlparse(url).hostname == MODULES_DOCS_HOST for url in urls) def _is_docs_markdown(path: Path) -> bool: @@ -127,91 +129,143 @@ def _is_published_docs_route_candidate(route: str) -> bool: return route not in {"/assets/main.css/", "/feed.xml/"} -def _resolve_internal_docs_target( +def _missing_route_failure(source: Path, route: str) -> tuple[str, None, str]: + return route, None, f"{source.relative_to(_repo_root())} -> {route}" + + +def _resolve_site_token_link(source: Path, stripped: str) -> tuple[str | None, str | None]: + if "{{" not in stripped or "site." not in stripped: + return stripped, None + + site_token_start = stripped.find("site.") + token_end = stripped.find("}}", site_token_start) + if site_token_start == -1 or token_end == -1: + return None, f"{source.relative_to(_repo_root())} -> unresolved site token {stripped}" + + token_body = stripped[site_token_start + len("site.") : token_end] + key = token_body.split("|", 1)[0].strip() + if not re.fullmatch(r"[A-Za-z0-9_]+", key): + return None, f"{source.relative_to(_repo_root())} -> unresolved site token {stripped}" + + config = _docs_config() + value = config.get(key) + if not isinstance(value, str) or not value.strip(): + return None, f"{source.relative_to(_repo_root())} -> docs/_config.yml missing non-empty site.{key}" + if key.endswith("_url") and not value.startswith("http"): + return None, f"{source.relative_to(_repo_root())} -> docs/_config.yml site.{key} must start with http" + + suffix = stripped[token_end + 2 :] + return value.strip() + suffix, None + + +def _resolve_published_route( source: Path, - raw_link: str, + route: 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("#"): + if not _is_published_docs_route_candidate(route): return None, None, None - if "{{" in stripped and "site." in stripped: - match = re.search(r"site\.([a-zA-Z0-9_]+)", stripped) - if not match: - return None, None, f"{source.relative_to(_repo_root())} -> unresolved site token {stripped}" - key = match.group(1) - config = _docs_config() - value = config.get(key) - if not isinstance(value, str) or not value.strip(): - return None, None, f"{source.relative_to(_repo_root())} -> docs/_config.yml missing non-empty site.{key}" - if key.endswith("_url") and not value.startswith("http"): - return None, None, f"{source.relative_to(_repo_root())} -> docs/_config.yml site.{key} must start with http" - stripped = value.strip() - 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 = route_to_path.get(route) + if target is None: + return _missing_route_failure(source, route) + return route, target, None - target_value = unquote(parsed.path) - if not target_value: + +def _resolve_http_docs_link( + source: Path, + parsed: ParseResult, + route_to_path: dict[str, Path], +) -> tuple[str | None, Path | None, str | None]: + if parsed.netloc != DOCS_HOST: return None, None, None + return _resolve_published_route(source, _normalize_route(parsed.path or "/"), route_to_path) - 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 +def _resolve_absolute_docs_link( + source: Path, + target_value: str, + route_to_path: dict[str, Path], +) -> tuple[str | None, Path | None, str | None]: + return _resolve_published_route(source, _normalize_route(target_value), route_to_path) + + +def _resolve_existing_candidate( + source: Path, + target_value: str, + candidate: Path, + path_to_route: dict[Path, str], +) -> tuple[str | None, Path | None, str | None]: + 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 + + +def _resolve_relative_docs_link( + source: Path, + target_value: str, + route_to_path: dict[str, Path], + path_to_route: dict[Path, str], +) -> tuple[str | None, Path | None, str | 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 _resolve_existing_candidate(source, target_value, readme_candidate, path_to_route) 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 + return _resolve_existing_candidate(source, target_value, candidate, path_to_route) 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 + return _resolve_existing_candidate(source, target_value, markdown_candidate.resolve(), path_to_route) 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 _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 + + stripped, site_token_failure = _resolve_site_token_link(source, stripped) + if site_token_failure is not None or stripped is None: + return None, None, site_token_failure + + parsed = urlparse(stripped) + if parsed.scheme in {"mailto", "javascript", "tel"}: + return None, None, None + if parsed.scheme in {"http", "https"}: + return _resolve_http_docs_link(source, parsed, route_to_path) + if parsed.scheme: + return None, None, None + + target_value = unquote(parsed.path) + if not target_value: + return None, None, None + + if target_value.startswith("/"): + return _resolve_absolute_docs_link(source, target_value, route_to_path) + + return _resolve_relative_docs_link(source, target_value, route_to_path, path_to_route) + + def _navigation_sources() -> list[Path]: return [ _repo_file("docs/index.md").resolve(), @@ -294,12 +348,13 @@ def test_module_contracts_reference_external_bundle_boundary() -> None: def test_readme_and_docs_index_define_core_and_modules_split() -> None: readme = _repo_file("README.md").read_text(encoding="utf-8") docs_index = _repo_file("docs/index.md").read_text(encoding="utf-8") - assert "canonical docs entry point" in readme - assert "module-specific deep docs are canonically owned by `specfact-cli-modules`" in readme + assert "validation and alignment layer for software delivery" in readme + assert "docs.specfact.io` is the canonical starting point for SpecFact" in readme + assert "Module-specific deep docs are canonically owned by `specfact-cli-modules`" in readme _assert_mentions_modules_docs_site(readme) - assert "Docs Home" in docs_index - assert "Core CLI" in docs_index - assert "Modules" in docs_index + assert "canonical starting point for the core CLI story" in docs_index + assert "docs.specfact.io` is the default starting point" in docs_index + _assert_mentions_modules_docs_site(docs_index) def test_top_navigation_exposes_docs_home_core_cli_and_modules() -> None: @@ -345,31 +400,36 @@ def _scan_authored_docs(pattern: str) -> list[tuple[str, int, str]]: """ hits: list[tuple[str, int, str]] = [] repo_root = _repo_root() - sources: list[Path] = [repo_root / "README.md"] - docs_dir = repo_root / "docs" - 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: + for src in _authored_doc_sources(repo_root): if not src.exists(): continue for lineno, line in enumerate(src.read_text(encoding="utf-8").splitlines(), 1): if pattern not in line: continue stripped = line.strip() - if stripped.startswith("#") and not stripped.startswith( - ("# ", "## ", "### ", "#### ", "##### ", "###### ") - ): - continue - if stripped.startswith(">"): - continue - lower = stripped.lower() - if "removed" in lower or "(removed)" in lower or "is removed" in lower: + if _skip_historical_pattern_hit(stripped): continue hits.append((str(src.relative_to(repo_root)), lineno, stripped)) return hits +def _authored_doc_sources(repo_root: Path) -> list[Path]: + sources: list[Path] = [repo_root / "README.md"] + docs_dir = repo_root / "docs" + sources.extend(path for path in docs_dir.rglob("*.md") if "_site" not in path.parts and "vendor" not in path.parts) + return sources + + +def _skip_historical_pattern_hit(stripped: str) -> bool: + if stripped.startswith("#") and not stripped.startswith(("# ", "## ", "### ", "#### ", "##### ", "###### ")): + return True + if stripped.startswith(">"): + return True + + lower = stripped.lower() + return "removed" in lower or "(removed)" in lower or "is removed" in lower + + def _fmt_hits(hits: list[tuple[str, int, str]]) -> str: return "\n".join(f" {path}:{lineno} {line}" for path, lineno, line in hits) diff --git a/tests/unit/test_core_docs_site_contract.py b/tests/unit/test_core_docs_site_contract.py index 89daf7aa..2fd61358 100644 --- a/tests/unit/test_core_docs_site_contract.py +++ b/tests/unit/test_core_docs_site_contract.py @@ -1,4 +1,4 @@ -from html.parser import HTMLParser +import re from pathlib import Path from urllib.parse import urlparse @@ -15,19 +15,8 @@ "nold-ai.github.io/specfact-cli-modules", "nold-ai.github.io/specfact-cli", ) - - -class _HrefParser(HTMLParser): - def __init__(self) -> None: - super().__init__() - self.hrefs: list[str] = [] - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - if tag != "a": - return - for key, value in attrs: - if key == "href" and value is not None: - self.hrefs.append(value) +ABSOLUTE_URL_RE = re.compile(r"https?://[^\s)>'\"`]+") +MARKDOWN_LINK_RE = re.compile(r"\[[^\]]+\]\((https?://[^)\s]+)\)") def _read(path: Path) -> str: @@ -41,18 +30,16 @@ def _config() -> dict[str, object]: return yaml.safe_load(_read(DOCS_CONFIG)) -def _index_hrefs() -> list[str]: - parser = _HrefParser() - parser.feed(_read(DOCS_INDEX)) - return parser.hrefs +def _contains_url_host(content: str, host: str) -> bool: + """Return whether the text contains at least one absolute URL for the host.""" + + return any(urlparse(match.group(0).rstrip(".,;:")).netloc == host for match in ABSOLUTE_URL_RE.finditer(content)) + +def _contains_markdown_link_target(content: str, target_url: str) -> bool: + """Return whether the text contains a markdown link targeting the URL.""" -def _has_modules_docs_home_link(hrefs: list[str]) -> bool: - for href in hrefs: - parsed = urlparse(href) - if parsed.scheme == "https" and parsed.netloc == "modules.specfact.io" and parsed.path == "/": - return True - return False + return any(match.group(1).rstrip(".,;:") == target_url for match in MARKDOWN_LINK_RE.finditer(content)) def test_core_docs_config_targets_public_core_domain() -> None: @@ -67,12 +54,11 @@ def test_core_docs_config_targets_public_core_domain() -> None: def test_core_landing_page_marks_core_repo_as_canonical_owner() -> None: index = _read(DOCS_INDEX) - hrefs = _index_hrefs() - assert "This site covers the core platform" in index - assert "module-specific workflows" in index - assert "shared portal navigation" in index - assert _has_modules_docs_home_link(hrefs) + assert "SpecFact is the validation and alignment layer for software delivery." in index + assert "canonical starting point for the core CLI story" in index + assert "module-deep workflows" in index + assert _contains_markdown_link_target(index, "https://modules.specfact.io/") assert "nold-ai.github.io/specfact-cli" not in index diff --git a/tests/unit/utils/test_acceptance_criteria.py b/tests/unit/utils/test_acceptance_criteria.py new file mode 100644 index 00000000..f9a04a54 --- /dev/null +++ b/tests/unit/utils/test_acceptance_criteria.py @@ -0,0 +1,9 @@ +"""Unit tests for acceptance criteria helpers.""" + +from specfact_cli.utils.acceptance_criteria import is_code_specific_criteria + + +def test_is_code_specific_criteria_returns_false_for_control_character_input() -> None: + """Pathological control-character strings should not raise during matching.""" + + assert is_code_specific_criteria("\x02") is False diff --git a/tests/unit/utils/test_enrichment_parser.py b/tests/unit/utils/test_enrichment_parser.py index 564ec7d4..73a0c898 100644 --- a/tests/unit/utils/test_enrichment_parser.py +++ b/tests/unit/utils/test_enrichment_parser.py @@ -53,6 +53,13 @@ def test_add_business_context(self): class TestEnrichmentParser: """Test EnrichmentParser class.""" + def test_parse_feature_block_returns_none_for_control_character_text(self) -> None: + """Control-character feature blocks should fail closed instead of raising regex errors.""" + + parser = EnrichmentParser() + + assert parser._parse_feature_block("\x02") is None + def test_parse_missing_features(self, tmp_path: Path): """Test parsing missing features from enrichment report.""" report_content = """# Enrichment Report @@ -153,6 +160,21 @@ def test_parse_nonexistent_file(self, tmp_path: Path): with pytest.raises(FileNotFoundError): parser.parse(nonexistent_file) + def test_parse_returns_empty_report_for_control_character_path(self) -> None: + """Control-character report paths should fail closed with an empty report.""" + + parser = EnrichmentParser() + report = parser.parse("\x00\x00") + + assert isinstance(report, EnrichmentReport) + assert report.missing_features == [] + assert report.confidence_adjustments == {} + assert report.business_context == { + "priorities": [], + "constraints": [], + "unknowns": [], + } + class TestApplyEnrichment: """Test apply_enrichment function.""" diff --git a/tests/unit/utils/test_optional_deps.py b/tests/unit/utils/test_optional_deps.py new file mode 100644 index 00000000..1b89efaa --- /dev/null +++ b/tests/unit/utils/test_optional_deps.py @@ -0,0 +1,9 @@ +"""Unit tests for optional dependency helpers.""" + +from specfact_cli.utils.optional_deps import check_python_package_available + + +def test_check_python_package_available_returns_false_for_control_character_name() -> None: + """Control-character package names should fail closed instead of raising.""" + + assert check_python_package_available("\x00") is False