diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..1bbf94a5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +* @TensorGymnastic + +/codewiki/src/enduser/ @TensorGymnastic +/codewiki/cli/commands/enduser.py @TensorGymnastic +/scripts/ @TensorGymnastic +/examples/ @TensorGymnastic +/pyproject.toml @TensorGymnastic +/requirements.txt @TensorGymnastic diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..651f13dd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..4e9b5a14 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,32 @@ +name: codeql + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "0 4 * * 1" + +jobs: + analyze: + name: codeql + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + actions: read + strategy: + fail-fast: false + matrix: + language: + - python + steps: + - uses: actions/checkout@v5 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/nightly-deep-assurance.yml b/.github/workflows/nightly-deep-assurance.yml new file mode 100644 index 00000000..15291700 --- /dev/null +++ b/.github/workflows/nightly-deep-assurance.yml @@ -0,0 +1,73 @@ +name: nightly-deep-assurance + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + mutation_testing: + name: mutation_testing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev,quality]' 'mutmut<3' + - run: mkdir -p .tmp-mutmut + - run: TMPDIR=$PWD/.tmp-mutmut python -m mutmut run --paths-to-mutate codewiki/src/enduser + + api_fuzz_and_contracts: + name: api_fuzz_and_contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev]' schemathesis + - run: echo "No OpenAPI contract is currently published; add one to enable schemathesis targets." + + parser_fuzzing: + name: parser_fuzzing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: python -m pip install atheris pyyaml + - run: echo "Add dedicated parser fuzz targets under scripts/fuzz/ to activate atheris." + + image_and_sbom_scan: + name: image_and_sbom_scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: aquasecurity/trivy-action@v0.30.0 + with: + scan-type: fs + scan-ref: . + - uses: anchore/sbom-action@v0 + with: + format: spdx-json + output-file: nightly-sbom.spdx.json + - uses: actions/upload-artifact@v4 + with: + name: nightly-sbom + path: nightly-sbom.spdx.json + + flaky_test_report: + name: flaky_test_report + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev]' pytest-repeat + - run: python -m pytest tests/test_enduser_review_e2e.py --count=5 -q diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml new file mode 100644 index 00000000..9d2c2cf6 --- /dev/null +++ b/.github/workflows/pr-gates.yml @@ -0,0 +1,209 @@ +name: pr-gates + +on: + pull_request: + merge_group: + push: + branches: + - main + +jobs: + detect_changes: + name: detect_changes + runs-on: ubuntu-latest + outputs: + python_files: ${{ steps.changed.outputs.python_all_changed_files }} + container_changed: ${{ steps.filter.outputs.container }} + iac_changed: ${{ steps.filter.outputs.iac }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: changed + uses: tj-actions/changed-files@v47 + with: + files: | + **/*.py + pyproject.toml + requirements.txt + .pre-commit-config.yaml + Makefile + - id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + container: + - '**/Dockerfile' + - '**/*.Dockerfile' + iac: + - 'infra/**' + - 'terraform/**' + - 'k8s/**' + - 'helm/**' + - '**/*.tf' + + lint_and_type: + name: lint_and_type + needs: detect_changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev,quality]' + - name: Ruff format check + if: ${{ needs.detect_changes.outputs.python_files != '' }} + run: python -m ruff format --check ${{ needs.detect_changes.outputs.python_files }} + - name: Ruff lint + if: ${{ needs.detect_changes.outputs.python_files != '' }} + run: python -m ruff check ${{ needs.detect_changes.outputs.python_files }} + - name: Mypy maintained surfaces + run: python scripts/run_mypy.py + + unit_and_integration_tests: + name: unit_and_integration_tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev,quality]' + - run: python -m pytest -n auto --cov=codewiki --cov-report=xml --cov-report=term-missing + - uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + + coverage_gate: + name: coverage_gate + needs: unit_and_integration_tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev,quality]' + - run: python -m pytest --cov=codewiki --cov-report=xml --cov-report=term-missing + - name: Maintained-surface coverage threshold + run: > + python -m coverage report + --include='codewiki/src/enduser/*,codewiki/cli/commands/enduser.py,codewiki/run_web_app.py' + --fail-under=85 + - name: Diff coverage threshold + run: > + diff-cover coverage.xml + --compare-branch=origin/${{ github.base_ref || 'main' }} + --fail-under=90 + + security_sast: + name: security_sast + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[quality]' semgrep + - run: > + python -m bandit -c pyproject.toml + -r codewiki/src/enduser codewiki/cli/commands/enduser.py codewiki/run_web_app.py + - run: > + semgrep scan --config auto --error + --exclude-rule python.lang.compatibility.python37.python37-compatibility-importlib2 + codewiki/src/enduser codewiki/cli/commands/enduser.py codewiki/run_web_app.py + + supply_chain: + name: supply_chain + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[quality]' + - run: python scripts/check_dependency_manifests.py + - run: python -m pip_audit -r requirements.txt + - uses: google/osv-scanner-action/osv-scanner-action@v2.3.5 + with: + scan-args: |- + --lockfile=requirements.txt + + secrets: + name: secrets + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + container_and_iac: + name: container_and_iac + needs: detect_changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Skip message + if: ${{ needs.detect_changes.outputs.container_changed != 'true' && needs.detect_changes.outputs.iac_changed != 'true' }} + run: echo "No container or IaC changes detected." + - name: Hadolint + if: ${{ needs.detect_changes.outputs.container_changed == 'true' }} + uses: hadolint/hadolint-action@v3.3.0 + with: + recursive: true + - name: Checkov + if: ${{ needs.detect_changes.outputs.iac_changed == 'true' }} + uses: bridgecrewio/checkov-action@v12 + with: + quiet: true + - name: Trivy filesystem scan + if: ${{ needs.detect_changes.outputs.container_changed == 'true' || needs.detect_changes.outputs.iac_changed == 'true' }} + uses: aquasecurity/trivy-action@v0.35.0 + with: + scan-type: fs + scan-ref: . + + build_and_package: + name: build_and_package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev,quality]' + - run: python -m build + - run: python scripts/smoke_install.py + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + + docs_and_contracts: + name: docs_and_contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev]' + - run: python scripts/run_docs_contracts.py diff --git a/.github/workflows/release-gates.yml b/.github/workflows/release-gates.yml new file mode 100644 index 00000000..9299e2a4 --- /dev/null +++ b/.github/workflows/release-gates.yml @@ -0,0 +1,52 @@ +name: release-gates + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + release_gates: + name: release_gates + runs-on: ubuntu-latest + permissions: + attestations: write + contents: write + id-token: write + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + - run: python -m pip install -e '.[dev,quality]' + - run: python -m build + - uses: anchore/sbom-action@v0 + with: + path: . + format: spdx-json + output-file: release-sbom.spdx.json + - uses: aquasecurity/trivy-action@0.30.0 + with: + scan-type: fs + scan-ref: . + format: table + exit-code: "1" + severity: CRITICAL,HIGH + - uses: anchore/scan-action@v6 + with: + path: . + fail-build: true + severity-cutoff: high + - uses: actions/upload-artifact@v4 + with: + name: release-artifacts + path: | + dist/* + release-sbom.spdx.json + - uses: actions/attest-build-provenance@v2 + with: + subject-path: | + dist/* + release-sbom.spdx.json diff --git a/.gitignore b/.gitignore index b239c5b0..5d91bd8e 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ Thumbs.db *.tmp *.log *.bak +.tmp_make/ +.tmp_manual_review/ +.tmp_manual_review_live/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..a8755c2d --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,5 @@ +[allowlist] +description = "Repository-specific false positives" +paths = [ + '''^\.secrets\.baseline$''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..85eee2c4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,97 @@ +minimum_pre_commit_version: "3.7.0" +default_install_hook_types: + - pre-commit + - pre-push +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.0 + hooks: + - id: ruff-format + args: ["--check"] + stages: [pre-commit] + - id: ruff + stages: [pre-commit] + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: ["--baseline", ".secrets.baseline"] + stages: [pre-commit] + - repo: local + hooks: + - id: mypy-main-package + name: mypy (enduser package) + entry: python scripts/run_mypy.py + language: python + additional_dependencies: + - mypy==1.18.2 + - click>=8.1.0 + - pydantic>=2.11.7 + - PyYAML>=6.0.2 + - types-PyYAML>=6.0.12.20250822 + pass_filenames: false + stages: [pre-commit] + - id: vulture-high-confidence + name: vulture (high confidence) + entry: python -m vulture codewiki/src/enduser codewiki/cli/commands/enduser.py tests --min-confidence 90 --ignore-names _cls,_args + language: python + additional_dependencies: + - vulture==2.14 + pass_filenames: false + stages: [pre-commit] + - id: deptry + name: deptry + entry: python -m deptry . --extend-exclude .tmp_make --extend-exclude .tmp_manual_review --extend-exclude .tmp_manual_review_live --ignore-notebooks --known-first-party codewiki --pep621-dev-dependency-groups dev,quality --ignore DEP002 --per-rule-ignores DEP001=mermaid_parser|mermaid --package-module-name-map GitPython=git,PyYAML=yaml,python-dotenv=dotenv,tree-sitter-language-pack=tree_sitter_languages|tree_sitter_language_pack,markdown-it-py=markdown_it + language: python + additional_dependencies: + - deptry==0.23.1 + pass_filenames: false + stages: [pre-commit] + - id: hadolint + name: hadolint + entry: bash -lc 'command -v hadolint >/dev/null 2>&1 || { echo "hadolint is required when Dockerfiles change"; exit 1; }; hadolint "$@"' -- + language: system + files: (^|/)(Dockerfile|.*\.Dockerfile)$ + stages: [pre-commit] + - id: checkov + name: checkov + entry: bash -lc 'command -v checkov >/dev/null 2>&1 || { echo "checkov is required when IaC files change"; exit 1; }; checkov -q -d .' + language: system + pass_filenames: false + files: (^|/)(infra|terraform|k8s|helm)/|\.tf$ + stages: [pre-commit] + - id: pytest-fast + name: pytest (fast pre-push) + entry: python -m pytest -m "not slow and not e2e" -n auto + language: python + additional_dependencies: + - click>=8.1.0 + - pydantic>=2.11.7 + - PyYAML>=6.0.2 + - pytest==9.0.3 + - pytest-asyncio==1.3.0 + - pytest-cov==7.1.0 + - pytest-xdist==3.8.0 + pass_filenames: false + stages: [pre-push] + - id: pytest-maxfail + name: pytest (--maxfail=1) + entry: python -m pytest --maxfail=1 + language: python + additional_dependencies: + - click>=8.1.0 + - pydantic>=2.11.7 + - PyYAML>=6.0.2 + - pytest==9.0.3 + - pytest-asyncio==1.3.0 + - pytest-cov==7.1.0 + pass_filenames: false + stages: [pre-push] + - id: pip-audit-lockfile + name: pip-audit (lockfile-sensitive) + entry: python scripts/run_quick_pip_audit.py + language: python + additional_dependencies: + - pip-audit==2.9.0 + pass_filenames: false + stages: [pre-push] diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 00000000..d91d209d --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,177 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": { + ".mypy_cache/CACHEDIR.TAG": [ + { + "type": "Hex High Entropy String", + "filename": ".mypy_cache/CACHEDIR.TAG", + "hashed_secret": "e8f8c345877b2411a59897798e422b15b0c16d76", + "is_verified": false, + "line_number": 1 + } + ], + ".pytest_cache/CACHEDIR.TAG": [ + { + "type": "Hex High Entropy String", + "filename": ".pytest_cache/CACHEDIR.TAG", + "hashed_secret": "e8f8c345877b2411a59897798e422b15b0c16d76", + "is_verified": false, + "line_number": 1 + } + ], + ".ruff_cache/CACHEDIR.TAG": [ + { + "type": "Hex High Entropy String", + "filename": ".ruff_cache/CACHEDIR.TAG", + "hashed_secret": "e8f8c345877b2411a59897798e422b15b0c16d76", + "is_verified": false, + "line_number": 1 + } + ], + "codewiki/cli/commands/config.py": [ + { + "type": "Secret Keyword", + "filename": "codewiki/cli/commands/config.py", + "hashed_secret": "27b4ddb0813941ff1f609eccf2624acebf692e65", + "is_verified": false, + "line_number": 335 + } + ], + "codewiki/cli/config_manager.py": [ + { + "type": "Secret Keyword", + "filename": "codewiki/cli/config_manager.py", + "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", + "is_verified": false, + "line_number": 32 + } + ] + }, + "generated_at": "2026-04-14T05:39:44Z" +} diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..cdfe429d --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +PYTHON ?= python3 +CODEWIKI ?= $(PYTHON) -m codewiki.cli.main + +ENDUSER_TESTS := tests/test_enduser_docs.py tests/test_enduser_cli.py tests/test_enduser_review.py tests/test_enduser_review_e2e.py tests/test_enduser_review_integration.py +ENDUSER_LINT_PATHS := codewiki/src/enduser codewiki/cli/commands/enduser.py tests/test_enduser_docs.py tests/test_enduser_cli.py tests/test_enduser_review.py tests/test_enduser_review_e2e.py tests/test_enduser_review_integration.py +LOCAL_GATE_FILES := $(ENDUSER_LINT_PATHS) .pre-commit-config.yaml pyproject.toml README.md Makefile scripts/run_mypy.py scripts/run_quick_pip_audit.py scripts/fake_codex_sample.py scripts/run_docs_contracts.py scripts/smoke_install.py examples/enduser/customer-search.catalog.yaml .gitignore .secrets.baseline + +SAMPLE_DIR ?= $(CURDIR)/.tmp_make +SAMPLE_CATALOG ?= $(CURDIR)/examples/enduser/customer-search.catalog.yaml +SAMPLE_PAGE ?= page.customers_search +SAMPLE_TEMPLATE ?= page-default +SAMPLE_GUIDE := $(SAMPLE_DIR)/guide.md +SAMPLE_REVIEW := $(SAMPLE_DIR)/review.json +SAMPLE_FAKE_BIN := $(SAMPLE_DIR)/bin + +.PHONY: help install-hooks lint test-enduser check local-gates render-sample review-sample clean-sample + +help: + @printf '%s\n' \ + 'Available targets:' \ + ' make install-hooks - Install pre-commit and pre-push hooks via pre-commit' \ + ' make lint - Run ruff checks on the enduser workflow files' \ + ' make test-enduser - Run the enduser pytest suite' \ + ' make check - Run lint + test-enduser' \ + ' make local-gates - Run the fast local pre-commit gate suite' \ + ' make render-sample - Render a real page-scoped sample guide from $(SAMPLE_CATALOG)' \ + ' make review-sample - Run review-doc on the sample guide with a deterministic local codex shim' \ + ' make clean-sample - Remove sample artifacts under $(SAMPLE_DIR)' + +install-hooks: + $(PYTHON) -m pre_commit install --hook-type pre-commit --hook-type pre-push + +lint: + $(PYTHON) -m ruff format --check $(ENDUSER_LINT_PATHS) + $(PYTHON) -m ruff check $(ENDUSER_LINT_PATHS) + $(PYTHON) scripts/run_mypy.py + +test-enduser: + $(PYTHON) -m pytest $(ENDUSER_TESTS) -q + +check: lint test-enduser + +local-gates: + $(PYTHON) -m pre_commit run --hook-stage pre-commit --files $(LOCAL_GATE_FILES) + +render-sample: + mkdir -p $(SAMPLE_DIR) + $(CODEWIKI) enduser render-doc $(SAMPLE_CATALOG) --page $(SAMPLE_PAGE) --template $(SAMPLE_TEMPLATE) --output $(SAMPLE_GUIDE) + +review-sample: render-sample + mkdir -p $(SAMPLE_FAKE_BIN) + cp scripts/fake_codex_sample.py $(SAMPLE_FAKE_BIN)/codex + chmod +x $(SAMPLE_FAKE_BIN)/codex + PATH="$(SAMPLE_FAKE_BIN):$$PATH" $(CODEWIKI) enduser review-doc $(SAMPLE_GUIDE) --catalog $(SAMPLE_CATALOG) --template $(SAMPLE_TEMPLATE) --output $(SAMPLE_REVIEW) + +clean-sample: + rm -rf $(SAMPLE_DIR) diff --git a/README.md b/README.md index 60b82e65..8fba6e51 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,99 @@ --- +## Fork Direction + +This repository is being adapted into `enduser-wiki`: a code-first, transaction-oriented documentation system. + +The immediate design analysis for the fork lives in: + +- [`docs/2026-04-10-enduser-wiki-analysis.md`](docs/2026-04-10-enduser-wiki-analysis.md) + +The target direction differs from upstream `CodeWiki`: +- upstream focuses on repository/module documentation +- `enduser-wiki` will focus on linked catalogs for entities, pages, fields, and transactions +- screenshots and Playwright evidence will validate runtime UI behavior +- canonical generated state will be YAML-first, rendered to markdown + +### Current Enduser Review Flow + +The first enduser-facing publication gate now uses: + +- validated YAML catalogs as the canonical source +- packaged Markdown templates with required sections plus YAML metadata rules +- packaged external prompt files for `codex` +- `codex` as the adversarial reviewer with repository-root access +- `codex` to produce the revised final draft +- `codex` as the final LLM judge on that revised draft with schema-constrained output + +Current implementation status: + +- `validate`, `format`, `extract-playwright`, `render-doc`, and `review-doc` are implemented +- packaged templates currently support `page-default` and `page-ops-checklist` +- prompt composition includes repository context, catalog summary, and rewrite graph context for Codex +- the review artifact includes both adversarial and judge results, but both are now produced by Codex +- page selection is explicit for multi-page catalogs via `render-doc --page ` + +The fixed Markdown format requires these sections: + +- `# ` +- `## Purpose` +- `## Audience` +- `## Preconditions` +- `## Steps` +- `## Fields` +- `## Navigation` +- `## Evidence` +- `## Review Status` + +Current CLI flow: + +```bash +codewiki enduser validate catalog.yaml +codewiki enduser render-doc catalog.yaml --page page.customers_search --template page-default --output guide.md +codewiki enduser review-doc guide.md --catalog catalog.yaml --template page-default --output review.json +``` + +For repo-local guidance, use the Makefile targets instead of reassembling commands by hand: + +```bash +make help +make check +make render-sample +make review-sample +make clean-sample +``` + +`make review-sample` uses a deterministic local `codex` shim so contributors can verify the `review-doc` command path without relying on live model output. + +Review policy: + +- generation happens before `review-doc` +- prompt composition is deterministic: external instructions first, then template contract, then dynamic document/catalog payload +- template metadata defines hard validation rules for steps, fields, and evidence formatting +- `codex` runs the adversarial pass first and must succeed +- `codex` writes a final draft based on the adversarial review +- `codex` judges the final draft last +- the output artifact contains `final_document_path`, `judge`, `adversarial`, and `publication_decision` + +Current limitations: + +- rendered documents are still generic before the Codex rewrite stage +- multi-page catalogs are flattened into one document; page-scoped rendering is not implemented yet +- field and evidence selection is not yet page-aware +- MCP exposure still reflects upstream repository-doc tooling rather than the new enduser CLI flow + +Opt-in real-runner integration test: + +```bash +ENDUSER_ENABLE_REAL_REVIEW_TEST=1 \ +python3 -m pytest tests/test_enduser_review_integration.py -q +``` + +This requires `codex` on `PATH` and any credentials the CLI requires. + +--- + ## Quick Start ### 1. Install CodeWiki diff --git a/codewiki/__init__.py b/codewiki/__init__.py index 77f63b9a..92130e33 100644 --- a/codewiki/__init__.py +++ b/codewiki/__init__.py @@ -8,7 +8,10 @@ __author__ = "CodeWiki Contributors" __license__ = "MIT" -from codewiki.cli.main import cli +def cli(): + """Lazy CLI entrypoint to avoid importing optional CLI deps at package import time.""" + from codewiki.cli.main import cli as _cli -__all__ = ["cli", "__version__"] + return _cli() +__all__ = ["cli", "__version__"] diff --git a/codewiki/cli/commands/enduser.py b/codewiki/cli/commands/enduser.py new file mode 100644 index 00000000..c6b63abe --- /dev/null +++ b/codewiki/cli/commands/enduser.py @@ -0,0 +1,188 @@ +"""Click commands for enduser catalog workflows.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml +from click import ClickException, argument, echo, group, option, Path as ClickPath +from pydantic import ValidationError + +from codewiki.src.enduser.docs import ( + DEFAULT_ENDUSER_DOC_TEMPLATE, + load_enduser_doc_template, + render_enduser_document, + validate_rendered_enduser_document, +) +from codewiki.src.enduser.io import ( + dump_enduser_catalog, + load_enduser_catalog, + save_enduser_catalog, +) +from codewiki.src.enduser.playwright import ( + PlaywrightCatalogExtractor, + load_playwright_crawl, +) +from codewiki.src.enduser.review import ( + EnduserReviewArtifact, + PublicationDecision, + run_codex_adversarial, + run_codex_final_draft, + run_codex_judge, +) + + +@group(name="enduser") +def enduser_group(): + """Commands for validating and formatting enduser catalogs.""" + + +def _load_catalog(path: Path): + try: + return load_enduser_catalog(path) + except (ValidationError, ValueError, yaml.YAMLError) as exc: + raise ClickException(f"Failed to load catalog '{path}': {exc}") + + +@enduser_group.command(name="validate") +@argument("path", type=ClickPath(exists=True, dir_okay=False, path_type=Path)) +def validate(path: Path): + """Validate an enduser catalog YAML file.""" + + _load_catalog(path) + echo(f"Catalog '{path}' is valid.") + + +@enduser_group.command(name="format") +@argument("source", type=ClickPath(exists=True, dir_okay=False, path_type=Path)) +@option( + "--output", + "-o", + type=ClickPath(dir_okay=False, path_type=Path), + help="Write canonical YAML to this path instead of stdout.", +) +def format(source: Path, output: Path | None): + """Format an enduser catalog into canonical YAML.""" + + catalog = _load_catalog(source) + canonical = dump_enduser_catalog(catalog) + if output: + save_enduser_catalog(catalog, output) + echo(f"Catalog written to {output}") + else: + echo(canonical, nl=False) + + +@enduser_group.command(name="extract-playwright") +@argument("source", type=ClickPath(exists=True, dir_okay=False, path_type=Path)) +@option( + "--output", + "-o", + type=ClickPath(dir_okay=False, path_type=Path), + required=True, + help="Write extracted catalog YAML to this path.", +) +def extract_playwright(source: Path, output: Path): + """Extract page, field, and navigation records from saved Playwright crawl JSON.""" + + try: + crawl = load_playwright_crawl(source) + catalog = PlaywrightCatalogExtractor().extract(crawl) + save_enduser_catalog(catalog, output) + echo(f"Catalog written to {output}") + except (ValueError, ValidationError, yaml.YAMLError, json.JSONDecodeError) as exc: + raise ClickException(f"Failed to extract Playwright crawl '{source}': {exc}") + + +@enduser_group.command(name="render-doc") +@argument("source", type=ClickPath(exists=True, dir_okay=False, path_type=Path)) +@option( + "--output", + "-o", + type=ClickPath(dir_okay=False, path_type=Path), + required=True, + help="Write rendered Markdown to this path.", +) +@option( + "--template", + "template_id", + default=DEFAULT_ENDUSER_DOC_TEMPLATE.template_id, + show_default=True, + help="Packaged enduser markdown template to render.", +) +@option( + "--page", + "page_id", + help="Render a specific page id from a multi-page catalog.", +) +def render_doc(source: Path, output: Path, template_id: str, page_id: str | None): + """Render a fixed-format Markdown document from an enduser catalog.""" + + try: + catalog = _load_catalog(source) + template = load_enduser_doc_template(template_id) + document = render_enduser_document(catalog, template=template, page_id=page_id) + validate_rendered_enduser_document(document, template=template) + output.write_text(document, encoding="utf-8") + echo(f"Document written to {output}") + except (ValueError, ValidationError, yaml.YAMLError) as exc: + raise ClickException(f"Failed to render enduser document '{source}': {exc}") + + +@enduser_group.command(name="review-doc") +@argument("source", type=ClickPath(exists=True, dir_okay=False, path_type=Path)) +@option( + "--catalog", + type=ClickPath(exists=True, dir_okay=False, path_type=Path), + required=True, + help="Validated enduser catalog used as review evidence.", +) +@option( + "--output", + "-o", + type=ClickPath(dir_okay=False, path_type=Path), + required=True, + help="Write normalized review JSON to this path.", +) +@option( + "--template", + "template_id", + default=DEFAULT_ENDUSER_DOC_TEMPLATE.template_id, + show_default=True, + help="Packaged enduser markdown template that the document must satisfy.", +) +def review_doc(source: Path, catalog: Path, output: Path, template_id: str): + """Run Codex adversarial review, produce a final draft, then judge the final draft.""" + + try: + template = load_enduser_doc_template(template_id) + _load_catalog(catalog) + validate_rendered_enduser_document(source.read_text(encoding="utf-8"), template=template) + adversarial = run_codex_adversarial(source, catalog, template) + final_document = run_codex_final_draft( + source, + catalog, + template, + adversarial, + ) + final_document_path = source.with_suffix(".final.md") + validate_rendered_enduser_document(final_document, template=template) + final_document_path.write_text(final_document, encoding="utf-8") + judge = run_codex_judge(final_document_path, catalog, template) + artifact = EnduserReviewArtifact( + document_path=str(source), + final_document_path=str(final_document_path), + catalog_path=str(catalog), + template_id=template.template_id, + judge=judge, + adversarial=adversarial, + publication_decision=PublicationDecision( + status="rejected" if judge.status == "fail" else "approved", + reasons=judge.findings if judge.status == "fail" else [], + ), + ) + output.write_text(json.dumps(artifact.model_dump(), indent=2), encoding="utf-8") + echo(f"Review written to {output}") + except (ValueError, ValidationError, yaml.YAMLError, json.JSONDecodeError) as exc: + raise ClickException(f"Failed to review enduser document '{source}': {exc}") diff --git a/codewiki/cli/config_manager.py b/codewiki/cli/config_manager.py index a87df025..8747db12 100644 --- a/codewiki/cli/config_manager.py +++ b/codewiki/cli/config_manager.py @@ -11,8 +11,15 @@ import logging from pathlib import Path from typing import Optional -import keyring -from keyring.errors import KeyringError + +try: + import keyring + from keyring.errors import KeyringError +except ModuleNotFoundError: # pragma: no cover - exercised via import path behavior + keyring = None + + class KeyringError(Exception): + """Fallback keyring error type when optional dependency is absent.""" from codewiki.cli.models.config import Configuration from codewiki.cli.utils.errors import ConfigurationError, FileSystemError @@ -56,6 +63,9 @@ def _check_keyring_available(self) -> bool: if self._force_no_keyring: logger.debug("Keyring disabled via CODEWIKI_NO_KEYRING") return False + if keyring is None: + logger.debug("keyring package is not installed; using file-based credentials") + return False try: # Try to get/set a test value keyring.get_password(KEYRING_SERVICE, "__test__") @@ -318,4 +328,3 @@ def keyring_available(self) -> bool: def config_file_path(self) -> Path: """Get configuration file path.""" return CONFIG_FILE - diff --git a/codewiki/cli/main.py b/codewiki/cli/main.py index 23ebc319..8bf729d0 100644 --- a/codewiki/cli/main.py +++ b/codewiki/cli/main.py @@ -2,14 +2,60 @@ Main CLI application for CodeWiki using Click framework. """ +import importlib import sys + import click -from pathlib import Path from codewiki import __version__ -@click.group() +_LAZY_COMMANDS = { + "config": ("codewiki.cli.commands.config", "config_group"), + "enduser": ("codewiki.cli.commands.enduser", "enduser_group"), + "generate": ("codewiki.cli.commands.generate", "generate_command"), +} + + +class UnavailableCommand(click.Command): + """Command placeholder shown when optional dependencies are missing.""" + + def __init__(self, name: str, missing_module: str): + super().__init__( + name=name, + help=f"Unavailable because optional dependency '{missing_module}' is not installed.", + ) + self._missing_module = missing_module + + def invoke(self, ctx): + raise click.ClickException( + f"Command '{self.name}' is unavailable because optional dependency " + f"'{self._missing_module}' is not installed." + ) + + +class LazyGroup(click.Group): + """Load heavyweight subcommands only when they are actually invoked.""" + + def list_commands(self, ctx): + commands = set(super().list_commands(ctx)) + commands.update(_LAZY_COMMANDS) + return sorted(commands) + + def get_command(self, ctx, cmd_name): + command = super().get_command(ctx, cmd_name) + if command is not None or cmd_name not in _LAZY_COMMANDS: + return command + + module_name, attribute_name = _LAZY_COMMANDS[cmd_name] + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + return UnavailableCommand(cmd_name, exc.name or "unknown") + return getattr(module, attribute_name) + + +@click.group(cls=LazyGroup) @click.version_option(version=__version__, prog_name="CodeWiki CLI") @click.pass_context def cli(ctx): @@ -28,15 +74,6 @@ def version(): """Display version information.""" click.echo(f"CodeWiki CLI v{__version__}") click.echo("Python-based documentation generator using AI analysis") - - -# Import commands -from codewiki.cli.commands.config import config_group -from codewiki.cli.commands.generate import generate_command - -# Register command groups -cli.add_command(config_group) -cli.add_command(generate_command, name="generate") @cli.command(name="mcp") @@ -75,4 +112,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/codewiki/prompts/enduser/base_generation.md b/codewiki/prompts/enduser/base_generation.md new file mode 100644 index 00000000..c2203149 --- /dev/null +++ b/codewiki/prompts/enduser/base_generation.md @@ -0,0 +1,28 @@ +Render a user-facing markdown document from the provided catalog. + +Follow these rules exactly: +- Use the template contract and output template as hard constraints. +- Preserve the required section headings exactly. +- Ground every claim in the catalog content. +- Prefer precise, operational language over generic prose. +- Do not invent entities, pages, fields, transactions, permissions, or evidence. +- If the catalog represents more than one page, stay within one coherent page scope instead of blending unrelated fields or evidence. +- Do not invent buttons, result tables, save actions, redirects, or navigation targets that are not directly evidenced. +- Treat routes and page access as catalog-derived unless the evidence explicitly confirms runtime behavior. +- Do not imply that users can combine fields, submit criteria, or complete a transaction action unless that interaction is directly evidenced. +- Extract as much supported detail as possible from routes, field metadata, evidence summaries, entity descriptions, transaction goals, and relation structure. + +Depth requirements by section: +- `Purpose`: explain the page or workflow in concrete product terms, using page names, routes, entities, and transaction goals when present. +- `Audience`: infer likely operators or business users only when supported by names, workflow context, or catalog wording; otherwise stay neutral. +- `Preconditions`: include access, navigation, or state preconditions only when directly implied by the catalog. +- `Steps`: turn the catalog into a specific workflow with observable page actions, not generic filler. If the catalog does not prove a UI action, prefer a scope-limited step over an invented one. +- `Fields`: preserve the table format and use exact field labels, types, required flags, and readonly state. +- `Navigation`: mention concrete route transitions or explicitly state that no supported navigation target is known. +- `Evidence`: include all relevant evidence ids with short summaries tied to claims elsewhere in the document. +- `Review Status`: keep this section brief and procedural as a current-state line, not a review summary. + +Writing rules: +- Prefer explicit nouns from the catalog over pronouns or vague references. +- When the catalog is sparse, say less rather than inventing behavior. +- When multiple records support the same workflow, synthesize them into a coherent operational explanation. diff --git a/codewiki/prompts/enduser/codex_adversarial.md b/codewiki/prompts/enduser/codex_adversarial.md new file mode 100644 index 00000000..61ce770d --- /dev/null +++ b/codewiki/prompts/enduser/codex_adversarial.md @@ -0,0 +1,20 @@ +Perform an adversarial review of the provided markdown document. + +You are running inside the repository root with Codex CLI and may inspect relevant files when the inline catalog or document suggests a claim should be verified against the codebase. + +Return only a JSON object that matches the required schema. + +Adversarial goals: +- find unsupported claims +- find missing evidence +- find format violations +- find generic wording that ignores stronger graph-supported detail +- use repository inspection only when needed to catch direct contradictions between the document and the codebase + +Rules: +- Treat the inline catalog graph as the primary structural map of the workflow. +- Use the relation graph to reason about page -> field -> transaction -> entity paths. +- Do not require the document to mention that a route or action is "not code-confirmed"; absence of code confirmation is reviewer context, not end-user content. +- When repository inspection disagrees with the document, report the contradiction clearly, but prefer catalog-derived phrasing over repo-status commentary in the document itself. +- Fail closed when required sections or hard format rules are broken. +- Findings must be specific and actionable. diff --git a/codewiki/prompts/enduser/codex_judge.md b/codewiki/prompts/enduser/codex_judge.md new file mode 100644 index 00000000..e12e5e48 --- /dev/null +++ b/codewiki/prompts/enduser/codex_judge.md @@ -0,0 +1,19 @@ +Review the provided markdown document against the catalog and template contract. + +You are running inside the repository root with Codex CLI and may inspect relevant files when the inline artifacts suggest a claim should be checked against the actual codebase. + +Return only a JSON object that matches the required schema. + +Scoring rules: +- `coverage`: how completely the document reflects supported catalog facts. +- `evidence_alignment`: how well claims are tied to supplied evidence. +- `format_compliance`: whether the document obeys the template contract. +- `clarity`: whether the workflow is understandable and operationally precise. + +Evaluation rules: +- Fail the review when the document contains unsupported claims, missing required sections, or broken format rules. +- Findings must be concrete and action-oriented. +- Use the supplied inline document and catalog as the only source of truth. +- Penalize shallow generic prose when the catalog supports stronger detail. +- Reward documents that connect workflow steps, fields, navigation, entities, transactions, and evidence into a coherent operator narrative. +- Use repository inspection sparingly and only to identify direct contradictions, not to force repo-status caveats into the end-user document. diff --git a/codewiki/prompts/enduser/codex_rewrite.md b/codewiki/prompts/enduser/codex_rewrite.md new file mode 100644 index 00000000..0d36678c --- /dev/null +++ b/codewiki/prompts/enduser/codex_rewrite.md @@ -0,0 +1,42 @@ +Revise the provided markdown document using the adversarial review, catalog, and template contract. + +You are running inside the repository root with Codex CLI and may inspect relevant files when the catalog graph suggests the document is missing grounded workflow detail. + +Return only a JSON object with a single `document` field containing the full revised markdown. + +Rewrite rules: +- Preserve the required section headings exactly. +- Address adversarial findings when supported by the catalog. +- Remove unsupported claims instead of softening them. +- Keep the document concise, operational, and evidence-grounded. +- Do not add claims that are absent from the inline catalog. +- Keep page scope strict. Do not blend fields, transactions, evidence, or navigation from unrelated pages. +- Distinguish observed page structure from inferred transaction intent. +- If a transaction goal exists without an explicit UI action, you may describe the goal linkage but not invent the missing action. +- Do not present any claim as code-confirmed unless repository inspection actually confirms it. +- Treat routes as catalog-derived unless repository inspection confirms runtime behavior. +- If repository inspection does not confirm a route or behavior, do not turn that negative finding into end-user prose. Keep the document limited to catalog-derived wording instead. +- Keep `Review Status` procedural. Do not mention prior rewrite history, adversarial revisions, or provenance claims unless they are explicitly provided as evidence. +- `Review Status` should be a short current-state line, not a narrative summary. Good patterns: "Ready for publication review." or "Catalog-scoped draft ready for approval review." +- Prefer richer supported detail over generic phrasing. + +Depth expectations: +- Expand `Purpose` to describe the concrete business object, screen, or workflow supported by the catalog. +- Make `Steps` read like a real operator walkthrough using page names, supported field interactions, and only cataloged navigation targets. +- Use the `Fields` section to preserve exact field facts and let the prose sections explain how those fields are used. +- Tie `Navigation` and `Evidence` back to the workflow so the document feels traceable rather than templated. +- When transactions or entities are present, weave them into `Purpose`, `Preconditions`, and `Steps` without inventing unsupported behavior. +- Use the relation graph and repository inspection to sharpen workflow detail when the current draft is too generic. + +Unsupported operational actions: +- Do not introduce submit buttons, save buttons, result tables, confirmation messages, redirects, or destination pages unless they are directly supported by the catalog evidence or confirmed in repository code. +- Do not turn a search field into a search-results workflow unless the catalog or repository proves the result state. +- Do not turn an update transaction into a save action unless the catalog or repository proves that action. +- Do not tell the user to combine fields, submit criteria, or execute a search unless that specific interaction is evidenced. +- When the catalog only proves field presence plus transaction intent, prefer wording like "The page provides..." or "Use this field for the cataloged goal..." over stronger imperative step claims. +- Do not mark the document as approved, approval-ready, or reviewed-complete in `Review Status`. Prefer neutral current-state wording such as "Draft; not yet approved." + +Editing strategy: +- Replace generic filler sentences first. +- Prefer merging overlapping facts into one sharper sentence instead of adding verbosity. +- If the catalog is thin, produce a narrow but precise document. diff --git a/codewiki/prompts/enduser/opencode_adversarial.md b/codewiki/prompts/enduser/opencode_adversarial.md new file mode 100644 index 00000000..82af28b9 --- /dev/null +++ b/codewiki/prompts/enduser/opencode_adversarial.md @@ -0,0 +1,16 @@ +Perform an adversarial review of the provided markdown document. + +Return only a JSON object that matches the required review shape. + +Attack surface: +- unsupported claims +- missing evidence +- format violations +- wording that overstates what the catalog proves +- generic filler that avoids catalog-specific detail even when the catalog supports it + +Rules: +- Fail closed when required sections or hard format rules are broken. +- Prefer precise findings over broad commentary. +- Use the supplied inline document and catalog as the only source of truth. +- Flag shallow wording when a stronger catalog-grounded statement was possible. diff --git a/codewiki/run_web_app.py b/codewiki/run_web_app.py index 940a1d84..062c07ae 100644 --- a/codewiki/run_web_app.py +++ b/codewiki/run_web_app.py @@ -1,16 +1,7 @@ #!/usr/bin/env python3 -""" -Startup script for CodeWiki Web Application -""" +"""Startup script for the packaged CodeWiki web application.""" -import os -import sys - -# Add src directory to Python path -src_dir = os.path.join(os.path.dirname(__file__), 'src') -sys.path.insert(0, src_dir) - -from fe.web_app import main +from codewiki.src.fe.web_app import main if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/codewiki/src/enduser/__init__.py b/codewiki/src/enduser/__init__.py new file mode 100644 index 00000000..655ea67e --- /dev/null +++ b/codewiki/src/enduser/__init__.py @@ -0,0 +1,99 @@ +"""Enduser-wiki canonical catalog models.""" + +from .docs import ( + DEFAULT_ENDUSER_DOC_TEMPLATE, + build_enduser_doc_scope, + EnduserDocScope, + EnduserDocTemplate, + infer_enduser_document_page_id, + load_enduser_doc_template, + render_enduser_document, + validate_rendered_enduser_document, +) +from .io import ( + dump_enduser_catalog, + load_enduser_catalog, + load_enduser_catalog_from_string, + load_enduser_catalog_from_stream, + save_enduser_catalog, +) +from .models import ( + EnduserCatalog, + EntityRecord, + EvidenceRecord, + FieldRecord, + PageRecord, + RelationRecord, + TransactionRecord, +) +from .playwright import ( + PlaywrightActionCapture, + PlaywrightCatalogExtractor, + PlaywrightCrawl, + PlaywrightExtractorConfig, + PlaywrightFieldCapture, + PlaywrightNetworkRequestCapture, + PlaywrightPageCapture, + load_playwright_crawl, +) +from .prompting import ( + build_codex_adversarial_prompt, + build_codex_final_draft_prompt, + build_codex_judge_prompt, + build_generation_prompt, +) +from .review import ( + AdversarialReview, + EnduserReviewArtifact, + JudgeReview, + PublicationDecision, + ReviewScoreSet, + build_review_prompt, + run_codex_adversarial, + run_codex_final_draft, + run_codex_judge, +) + +__all__ = [ + "AdversarialReview", + "build_codex_adversarial_prompt", + "build_codex_final_draft_prompt", + "build_codex_judge_prompt", + "build_enduser_doc_scope", + "build_generation_prompt", + "build_review_prompt", + "DEFAULT_ENDUSER_DOC_TEMPLATE", + "EnduserDocScope", + "dump_enduser_catalog", + "EnduserDocTemplate", + "infer_enduser_document_page_id", + "load_enduser_doc_template", + "load_enduser_catalog", + "load_enduser_catalog_from_string", + "load_enduser_catalog_from_stream", + "save_enduser_catalog", + "render_enduser_document", + "validate_rendered_enduser_document", + "PlaywrightActionCapture", + "PlaywrightCatalogExtractor", + "PlaywrightCrawl", + "PlaywrightExtractorConfig", + "PlaywrightFieldCapture", + "PlaywrightNetworkRequestCapture", + "PlaywrightPageCapture", + "load_playwright_crawl", + "EnduserReviewArtifact", + "EnduserCatalog", + "EntityRecord", + "EvidenceRecord", + "FieldRecord", + "JudgeReview", + "PageRecord", + "PublicationDecision", + "RelationRecord", + "ReviewScoreSet", + "TransactionRecord", + "run_codex_adversarial", + "run_codex_final_draft", + "run_codex_judge", +] diff --git a/codewiki/src/enduser/docs.py b/codewiki/src/enduser/docs.py new file mode 100644 index 00000000..bda79ca2 --- /dev/null +++ b/codewiki/src/enduser/docs.py @@ -0,0 +1,569 @@ +"""Render fixed-format enduser documentation from validated catalogs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib.resources import files +import re +from typing import TypeVar, cast + +from pydantic import BaseModel, Field, field_validator +import yaml + +from codewiki.src.enduser.models import ( + EnduserCatalog, + EntityRecord, + EvidenceRecord, + FieldRecord, + PageRecord, + RelationRecord, + TransactionRecord, +) + + +REQUIRED_DOC_SECTIONS = [ + "Purpose", + "Audience", + "Preconditions", + "Steps", + "Fields", + "Navigation", + "Evidence", + "Review Status", +] + + +class EnduserDocTemplate(BaseModel): + template_id: str = Field(min_length=1) + title_template: str = Field(min_length=1) + body_template: str = Field(min_length=1) + required_sections: list[str] = Field(default_factory=lambda: list(REQUIRED_DOC_SECTIONS)) + steps_must_be_numbered: bool = True + fields_must_be_table: bool = True + evidence_requires_ids: bool = True + document_kind: str = Field(default="page-guide", min_length=1) + emphasize_verification: bool = False + mention_scope_limits: bool = True + + @field_validator("template_id", "title_template", "body_template", "document_kind") + @classmethod + def _strip_required(_cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("value must not be empty") + return value + + +@dataclass(frozen=True) +class EnduserDocScope: + page: PageRecord + related_pages: tuple[PageRecord, ...] + fields: tuple[FieldRecord, ...] + transactions: tuple[TransactionRecord, ...] + entities: tuple[EntityRecord, ...] + evidence: tuple[EvidenceRecord, ...] + relations: tuple[RelationRecord, ...] + + +AVAILABLE_ENDUSER_DOC_TEMPLATES = { + "page-default": "page-default.md", + "page-ops-checklist": "page-ops-checklist.md", +} + + +def _load_packaged_template_body(filename: str) -> str: + return files("codewiki").joinpath("templates", "enduser", filename).read_text(encoding="utf-8") + + +def _load_packaged_template_metadata(filename: str) -> dict: + raw = files("codewiki").joinpath("templates", "enduser", filename).read_text(encoding="utf-8") + parsed = yaml.safe_load(raw) + if parsed is None: + return {} + if not isinstance(parsed, dict): + raise ValueError(f"template metadata '{filename}' must be a mapping") + return parsed + + +def _extract_markdown_sections(markdown: str) -> set[str]: + sections: set[str] = set() + for line in markdown.splitlines(): + if line.startswith("## "): + sections.add(line[3:].strip()) + return sections + + +def _extract_markdown_section_bodies(markdown: str) -> dict[str, str]: + sections: dict[str, list[str]] = {} + current_section: str | None = None + for line in markdown.splitlines(): + if line.startswith("## "): + current_section = line[3:].strip() + sections[current_section] = [] + continue + if current_section is not None: + sections[current_section].append(line) + return {name: "\n".join(lines).strip() for name, lines in sections.items()} + + +def load_enduser_doc_template(template_id: str) -> EnduserDocTemplate: + try: + filename = AVAILABLE_ENDUSER_DOC_TEMPLATES[template_id] + except KeyError as exc: + available = ", ".join(sorted(AVAILABLE_ENDUSER_DOC_TEMPLATES)) + raise ValueError( + f"unknown enduser template '{template_id}'; available templates: {available}" + ) from exc + + metadata_filename = filename.removesuffix(".md") + ".yaml" + metadata = _load_packaged_template_metadata(metadata_filename) + if metadata.get("template_id") and metadata["template_id"] != template_id: + raise ValueError( + f"template metadata '{metadata_filename}' has mismatched template_id '{metadata['template_id']}'" + ) + + return cast( + EnduserDocTemplate, + EnduserDocTemplate.model_validate( + { + "template_id": template_id, + "title_template": metadata.get("title_template", "{page_name} User Guide"), + "body_template": _load_packaged_template_body(filename), + "required_sections": metadata.get("required_sections", REQUIRED_DOC_SECTIONS), + "steps_must_be_numbered": metadata.get("rules", {}).get( + "steps_must_be_numbered", True + ), + "fields_must_be_table": metadata.get("rules", {}).get("fields_must_be_table", True), + "evidence_requires_ids": metadata.get("rules", {}).get( + "evidence_requires_ids", True + ), + "document_kind": metadata.get("strategy", {}).get("document_kind", "page-guide"), + "emphasize_verification": metadata.get("strategy", {}).get( + "emphasize_verification", False + ), + "mention_scope_limits": metadata.get("strategy", {}).get( + "mention_scope_limits", True + ), + } + ), + ) + + +DEFAULT_ENDUSER_DOC_TEMPLATE = load_enduser_doc_template("page-default") + +RecordT = TypeVar("RecordT") + + +def _sorted_records(records: list[RecordT], included_ids: set[str]) -> tuple[RecordT, ...]: + return tuple(record for record in records if getattr(record, "id") in included_ids) + + +def _select_render_page(catalog: EnduserCatalog, page_id: str | None) -> PageRecord: + if page_id: + for page in catalog.pages: + if page.id == page_id: + return page + available = ", ".join(page.id for page in catalog.pages) or "" + raise ValueError(f"unknown page '{page_id}'; available pages: {available}") + + if not catalog.pages: + raise ValueError("catalog does not contain any pages") + if len(catalog.pages) > 1: + available = ", ".join(page.id for page in catalog.pages) + raise ValueError(f"catalog contains multiple pages; select one with --page ({available})") + return catalog.pages[0] + + +def infer_enduser_document_page_id(markdown: str, catalog: EnduserCatalog) -> str | None: + title_line = next( + (line[2:].strip() for line in markdown.splitlines() if line.startswith("# ")), "" + ) + if title_line: + for page in sorted(catalog.pages, key=lambda item: len(item.name), reverse=True): + if page.name in title_line: + return page.id + + for page in sorted(catalog.pages, key=lambda item: len(item.name), reverse=True): + if f"`{page.name}`" in markdown or page.route in markdown: + return page.id + return None + + +def build_enduser_doc_scope(catalog: EnduserCatalog, page_id: str | None = None) -> EnduserDocScope: + page = _select_render_page(catalog, page_id) + record_types = catalog.index_ids() + screenshot_refs = set(page.screenshot_refs) + + field_ids: set[str] = set() + transaction_ids: set[str] = set() + entity_ids: set[str] = set() + related_page_ids: set[str] = {page.id} + evidence_ids: set[str] = set() + + for relation in catalog.relations: + if relation.source != page.id and relation.target != page.id: + continue + + other_id = relation.target if relation.source == page.id else relation.source + other_type = record_types.get(other_id) + if other_type == "field": + field_ids.add(other_id) + elif other_type == "transaction": + transaction_ids.add(other_id) + elif other_type == "entity": + entity_ids.add(other_id) + elif other_type == "page": + related_page_ids.add(other_id) + elif other_type == "evidence": + evidence_ids.add(other_id) + evidence_ids.update(relation.evidence_ids) + + for relation in catalog.relations: + if relation.source in transaction_ids and record_types.get(relation.target) == "entity": + entity_ids.add(relation.target) + evidence_ids.update(relation.evidence_ids) + if relation.target in transaction_ids and record_types.get(relation.source) == "entity": + entity_ids.add(relation.source) + evidence_ids.update(relation.evidence_ids) + + for evidence in catalog.evidence: + if evidence.source_ref == page.route or evidence.source_ref in screenshot_refs: + evidence_ids.add(evidence.id) + + included_ids = related_page_ids | field_ids | transaction_ids | entity_ids | evidence_ids + relations = tuple( + relation + for relation in catalog.relations + if relation.source in included_ids and relation.target in included_ids + ) + + return EnduserDocScope( + page=page, + related_pages=_sorted_records(catalog.pages, related_page_ids), + fields=_sorted_records(catalog.fields, field_ids), + transactions=_sorted_records(catalog.transactions, transaction_ids), + entities=_sorted_records(catalog.entities, entity_ids), + evidence=_sorted_records(catalog.evidence, evidence_ids), + relations=relations, + ) + + +def _render_fields_table(scope: EnduserDocScope) -> list[str]: + lines = [ + "| Field | Label | Type | Required | Readonly |", + "| --- | --- | --- | --- | --- |", + ] + for field in scope.fields: + lines.append( + f"| `{field.name}` | {field.label} | `{field.field_type}` | " + f"{'yes' if field.required else 'no'} | {'yes' if field.readonly else 'no'} |" + ) + if len(lines) == 2: + lines.append("| _none_ | No page-scoped fields are cataloged | - | - | - |") + return lines + + +def _render_navigation(scope: EnduserDocScope) -> list[str]: + lines = [f"- Route: `{scope.page.route}`"] + for relation in scope.relations: + if relation.source != scope.page.id or relation.relation != "navigates_to": + continue + target_page = next( + (page for page in scope.related_pages if page.id == relation.target), None + ) + if target_page is not None: + lines.append(f"- `{scope.page.name}` -> `{target_page.name}` (`{target_page.route}`)") + if len(lines) == 1: + lines.append("- No cataloged navigation targets are linked from this page.") + return lines + + +def _render_evidence(scope: EnduserDocScope) -> list[str]: + if not scope.evidence: + return ["- `evidence.none`: No page-scoped evidence is linked in the catalog."] + + lines: list[str] = [] + field_by_id = {field.id: field for field in scope.fields} + transaction_by_id = {transaction.id: transaction for transaction in scope.transactions} + entity_by_id = {entity.id: entity for entity in scope.entities} + for item in scope.evidence: + field_labels: list[str] = [] + transaction_names: list[str] = [] + entity_names: list[str] = [] + supports_page_scope = False + for relation in scope.relations: + if item.id not in relation.evidence_ids: + continue + if relation.source == scope.page.id or relation.target == scope.page.id: + supports_page_scope = True + if relation.source in field_by_id: + field_labels.append(field_by_id[relation.source].label) + if relation.target in field_by_id: + field_labels.append(field_by_id[relation.target].label) + if relation.source in transaction_by_id: + transaction_names.append(transaction_by_id[relation.source].name) + if relation.target in transaction_by_id: + transaction_names.append(transaction_by_id[relation.target].name) + if relation.source in entity_by_id: + entity_names.append(entity_by_id[relation.source].name) + if relation.target in entity_by_id: + entity_names.append(entity_by_id[relation.target].name) + if item.evidence_type == "screenshot": + lines.append( + f"- `{item.id}`: {item.summary} Supports visual confirmation of `{scope.page.name}`." + ) + continue + + support_parts: list[str] = [] + if supports_page_scope: + support_parts.append(f"the page scope `{scope.page.name}`") + if field_labels: + field_names = ", ".join(f"`{label}`" for label in dict.fromkeys(field_labels)) + support_parts.append(f"the page fields {field_names}") + if transaction_names: + names = ", ".join(f"`{name}`" for name in dict.fromkeys(transaction_names)) + support_parts.append(f"the linked transaction scope {names}") + if entity_names: + names = ", ".join(f"`{name}`" for name in dict.fromkeys(entity_names)) + support_parts.append(f"the linked entities {names}") + + if support_parts: + lines.append(f"- `{item.id}`: {item.summary} Supports {', '.join(support_parts)}.") + else: + lines.append(f"- `{item.id}`: {item.summary}") + return lines + + +def _format_field_instruction(field: FieldRecord, *, verification_mode: bool) -> str: + if verification_mode: + qualifiers = [ + "required" if field.required else "optional", + "readonly" if field.readonly else "editable", + ] + return f"Verify `{field.label}` as a `{field.field_type}` field that is {', '.join(qualifiers)}." + + if field.readonly: + return f"Review `{field.label}` as a readonly `{field.field_type}` field on the page." + action = "Provide a value for" if field.required else "Use" + return f"{action} `{field.label}` as an available `{field.field_type}` field within the cataloged page scope." + + +def _page_scope_summary(scope: EnduserDocScope) -> str: + components = ["page-scoped fields"] + if scope.transactions: + components.append("linked transactions") + if scope.entities: + components.append("linked entities") + components.extend(["navigation", "cited evidence"]) + return ", ".join(components) + + +def _field_step_for_scope( + field: FieldRecord, scope: EnduserDocScope, *, verification_mode: bool +) -> str: + if verification_mode: + return _format_field_instruction(field, verification_mode=True) + + primary_transaction = scope.transactions[0] if scope.transactions else None + if primary_transaction is None: + state = "readonly" if field.readonly else "editable" + required = "required" if field.required else "optional" + return ( + f"Review `{field.label}` as an {state} `{field.field_type}` field that is " + f"{required} on the cataloged page." + ) + + if field.readonly: + return ( + f"Review `{field.label}` as a readonly `{field.field_type}` field linked to the " + f"cataloged `{primary_transaction.name}` goal: {primary_transaction.goal}." + ) + + requirement = "required" if field.required else "optional" + return ( + f"Review `{field.label}` as an editable `{field.field_type}` field that is {requirement} " + f"for the cataloged `{primary_transaction.name}` goal: {primary_transaction.goal}." + ) + + +def _render_purpose(scope: EnduserDocScope, template: EnduserDocTemplate) -> str: + route_text = f" at `{scope.page.route}`" if scope.page.route else "" + transaction_text = "" + if scope.transactions: + transaction_names = ", ".join(f"`{transaction.name}`" for transaction in scope.transactions) + transaction_text = f" It is linked to {transaction_names}." + field_text = "" + if scope.fields and scope.transactions and template.document_kind != "ops-checklist": + field_names = ", ".join(f"`{field.label}`" for field in scope.fields) + field_text = f" Within this page scope, users can work with {field_names} to support the documented workflow." + entity_text = "" + if scope.entities: + entity_names = ", ".join(f"`{entity.name}`" for entity in scope.entities) + entity_text = f" The linked business records are {entity_names}." + scope_limit = "" + if template.mention_scope_limits: + scope_limit = f" This draft is limited to {_page_scope_summary(scope)}." + + if template.document_kind == "ops-checklist": + return ( + f"Use this checklist to verify the cataloged operator-facing behavior for `{scope.page.name}`{route_text}." + f"{transaction_text}{entity_text}{scope_limit}" + ) + return ( + f"Use `{scope.page.name}`{route_text} as documented in the catalog." + f"{transaction_text}{field_text}{entity_text}{scope_limit}" + ) + + +def _render_audience(scope: EnduserDocScope, template: EnduserDocTemplate) -> str: + if template.document_kind == "ops-checklist": + return f"Operators or reviewers confirming the supported behavior for `{scope.page.name}`." + if scope.transactions: + return ( + f"Users working on the `{scope.page.name}` page to support the cataloged " + f"`{scope.transactions[0].name}` goal: {scope.transactions[0].goal}." + ) + return f"Users who need a page-scoped guide for `{scope.page.name}`." + + +def _render_preconditions(scope: EnduserDocScope, template: EnduserDocTemplate) -> str: + lines = [f"- Use this document only for the cataloged page scope `{scope.page.name}`."] + if scope.page.route: + lines.append(f"- Treat `{scope.page.route}` as catalog metadata for this page scope.") + if template.document_kind == "ops-checklist": + lines.append( + "- Treat any uncataloged buttons, results, save actions, or navigation as unsupported until separately evidenced." + ) + elif template.mention_scope_limits: + lines.append( + "- Use only the page-scoped fields, relations, and evidence listed in this document when describing the workflow." + ) + if scope.evidence: + lines.append( + "- Refer to the cited evidence ids when you need to confirm a page or workflow claim." + ) + return "\n".join(lines) + + +def _render_steps(scope: EnduserDocScope, template: EnduserDocTemplate) -> str: + steps: list[str] = [f"Open the cataloged page scope `{scope.page.name}`."] + for field in scope.fields: + steps.append( + _field_step_for_scope(field, scope, verification_mode=template.emphasize_verification) + ) + + if scope.transactions and template.document_kind == "ops-checklist": + transaction_summaries = "; ".join( + f"`{transaction.name}`: {transaction.goal}" for transaction in scope.transactions + ) + steps.append( + f"Confirm that the page is linked to these cataloged transactions: {transaction_summaries}." + ) + + if scope.entities and template.document_kind == "ops-checklist": + entity_names = ", ".join(f"`{entity.name}`" for entity in scope.entities) + steps.append( + f"Verify that the page workflow is tied to these business records: {entity_names}." + ) + + navigation_targets = [item for item in scope.related_pages if item.id != scope.page.id] + if navigation_targets: + target_text = ", ".join(f"`{item.name}` (`{item.route}`)" for item in navigation_targets) + steps.append(f"Only continue to catalog-linked destinations when needed: {target_text}.") + else: + steps.append( + "Do not assume any additional page transitions because no navigation targets are evidenced for this page." + ) + + return "\n".join(f"{index}. {step}" for index, step in enumerate(steps, start=1)) + + +def render_enduser_document( + catalog: EnduserCatalog, + template: EnduserDocTemplate = DEFAULT_ENDUSER_DOC_TEMPLATE, + page_id: str | None = None, +) -> str: + template_sections = _extract_markdown_sections(template.body_template) + missing_sections = [ + section + for section in REQUIRED_DOC_SECTIONS + if section not in template.required_sections or section not in template_sections + ] + if missing_sections: + raise ValueError(f"missing required sections: {', '.join(missing_sections)}") + + scope = build_enduser_doc_scope(catalog, page_id=page_id) + page_name = scope.page.name + title = template.title_template.format(page_name=page_name) + return template.body_template.format( + title=title, + page_name=page_name, + purpose=_render_purpose(scope, template), + audience=_render_audience(scope, template), + preconditions=_render_preconditions(scope, template), + steps=_render_steps(scope, template), + fields_table="\n".join(_render_fields_table(scope)), + navigation="\n".join(_render_navigation(scope)), + evidence="\n".join(_render_evidence(scope)), + review_status=( + "Checklist draft ready for review." + if template.document_kind == "ops-checklist" + else "Catalog-scoped draft ready for review." + ), + ) + + +def validate_rendered_enduser_document( + markdown: str, + template: EnduserDocTemplate = DEFAULT_ENDUSER_DOC_TEMPLATE, +) -> None: + if not markdown.lstrip().startswith("# "): + raise ValueError("document must start with a level-1 markdown title") + + section_bodies = _extract_markdown_section_bodies(markdown) + missing_sections = [ + section for section in template.required_sections if section not in section_bodies + ] + if missing_sections: + raise ValueError(f"document is missing required sections: {', '.join(missing_sections)}") + + steps_body = section_bodies["Steps"] + if template.steps_must_be_numbered and not re.search(r"(?m)^\d+\.\s", steps_body): + raise ValueError("document Steps section must contain a numbered list") + + fields_body = section_bodies["Fields"] + if template.fields_must_be_table: + fields_lines = [line.strip() for line in fields_body.splitlines() if line.strip()] + if ( + len(fields_lines) < 2 + or not fields_lines[0].startswith("|") + or not fields_lines[1].startswith("|") + ): + raise ValueError("document Fields section must contain a markdown table") + + evidence_body = section_bodies["Evidence"] + if template.evidence_requires_ids: + evidence_lines = [line.strip() for line in evidence_body.splitlines() if line.strip()] + if not evidence_lines: + raise ValueError("document Evidence section must contain evidence entries") + invalid_evidence = [ + line for line in evidence_lines if not re.match(r"^[-*]\s+`[^`]+`:\s+\S+", line) + ] + if invalid_evidence: + raise ValueError( + "document Evidence section must contain bullet entries with evidence ids" + ) + + +__all__ = [ + "AVAILABLE_ENDUSER_DOC_TEMPLATES", + "build_enduser_doc_scope", + "DEFAULT_ENDUSER_DOC_TEMPLATE", + "EnduserDocScope", + "EnduserDocTemplate", + "infer_enduser_document_page_id", + "REQUIRED_DOC_SECTIONS", + "load_enduser_doc_template", + "render_enduser_document", + "validate_rendered_enduser_document", +] diff --git a/codewiki/src/enduser/io.py b/codewiki/src/enduser/io.py new file mode 100644 index 00000000..c0058b26 --- /dev/null +++ b/codewiki/src/enduser/io.py @@ -0,0 +1,59 @@ +"""Helpers for reading and writing YAML-first enduser catalogs.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TextIO, cast + +import yaml + +from codewiki.src.enduser.models import EnduserCatalog + + +def load_enduser_catalog(path: Path | str) -> EnduserCatalog: + """Load a catalog from a filesystem path and validate via the Pydantic models.""" + + text = Path(path).read_text(encoding="utf-8") + return load_enduser_catalog_from_string(text) + + +def load_enduser_catalog_from_string(source: str) -> EnduserCatalog: + """Load a catalog from a YAML string and return a validated model.""" + + parsed = yaml.safe_load(source) + if parsed is None: + parsed = {} + if not isinstance(parsed, dict): + raise ValueError("catalog root must be a mapping") + return cast(EnduserCatalog, EnduserCatalog.model_validate(parsed)) + + +def load_enduser_catalog_from_stream(stream: TextIO) -> EnduserCatalog: + """Load a catalog from a text stream.""" + + return load_enduser_catalog_from_string(stream.read()) + + +def dump_enduser_catalog(catalog: EnduserCatalog) -> str: + """Return a canonical YAML representation of the catalog.""" + + payload = catalog.model_dump() + return yaml.safe_dump(payload, sort_keys=True, indent=2) + + +def save_enduser_catalog(catalog: EnduserCatalog, path: Path | str) -> Path: + """Write the canonical YAML catalog to `path` and return the path.""" + + canonical = dump_enduser_catalog(catalog) + destination = Path(path) + destination.write_text(canonical, encoding="utf-8") + return destination + + +__all__ = [ + "load_enduser_catalog", + "load_enduser_catalog_from_string", + "load_enduser_catalog_from_stream", + "dump_enduser_catalog", + "save_enduser_catalog", +] diff --git a/codewiki/src/enduser/models.py b/codewiki/src/enduser/models.py new file mode 100644 index 00000000..b55095fc --- /dev/null +++ b/codewiki/src/enduser/models.py @@ -0,0 +1,118 @@ +"""Canonical YAML-first records for enduser-wiki catalogs.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + + +RecordType = Literal["entity", "page", "field", "transaction", "evidence"] +EvidenceType = Literal["code", "playwright", "screenshot", "network", "llm"] + + +class _BaseRecord(BaseModel): + id: str = Field(min_length=3) + name: str = Field(min_length=1) + + @field_validator("id", "name") + @classmethod + def _strip_required(_cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("value must not be empty") + return value + + +class EntityRecord(_BaseRecord): + description: str = Field(min_length=1) + + +class PageRecord(_BaseRecord): + route: str = Field(min_length=1) + screenshot_refs: list[str] = Field(default_factory=list) + + +class FieldRecord(_BaseRecord): + label: str = Field(min_length=1) + field_type: str = Field(min_length=1) + required: bool = False + readonly: bool = False + + @field_validator("label", "field_type") + @classmethod + def _field_strings_required(_cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("value must not be empty") + return value + + +class TransactionRecord(_BaseRecord): + goal: str = Field(min_length=1) + + +class EvidenceRecord(BaseModel): + id: str = Field(min_length=3) + evidence_type: EvidenceType + source_ref: str = Field(min_length=1) + summary: str = Field(min_length=1) + + @field_validator("id", "source_ref", "summary") + @classmethod + def _evidence_strings_required(_cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("value must not be empty") + return value + + +class RelationRecord(BaseModel): + source: str = Field(min_length=3) + relation: str = Field(min_length=1) + target: str = Field(min_length=3) + evidence_ids: list[str] = Field(default_factory=list) + + @field_validator("source", "relation", "target") + @classmethod + def _relation_strings_required(_cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("value must not be empty") + return value + + +class EnduserCatalog(BaseModel): + entities: list[EntityRecord] = Field(default_factory=list) + pages: list[PageRecord] = Field(default_factory=list) + fields: list[FieldRecord] = Field(default_factory=list) + transactions: list[TransactionRecord] = Field(default_factory=list) + evidence: list[EvidenceRecord] = Field(default_factory=list) + relations: list[RelationRecord] = Field(default_factory=list) + + def index_ids(self) -> dict[str, RecordType]: + record_types: dict[str, RecordType] = {} + for record in self.entities: + record_types[record.id] = "entity" + for record in self.pages: + record_types[record.id] = "page" + for record in self.fields: + record_types[record.id] = "field" + for record in self.transactions: + record_types[record.id] = "transaction" + for record in self.evidence: + record_types[record.id] = "evidence" + return record_types + + @model_validator(mode="after") + def _validate_relations(self) -> "EnduserCatalog": + known_ids = self.index_ids() + for relation in self.relations: + if relation.source not in known_ids: + raise ValueError(f"unknown relation source: {relation.source}") + if relation.target not in known_ids: + raise ValueError(f"unknown relation target: {relation.target}") + for evidence_id in relation.evidence_ids: + if evidence_id not in known_ids or known_ids[evidence_id] != "evidence": + raise ValueError(f"unknown relation evidence: {evidence_id}") + return self diff --git a/codewiki/src/enduser/playwright.py b/codewiki/src/enduser/playwright.py new file mode 100644 index 00000000..10539e9e --- /dev/null +++ b/codewiki/src/enduser/playwright.py @@ -0,0 +1,243 @@ +"""Import deterministic Playwright crawl artifacts into enduser catalog records.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import cast + +from pydantic import BaseModel, Field + +from codewiki.src.enduser.models import ( + EnduserCatalog, + EvidenceRecord, + FieldRecord, + PageRecord, + RelationRecord, +) + + +class PlaywrightFieldCapture(BaseModel): + name: str = Field(min_length=1) + label: str = Field(min_length=1) + role: str = Field(min_length=1) + required: bool = False + readonly: bool = False + + +class PlaywrightActionCapture(BaseModel): + name: str = Field(min_length=1) + label: str = Field(min_length=1) + role: str = Field(min_length=1) + target_route: str | None = None + + +class PlaywrightNetworkRequestCapture(BaseModel): + method: str = Field(min_length=1) + url: str = Field(min_length=1) + resource_type: str | None = None + + +class PlaywrightPageCapture(BaseModel): + route: str = Field(min_length=1) + title: str | None = None + screenshot_path: str | None = None + network_requests: list[PlaywrightNetworkRequestCapture] = Field(default_factory=list) + fields: list[PlaywrightFieldCapture] = Field(default_factory=list) + actions: list[PlaywrightActionCapture] = Field(default_factory=list) + + +class PlaywrightCrawl(BaseModel): + pages: list[PlaywrightPageCapture] = Field(default_factory=list) + + +class PlaywrightExtractorConfig(BaseModel): + page_prefix: str = "page" + field_prefix: str = "field" + page_evidence_prefix: str = "ev.playwright.page" + screenshot_evidence_prefix: str = "ev.screenshot.page" + network_evidence_prefix: str = "ev.network.page" + contains_relation: str = "contains" + navigation_relation: str = "navigates_to" + screenshot_relation: str = "validated_by" + network_relation: str = "invokes" + fallback_field_type: str = "text" + field_type_by_role: dict[str, str] = Field( + default_factory=lambda: { + "textbox": "text", + "searchbox": "search", + "combobox": "select", + "checkbox": "checkbox", + "radio": "radio", + "spinbutton": "number", + "switch": "toggle", + "button": "button", + } + ) + + def slugify_route(self, route: str) -> str: + cleaned = route.strip().strip("/") + if not cleaned: + return "root" + return re.sub(r"[^a-z0-9]+", "_", cleaned.lower()).strip("_") + + def page_id(self, route: str) -> str: + return f"{self.page_prefix}.{self.slugify_route(route)}" + + def field_id(self, route: str, field_name: str) -> str: + field_slug = re.sub(r"[^a-z0-9]+", "_", field_name.lower()).strip("_") + return f"{self.field_prefix}.{self.slugify_route(route)}.{field_slug}" + + def page_evidence_id(self, route: str) -> str: + return f"{self.page_evidence_prefix}.{self.slugify_route(route)}" + + def screenshot_evidence_id(self, route: str) -> str: + return f"{self.screenshot_evidence_prefix}.{self.slugify_route(route)}" + + def network_evidence_id(self, route: str, ordinal: int) -> str: + return f"{self.network_evidence_prefix}.{self.slugify_route(route)}.{ordinal}" + + def field_type(self, role: str) -> str: + return self.field_type_by_role.get(role.lower(), self.fallback_field_type) + + +class PlaywrightCatalogExtractor: + """Build page and field catalog records from saved Playwright crawl data.""" + + def __init__(self, config: PlaywrightExtractorConfig | None = None): + self.config = config or PlaywrightExtractorConfig() + + def extract(self, crawl: PlaywrightCrawl) -> EnduserCatalog: + pages: list[PageRecord] = [] + fields: list[FieldRecord] = [] + evidence: list[EvidenceRecord] = [] + relations: list[RelationRecord] = [] + + route_to_page_id = {page.route: self.config.page_id(page.route) for page in crawl.pages} + + for page in crawl.pages: + page_id = route_to_page_id[page.route] + evidence_id = self.config.page_evidence_id(page.route) + page_name = page.title.strip() if page.title else page.route + screenshot_refs = [page.screenshot_path] if page.screenshot_path else [] + + pages.append( + PageRecord( + id=page_id, + name=page_name, + route=page.route, + screenshot_refs=screenshot_refs, + ) + ) + evidence.append( + EvidenceRecord( + id=evidence_id, + evidence_type="playwright", + source_ref=page.route, + summary=f"Playwright crawl evidence for {page.route}", + ) + ) + if page.screenshot_path: + screenshot_evidence_id = self.config.screenshot_evidence_id(page.route) + evidence.append( + EvidenceRecord( + id=screenshot_evidence_id, + evidence_type="screenshot", + source_ref=page.screenshot_path, + summary=f"Screenshot for {page.route}", + ) + ) + relations.append( + RelationRecord( + source=page_id, + relation=self.config.screenshot_relation, + target=screenshot_evidence_id, + evidence_ids=[evidence_id, screenshot_evidence_id], + ) + ) + + for index, request in enumerate(page.network_requests, start=1): + network_evidence_id = self.config.network_evidence_id(page.route, index) + evidence.append( + EvidenceRecord( + id=network_evidence_id, + evidence_type="network", + source_ref=f"{request.method} {request.url}", + summary=f"Observed {request.method} request for {page.route}", + ) + ) + relations.append( + RelationRecord( + source=page_id, + relation=self.config.network_relation, + target=network_evidence_id, + evidence_ids=[evidence_id, network_evidence_id], + ) + ) + + for field in page.fields: + field_id = self.config.field_id(page.route, field.name) + fields.append( + FieldRecord( + id=field_id, + name=field.name, + label=field.label, + field_type=self.config.field_type(field.role), + required=field.required, + readonly=field.readonly, + ) + ) + relations.append( + RelationRecord( + source=page_id, + relation=self.config.contains_relation, + target=field_id, + evidence_ids=[evidence_id], + ) + ) + + for action in page.actions: + if not action.target_route: + continue + target_page_id = route_to_page_id.get(action.target_route) + if target_page_id is None: + continue + relations.append( + RelationRecord( + source=page_id, + relation=self.config.navigation_relation, + target=target_page_id, + evidence_ids=[evidence_id], + ) + ) + + return EnduserCatalog( + pages=pages, + fields=fields, + evidence=evidence, + relations=relations, + ) + + +def load_playwright_crawl(source: Path | str | dict) -> PlaywrightCrawl: + """Load crawl input from a path or in-memory mapping.""" + + if isinstance(source, dict): + return cast(PlaywrightCrawl, PlaywrightCrawl.model_validate(source)) + + path = Path(source) + payload = json.loads(path.read_text(encoding="utf-8")) + return cast(PlaywrightCrawl, PlaywrightCrawl.model_validate(payload)) + + +__all__ = [ + "PlaywrightActionCapture", + "PlaywrightCatalogExtractor", + "PlaywrightCrawl", + "PlaywrightExtractorConfig", + "PlaywrightFieldCapture", + "PlaywrightNetworkRequestCapture", + "PlaywrightPageCapture", + "load_playwright_crawl", +] diff --git a/codewiki/src/enduser/prompting.py b/codewiki/src/enduser/prompting.py new file mode 100644 index 00000000..467048ce --- /dev/null +++ b/codewiki/src/enduser/prompting.py @@ -0,0 +1,436 @@ +"""Prompt composition helpers for enduser documentation workflows.""" + +from __future__ import annotations + +from importlib.resources import files +from pathlib import Path +import subprocess # nosec B404 - fixed git invocation is used for repository root discovery + +import yaml + +from codewiki.src.enduser.docs import ( + EnduserDocTemplate, + build_enduser_doc_scope, + infer_enduser_document_page_id, +) +from codewiki.src.enduser.io import load_enduser_catalog + + +def _load_prompt(name: str) -> str: + return ( + files("codewiki").joinpath("prompts", "enduser", name).read_text(encoding="utf-8").strip() + ) + + +def _markdown_block(title: str, body: str, fence: str) -> str: + return f"## {title}\n```{fence}\n{body.strip()}\n```" + + +def _template_contract(template: EnduserDocTemplate) -> str: + return yaml.safe_dump( + { + "template_id": template.template_id, + "required_sections": template.required_sections, + "rules": { + "steps_must_be_numbered": template.steps_must_be_numbered, + "fields_must_be_table": template.fields_must_be_table, + "evidence_requires_ids": template.evidence_requires_ids, + }, + "strategy": { + "document_kind": template.document_kind, + "emphasize_verification": template.emphasize_verification, + "mention_scope_limits": template.mention_scope_limits, + }, + }, + sort_keys=False, + ).strip() + + +def resolve_repository_root(*paths: Path | str) -> Path: + resolved_paths = [Path(path).resolve() for path in paths] + for path in resolved_paths: + probe = path if path.is_dir() else path.parent + try: + completed = subprocess.run( # nosec B603 B607 - fixed git command, cwd is a resolved local path + ["git", "rev-parse", "--show-toplevel"], + cwd=str(probe), + check=True, + capture_output=True, + text=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + continue + root = completed.stdout.strip() + if root: + return Path(root).resolve() + + if not resolved_paths: + return Path.cwd().resolve() + first_path = resolved_paths[0] + return (first_path if first_path.is_dir() else first_path.parent).resolve() + + +def _repository_context(repository_root: Path) -> str: + return yaml.safe_dump( + { + "repository_root": str(repository_root), + "execution_model": "codex-cli", + "codebase_access": [ + "The agent is running in the repository root and may inspect relevant files.", + "Use the inline catalog and document as primary artifacts, then use repository inspection only to verify claims, not to invent missing UI behavior.", + "Prefer code paths, routes, handlers, validations, and persistence logic that align with the selected page scope.", + "If repository inspection does not confirm a behavior, keep the wording catalog-derived rather than code-confirmed.", + "Do not surface negative repository-inspection findings inside the end-user document unless the task explicitly asks for repo validation notes.", + ], + }, + sort_keys=False, + ).strip() + + +def _resolve_scope(catalog_path: Path | str, document_path: Path | str | None = None): + catalog = load_enduser_catalog(catalog_path) + selection_method = "catalog-default" + page_id = None + + if document_path is not None: + markdown = Path(document_path).read_text(encoding="utf-8") + page_id = infer_enduser_document_page_id(markdown, catalog) + if page_id is not None: + selection_method = "document-inferred" + + try: + scope = build_enduser_doc_scope(catalog, page_id=page_id) + except ValueError: + return catalog, None, {"selection_method": "unresolved", "selected_page_id": None} + + return catalog, scope, {"selection_method": selection_method, "selected_page_id": scope.page.id} + + +def _catalog_summary(catalog_path: Path | str, document_path: Path | str | None = None) -> str: + catalog, scope, scope_meta = _resolve_scope(catalog_path, document_path=document_path) + if scope is None: + relation_items = [ + { + "source": relation.source, + "relation": relation.relation, + "target": relation.target, + "evidence_ids": relation.evidence_ids, + } + for relation in catalog.relations[:20] + ] + return yaml.safe_dump( + { + "scope": scope_meta, + "counts": { + "entities": len(catalog.entities), + "pages": len(catalog.pages), + "fields": len(catalog.fields), + "transactions": len(catalog.transactions), + "evidence": len(catalog.evidence), + "relations": len(catalog.relations), + }, + "pages": [ + { + "id": page.id, + "name": page.name, + "route": page.route, + "screenshots": page.screenshot_refs, + } + for page in catalog.pages[:10] + ], + "relations": relation_items, + }, + sort_keys=False, + ).strip() + + navigation_targets = [page for page in scope.related_pages if page.id != scope.page.id] + return yaml.safe_dump( + { + "scope": scope_meta, + "selected_page": { + "id": scope.page.id, + "name": scope.page.name, + "route": scope.page.route, + "screenshots": scope.page.screenshot_refs, + }, + "counts": { + "pages": len(scope.related_pages), + "fields": len(scope.fields), + "transactions": len(scope.transactions), + "entities": len(scope.entities), + "evidence": len(scope.evidence), + "relations": len(scope.relations), + }, + "fields": [ + { + "id": field.id, + "name": field.name, + "label": field.label, + "field_type": field.field_type, + "required": field.required, + "readonly": field.readonly, + } + for field in scope.fields + ], + "transactions": [ + { + "id": transaction.id, + "name": transaction.name, + "goal": transaction.goal, + } + for transaction in scope.transactions + ], + "entities": [ + { + "id": entity.id, + "name": entity.name, + "description": entity.description, + } + for entity in scope.entities + ], + "navigation_targets": [ + { + "id": page.id, + "name": page.name, + "route": page.route, + } + for page in navigation_targets + ], + "evidence": [ + { + "id": evidence.id, + "evidence_type": evidence.evidence_type, + "summary": evidence.summary, + "source_ref": evidence.source_ref, + } + for evidence in scope.evidence + ], + "relations": [ + { + "source": relation.source, + "relation": relation.relation, + "target": relation.target, + "evidence_ids": relation.evidence_ids, + } + for relation in scope.relations + ], + }, + sort_keys=False, + ).strip() + + +def _rewrite_context(catalog_path: Path | str, document_path: Path | str | None = None) -> str: + catalog, scope, scope_meta = _resolve_scope(catalog_path, document_path=document_path) + if scope is None: + return yaml.safe_dump( + { + "scope": scope_meta, + "rewrite_priority": [ + "Resolve page scope before expanding workflow language.", + "Fail closed on unsupported actions when page inference is ambiguous.", + ], + }, + sort_keys=False, + ).strip() + + navigation_targets = [page for page in scope.related_pages if page.id != scope.page.id] + return yaml.safe_dump( + { + "scope": scope_meta, + "document_focus": { + "page_name": scope.page.name, + "page_route": scope.page.route, + "field_labels": [field.label for field in scope.fields], + "transaction_names": [transaction.name for transaction in scope.transactions], + "entity_names": [entity.name for entity in scope.entities], + "rewrite_priority": [ + "Keep the document inside the selected page scope.", + "Treat page structure, linked transactions, and linked entities as different evidence layers.", + "Prefer explicit scope limits over plausible but unsupported workflow prose.", + "If the catalog does not show a button, result set, save action, or destination, say less.", + ], + }, + "page_workflow_context": { + "selected_page": { + "id": scope.page.id, + "name": scope.page.name, + "route": scope.page.route, + }, + "fields_on_page": [ + { + "id": field.id, + "name": field.name, + "label": field.label, + "field_type": field.field_type, + "required": field.required, + "readonly": field.readonly, + } + for field in scope.fields + ], + "transactions_on_page": [ + { + "id": transaction.id, + "name": transaction.name, + "goal": transaction.goal, + } + for transaction in scope.transactions + ], + "entities_reachable_from_transactions": [ + { + "id": entity.id, + "name": entity.name, + "description": entity.description, + } + for entity in scope.entities + ], + "navigation_targets": [ + { + "id": page.id, + "name": page.name, + "route": page.route, + } + for page in navigation_targets + ], + "evidence_for_scope": [ + { + "id": evidence.id, + "summary": evidence.summary, + "evidence_type": evidence.evidence_type, + "source_ref": evidence.source_ref, + } + for evidence in scope.evidence + ], + }, + "graph_reasoning_guidance": { + "preferred_walks": [ + "page -> contains -> field", + "page -> participates_in -> transaction", + "transaction -> updates -> entity", + "page -> navigates_to -> page", + ], + "disallowed_inference_patterns": [ + "field presence -> submit button", + "transaction goal -> save button", + "search page -> result table", + "route existence -> cross-page navigation", + ], + "rewrite_goal": "Use the selected page subgraph to produce a narrow, supportable user document.", + }, + }, + sort_keys=False, + ).strip() + + +def build_generation_prompt(catalog_yaml: str, template: EnduserDocTemplate) -> str: + return "\n\n".join( + [ + _load_prompt("base_generation.md"), + _markdown_block("Template Contract", _template_contract(template), "yaml"), + _markdown_block("Output Template", template.body_template, "markdown"), + _markdown_block("Catalog YAML", catalog_yaml, "yaml"), + ] + ) + + +def build_codex_judge_prompt( + document_path: Path | str, catalog_path: Path | str, template: EnduserDocTemplate +) -> str: + repository_root = resolve_repository_root(document_path, catalog_path) + return "\n\n".join( + [ + _load_prompt("codex_judge.md"), + _markdown_block("Repository Context", _repository_context(repository_root), "yaml"), + _markdown_block("Template Contract", _template_contract(template), "yaml"), + _markdown_block("Output Template", template.body_template, "markdown"), + _markdown_block("Document Path", str(Path(document_path)), "text"), + _markdown_block("Catalog Path", str(Path(catalog_path)), "text"), + _markdown_block( + "Catalog Summary", + _catalog_summary(catalog_path, document_path=document_path), + "yaml", + ), + _markdown_block( + "Document Markdown", Path(document_path).read_text(encoding="utf-8"), "markdown" + ), + _markdown_block("Catalog YAML", Path(catalog_path).read_text(encoding="utf-8"), "yaml"), + ] + ) + + +def build_codex_final_draft_prompt( + document_path: Path | str, + catalog_path: Path | str, + template: EnduserDocTemplate, + adversarial_review: dict, +) -> str: + repository_root = resolve_repository_root(document_path, catalog_path) + return "\n\n".join( + [ + _load_prompt("codex_rewrite.md"), + _markdown_block("Repository Context", _repository_context(repository_root), "yaml"), + _markdown_block("Template Contract", _template_contract(template), "yaml"), + _markdown_block("Output Template", template.body_template, "markdown"), + _markdown_block( + "Adversarial Review", + yaml.safe_dump(adversarial_review, sort_keys=False).strip(), + "yaml", + ), + _markdown_block("Document Path", str(Path(document_path)), "text"), + _markdown_block("Catalog Path", str(Path(catalog_path)), "text"), + _markdown_block( + "Rewrite Context", + _rewrite_context(catalog_path, document_path=document_path), + "yaml", + ), + _markdown_block( + "Catalog Summary", + _catalog_summary(catalog_path, document_path=document_path), + "yaml", + ), + _markdown_block( + "Document Markdown", Path(document_path).read_text(encoding="utf-8"), "markdown" + ), + _markdown_block("Catalog YAML", Path(catalog_path).read_text(encoding="utf-8"), "yaml"), + ] + ) + + +def build_codex_adversarial_prompt( + document_path: Path | str, + catalog_path: Path | str, + template: EnduserDocTemplate, +) -> str: + repository_root = resolve_repository_root(document_path, catalog_path) + return "\n\n".join( + [ + _load_prompt("codex_adversarial.md"), + _markdown_block("Repository Context", _repository_context(repository_root), "yaml"), + _markdown_block("Template Contract", _template_contract(template), "yaml"), + _markdown_block("Output Template", template.body_template, "markdown"), + _markdown_block("Document Path", str(Path(document_path)), "text"), + _markdown_block("Catalog Path", str(Path(catalog_path)), "text"), + _markdown_block( + "Rewrite Context", + _rewrite_context(catalog_path, document_path=document_path), + "yaml", + ), + _markdown_block( + "Catalog Summary", + _catalog_summary(catalog_path, document_path=document_path), + "yaml", + ), + _markdown_block( + "Document Markdown", Path(document_path).read_text(encoding="utf-8"), "markdown" + ), + _markdown_block("Catalog YAML", Path(catalog_path).read_text(encoding="utf-8"), "yaml"), + ] + ) + + +__all__ = [ + "build_codex_adversarial_prompt", + "build_codex_final_draft_prompt", + "build_codex_judge_prompt", + "build_generation_prompt", + "resolve_repository_root", +] diff --git a/codewiki/src/enduser/review.py b/codewiki/src/enduser/review.py new file mode 100644 index 00000000..f7a9d0fc --- /dev/null +++ b/codewiki/src/enduser/review.py @@ -0,0 +1,402 @@ +"""Structured review artifacts and external runner wrappers for enduser docs.""" + +from __future__ import annotations + +import json +import tempfile +import subprocess # nosec B404 - controlled codex subprocess execution is the review engine boundary +from pathlib import Path +from typing import Any, Literal, Protocol, cast + +from pydantic import BaseModel, Field, field_validator, model_validator + +from codewiki.src.enduser.docs import DEFAULT_ENDUSER_DOC_TEMPLATE, EnduserDocTemplate +from codewiki.src.enduser.prompting import ( + build_codex_adversarial_prompt, + build_codex_final_draft_prompt, + build_codex_judge_prompt, + resolve_repository_root, +) + + +ReviewStatus = Literal["pass", "fail"] +PublicationStatus = Literal["approved", "rejected"] +RunnerName = Literal["codex"] +DEFAULT_REVIEW_TIMEOUT_SECONDS = 120 +STRING_ARRAY_SCHEMA = {"type": "array", "items": {"type": "string"}} + + +class NamedTemporaryFileHandle(Protocol): + name: str + + def close(self) -> None: ... + + +class ReviewScoreSet(BaseModel): + coverage: int = Field(ge=1, le=5) + evidence_alignment: int = Field(ge=1, le=5) + format_compliance: int = Field(ge=1, le=5) + clarity: int = Field(ge=1, le=5) + + +class JudgeReview(BaseModel): + runner: Literal["codex"] + status: ReviewStatus + scores: ReviewScoreSet + summary: str = Field(min_length=1) + findings: list[str] = Field(default_factory=list) + + +class AdversarialReview(BaseModel): + runner: Literal["codex"] + status: ReviewStatus + findings: list[str] = Field(default_factory=list) + unsupported_claims: list[str] = Field(default_factory=list) + missing_evidence: list[str] = Field(default_factory=list) + format_attacks: list[str] = Field(default_factory=list) + summary: str = Field(min_length=1) + + +class PublicationDecision(BaseModel): + status: PublicationStatus + reasons: list[str] = Field(default_factory=list) + + +class EnduserReviewArtifact(BaseModel): + document_path: str = Field(min_length=1) + final_document_path: str = Field(min_length=1) + catalog_path: str = Field(min_length=1) + template_id: str = Field(min_length=1) + judge: JudgeReview + adversarial: AdversarialReview + publication_decision: PublicationDecision + + @field_validator("document_path", "final_document_path", "catalog_path", "template_id") + @classmethod + def _strip_required(_cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("value must not be empty") + return value + + @model_validator(mode="after") + def _validate_publication_decision(self) -> "EnduserReviewArtifact": + judge_failed = self.judge.status == "fail" + if judge_failed and self.publication_decision.status != "rejected": + raise ValueError("publication decision must reject failed final judge reviews") + if not judge_failed and self.publication_decision.status != "approved": + raise ValueError("publication decision must approve passing final judge reviews") + return self + + +def _strip_markdown_code_fences(text: str) -> str: + stripped = text.strip() + if not stripped.startswith("```"): + return stripped + + lines = stripped.splitlines() + if len(lines) < 2: + return stripped + if lines[-1].strip() != "```": + return stripped + return "\n".join(lines[1:-1]).strip() + + +def _extract_json_object(text: str) -> dict[str, Any]: + stripped = _strip_markdown_code_fences(text) + try: + return cast(dict[str, Any], json.loads(stripped)) + except json.JSONDecodeError: + start = stripped.find("{") + end = stripped.rfind("}") + if start == -1 or end == -1 or end < start: + raise + return cast(dict[str, Any], json.loads(stripped[start : end + 1])) + + +def _coerce_status(value: object, *, default: ReviewStatus | None = None) -> ReviewStatus | None: + if not isinstance(value, str): + return default + normalized = value.strip().lower() + if normalized in {"pass", "passed", "approve", "approved"} or normalized.startswith("pass"): + return "pass" + if normalized in {"fail", "failed", "reject", "rejected"} or normalized.startswith("fail"): + return "fail" + return default + + +def _stringify_items(items: object) -> list[str]: + if not isinstance(items, list): + return [] + normalized: list[str] = [] + for item in items: + if isinstance(item, str): + text = item.strip() + elif isinstance(item, dict): + text = str(item.get("message") or item.get("detail") or item.get("text") or "").strip() + else: + text = str(item).strip() + if text: + normalized.append(text) + return normalized + + +def _normalize_adversarial_response(response: dict[str, Any]) -> dict[str, Any]: + if "runner" in response and "status" in response and "summary" in response: + normalized = dict(response) + status = _coerce_status(normalized.get("status")) + normalized["findings"] = _stringify_items(response.get("findings", [])) + normalized["unsupported_claims"] = _stringify_items(response.get("unsupported_claims", [])) + normalized["missing_evidence"] = _stringify_items(response.get("missing_evidence", [])) + normalized["format_attacks"] = _stringify_items(response.get("format_attacks", [])) + if status is None: + has_issues = any( + [ + normalized["findings"], + normalized["unsupported_claims"], + normalized["missing_evidence"], + normalized["format_attacks"], + ] + ) + status = "fail" if has_issues else "pass" + normalized["status"] = status + return normalized + + raise ValueError("unrecognized codex adversarial response shape") + + +def _normalize_score(value: object) -> int: + if not isinstance(value, (int, float)): + raise ValueError("missing or invalid score value") + numeric = float(value) + if numeric <= 1: + return max(1, min(5, round(1 + numeric * 4))) + if numeric <= 5: + return max(1, min(5, round(numeric))) + return max(1, min(5, round(numeric / 20))) + + +def _normalize_judge_response(response: dict[str, Any]) -> dict[str, Any]: + if "runner" in response and "status" in response and "summary" in response: + normalized = dict(response) + status = _coerce_status(normalized.get("status")) + if status is None: + raise ValueError("unrecognized codex status") + normalized["status"] = status + scores = normalized.get("scores", {}) + if not isinstance(scores, dict): + raise ValueError("missing or invalid scores object") + normalized["scores"] = { + "coverage": _normalize_score(scores.get("coverage")), + "evidence_alignment": _normalize_score(scores.get("evidence_alignment")), + "format_compliance": _normalize_score(scores.get("format_compliance")), + "clarity": _normalize_score(scores.get("clarity")), + } + normalized["findings"] = _stringify_items(response.get("findings", [])) + return normalized + + if "result" not in response: + raise ValueError("unrecognized codex response shape") + scores = response.get("scores", {}) if isinstance(response.get("scores"), dict) else {} + findings = _stringify_items(response.get("findings", [])) + result = response.get("status") or response.get("result") + status = _coerce_status(result) + if status is None: + raise ValueError("unrecognized codex review status") + return { + "runner": "codex", + "status": status, + "scores": { + "coverage": _normalize_score( + scores.get("coverage", scores.get("catalog_coverage", scores.get("overall"))) + ), + "evidence_alignment": _normalize_score( + scores.get("evidence_alignment", scores.get("overall")) + ), + "format_compliance": _normalize_score( + scores.get("format_compliance", scores.get("overall")) + ), + "clarity": _normalize_score(scores.get("clarity", scores.get("overall"))), + }, + "summary": ( + str(response.get("summary")).strip() + if isinstance(response.get("summary"), str) and str(response.get("summary")).strip() + else (findings[0] if findings else "Judge completed review.") + ), + "findings": findings, + } + + +def _write_output_schema(schema: dict[str, Any]) -> NamedTemporaryFileHandle: + schema_file = tempfile.NamedTemporaryFile(mode="w+", suffix=".json", encoding="utf-8") + json.dump(schema, schema_file) + schema_file.flush() + return schema_file + + +def _run_codex_command( + input_payload: str, + *, + cwd: Path, + output_schema: dict | None = None, + timeout_seconds: int = DEFAULT_REVIEW_TIMEOUT_SECONDS, +) -> dict: + with tempfile.NamedTemporaryFile(mode="r+", suffix=".json", encoding="utf-8") as output_file: + command = [ + "codex", + "exec", + "--skip-git-repo-check", + ] + schema_file = _write_output_schema(output_schema) if output_schema else None + if schema_file is not None: + command.extend(["--output-schema", schema_file.name]) + command.extend(["--output-last-message", output_file.name, "-"]) + try: + completed = subprocess.run( # nosec B603 - command list is constructed from fixed codex args + command, + input=input_payload, + text=True, + capture_output=True, + check=True, + cwd=str(cwd), + timeout=timeout_seconds, + ) + raw_output = Path(output_file.name).read_text(encoding="utf-8") + except subprocess.TimeoutExpired as exc: + raise ValueError(f"codex timed out after {timeout_seconds}s") from exc + except subprocess.CalledProcessError as exc: + raise ValueError(f"codex failed: {exc.stderr.strip() or exc.stdout.strip()}") from exc + finally: + if schema_file is not None: + schema_file.close() + try: + return _extract_json_object(raw_output) + except json.JSONDecodeError as exc: + raise ValueError( + f"codex returned non-JSON output: {exc}. stderr={completed.stderr.strip()}" + ) from exc + + +def build_review_prompt( + document_path: Path | str, + catalog_path: Path | str, + template: EnduserDocTemplate = DEFAULT_ENDUSER_DOC_TEMPLATE, +) -> str: + return build_codex_judge_prompt(document_path, catalog_path, template) + + +def _review_workspace(*paths: Path | str) -> Path: + return resolve_repository_root(*paths) + + +def run_codex_judge( + document_path: Path | str, + catalog_path: Path | str, + template: EnduserDocTemplate = DEFAULT_ENDUSER_DOC_TEMPLATE, +) -> JudgeReview: + payload = build_codex_judge_prompt(document_path, catalog_path, template) + response = _run_codex_command( + payload, + cwd=_review_workspace(document_path, catalog_path), + output_schema={ + "type": "object", + "properties": { + "runner": {"type": "string", "const": "codex"}, + "status": {"type": "string"}, + "scores": { + "type": "object", + "properties": { + "coverage": {"type": "number"}, + "evidence_alignment": {"type": "number"}, + "format_compliance": {"type": "number"}, + "clarity": {"type": "number"}, + }, + "required": ["coverage", "evidence_alignment", "format_compliance", "clarity"], + "additionalProperties": False, + }, + "summary": {"type": "string"}, + "findings": STRING_ARRAY_SCHEMA, + }, + "required": ["runner", "status", "scores", "summary", "findings"], + "additionalProperties": False, + }, + ) + response = _normalize_judge_response(response) + return cast(JudgeReview, JudgeReview.model_validate(response)) + + +def run_codex_adversarial( + document_path: Path | str, + catalog_path: Path | str, + template: EnduserDocTemplate = DEFAULT_ENDUSER_DOC_TEMPLATE, +) -> AdversarialReview: + payload = build_codex_adversarial_prompt(document_path, catalog_path, template) + response = _run_codex_command( + payload, + cwd=_review_workspace(document_path, catalog_path), + output_schema={ + "type": "object", + "properties": { + "runner": {"type": "string", "const": "codex"}, + "status": {"type": "string"}, + "findings": STRING_ARRAY_SCHEMA, + "unsupported_claims": STRING_ARRAY_SCHEMA, + "missing_evidence": STRING_ARRAY_SCHEMA, + "format_attacks": STRING_ARRAY_SCHEMA, + "summary": {"type": "string"}, + }, + "required": [ + "runner", + "status", + "findings", + "unsupported_claims", + "missing_evidence", + "format_attacks", + "summary", + ], + "additionalProperties": False, + }, + ) + response = _normalize_adversarial_response(response) + return cast(AdversarialReview, AdversarialReview.model_validate(response)) + + +def run_codex_final_draft( + document_path: Path | str, + catalog_path: Path | str, + template: EnduserDocTemplate, + adversarial_review: AdversarialReview, +) -> str: + payload = build_codex_final_draft_prompt( + document_path, + catalog_path, + template, + adversarial_review.model_dump(), + ) + response = _run_codex_command( + payload, + cwd=_review_workspace(document_path, catalog_path), + output_schema={ + "type": "object", + "properties": {"document": {"type": "string"}}, + "required": ["document"], + "additionalProperties": False, + }, + ) + document = response.get("document", "").strip() + if not document: + raise ValueError("codex final draft response did not include a document") + return cast(str, document + ("\n" if not document.endswith("\n") else "")) + + +__all__ = [ + "AdversarialReview", + "EnduserReviewArtifact", + "JudgeReview", + "PublicationDecision", + "ReviewScoreSet", + "build_review_prompt", + "run_codex_adversarial", + "run_codex_final_draft", + "run_codex_judge", +] diff --git a/codewiki/templates/enduser/page-default.md b/codewiki/templates/enduser/page-default.md new file mode 100644 index 00000000..8e4d5471 --- /dev/null +++ b/codewiki/templates/enduser/page-default.md @@ -0,0 +1,25 @@ +# {title} + +## Purpose +{purpose} + +## Audience +{audience} + +## Preconditions +{preconditions} + +## Steps +{steps} + +## Fields +{fields_table} + +## Navigation +{navigation} + +## Evidence +{evidence} + +## Review Status +{review_status} diff --git a/codewiki/templates/enduser/page-default.yaml b/codewiki/templates/enduser/page-default.yaml new file mode 100644 index 00000000..5b229615 --- /dev/null +++ b/codewiki/templates/enduser/page-default.yaml @@ -0,0 +1,19 @@ +template_id: page-default +title_template: "{page_name} User Guide" +required_sections: + - Purpose + - Audience + - Preconditions + - Steps + - Fields + - Navigation + - Evidence + - Review Status +rules: + steps_must_be_numbered: true + fields_must_be_table: true + evidence_requires_ids: true +strategy: + document_kind: page-guide + emphasize_verification: false + mention_scope_limits: true diff --git a/codewiki/templates/enduser/page-ops-checklist.md b/codewiki/templates/enduser/page-ops-checklist.md new file mode 100644 index 00000000..8e4d5471 --- /dev/null +++ b/codewiki/templates/enduser/page-ops-checklist.md @@ -0,0 +1,25 @@ +# {title} + +## Purpose +{purpose} + +## Audience +{audience} + +## Preconditions +{preconditions} + +## Steps +{steps} + +## Fields +{fields_table} + +## Navigation +{navigation} + +## Evidence +{evidence} + +## Review Status +{review_status} diff --git a/codewiki/templates/enduser/page-ops-checklist.yaml b/codewiki/templates/enduser/page-ops-checklist.yaml new file mode 100644 index 00000000..3e250589 --- /dev/null +++ b/codewiki/templates/enduser/page-ops-checklist.yaml @@ -0,0 +1,19 @@ +template_id: page-ops-checklist +title_template: "{page_name} Operations Checklist" +required_sections: + - Purpose + - Audience + - Preconditions + - Steps + - Fields + - Navigation + - Evidence + - Review Status +rules: + steps_must_be_numbered: true + fields_must_be_table: true + evidence_requires_ids: true +strategy: + document_kind: ops-checklist + emphasize_verification: true + mention_scope_limits: true diff --git a/docs/2026-04-10-enduser-wiki-analysis.md b/docs/2026-04-10-enduser-wiki-analysis.md new file mode 100644 index 00000000..dc83db29 --- /dev/null +++ b/docs/2026-04-10-enduser-wiki-analysis.md @@ -0,0 +1,477 @@ +# Enduser Wiki Analysis + +## Purpose + +`enduser-wiki` starts as a fork of `CodeWiki`, but the target problem is different. + +`CodeWiki` is optimized for repository-level documentation: +- module decomposition +- code/component summaries +- architecture-aware markdown +- repository hierarchy and diagrams + +`enduser-wiki` is intended to generate product-facing, transaction-oriented documentation from code-first evidence, validated by runtime UI evidence. + +The documentation target is not: +- "what modules exist?" +- "what classes call which functions?" + +The documentation target is: +- what entities exist in the product +- what pages expose them +- what fields appear on those pages +- what transactions users can execute +- what validations, rules, permissions, handlers, APIs, and downstream effects those fields and transactions trigger + +## Why Fork CodeWiki + +`CodeWiki` is a strong base for this direction because it already provides: +- repository parsing and dependency analysis +- hierarchical decomposition for large codebases +- agentic documentation generation +- markdown and HTML generation +- diagram-aware generation workflows +- incremental generation concepts + +Those capabilities are useful for the code-side of the problem. + +However, `CodeWiki` is still module-centric. It does not natively model: +- pages +- screens +- fields +- navigation +- runtime interactions +- transactions as first-class documentation objects + +So the fork strategy is: +- keep the code decomposition strengths +- add a UI/runtime evidence layer +- introduce a new YAML-first canonical model +- render markdown catalogs from that model + +## Source-of-Truth Decisions + +The design decisions behind this fork are: + +- **Code first** + Code is the authoritative source for behavior, validation, rules, payloads, handlers, persistence, and functional impact. + +- **Screenshots as validation** + Screenshots do not define behavior. They confirm what is actually visible and how it is presented to the user. + +- **Runtime UI evidence** + Playwright crawl data and network traces provide observable evidence for: + - page existence + - visible controls and fields + - navigation transitions + - form actions + - request/response side effects + +- **YAML first** + Canonical generated documentation should live in structured YAML, then render to markdown and HTML. + +- **Three linked catalogs** + All of these are first-class outputs: + - entity catalog + - page catalog + - transaction catalog + +## Documentation Objects + +The core documentation model should include these object types: + +- `Entity` +- `Page` +- `Field` +- `Transaction` +- `ValidationRule` +- `Action` +- `Transition` +- `ApiOperation` +- `Handler` +- `PermissionRule` +- `Evidence` + +This is broader than CodeWiki's component/module model because product understanding requires domain and interaction objects, not just code objects. + +## Canonical Relationship Model + +Relationships should be stored explicitly instead of being implicit in prose. + +Examples: + +- `entity -> appears_on -> page` +- `entity -> affected_by -> transaction` +- `entity -> represented_by -> field` +- `page -> contains -> field` +- `page -> participates_in -> transaction` +- `page -> navigates_to -> page` +- `field -> belongs_to -> entity` +- `field -> appears_on -> page` +- `field -> used_in -> transaction` +- `field -> triggers -> validation_rule` +- `field -> maps_to -> api_operation` +- `field -> maps_to -> handler` +- `field -> maps_to -> persistence_target` +- `transaction -> starts_on -> page` +- `transaction -> includes -> action` +- `transaction -> updates -> entity` +- `transaction -> invokes -> api_operation` +- `transaction -> invokes -> handler` +- `transaction -> requires -> permission_rule` + +These relationships are the backbone for all catalogs and rendered views. + +## Catalog Goals + +### Entity Catalog + +The entity catalog should answer: +- what business object is this +- where does it appear +- which fields represent it +- which transactions create, update, search, submit, approve, or cancel it +- what APIs, handlers, and persistence targets it maps to + +Entity pages should include: +- purpose +- attributes +- page usage +- field mappings +- transaction usage +- backend traceability +- evidence + +### Page Catalog + +The page catalog should answer: +- what page/screen is this +- what its purpose is +- what fields and actions it exposes +- how users navigate into and out of it +- what transactions it participates in +- what code and APIs support it + +Page pages should include: +- route/url +- title/labels +- screenshot references +- sections/regions +- fields +- actions +- transitions +- related entities +- related transactions +- code evidence + +### Transaction Catalog + +The transaction catalog should answer: +- what task the user is performing +- what steps are involved +- what pages are traversed +- what fields are touched +- what validations occur +- what APIs and handlers run +- what entities change +- what failure paths exist + +Transaction pages should include: +- goal +- actor +- preconditions +- ordered steps +- pages traversed +- fields used +- validations +- backend calls +- entity impact +- side effects +- evidence + +## Field Documentation Depth + +Field documentation should be layered. + +### Operational layer +- label +- control type +- required/optional +- editable/read-only +- default value +- visible/hidden conditions +- pages where it appears +- transactions where it is used + +### Behavioral layer +- validation rules +- formatting rules +- source of values +- computed/derived behavior +- payload mappings +- handler mappings +- permission impact +- state changes triggered by edits or submission + +### Full traceability layer +- downstream reports +- notifications +- integration impact +- audit/history impact +- exact code evidence links supporting each claim + +The system should not require every field to reach full traceability if the evidence does not support it cleanly. Coverage should be explicit. + +## Evidence Sources + +The target ingestion model uses four evidence classes: + +### 1. Static code + +Used for: +- routes +- components +- handlers +- validators +- DTOs +- models +- persistence mappings +- permissions +- downstream side effects discoverable in code + +This is where CodeWiki contributes most. + +### 2. Playwright crawl + +Used for: +- page discovery +- visible controls +- DOM/accessibility structure +- navigation transitions +- click paths +- form flows +- route-level runtime evidence + +This is the primary runtime UI collector. + +### 3. Screenshots + +Used for: +- confirming visible page state +- confirming labels and grouping +- validating that extracted fields are actually visible +- adding human-readable evidence to rendered docs + +Screenshots are validators and presentation artifacts, not the behavior source of truth. + +### 4. Runtime/network traces + +Used for: +- actual request paths +- payload structures +- submit/search/update/approve effects +- transaction-level API evidence +- observed state changes across steps + +This is essential for transaction docs that go beyond static page descriptions. + +## Proposed Pipeline + +### Stage 1: Code-side extraction + +Adapt the CodeWiki dependency and hierarchy pipeline to produce structured code evidence for: +- routes +- UI components +- backend handlers +- validators +- data models +- APIs +- persistence targets +- permission checks + +### Stage 2: UI/runtime extraction + +Add a Playwright crawler that captures: +- route inventory +- page titles +- accessibility tree +- visible fields and controls +- action elements +- page-to-page transitions +- screenshots +- network requests + +### Stage 3: Evidence alignment + +Merge the static and runtime worlds: +- align page routes with frontend modules +- align field labels with internal field names and model attributes +- align actions with handlers and API operations +- align navigation with transaction candidates + +### Stage 4: Transaction synthesis + +Build transaction records from: +- observed page transitions +- form submissions +- code handlers and payload mappings +- entity updates + +This is the key move beyond graph-only or module-only documentation. + +### Stage 5: YAML generation + +Emit canonical YAML artifacts for: +- entities +- pages +- fields +- transactions +- relationships +- evidence + +### Stage 6: Validation + +Run deterministic validation: +- schema validation +- referential integrity +- missing evidence checks +- duplicate object checks +- unsupported-claim checks +- contradiction checks between code and runtime evidence + +### Stage 7: LLM judge + +Use an LLM judge to score: +- completeness +- clarity +- traceability +- contradiction risk +- evidence quality +- coverage quality per catalog + +Implementation note for the current slice: + +- use `codex` as the first runner +- require structured JSON output that can be normalized into a review artifact +- fail the publication gate if the judge call itself fails + +### Stage 8: Adversarial review + +Use a second agent to challenge: +- unsupported inferences +- missing fields +- missing transitions +- incorrect entity mappings +- overstated transaction claims +- weak field-impact claims + +Implementation note for the current slice: + +- use `opencode` as the second runner +- focus the first adversarial pass on unsupported claims, missing evidence, and format attacks +- fail the publication gate if the adversarial call itself fails + +### Stage 9: Markdown rendering + +Render YAML into: +- entity catalog pages +- page catalog pages +- field pages +- transaction pages +- relationship indexes +- overview landing pages + +The first rendered template should stay intentionally narrow and machine-checkable: + +- title +- purpose +- audience +- preconditions +- numbered steps +- fields table +- navigation section +- evidence section +- review status section + +## Why YAML Instead of Markdown-First + +Markdown is good for reading and publishing, but poor as canonical machine-checked state. + +YAML is better for: +- validation +- referential integrity +- judge pipelines +- diffable structured changes +- deterministic rendering +- graph/index generation + +Markdown should be a view layer over YAML, not the primary store. + +## Proper Relationship Between CodeWiki and Enduser Wiki + +The correct architecture is not "rename CodeWiki and keep going". + +The correct architecture is: + +- **CodeWiki subsystem** + Repository analysis, decomposition, component summaries, code-side evidence extraction + +- **Enduser Wiki product-doc layer** + Entities, pages, fields, transactions, and evidence alignment + +- **Runtime UI subsystem** + Playwright crawling, screenshots, DOM/accessibility extraction, network tracing + +- **Documentation synthesis layer** + YAML objects, validation, judge, adversarial review, markdown render + +This fork should evolve from module-first repo docs to evidence-based product documentation. + +## Initial Implementation Direction + +The first implementation slices should avoid trying to build the full system at once. + +Recommended order: + +1. Define YAML schemas for `Entity`, `Page`, `Field`, `Transaction`, `Relation`, and `Evidence` +2. Add deterministic validators for schema and relationship integrity +3. Build Playwright-based page/field/navigation extraction +4. Add screenshot capture and page evidence binding +5. Add code-to-page and code-to-field mapping +6. Render initial markdown catalogs from YAML +7. Add transaction synthesis +8. Add judge and adversarial review pipelines + +## Fork Naming Rationale + +The repository name `enduser-wiki` is appropriate because the output is meant to explain a product in user-facing operational terms, even though the evidence remains code-first. + +This is not limited to: +- developers reading code structure +- architecture diagrams +- internal module summaries + +It is aimed at: +- product understanding +- implementation analysis +- field impact analysis +- workflow documentation +- transaction traceability + +## Summary + +`enduser-wiki` should become a code-first, evidence-backed documentation system that produces three linked catalogs: +- entities +- pages +- transactions + +Fields are not a side note. They are a first-class bridge between UI, business meaning, and backend behavior. + +The fork should use: +- CodeWiki for code analysis and hierarchical decomposition +- Playwright for runtime UI and navigation evidence +- screenshots for validation and presentation +- runtime/network traces for transaction evidence +- YAML as canonical output +- markdown as rendered output +- validation, LLM judge, and adversarial review as publication gates diff --git a/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 00000000..347cda02 --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,65 @@ +# Quality Gates + +This repository now uses a layered assurance model: + +- Local hooks: `pre-commit` and `pre-push` via `.pre-commit-config.yaml` +- PR gates: `.github/workflows/pr-gates.yml` plus `.github/workflows/codeql.yml` +- Nightly deep assurance: `.github/workflows/nightly-deep-assurance.yml` +- Release gates: `.github/workflows/release-gates.yml` + +## Required PR Checks + +Configure branch protection to require these checks: + +- `lint_and_type` +- `unit_and_integration_tests` +- `coverage_gate` +- `security_sast` +- `supply_chain` +- `secrets` +- `container_and_iac` +- `build_and_package` +- `docs_and_contracts` +- `codeql` + +## Branch Protection Policy + +Apply these GitHub settings to `main`: + +- Require pull requests before merging +- Require 2 approvals +- Require review from code owners +- Dismiss stale approvals when new commits are pushed +- Require status checks to pass before merging +- Require branches to be up to date before merging +- Enable merge queue +- Disable force-push +- Disable branch deletion + +## Ownership And Automation + +- `CODEOWNERS` is defined in `.github/CODEOWNERS` +- Dependabot is defined in `.github/dependabot.yml` +- Use `merge_group` triggers so merge queue evaluates the same gate set as pull requests + +## Coverage Policy + +The enforced package-level coverage threshold currently targets the maintained enduser surfaces: + +- `codewiki/src/enduser/*` +- `codewiki/cli/commands/enduser.py` +- `codewiki/run_web_app.py` + +Diff coverage is enforced at `90%` for changed lines. + +Expand the package-level coverage include list as legacy modules gain stable tests. + +## Release Provenance + +Release builds now generate and upload: + +- Built distributions +- An SPDX SBOM +- A GitHub build provenance attestation + +Tag protection and release permissions still need to be enforced in GitHub repository settings. diff --git a/docs/superpowers/plans/2026-04-10-enduser-doc-review.md b/docs/superpowers/plans/2026-04-10-enduser-doc-review.md new file mode 100644 index 00000000..f5f1ef61 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-enduser-doc-review.md @@ -0,0 +1,265 @@ +# Enduser Doc Review Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first end-to-end enduser documentation path with a fixed markdown template, `codex` judge review, `opencode` adversarial review, and validated review artifacts. + +**Architecture:** Add a focused renderer and review subsystem under `codewiki/src/enduser/`, expose it through new CLI commands, and gate output through template validation plus normalized review artifacts. Keep deterministic tests local and make real CLI execution opt-in. + +**Tech Stack:** Python, Click, Pydantic, pytest, subprocess, YAML/JSON, Markdown + +--- + +### Task 1: Add failing tests for template, renderer, and review models + +**Files:** +- Create: `tests/test_enduser_docs.py` +- Create: `tests/test_enduser_review.py` + +- [ ] **Step 1: Write the failing template and render tests** + +```python +def test_render_doc_produces_required_sections(): + ... + +def test_render_doc_rejects_missing_template_sections(): + ... +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_enduser_docs.py -q` +Expected: FAIL because renderer/template modules do not exist yet + +- [ ] **Step 3: Write the failing review model tests** + +```python +def test_review_artifact_requires_codex_judge_and_opencode_adversarial_sections(): + ... + +def test_publication_decision_rejects_failed_reviews(): + ... +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_enduser_review.py -q` +Expected: FAIL because review models do not exist yet + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_enduser_docs.py tests/test_enduser_review.py +git commit -m "test: add failing enduser doc review tests" +``` + +### Task 2: Implement template and renderer + +**Files:** +- Create: `codewiki/src/enduser/docs.py` +- Modify: `codewiki/src/enduser/__init__.py` +- Test: `tests/test_enduser_docs.py` + +- [ ] **Step 1: Write minimal template and renderer implementation** + +```python +class EnduserDocTemplate(BaseModel): + ... + +def render_enduser_document(...): + ... +``` + +- [ ] **Step 2: Run targeted tests** + +Run: `python3 -m pytest tests/test_enduser_docs.py -q` +Expected: PASS + +- [ ] **Step 3: Refactor only if needed to keep template validation isolated from rendering** + +- [ ] **Step 4: Commit** + +```bash +git add codewiki/src/enduser/docs.py codewiki/src/enduser/__init__.py tests/test_enduser_docs.py +git commit -m "feat: add enduser document template renderer" +``` + +### Task 3: Add failing CLI tests for render and review commands + +**Files:** +- Modify: `tests/test_enduser_cli.py` + +- [ ] **Step 1: Write failing CLI tests** + +```python +def test_enduser_render_doc_writes_markdown(...): + ... + +def test_enduser_review_doc_writes_review_artifact(...): + ... +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_enduser_cli.py -q` +Expected: FAIL because the new commands do not exist yet + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_enduser_cli.py +git commit -m "test: add failing enduser render and review cli tests" +``` + +### Task 4: Implement normalized review models and runner wrappers + +**Files:** +- Create: `codewiki/src/enduser/review.py` +- Test: `tests/test_enduser_review.py` + +- [ ] **Step 1: Implement the review artifact schema and publication gate** + +```python +class ReviewScoreSet(BaseModel): + ... + +class EnduserReviewArtifact(BaseModel): + ... +``` + +- [ ] **Step 2: Implement `codex` and `opencode` command wrappers with normalized parsing** + +```python +def run_codex_judge(...): + ... + +def run_opencode_adversarial(...): + ... +``` + +- [ ] **Step 3: Run targeted tests** + +Run: `python3 -m pytest tests/test_enduser_review.py -q` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add codewiki/src/enduser/review.py tests/test_enduser_review.py +git commit -m "feat: add enduser review artifact and runner wrappers" +``` + +### Task 5: Implement CLI commands for rendering and reviewing + +**Files:** +- Modify: `codewiki/cli/commands/enduser.py` +- Modify: `tests/test_enduser_cli.py` + +- [ ] **Step 1: Add `render-doc` and `review-doc` commands** + +```python +@enduser_group.command(name="render-doc") +def render_doc(...): + ... + +@enduser_group.command(name="review-doc") +def review_doc(...): + ... +``` + +- [ ] **Step 2: Run CLI tests** + +Run: `python3 -m pytest tests/test_enduser_cli.py -q` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add codewiki/cli/commands/enduser.py tests/test_enduser_cli.py +git commit -m "feat: add enduser render and review commands" +``` + +### Task 6: Add end-to-end deterministic flow tests + +**Files:** +- Create: `tests/test_enduser_review_e2e.py` +- Test: `tests/test_enduser_review_e2e.py` + +- [ ] **Step 1: Write the deterministic end-to-end test with monkeypatched subprocess calls** + +```python +def test_enduser_review_e2e_generates_doc_and_review_artifact(...): + ... +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_enduser_review_e2e.py -q` +Expected: FAIL until the full flow is wired correctly + +- [ ] **Step 3: Adjust implementation minimally until it passes** + +- [ ] **Step 4: Run the targeted end-to-end test** + +Run: `python3 -m pytest tests/test_enduser_review_e2e.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_enduser_review_e2e.py +git commit -m "test: cover enduser review flow end to end" +``` + +### Task 7: Add opt-in real-runner integration test and user-facing docs + +**Files:** +- Create: `tests/test_enduser_review_integration.py` +- Modify: `README.md` +- Modify: `docs/2026-04-10-enduser-wiki-analysis.md` + +- [ ] **Step 1: Add an opt-in integration test guarded by environment and binary presence** + +```python +@pytest.mark.integration +def test_enduser_review_with_real_codex_and_opencode(...): + ... +``` + +- [ ] **Step 2: Document the template format, runner order, and environment expectations** + +- [ ] **Step 3: Run docs and integration-adjacent tests as applicable** + +Run: `python3 -m pytest tests/test_enduser_docs.py tests/test_enduser_review.py tests/test_enduser_cli.py tests/test_enduser_review_e2e.py -q` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_enduser_review_integration.py README.md docs/2026-04-10-enduser-wiki-analysis.md +git commit -m "docs: describe enduser review pipeline" +``` + +### Task 8: Run full verification + +**Files:** +- Modify: none + +- [ ] **Step 1: Run the full targeted suite** + +Run: `python3 -m pytest tests/test_enduser_models.py tests/test_enduser_catalog.py tests/test_enduser_cli.py tests/test_enduser_playwright.py tests/test_enduser_extract_cli.py tests/test_enduser_docs.py tests/test_enduser_review.py tests/test_enduser_review_e2e.py -q` +Expected: PASS + +- [ ] **Step 2: Run a manual CLI smoke flow** + +Run: `python3 -m codewiki.cli.main enduser render-doc --output ` +Expected: Markdown document with the required headings + +Run: `python3 -m codewiki.cli.main enduser review-doc --catalog --output ` +Expected: JSON artifact containing `judge`, `adversarial`, and `publication_decision` + +- [ ] **Step 3: Commit final verification if needed** + +```bash +git status +``` diff --git a/docs/superpowers/specs/2026-04-10-enduser-doc-review-design.md b/docs/superpowers/specs/2026-04-10-enduser-doc-review-design.md new file mode 100644 index 00000000..dd5706fb --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-enduser-doc-review-design.md @@ -0,0 +1,152 @@ +# Enduser Documentation Review Design + +## Goal + +Add a first end-to-end documentation path for `enduser-wiki` that: + +- renders user-facing documentation from a validated enduser catalog +- enforces a fixed template/format +- reviews the rendered content with a real LLM judge using `codex` +- runs adversarial review with `opencode` as the second runner +- saves normalized review artifacts that can gate publication + +## Scope + +This slice covers one vertical path from catalog input to review output: + +1. validated catalog YAML +2. rendered markdown document in a fixed format +3. normalized review request payload +4. `codex` review execution +5. `opencode` adversarial review execution +6. structured review artifact validation +7. end-to-end CLI test coverage for the format and artifact flow + +This slice does not yet cover: + +- HTML rendering +- multi-page site generation +- automatic remediation loops +- CI secrets/bootstrap for external CLIs +- full transaction/entity/page catalog generation beyond the initial template target + +## User-Facing Flow + +The intended user flow is: + +1. `codewiki enduser validate catalog.yaml` +2. `codewiki enduser render-doc catalog.yaml --template