From e04cb795a887924c4bc542d8632898fda4276f29 Mon Sep 17 00:00:00 2001 From: simonlavigne Date: Fri, 10 Apr 2026 22:02:09 -0400 Subject: [PATCH 01/18] Add initial enduser catalog schema layer --- README.md | 16 + codewiki/__init__.py | 7 +- codewiki/src/enduser/__init__.py | 21 ++ codewiki/src/enduser/models.py | 118 ++++++ docs/2026-04-10-enduser-wiki-analysis.md | 453 +++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_enduser_models.py | 116 ++++++ 7 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 codewiki/src/enduser/__init__.py create mode 100644 codewiki/src/enduser/models.py create mode 100644 docs/2026-04-10-enduser-wiki-analysis.md create mode 100644 tests/test_enduser_models.py diff --git a/README.md b/README.md index 60b82e65..e8a5d931 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,22 @@ --- +## 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 + +--- + ## 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/src/enduser/__init__.py b/codewiki/src/enduser/__init__.py new file mode 100644 index 00000000..0b273254 --- /dev/null +++ b/codewiki/src/enduser/__init__.py @@ -0,0 +1,21 @@ +"""Enduser-wiki canonical catalog models.""" + +from .models import ( + EnduserCatalog, + EntityRecord, + EvidenceRecord, + FieldRecord, + PageRecord, + RelationRecord, + TransactionRecord, +) + +__all__ = [ + "EnduserCatalog", + "EntityRecord", + "EvidenceRecord", + "FieldRecord", + "PageRecord", + "RelationRecord", + "TransactionRecord", +] diff --git a/codewiki/src/enduser/models.py b/codewiki/src/enduser/models.py new file mode 100644 index 00000000..3f8f9c0c --- /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/docs/2026-04-10-enduser-wiki-analysis.md b/docs/2026-04-10-enduser-wiki-analysis.md new file mode 100644 index 00000000..ed63c004 --- /dev/null +++ b/docs/2026-04-10-enduser-wiki-analysis.md @@ -0,0 +1,453 @@ +# 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 + +### 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 + +### Stage 9: Markdown rendering + +Render YAML into: +- entity catalog pages +- page catalog pages +- field pages +- transaction pages +- relationship indexes +- overview landing pages + +## 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/pyproject.toml b/pyproject.toml index c6eb08a9..2832da2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ packages = [ "codewiki.src.be.dependency_analyzer.analyzers", "codewiki.src.be.dependency_analyzer.models", "codewiki.src.be.dependency_analyzer.utils", + "codewiki.src.enduser", "codewiki.src.fe" ] @@ -127,4 +128,3 @@ python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = "-v --cov=codewiki --cov-report=term-missing" - diff --git a/tests/test_enduser_models.py b/tests/test_enduser_models.py new file mode 100644 index 00000000..0c883031 --- /dev/null +++ b/tests/test_enduser_models.py @@ -0,0 +1,116 @@ +from pydantic import ValidationError + +from codewiki.src.enduser.models import ( + EnduserCatalog, + EntityRecord, + EvidenceRecord, + FieldRecord, + PageRecord, + RelationRecord, + TransactionRecord, +) + + +def test_catalog_accepts_cross_linked_records(): + catalog = EnduserCatalog( + entities=[ + EntityRecord( + id="entity.customer", + name="Customer", + description="Customer business object", + ) + ], + pages=[ + PageRecord( + id="page.customer_edit", + name="Customer Edit", + route="/customers/{id}/edit", + ) + ], + fields=[ + FieldRecord( + id="field.customer.name", + name="Customer Name", + label="Customer Name", + field_type="text", + ) + ], + transactions=[ + TransactionRecord( + id="txn.customer.update", + name="Update Customer", + goal="Edit and save a customer record", + ) + ], + evidence=[ + EvidenceRecord( + id="ev.code.route.customer_edit", + evidence_type="code", + source_ref="src/routes/customer.py:42", + summary="Customer edit route definition", + ) + ], + relations=[ + RelationRecord( + source="entity.customer", + relation="appears_on", + target="page.customer_edit", + evidence_ids=["ev.code.route.customer_edit"], + ), + RelationRecord( + source="page.customer_edit", + relation="contains", + target="field.customer.name", + evidence_ids=["ev.code.route.customer_edit"], + ), + RelationRecord( + source="txn.customer.update", + relation="starts_on", + target="page.customer_edit", + evidence_ids=["ev.code.route.customer_edit"], + ), + ], + ) + + assert catalog.index_ids()["entity.customer"] == "entity" + assert catalog.index_ids()["page.customer_edit"] == "page" + assert len(catalog.relations) == 3 + + +def test_catalog_rejects_relation_to_unknown_target(): + try: + EnduserCatalog( + entities=[ + EntityRecord( + id="entity.customer", + name="Customer", + description="Customer business object", + ) + ], + relations=[ + RelationRecord( + source="entity.customer", + relation="appears_on", + target="page.missing", + ) + ], + ) + except ValidationError as exc: + assert "unknown relation target" in str(exc) + else: + raise AssertionError("Expected validation error for missing relation target") + + +def test_field_requires_label_and_type(): + try: + FieldRecord( + id="field.customer.name", + name="Customer Name", + label="", + field_type="", + ) + except ValidationError as exc: + assert "label" in str(exc) + assert "field_type" in str(exc) + else: + raise AssertionError("Expected validation error for invalid field metadata") From 5678003a99317e816b2b791b9581f757b9201ebc Mon Sep 17 00:00:00 2001 From: simonlavigne Date: Fri, 10 Apr 2026 22:10:34 -0400 Subject: [PATCH 02/18] Add YAML catalog IO and enduser CLI --- codewiki/cli/commands/enduser.py | 56 +++++++++++++++++++ codewiki/cli/config_manager.py | 15 ++++- codewiki/cli/main.py | 60 ++++++++++++++++---- codewiki/src/enduser/__init__.py | 12 ++++ codewiki/src/enduser/io.py | 59 ++++++++++++++++++++ tests/test_enduser_catalog.py | 89 ++++++++++++++++++++++++++++++ tests/test_enduser_cli.py | 95 ++++++++++++++++++++++++++++++++ 7 files changed, 371 insertions(+), 15 deletions(-) create mode 100644 codewiki/cli/commands/enduser.py create mode 100644 codewiki/src/enduser/io.py create mode 100644 tests/test_enduser_catalog.py create mode 100644 tests/test_enduser_cli.py diff --git a/codewiki/cli/commands/enduser.py b/codewiki/cli/commands/enduser.py new file mode 100644 index 00000000..877bfaa4 --- /dev/null +++ b/codewiki/cli/commands/enduser.py @@ -0,0 +1,56 @@ +"""Click commands for enduser catalog workflows.""" + +from __future__ import annotations + +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.io import ( + dump_enduser_catalog, + load_enduser_catalog, + save_enduser_catalog, +) + + +@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) 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/src/enduser/__init__.py b/codewiki/src/enduser/__init__.py index 0b273254..5df09ae1 100644 --- a/codewiki/src/enduser/__init__.py +++ b/codewiki/src/enduser/__init__.py @@ -1,5 +1,12 @@ """Enduser-wiki canonical catalog models.""" +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, @@ -11,6 +18,11 @@ ) __all__ = [ + "dump_enduser_catalog", + "load_enduser_catalog", + "load_enduser_catalog_from_string", + "load_enduser_catalog_from_stream", + "save_enduser_catalog", "EnduserCatalog", "EntityRecord", "EvidenceRecord", diff --git a/codewiki/src/enduser/io.py b/codewiki/src/enduser/io.py new file mode 100644 index 00000000..901d08c4 --- /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 + +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 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/tests/test_enduser_catalog.py b/tests/test_enduser_catalog.py new file mode 100644 index 00000000..740fc7c0 --- /dev/null +++ b/tests/test_enduser_catalog.py @@ -0,0 +1,89 @@ +import textwrap + +import pytest +from pydantic import ValidationError + +from codewiki.src.enduser.io import ( + dump_enduser_catalog, + load_enduser_catalog, + load_enduser_catalog_from_string, +) + + +VALID_CATALOG = textwrap.dedent( + """\ + entities: + - id: entity-one + name: Entity One + description: First entity description + pages: + - id: page-one + name: Page One + route: /page-one + screenshot_refs: [] + fields: + - id: field-one + name: Field One + label: Field One + field_type: text + required: true + readonly: false + transactions: + - id: transaction-one + name: Transaction One + goal: Process something + evidence: + - id: evidence-one + evidence_type: code + source_ref: src/code.py + summary: Sample evidence + relations: + - source: entity-one + relation: maps-to + target: transaction-one + evidence_ids: + - evidence-one + """ +) + +INVALID_RELATIONS = textwrap.dedent( + """\ + entities: + - id: entity-one + name: Entity One + description: First entity description + pages: [] + fields: [] + transactions: + - id: transaction-one + name: Transaction One + goal: Process something + evidence: + - id: evidence-one + evidence_type: code + source_ref: src/code.py + summary: Sample evidence + relations: + - source: transaction-one + relation: links + target: missing-target + evidence_ids: + - missing-evidence + """ +) + + +def test_catalog_round_trip(tmp_path): + path = tmp_path / "catalog.yaml" + path.write_text(VALID_CATALOG, encoding="utf-8") + + catalog = load_enduser_catalog(path) + canonical = dump_enduser_catalog(catalog) + reloaded = load_enduser_catalog_from_string(canonical) + + assert catalog.model_dump() == reloaded.model_dump() + + +def test_validation_rejects_invalid_relations(): + with pytest.raises(ValidationError): + load_enduser_catalog_from_string(INVALID_RELATIONS) diff --git a/tests/test_enduser_cli.py b/tests/test_enduser_cli.py new file mode 100644 index 00000000..f60787f8 --- /dev/null +++ b/tests/test_enduser_cli.py @@ -0,0 +1,95 @@ +import textwrap + +from click.testing import CliRunner + +from codewiki.cli.main import cli + +VALID_CATALOG = textwrap.dedent( + """\ + entities: + - id: entity-one + name: Entity One + description: First entity description + pages: + - id: page-one + name: Page One + route: /page-one + screenshot_refs: [] + fields: + - id: field-one + name: Field One + label: Field One + field_type: text + required: true + readonly: false + transactions: + - id: transaction-one + name: Transaction One + goal: Process something + evidence: + - id: evidence-one + evidence_type: code + source_ref: src/code.py + summary: Sample evidence + relations: + - source: entity-one + relation: maps-to + target: transaction-one + evidence_ids: + - evidence-one + """ +) + +INVALID_CATALOG = textwrap.dedent( + """\ + entities: + - id: entity-one + name: Entity One + description: First entity description + pages: [] + fields: [] + transactions: + - id: transaction-one + name: Transaction One + goal: Process something + evidence: + - id: evidence-one + evidence_type: code + source_ref: src/code.py + summary: Sample evidence + relations: + - source: transaction-one + relation: links + target: missing-target + evidence_ids: + - missing-evidence + """ +) + + +def _write_sample(tmp_path, content): + path = tmp_path / "catalog.yaml" + path.write_text(content, encoding="utf-8") + return path + + +def test_enduser_validate_success(tmp_path): + path = _write_sample(tmp_path, VALID_CATALOG) + runner = CliRunner() + result = runner.invoke(cli, ["enduser", "validate", str(path)]) + + assert result.exit_code == 0 + assert "valid" in result.output.lower() + + +def test_enduser_validate_failure(tmp_path): + path = _write_sample(tmp_path, INVALID_CATALOG) + runner = CliRunner() + result = runner.invoke(cli, ["enduser", "validate", str(path)]) + + assert result.exit_code != 0 + assert ( + "invalid" in result.output.lower() + or "failed" in result.output.lower() + or "unknown relation target" in result.output.lower() + ) From aae7d1560081a23b11e9b45bfc87cc3bed1d3228 Mon Sep 17 00:00:00 2001 From: simonlavigne Date: Fri, 10 Apr 2026 22:19:30 -0400 Subject: [PATCH 03/18] Add Playwright crawl catalog extractor --- codewiki/cli/commands/enduser.py | 26 ++++ codewiki/src/enduser/__init__.py | 16 +++ codewiki/src/enduser/playwright.py | 189 +++++++++++++++++++++++++++++ tests/test_enduser_extract_cli.py | 45 +++++++ tests/test_enduser_playwright.py | 102 ++++++++++++++++ 5 files changed, 378 insertions(+) create mode 100644 codewiki/src/enduser/playwright.py create mode 100644 tests/test_enduser_extract_cli.py create mode 100644 tests/test_enduser_playwright.py diff --git a/codewiki/cli/commands/enduser.py b/codewiki/cli/commands/enduser.py index 877bfaa4..3c258a88 100644 --- a/codewiki/cli/commands/enduser.py +++ b/codewiki/cli/commands/enduser.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import yaml @@ -13,6 +14,10 @@ load_enduser_catalog, save_enduser_catalog, ) +from codewiki.src.enduser.playwright import ( + PlaywrightCatalogExtractor, + load_playwright_crawl, +) @group(name="enduser") @@ -54,3 +59,24 @@ def format(source: Path, output: Path | None): 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}") diff --git a/codewiki/src/enduser/__init__.py b/codewiki/src/enduser/__init__.py index 5df09ae1..0f903d55 100644 --- a/codewiki/src/enduser/__init__.py +++ b/codewiki/src/enduser/__init__.py @@ -16,6 +16,15 @@ RelationRecord, TransactionRecord, ) +from .playwright import ( + PlaywrightActionCapture, + PlaywrightCatalogExtractor, + PlaywrightCrawl, + PlaywrightExtractorConfig, + PlaywrightFieldCapture, + PlaywrightPageCapture, + load_playwright_crawl, +) __all__ = [ "dump_enduser_catalog", @@ -23,6 +32,13 @@ "load_enduser_catalog_from_string", "load_enduser_catalog_from_stream", "save_enduser_catalog", + "PlaywrightActionCapture", + "PlaywrightCatalogExtractor", + "PlaywrightCrawl", + "PlaywrightExtractorConfig", + "PlaywrightFieldCapture", + "PlaywrightPageCapture", + "load_playwright_crawl", "EnduserCatalog", "EntityRecord", "EvidenceRecord", diff --git a/codewiki/src/enduser/playwright.py b/codewiki/src/enduser/playwright.py new file mode 100644 index 00000000..592d4d35 --- /dev/null +++ b/codewiki/src/enduser/playwright.py @@ -0,0 +1,189 @@ +"""Import deterministic Playwright crawl artifacts into enduser catalog records.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +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 PlaywrightPageCapture(BaseModel): + route: str = Field(min_length=1) + title: str | None = None + screenshot_path: str | None = None + 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" + contains_relation: str = "contains" + navigation_relation: str = "navigates_to" + 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 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}", + ) + ) + + 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 PlaywrightCrawl.model_validate(source) + + path = Path(source) + return PlaywrightCrawl.model_validate(json.loads(path.read_text(encoding="utf-8"))) + + +__all__ = [ + "PlaywrightActionCapture", + "PlaywrightCatalogExtractor", + "PlaywrightCrawl", + "PlaywrightExtractorConfig", + "PlaywrightFieldCapture", + "PlaywrightPageCapture", + "load_playwright_crawl", +] diff --git a/tests/test_enduser_extract_cli.py b/tests/test_enduser_extract_cli.py new file mode 100644 index 00000000..4ac848da --- /dev/null +++ b/tests/test_enduser_extract_cli.py @@ -0,0 +1,45 @@ +import json + +from click.testing import CliRunner + +from codewiki.cli.main import cli +from codewiki.src.enduser.io import load_enduser_catalog + + +def test_enduser_extract_playwright_writes_catalog_yaml(tmp_path): + crawl_path = tmp_path / "crawl.json" + output_path = tmp_path / "catalog.yaml" + crawl_path.write_text( + json.dumps( + { + "pages": [ + { + "route": "/customers/edit", + "title": "Customer Edit", + "fields": [ + { + "name": "customer_name", + "label": "Customer Name", + "role": "textbox", + } + ], + "actions": [], + } + ] + } + ), + encoding="utf-8", + ) + + runner = CliRunner() + result = runner.invoke( + cli, + ["enduser", "extract-playwright", str(crawl_path), "--output", str(output_path)], + ) + + assert result.exit_code == 0 + assert output_path.exists() + + catalog = load_enduser_catalog(output_path) + assert [page.id for page in catalog.pages] == ["page.customers_edit"] + assert [field.id for field in catalog.fields] == ["field.customers_edit.customer_name"] diff --git a/tests/test_enduser_playwright.py b/tests/test_enduser_playwright.py new file mode 100644 index 00000000..3ef63edf --- /dev/null +++ b/tests/test_enduser_playwright.py @@ -0,0 +1,102 @@ +import json + +from codewiki.src.enduser.playwright import ( + PlaywrightCatalogExtractor, + load_playwright_crawl, +) + + +def test_extractor_builds_catalog_from_playwright_crawl(tmp_path): + crawl_path = tmp_path / "crawl.json" + crawl_path.write_text( + json.dumps( + { + "pages": [ + { + "route": "/customers/edit", + "title": "Customer Edit", + "screenshot_path": "screens/customer-edit.png", + "fields": [ + { + "name": "customer_name", + "label": "Customer Name", + "role": "textbox", + "required": True, + }, + { + "name": "status", + "label": "Status", + "role": "combobox", + }, + ], + "actions": [ + { + "name": "save", + "label": "Save", + "role": "button", + "target_route": "/customers/view", + } + ], + }, + { + "route": "/customers/view", + "title": "Customer View", + "fields": [], + "actions": [], + }, + ] + } + ), + encoding="utf-8", + ) + + crawl = load_playwright_crawl(crawl_path) + catalog = PlaywrightCatalogExtractor().extract(crawl) + + page_ids = {page.id for page in catalog.pages} + field_ids = {field.id for field in catalog.fields} + evidence_ids = {evidence.id for evidence in catalog.evidence} + relations = {(rel.source, rel.relation, rel.target) for rel in catalog.relations} + + assert "page.customers_edit" in page_ids + assert "page.customers_view" in page_ids + assert "field.customers_edit.customer_name" in field_ids + assert "field.customers_edit.status" in field_ids + assert "ev.playwright.page.customers_edit" in evidence_ids + assert ("page.customers_edit", "contains", "field.customers_edit.customer_name") in relations + assert ("page.customers_edit", "contains", "field.customers_edit.status") in relations + assert ("page.customers_edit", "navigates_to", "page.customers_view") in relations + + customer_name = next(field for field in catalog.fields if field.id == "field.customers_edit.customer_name") + status = next(field for field in catalog.fields if field.id == "field.customers_edit.status") + customer_edit = next(page for page in catalog.pages if page.id == "page.customers_edit") + + assert customer_name.field_type == "text" + assert status.field_type == "select" + assert customer_edit.screenshot_refs == ["screens/customer-edit.png"] + + +def test_extractor_ignores_transitions_to_unknown_routes(): + crawl = load_playwright_crawl( + { + "pages": [ + { + "route": "/customers/edit", + "title": "Customer Edit", + "fields": [], + "actions": [ + { + "name": "save", + "label": "Save", + "role": "button", + "target_route": "/customers/missing", + } + ], + } + ] + } + ) + + catalog = PlaywrightCatalogExtractor().extract(crawl) + + assert not catalog.relations From d3f5ba280b6fd80e9376519410fc1e0dd63f8ae4 Mon Sep 17 00:00:00 2001 From: simonlavigne Date: Fri, 10 Apr 2026 22:41:06 -0400 Subject: [PATCH 04/18] Bind screenshot and network crawl evidence --- codewiki/src/enduser/playwright.py | 55 ++++++++++++++++++++++++++++++ tests/test_enduser_playwright.py | 25 +++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/codewiki/src/enduser/playwright.py b/codewiki/src/enduser/playwright.py index 592d4d35..73c6bf0b 100644 --- a/codewiki/src/enduser/playwright.py +++ b/codewiki/src/enduser/playwright.py @@ -32,10 +32,17 @@ class PlaywrightActionCapture(BaseModel): 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) @@ -48,8 +55,12 @@ 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: { @@ -80,6 +91,12 @@ def field_id(self, route: str, field_name: str) -> str: 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) @@ -123,6 +140,43 @@ def extract(self, crawl: PlaywrightCrawl) -> EnduserCatalog: 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], + ) + ) + + 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], + ) + ) for field in page.fields: field_id = self.config.field_id(page.route, field.name) @@ -184,6 +238,7 @@ def load_playwright_crawl(source: Path | str | dict) -> PlaywrightCrawl: "PlaywrightCrawl", "PlaywrightExtractorConfig", "PlaywrightFieldCapture", + "PlaywrightNetworkRequestCapture", "PlaywrightPageCapture", "load_playwright_crawl", ] diff --git a/tests/test_enduser_playwright.py b/tests/test_enduser_playwright.py index 3ef63edf..a9e9338b 100644 --- a/tests/test_enduser_playwright.py +++ b/tests/test_enduser_playwright.py @@ -16,6 +16,13 @@ def test_extractor_builds_catalog_from_playwright_crawl(tmp_path): "route": "/customers/edit", "title": "Customer Edit", "screenshot_path": "screens/customer-edit.png", + "network_requests": [ + { + "method": "POST", + "url": "/api/customers/123", + "resource_type": "fetch", + } + ], "fields": [ { "name": "customer_name", @@ -63,9 +70,13 @@ def test_extractor_builds_catalog_from_playwright_crawl(tmp_path): assert "field.customers_edit.customer_name" in field_ids assert "field.customers_edit.status" in field_ids assert "ev.playwright.page.customers_edit" in evidence_ids + assert "ev.screenshot.page.customers_edit" in evidence_ids + assert "ev.network.page.customers_edit.1" in evidence_ids assert ("page.customers_edit", "contains", "field.customers_edit.customer_name") in relations assert ("page.customers_edit", "contains", "field.customers_edit.status") in relations assert ("page.customers_edit", "navigates_to", "page.customers_view") in relations + assert ("page.customers_edit", "validated_by", "ev.screenshot.page.customers_edit") in relations + assert ("page.customers_edit", "invokes", "ev.network.page.customers_edit.1") in relations customer_name = next(field for field in catalog.fields if field.id == "field.customers_edit.customer_name") status = next(field for field in catalog.fields if field.id == "field.customers_edit.status") @@ -75,6 +86,18 @@ def test_extractor_builds_catalog_from_playwright_crawl(tmp_path): assert status.field_type == "select" assert customer_edit.screenshot_refs == ["screens/customer-edit.png"] + screenshot_evidence = next( + item for item in catalog.evidence if item.id == "ev.screenshot.page.customers_edit" + ) + network_evidence = next( + item for item in catalog.evidence if item.id == "ev.network.page.customers_edit.1" + ) + + assert screenshot_evidence.evidence_type == "screenshot" + assert screenshot_evidence.source_ref == "screens/customer-edit.png" + assert network_evidence.evidence_type == "network" + assert network_evidence.source_ref == "POST /api/customers/123" + def test_extractor_ignores_transitions_to_unknown_routes(): crawl = load_playwright_crawl( @@ -99,4 +122,4 @@ def test_extractor_ignores_transitions_to_unknown_routes(): catalog = PlaywrightCatalogExtractor().extract(crawl) - assert not catalog.relations + assert {relation.relation for relation in catalog.relations} == set() From f1584f38403dc59308e25e23aa953deac5c5fdb9 Mon Sep 17 00:00:00 2001 From: simonlavigne Date: Fri, 10 Apr 2026 23:24:26 -0400 Subject: [PATCH 05/18] Fix Playwright evidence provenance --- codewiki/src/enduser/__init__.py | 2 ++ codewiki/src/enduser/playwright.py | 4 ++-- tests/test_enduser_playwright.py | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/codewiki/src/enduser/__init__.py b/codewiki/src/enduser/__init__.py index 0f903d55..964d93f1 100644 --- a/codewiki/src/enduser/__init__.py +++ b/codewiki/src/enduser/__init__.py @@ -22,6 +22,7 @@ PlaywrightCrawl, PlaywrightExtractorConfig, PlaywrightFieldCapture, + PlaywrightNetworkRequestCapture, PlaywrightPageCapture, load_playwright_crawl, ) @@ -37,6 +38,7 @@ "PlaywrightCrawl", "PlaywrightExtractorConfig", "PlaywrightFieldCapture", + "PlaywrightNetworkRequestCapture", "PlaywrightPageCapture", "load_playwright_crawl", "EnduserCatalog", diff --git a/codewiki/src/enduser/playwright.py b/codewiki/src/enduser/playwright.py index 73c6bf0b..973c1613 100644 --- a/codewiki/src/enduser/playwright.py +++ b/codewiki/src/enduser/playwright.py @@ -155,7 +155,7 @@ def extract(self, crawl: PlaywrightCrawl) -> EnduserCatalog: source=page_id, relation=self.config.screenshot_relation, target=screenshot_evidence_id, - evidence_ids=[evidence_id], + evidence_ids=[evidence_id, screenshot_evidence_id], ) ) @@ -174,7 +174,7 @@ def extract(self, crawl: PlaywrightCrawl) -> EnduserCatalog: source=page_id, relation=self.config.network_relation, target=network_evidence_id, - evidence_ids=[evidence_id], + evidence_ids=[evidence_id, network_evidence_id], ) ) diff --git a/tests/test_enduser_playwright.py b/tests/test_enduser_playwright.py index a9e9338b..6433e5ec 100644 --- a/tests/test_enduser_playwright.py +++ b/tests/test_enduser_playwright.py @@ -98,6 +98,26 @@ def test_extractor_builds_catalog_from_playwright_crawl(tmp_path): assert network_evidence.evidence_type == "network" assert network_evidence.source_ref == "POST /api/customers/123" + screenshot_relation = next( + item + for item in catalog.relations + if item.relation == "validated_by" and item.target == "ev.screenshot.page.customers_edit" + ) + network_relation = next( + item + for item in catalog.relations + if item.relation == "invokes" and item.target == "ev.network.page.customers_edit.1" + ) + + assert screenshot_relation.evidence_ids == [ + "ev.playwright.page.customers_edit", + "ev.screenshot.page.customers_edit", + ] + assert network_relation.evidence_ids == [ + "ev.playwright.page.customers_edit", + "ev.network.page.customers_edit.1", + ] + def test_extractor_ignores_transitions_to_unknown_routes(): crawl = load_playwright_crawl( From 31e0514c77812cf64422173ae3629321ac07ea26 Mon Sep 17 00:00:00 2001 From: simonlavigne Date: Fri, 10 Apr 2026 23:25:43 -0400 Subject: [PATCH 06/18] docs: add enduser review design and plan --- .../plans/2026-04-10-enduser-doc-review.md | 265 ++++++++++++++++++ .../2026-04-10-enduser-doc-review-design.md | 152 ++++++++++ 2 files changed, 417 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-enduser-doc-review.md create mode 100644 docs/superpowers/specs/2026-04-10-enduser-doc-review-design.md 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