feat: add local requirements browser for OpenSpec
Browse files- Introduced a read-only local browser interface for inspecting OpenSpec capabilities, requirements, and changes.
- Implemented a standalone Python module to serve the browser and handle OpenSpec CLI commands.
- Created a structured HTML/CSS/JavaScript interface for displaying specifications and active changes.
- Added functionality for searching and navigating requirements without modifying any OpenSpec artifacts.
- Included comprehensive tests to ensure the reliability of the new browser and its integration with existing OpenSpec functionality.
- README.md +18 -0
- openspec/changes/add-requirements-browser/.openspec.yaml +2 -0
- openspec/changes/add-requirements-browser/design.md +75 -0
- openspec/changes/add-requirements-browser/proposal.md +31 -0
- openspec/changes/add-requirements-browser/specs/requirements-browser/spec.md +91 -0
- openspec/changes/add-requirements-browser/tasks.md +23 -0
- scripts/__init__.py +1 -0
- scripts/requirements_browser.css +91 -0
- scripts/requirements_browser.html +55 -0
- scripts/requirements_browser.js +167 -0
- scripts/requirements_browser.py +283 -0
- tests/test_requirements_browser.py +175 -0
README.md
CHANGED
|
@@ -61,6 +61,24 @@ Tests (no model needed):
|
|
| 61 |
pip install -r requirements-dev.txt && pytest -q
|
| 62 |
```
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
## פועל יוצא (Switch & Speak)
|
| 65 |
|
| 66 |
The Games tab contains an RTL, touch-first conjugation drill with two paths: learning new verbs and a daily retrieval workout. Levels 1–3 implement recognition, pronoun matching, and timed switching. Level 4 records a spoken conjugation; Level 5 starts with a controlled Palestinian-Arabic prompt and evaluates the target conjugation in the response.
|
|
|
|
| 61 |
pip install -r requirements-dev.txt && pytest -q
|
| 62 |
```
|
| 63 |
|
| 64 |
+
### Browse OpenSpec requirements locally
|
| 65 |
+
|
| 66 |
+
From the project root, select the Node version from `.nvmrc` and start the read-only viewer:
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
nvm use && .venv/bin/python scripts/requirements_browser.py
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Open http://localhost:8765. The page shows the durable specifications and active-change artifacts, supports filtering and keyboard navigation, and reloads the files when **Refresh files** is selected. It binds only to `127.0.0.1`, does not modify the specifications, and makes no external requests.
|
| 73 |
+
|
| 74 |
+
If port 8765 is occupied, choose another local port:
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
nvm use && .venv/bin/python scripts/requirements_browser.py --port 8766
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
If startup says `openspec` is missing, install or expose the OpenSpec CLI for the Node version selected by `nvm use`, then confirm `openspec --version` works. If no OpenSpec root is found, run the command from this repository's root. A bind error identifies the occupied port and can be resolved with `--port` as shown above.
|
| 81 |
+
|
| 82 |
## פועל יוצא (Switch & Speak)
|
| 83 |
|
| 84 |
The Games tab contains an RTL, touch-first conjugation drill with two paths: learning new verbs and a daily retrieval workout. Levels 1–3 implement recognition, pronoun matching, and timed switching. Level 4 records a spoken conjugation; Level 5 starts with a controlled Palestinian-Arabic prompt and evaluates the target conjugation in the response.
|
openspec/changes/add-requirements-browser/.openspec.yaml
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
schema: spec-driven
|
| 2 |
+
created: 2026-10-23
|
openspec/changes/add-requirements-browser/design.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Design
|
| 2 |
+
|
| 3 |
+
## Context
|
| 4 |
+
|
| 5 |
+
See `proposal.md` for motivation. OpenSpec currently exposes repository state through terminal commands and Markdown files. This repository already requires Python and the OpenSpec CLI, but it has no JavaScript build system and its production FastAPI process loads large scoring dependencies that a requirements viewer does not need.
|
| 6 |
+
|
| 7 |
+
The viewer handles developer planning material, so its network and rendering boundaries matter even though it runs locally. It must not become a production application route or execute HTML embedded in repository artifacts.
|
| 8 |
+
|
| 9 |
+
## Goals / Non-Goals
|
| 10 |
+
|
| 11 |
+
**Goals:**
|
| 12 |
+
|
| 13 |
+
- Start quickly with the repository's existing Python and OpenSpec installations.
|
| 14 |
+
- Treat OpenSpec CLI output and resolved artifact paths as the authoritative project inventory.
|
| 15 |
+
- Provide a responsive, keyboard-accessible overview and detailed inspection page.
|
| 16 |
+
- Keep all reads inside the resolved OpenSpec root and all presentation read-only.
|
| 17 |
+
- Make parser and HTTP behavior testable without starting the production application.
|
| 18 |
+
|
| 19 |
+
**Non-Goals:**
|
| 20 |
+
|
| 21 |
+
- Editing or approving requirements from the browser.
|
| 22 |
+
- Serving the interface beyond loopback or adding production FastAPI endpoints.
|
| 23 |
+
- Persisting UI preferences or requirement content.
|
| 24 |
+
- Rendering arbitrary Markdown HTML or supporting every Markdown extension.
|
| 25 |
+
- Replacing OpenSpec validation, lifecycle commands, or AI workflows.
|
| 26 |
+
|
| 27 |
+
## Decisions
|
| 28 |
+
|
| 29 |
+
### Use a standalone standard-library Python server
|
| 30 |
+
|
| 31 |
+
Add a developer script that uses Python's HTTP server primitives and binds to `127.0.0.1` by default. It will serve one static client and a small read-only JSON API. A `--port` option allows recovery from port conflicts, while host selection is not exposed as a normal option.
|
| 32 |
+
|
| 33 |
+
This keeps startup independent of the model-heavy production application and adds no package to `requirements.txt`. Reusing FastAPI was rejected because it would couple local documentation tooling to production dependencies and routing. A Node development server was rejected because the repository has no JavaScript package or build setup.
|
| 34 |
+
|
| 35 |
+
### Use OpenSpec CLI output for discovery
|
| 36 |
+
|
| 37 |
+
At startup, invoke `openspec context --json` using a fixed argument vector and no shell. Use the returned root as the only readable project boundary. Inventory refreshes use JSON-capable OpenSpec commands for specifications, changes, status, and structured specification content.
|
| 38 |
+
|
| 39 |
+
This follows store and root resolution rules already implemented by OpenSpec instead of recreating them. If a command fails or returns invalid JSON, the server returns a typed error that the client can display. The implementation will never interpolate request data into a shell command.
|
| 40 |
+
|
| 41 |
+
### Read planning artifacts only from CLI-resolved paths
|
| 42 |
+
|
| 43 |
+
For active changes, use `openspec status --change <id> --json` to obtain artifact paths. Before reading a file, resolve it and verify that it remains inside the OpenSpec root and is one of the paths reported by the CLI. Requests do not accept arbitrary filesystem paths.
|
| 44 |
+
|
| 45 |
+
This permits proposal, design, task, and delta-spec inspection without creating a general local-file server. Symlink and traversal checks protect the boundary even on localhost.
|
| 46 |
+
|
| 47 |
+
### Send structured JSON and render with DOM text nodes
|
| 48 |
+
|
| 49 |
+
The server converts durable specs and planning Markdown into a small document model: headings, paragraphs, lists, requirements, scenarios, and task states. The browser creates elements with `textContent`; artifact-provided HTML is never inserted with `innerHTML`.
|
| 50 |
+
|
| 51 |
+
This provides enough visual hierarchy for requirements without adding a Markdown dependency. Full-fidelity Markdown rendering was rejected because it increases sanitization complexity and is not needed for inspection.
|
| 52 |
+
|
| 53 |
+
### Keep browser state ephemeral
|
| 54 |
+
|
| 55 |
+
Selection, search terms, expanded sections, loading state, and errors live only in page memory. The refresh action retrieves a complete new snapshot and preserves the current selection when it still exists. No local storage, cookies, service worker, or server-side session is used.
|
| 56 |
+
|
| 57 |
+
### Keep deployment surfaces unchanged
|
| 58 |
+
|
| 59 |
+
The implementation adds no application API endpoint, Supabase table, environment variable, Docker step, or Hugging Face Space configuration. Documentation clearly labels the tool as local development tooling.
|
| 60 |
+
|
| 61 |
+
## Risks / Trade-offs
|
| 62 |
+
|
| 63 |
+
- **OpenSpec JSON output changes between versions** -> Validate required fields, isolate command adapters, and show the failing command and concise diagnostic without crashing the server.
|
| 64 |
+
- **Repeated CLI calls make refresh slow** -> Build one snapshot per refresh request and keep the current repository small; introduce caching only if measured latency becomes disruptive.
|
| 65 |
+
- **A malformed artifact prevents complete structure extraction** -> Return a file-specific warning and preserve raw inert text plus all unaffected documents.
|
| 66 |
+
- **Localhost is mistaken for authorization** -> Bind only to loopback, expose no host override in normal usage, and never return files outside CLI-resolved OpenSpec paths.
|
| 67 |
+
- **Minimal Markdown support omits advanced formatting** -> Preserve content as readable text and prioritize headings, lists, requirements, scenarios, and tasks over visual fidelity.
|
| 68 |
+
- **The OpenSpec executable is unavailable** -> Fail startup with an installation/PATH message rather than silently scanning directories with different semantics.
|
| 69 |
+
|
| 70 |
+
## Migration Plan
|
| 71 |
+
|
| 72 |
+
1. Add the standalone server, static client, parser/adapter tests, and developer documentation.
|
| 73 |
+
2. Verify the viewer against the repository's current six specifications and against an active change.
|
| 74 |
+
3. Run focused tests, the full test suite, and a manual keyboard/narrow-window browser check.
|
| 75 |
+
4. Roll back by removing the local tooling files and documentation; production behavior and stored data are unaffected.
|
openspec/changes/add-requirements-browser/proposal.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Proposal
|
| 2 |
+
|
| 3 |
+
## Why
|
| 4 |
+
|
| 5 |
+
The project's OpenSpec requirements are valid Markdown but are cumbersome to inspect across multiple files. A local browser interface will help maintainers understand capabilities, requirements, scenarios, and active changes without exposing planning material through the deployed application.
|
| 6 |
+
|
| 7 |
+
## What Changes
|
| 8 |
+
|
| 9 |
+
- Add a local, read-only requirements browser that binds only to the loopback interface.
|
| 10 |
+
- Present durable specifications and active changes with navigation, counts, search, and expandable requirement scenarios.
|
| 11 |
+
- Reflect files from the repository on refresh and show actionable errors for missing or malformed OpenSpec content.
|
| 12 |
+
- Document a single command for starting the viewer and the local URL it opens.
|
| 13 |
+
- Keep the viewer completely separate from production application routes and deployment.
|
| 14 |
+
- Exclude editing, approval workflows, remote access, authentication, persistence, Supabase, and paid APIs.
|
| 15 |
+
|
| 16 |
+
## Capabilities
|
| 17 |
+
|
| 18 |
+
### New Capabilities
|
| 19 |
+
|
| 20 |
+
- `requirements-browser`: Local, read-only browser inspection of OpenSpec specifications and changes.
|
| 21 |
+
|
| 22 |
+
### Modified Capabilities
|
| 23 |
+
|
| 24 |
+
None.
|
| 25 |
+
|
| 26 |
+
## Impact
|
| 27 |
+
|
| 28 |
+
- Adds local developer tooling and its focused tests under the repository's scripts/tooling area.
|
| 29 |
+
- Reads `openspec/config.yaml`, `openspec/specs/`, and `openspec/changes/` without modifying them.
|
| 30 |
+
- Adds developer documentation for starting and using the viewer.
|
| 31 |
+
- Does not add production FastAPI endpoints, browser state, Supabase schema, deployment configuration, external services, or runtime dependencies for the learner application.
|
openspec/changes/add-requirements-browser/specs/requirements-browser/spec.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Spec Delta
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
|
| 5 |
+
Provide maintainers with a private, local browser interface for understanding OpenSpec capabilities, requirements, scenarios, and work in progress.
|
| 6 |
+
|
| 7 |
+
## ADDED Requirements
|
| 8 |
+
|
| 9 |
+
### Requirement: Local-only startup
|
| 10 |
+
The requirements browser SHALL start from the project root with one documented command and SHALL listen only on a loopback address by default.
|
| 11 |
+
|
| 12 |
+
#### Scenario: Maintainer starts the browser
|
| 13 |
+
- **GIVEN** the repository contains a valid OpenSpec root
|
| 14 |
+
- **WHEN** the maintainer runs the documented startup command
|
| 15 |
+
- **THEN** the tool reports a localhost URL
|
| 16 |
+
- **AND** the interface is reachable from a browser on the same machine
|
| 17 |
+
- **AND** it is not exposed on external network interfaces
|
| 18 |
+
|
| 19 |
+
#### Scenario: Default port is unavailable
|
| 20 |
+
- **WHEN** the configured port cannot be bound
|
| 21 |
+
- **THEN** the tool exits without starting a partial server
|
| 22 |
+
- **AND** reports an actionable message that explains how to choose another port
|
| 23 |
+
|
| 24 |
+
### Requirement: Requirements inventory
|
| 25 |
+
The interface SHALL summarize durable specifications and active changes from the repository's resolved OpenSpec root.
|
| 26 |
+
|
| 27 |
+
#### Scenario: Project contains specifications
|
| 28 |
+
- **WHEN** the requirements page loads
|
| 29 |
+
- **THEN** it lists every durable capability with its requirement count
|
| 30 |
+
- **AND** shows the total specification and requirement counts
|
| 31 |
+
|
| 32 |
+
#### Scenario: Project contains active changes
|
| 33 |
+
- **WHEN** one or more OpenSpec changes are in progress
|
| 34 |
+
- **THEN** the interface lists each change and its available planning artifacts
|
| 35 |
+
- **AND** distinguishes current changes from durable specifications
|
| 36 |
+
|
| 37 |
+
#### Scenario: Project has no active changes
|
| 38 |
+
- **WHEN** no change is in progress
|
| 39 |
+
- **THEN** the interface clearly reports that there are no active changes
|
| 40 |
+
- **AND** continues to show durable specifications
|
| 41 |
+
|
| 42 |
+
### Requirement: Structured requirement inspection
|
| 43 |
+
The interface SHALL present specification and change content as structured, readable HTML without executing markup or scripts contained in an artifact.
|
| 44 |
+
|
| 45 |
+
#### Scenario: Maintainer opens a capability
|
| 46 |
+
- **WHEN** the maintainer selects a capability
|
| 47 |
+
- **THEN** its purpose, requirements, and scenarios are displayed in document order
|
| 48 |
+
- **AND** requirement and scenario headings remain visually distinguishable
|
| 49 |
+
|
| 50 |
+
#### Scenario: Artifact contains unsafe markup
|
| 51 |
+
- **WHEN** a Markdown artifact contains embedded HTML or script-like text
|
| 52 |
+
- **THEN** the interface renders that content as inert text or safe markup
|
| 53 |
+
- **AND** does not execute artifact-provided code
|
| 54 |
+
|
| 55 |
+
### Requirement: Navigation and search
|
| 56 |
+
The interface SHALL let maintainers find content across capability names, requirement names, scenario names, and artifact text without requiring a mouse.
|
| 57 |
+
|
| 58 |
+
#### Scenario: Search matches requirement content
|
| 59 |
+
- **WHEN** the maintainer enters a search term
|
| 60 |
+
- **THEN** the interface limits or highlights results that contain the term
|
| 61 |
+
- **AND** reports clearly when there are no matches
|
| 62 |
+
|
| 63 |
+
#### Scenario: Keyboard-only inspection
|
| 64 |
+
- **WHEN** the maintainer navigates with a keyboard
|
| 65 |
+
- **THEN** all specification selection, change selection, search, expansion, and refresh controls are reachable and visibly focused
|
| 66 |
+
|
| 67 |
+
#### Scenario: Narrow browser window
|
| 68 |
+
- **WHEN** the interface is viewed in a narrow window
|
| 69 |
+
- **THEN** navigation and content remain usable without horizontal page scrolling
|
| 70 |
+
|
| 71 |
+
### Requirement: Read-only refresh
|
| 72 |
+
The requirements browser SHALL never modify OpenSpec artifacts and SHALL allow the maintainer to reload the latest files without restarting the production application.
|
| 73 |
+
|
| 74 |
+
#### Scenario: Artifact changes on disk
|
| 75 |
+
- **GIVEN** the requirements browser is running
|
| 76 |
+
- **WHEN** an OpenSpec artifact changes and the maintainer refreshes the view
|
| 77 |
+
- **THEN** the interface displays the latest saved content
|
| 78 |
+
- **AND** no OpenSpec file is written by the viewer
|
| 79 |
+
|
| 80 |
+
### Requirement: Actionable failure reporting
|
| 81 |
+
The tool SHALL distinguish missing OpenSpec setup, unreadable files, and malformed artifacts while keeping readable content available when possible.
|
| 82 |
+
|
| 83 |
+
#### Scenario: Command runs outside an OpenSpec project
|
| 84 |
+
- **WHEN** the maintainer starts the tool where no OpenSpec root can be resolved
|
| 85 |
+
- **THEN** startup fails with an explanation that OpenSpec must be initialized or the command must run inside a configured project
|
| 86 |
+
|
| 87 |
+
#### Scenario: One artifact cannot be parsed
|
| 88 |
+
- **GIVEN** other OpenSpec artifacts remain readable
|
| 89 |
+
- **WHEN** one artifact is malformed or unreadable
|
| 90 |
+
- **THEN** the interface identifies the affected file and problem
|
| 91 |
+
- **AND** continues to display unaffected specifications and changes
|
openspec/changes/add-requirements-browser/tasks.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tasks
|
| 2 |
+
|
| 3 |
+
## 1. Local data service
|
| 4 |
+
|
| 5 |
+
- [x] 1.1 Add a standalone Python requirements-browser module that resolves the OpenSpec root and invokes fixed OpenSpec CLI JSON commands without a shell; add focused tests proving valid discovery and actionable missing-CLI/root failures pass.
|
| 6 |
+
- [x] 1.2 Implement snapshot assembly for durable specs and active-change artifact paths, including containment checks, structured Markdown extraction, and partial file warnings; add focused tests for counts, requirements/scenarios/tasks, traversal or symlink refusal, malformed JSON, and one unreadable artifact while other content survives.
|
| 7 |
+
- [x] 1.3 Add the loopback-only HTTP entry point, static asset response, read-only snapshot endpoint, `--port` handling, and concise bind errors; add HTTP tests verifying successful responses, unsupported methods are refused, no arbitrary file path is accepted, and a busy port produces the documented failure.
|
| 8 |
+
|
| 9 |
+
## 2. Browser interface and usage
|
| 10 |
+
|
| 11 |
+
- [x] 2.1 Add the dependency-free HTML/CSS/JavaScript interface for summary counts, separate specification/change navigation, structured details, search/no-results feedback, refresh, loading, and partial-error states; verify browser rendering uses DOM text APIs rather than inserting artifact HTML.
|
| 12 |
+
- [x] 2.2 Add responsive layout, semantic controls, visible focus states, and keyboard-operable selection/expansion; manually verify current specs and this active change in a desktop and narrow viewport using keyboard-only navigation, including inert rendering of a script-like fixture.
|
| 13 |
+
- [x] 2.3 Document the single startup command, localhost URL, alternate-port option, read-only/local-only boundary, and troubleshooting in the developer documentation; verify every documented command runs as written from the project root.
|
| 14 |
+
|
| 15 |
+
## 3. Integration verification
|
| 16 |
+
|
| 17 |
+
- [x] 3.1 Run the focused requirements-browser tests and `openspec validate add-requirements-browser --strict --no-interactive`, resolving every failure.
|
| 18 |
+
- [x] 3.2 Run the full `pytest -q` suite and confirm the standalone viewer adds no production route, package dependency, Docker change, Supabase migration, or external request.
|
| 19 |
+
|
| 20 |
+
## Workflow follow-up
|
| 21 |
+
|
| 22 |
+
- Archive the change after implementation and review are complete.
|
| 23 |
+
- Verify the archived `requirements-browser` capability is present in the durable specifications.
|
scripts/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Developer and maintenance scripts that are also exercised by tests."""
|
scripts/requirements_browser.css
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light;
|
| 3 |
+
--ink: #17231d;
|
| 4 |
+
--muted: #617068;
|
| 5 |
+
--paper: #f8f6ef;
|
| 6 |
+
--panel: #fffdf8;
|
| 7 |
+
--line: #ddd9cb;
|
| 8 |
+
--green: #275d42;
|
| 9 |
+
--green-soft: #dfece4;
|
| 10 |
+
--amber: #a35918;
|
| 11 |
+
--shadow: 0 14px 45px rgb(42 55 46 / 9%);
|
| 12 |
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
* { box-sizing: border-box; }
|
| 16 |
+
body { margin: 0; min-width: 280px; background: var(--paper); color: var(--ink); }
|
| 17 |
+
button, input { font: inherit; }
|
| 18 |
+
button:focus-visible, input:focus-visible, summary:focus-visible {
|
| 19 |
+
outline: 3px solid #e7a341;
|
| 20 |
+
outline-offset: 3px;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.topbar { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 34px clamp(20px, 5vw, 72px) 24px; border-bottom: 1px solid var(--line); }
|
| 24 |
+
.eyebrow { margin: 0 0 5px; color: var(--green); font-size: .72rem; font-weight: 800; letter-spacing: .15em; }
|
| 25 |
+
h1 { margin: 0; font: 700 clamp(2rem, 5vw, 4.2rem)/.96 Georgia, serif; letter-spacing: -.045em; }
|
| 26 |
+
.root-path { margin: 11px 0 0; color: var(--muted); font: .78rem ui-monospace, monospace; overflow-wrap: anywhere; }
|
| 27 |
+
.refresh { border: 1px solid var(--green); border-radius: 999px; padding: 10px 17px; background: var(--green); color: white; cursor: pointer; white-space: nowrap; }
|
| 28 |
+
.refresh[disabled] { opacity: .55; cursor: wait; }
|
| 29 |
+
|
| 30 |
+
.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 960px; margin: 24px auto; padding: 0 20px; gap: 12px; }
|
| 31 |
+
.metric { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px 18px; box-shadow: var(--shadow); }
|
| 32 |
+
.metric strong { display: block; font: 700 1.8rem Georgia, serif; }
|
| 33 |
+
.metric span { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; }
|
| 34 |
+
|
| 35 |
+
.workspace { display: grid; grid-template-columns: minmax(220px, 310px) minmax(0, 1fr); gap: 22px; max-width: 1440px; margin: 0 auto; padding: 0 20px 50px; }
|
| 36 |
+
.sidebar, main { background: var(--panel); border: 1px solid var(--line); border-radius: 19px; box-shadow: var(--shadow); }
|
| 37 |
+
.sidebar { padding: 18px; align-self: start; position: sticky; top: 14px; max-height: calc(100vh - 28px); overflow: auto; }
|
| 38 |
+
.search-label { display: block; margin: 0 0 7px; font-size: .78rem; font-weight: 700; }
|
| 39 |
+
#search { width: 100%; border: 1px solid var(--line); border-radius: 11px; background: white; padding: 11px 12px; color: var(--ink); }
|
| 40 |
+
.search-status { min-height: 1.2em; margin: 6px 2px 10px; color: var(--muted); font-size: .75rem; }
|
| 41 |
+
.section-title { display: flex; align-items: center; justify-content: space-between; margin: 18px 2px 7px; }
|
| 42 |
+
.section-title h2 { margin: 0; font-size: .76rem; text-transform: uppercase; letter-spacing: .1em; }
|
| 43 |
+
.count { min-width: 25px; padding: 2px 7px; border-radius: 999px; background: var(--green-soft); color: var(--green); text-align: center; font-size: .7rem; font-weight: 800; }
|
| 44 |
+
.nav-list { display: grid; gap: 5px; }
|
| 45 |
+
.nav-button { width: 100%; border: 0; border-radius: 10px; padding: 10px; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
| 46 |
+
.nav-button:hover { background: #f2efe5; }
|
| 47 |
+
.nav-button[aria-current="page"] { background: var(--green-soft); color: var(--green); }
|
| 48 |
+
.nav-button strong { display: block; overflow-wrap: anywhere; }
|
| 49 |
+
.nav-button small { display: block; margin-top: 2px; color: var(--muted); }
|
| 50 |
+
|
| 51 |
+
main { min-height: 520px; padding: clamp(22px, 5vw, 58px); overflow: hidden; }
|
| 52 |
+
.document-head { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
| 53 |
+
.document-head .type { color: var(--green); font-size: .72rem; font-weight: 800; text-transform: uppercase; letter-spacing: .1em; }
|
| 54 |
+
.document-head h2 { margin: 7px 0 10px; font: 700 clamp(1.8rem, 4vw, 3.1rem)/1 Georgia, serif; overflow-wrap: anywhere; }
|
| 55 |
+
.document-head p { max-width: 760px; margin: 0; color: var(--muted); line-height: 1.65; }
|
| 56 |
+
.requirement { margin-top: 16px; border: 1px solid var(--line); border-radius: 14px; background: white; overflow: hidden; }
|
| 57 |
+
.requirement summary { padding: 16px 18px; cursor: pointer; font-weight: 750; }
|
| 58 |
+
.requirement-body { padding: 0 18px 18px; }
|
| 59 |
+
.requirement-body > p { line-height: 1.65; }
|
| 60 |
+
.scenario { margin-top: 15px; border-left: 3px solid var(--green); padding: 3px 0 3px 15px; }
|
| 61 |
+
.scenario h4 { margin: 0 0 8px; }
|
| 62 |
+
.scenario p { margin: 4px 0; line-height: 1.5; }
|
| 63 |
+
.artifact-tabs { display: flex; flex-wrap: wrap; gap: 7px; margin: 22px 0; }
|
| 64 |
+
.artifact-tabs button { border: 1px solid var(--line); border-radius: 999px; padding: 7px 11px; background: white; cursor: pointer; }
|
| 65 |
+
.artifact-tabs button[aria-pressed="true"] { border-color: var(--green); background: var(--green-soft); color: var(--green); }
|
| 66 |
+
.artifact { max-width: 850px; }
|
| 67 |
+
.artifact h2, .artifact h3, .artifact h4 { margin-top: 1.45em; }
|
| 68 |
+
.artifact p, .artifact li { line-height: 1.65; overflow-wrap: anywhere; }
|
| 69 |
+
.artifact .task { list-style: none; margin-left: 0; padding-left: 0; }
|
| 70 |
+
.artifact .task::before { content: "□"; margin-right: 8px; color: var(--amber); }
|
| 71 |
+
.artifact .task.done::before { content: "■"; color: var(--green); }
|
| 72 |
+
.artifact .code-boundary { font-family: ui-monospace, monospace; color: var(--muted); }
|
| 73 |
+
.empty-state { display: grid; place-items: center; min-height: 390px; color: var(--muted); text-align: center; }
|
| 74 |
+
.empty-state span { font-size: 4rem; color: var(--green); }
|
| 75 |
+
.no-results { padding: 30px 0; color: var(--muted); }
|
| 76 |
+
.warnings { max-width: 1400px; margin: -25px auto 40px; padding: 15px 20px; border: 1px solid #e4b478; border-radius: 14px; background: #fff3df; }
|
| 77 |
+
.warnings h2 { margin: 0 0 7px; font-size: .9rem; }
|
| 78 |
+
.warnings p { margin: 4px 0; font-size: .85rem; overflow-wrap: anywhere; }
|
| 79 |
+
|
| 80 |
+
@media (max-width: 720px) {
|
| 81 |
+
.topbar { align-items: start; flex-direction: column; }
|
| 82 |
+
.summary { grid-template-columns: 1fr; }
|
| 83 |
+
.workspace { grid-template-columns: 1fr; }
|
| 84 |
+
.sidebar { position: static; max-height: none; }
|
| 85 |
+
main { min-height: 420px; padding: 22px 16px; }
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 89 |
+
.requirement, .metric { transition: border-color .15s, transform .15s; }
|
| 90 |
+
.metric:hover { transform: translateY(-2px); }
|
| 91 |
+
}
|
scripts/requirements_browser.html
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>OpenSpec requirements</title>
|
| 7 |
+
<link rel="stylesheet" href="/app.css">
|
| 8 |
+
<script src="/app.js" defer></script>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<header class="topbar">
|
| 12 |
+
<div>
|
| 13 |
+
<p class="eyebrow">LOCAL REQUIREMENTS</p>
|
| 14 |
+
<h1>OpenSpec atlas</h1>
|
| 15 |
+
<p id="rootPath" class="root-path"></p>
|
| 16 |
+
</div>
|
| 17 |
+
<button id="refresh" class="refresh" type="button">Refresh files</button>
|
| 18 |
+
</header>
|
| 19 |
+
|
| 20 |
+
<section id="summary" class="summary" aria-label="Project summary"></section>
|
| 21 |
+
|
| 22 |
+
<div class="workspace">
|
| 23 |
+
<aside class="sidebar" aria-label="Requirements navigation">
|
| 24 |
+
<label class="search-label" for="search">Search requirements</label>
|
| 25 |
+
<input id="search" type="search" placeholder="Capability, requirement, scenario…" autocomplete="off">
|
| 26 |
+
<p id="searchStatus" class="search-status" aria-live="polite"></p>
|
| 27 |
+
|
| 28 |
+
<section aria-labelledby="specHeading">
|
| 29 |
+
<div class="section-title">
|
| 30 |
+
<h2 id="specHeading">Specifications</h2>
|
| 31 |
+
<span id="specCount" class="count"></span>
|
| 32 |
+
</div>
|
| 33 |
+
<nav id="specNav" class="nav-list" aria-label="Specifications"></nav>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section aria-labelledby="changeHeading">
|
| 37 |
+
<div class="section-title">
|
| 38 |
+
<h2 id="changeHeading">Active changes</h2>
|
| 39 |
+
<span id="changeCount" class="count"></span>
|
| 40 |
+
</div>
|
| 41 |
+
<nav id="changeNav" class="nav-list" aria-label="Active changes"></nav>
|
| 42 |
+
</section>
|
| 43 |
+
</aside>
|
| 44 |
+
|
| 45 |
+
<main id="content" tabindex="-1">
|
| 46 |
+
<div class="empty-state">
|
| 47 |
+
<span aria-hidden="true">◇</span>
|
| 48 |
+
<p>Loading OpenSpec…</p>
|
| 49 |
+
</div>
|
| 50 |
+
</main>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<aside id="warnings" class="warnings" aria-live="polite" hidden></aside>
|
| 54 |
+
</body>
|
| 55 |
+
</html>
|
scripts/requirements_browser.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const $ = id => document.getElementById(id);
|
| 4 |
+
const state = {data: null, selection: null, artifact: 0, query: ""};
|
| 5 |
+
|
| 6 |
+
function node(tag, text, className) {
|
| 7 |
+
const el = document.createElement(tag);
|
| 8 |
+
if (text !== undefined && text !== null) el.textContent = String(text);
|
| 9 |
+
if (className) el.className = className;
|
| 10 |
+
return el;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function matches(value) {
|
| 14 |
+
return !state.query || JSON.stringify(value).toLocaleLowerCase().includes(state.query);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function metric(value, label) {
|
| 18 |
+
const box = node("div", null, "metric");
|
| 19 |
+
box.append(node("strong", value), node("span", label));
|
| 20 |
+
return box;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function navButton(kind, item, detail) {
|
| 24 |
+
const button = node("button", null, "nav-button");
|
| 25 |
+
button.type = "button";
|
| 26 |
+
button.append(node("strong", item.title || item.id), node("small", detail));
|
| 27 |
+
button.setAttribute("aria-current", state.selection?.kind === kind && state.selection.id === item.id ? "page" : "false");
|
| 28 |
+
button.onclick = () => {
|
| 29 |
+
state.selection = {kind, id: item.id};
|
| 30 |
+
state.artifact = 0;
|
| 31 |
+
render();
|
| 32 |
+
$("content").focus();
|
| 33 |
+
};
|
| 34 |
+
return button;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function scenarioLines(raw) {
|
| 38 |
+
return String(raw || "").split("\n").map(line => line.replace(/^-\s+/, "").replace(/\*\*/g, "").trim()).filter(Boolean);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function renderSpec(spec) {
|
| 42 |
+
const main = $("content");
|
| 43 |
+
const head = node("header", null, "document-head");
|
| 44 |
+
head.append(node("span", "Durable specification", "type"), node("h2", spec.title || spec.id));
|
| 45 |
+
if (spec.purpose) head.append(node("p", spec.purpose));
|
| 46 |
+
main.append(head);
|
| 47 |
+
const requirements = spec.requirements.filter(matches);
|
| 48 |
+
if (!requirements.length) main.append(node("p", "No requirements match this search.", "no-results"));
|
| 49 |
+
for (const requirement of requirements) {
|
| 50 |
+
const details = node("details", null, "requirement");
|
| 51 |
+
if (state.query) details.open = true;
|
| 52 |
+
details.append(node("summary", requirement.name));
|
| 53 |
+
const body = node("div", null, "requirement-body");
|
| 54 |
+
body.append(node("p", requirement.text));
|
| 55 |
+
for (const item of requirement.scenarios || []) {
|
| 56 |
+
if (!matches(item) && state.query) continue;
|
| 57 |
+
const scenario = node("section", null, "scenario");
|
| 58 |
+
scenario.append(node("h4", item.name));
|
| 59 |
+
for (const line of scenarioLines(item.rawText)) scenario.append(node("p", line));
|
| 60 |
+
body.append(scenario);
|
| 61 |
+
}
|
| 62 |
+
details.append(body);
|
| 63 |
+
main.append(details);
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function renderBlock(block, target) {
|
| 68 |
+
if (block.type === "heading") target.append(node(`h${Math.min(6, Math.max(2, block.level || 2))}`, block.text));
|
| 69 |
+
else if (block.type === "list") {
|
| 70 |
+
const list = target.lastElementChild?.tagName === "UL" ? target.lastElementChild : node("ul");
|
| 71 |
+
if (!list.isConnected) target.append(list);
|
| 72 |
+
list.append(node("li", block.text));
|
| 73 |
+
} else if (block.type === "task") target.append(node("p", block.text, `task${block.done ? " done" : ""}`));
|
| 74 |
+
else if (block.type === "code-boundary") target.append(node("p", block.text || "code", "code-boundary"));
|
| 75 |
+
else target.append(node("p", block.text));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function renderChange(change) {
|
| 79 |
+
const main = $("content");
|
| 80 |
+
const head = node("header", null, "document-head");
|
| 81 |
+
head.append(node("span", `Active change · ${change.schema || "OpenSpec"}`, "type"), node("h2", change.id),
|
| 82 |
+
node("p", `${change.completedTasks}/${change.totalTasks} implementation tasks complete`));
|
| 83 |
+
main.append(head);
|
| 84 |
+
if (!change.artifacts.length) {
|
| 85 |
+
main.append(node("p", "No readable planning artifacts are available.", "no-results"));
|
| 86 |
+
return;
|
| 87 |
+
}
|
| 88 |
+
state.artifact = Math.min(state.artifact, change.artifacts.length - 1);
|
| 89 |
+
const tabs = node("div", null, "artifact-tabs");
|
| 90 |
+
change.artifacts.forEach((artifact, index) => {
|
| 91 |
+
const button = node("button", artifact.id);
|
| 92 |
+
button.type = "button";
|
| 93 |
+
button.setAttribute("aria-pressed", index === state.artifact ? "true" : "false");
|
| 94 |
+
button.onclick = () => { state.artifact = index; render(); };
|
| 95 |
+
tabs.append(button);
|
| 96 |
+
});
|
| 97 |
+
main.append(tabs);
|
| 98 |
+
const artifact = change.artifacts[state.artifact];
|
| 99 |
+
const article = node("article", null, "artifact");
|
| 100 |
+
article.setAttribute("aria-label", artifact.path);
|
| 101 |
+
const blocks = state.query ? artifact.blocks.filter(matches) : artifact.blocks;
|
| 102 |
+
if (!blocks.length) article.append(node("p", "No artifact content matches this search.", "no-results"));
|
| 103 |
+
for (const block of blocks) renderBlock(block, article);
|
| 104 |
+
main.append(article);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function renderWarnings() {
|
| 108 |
+
const box = $("warnings"), warnings = state.data.warnings || [];
|
| 109 |
+
box.replaceChildren();
|
| 110 |
+
box.hidden = !warnings.length;
|
| 111 |
+
if (!warnings.length) return;
|
| 112 |
+
box.append(node("h2", `${warnings.length} file warning${warnings.length === 1 ? "" : "s"}`));
|
| 113 |
+
for (const warning of warnings) box.append(node("p", `${warning.file}: ${warning.message}`));
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function render() {
|
| 117 |
+
if (!state.data) return;
|
| 118 |
+
const specs = state.data.specs.filter(matches), changes = state.data.changes.filter(matches);
|
| 119 |
+
$("summary").replaceChildren(metric(state.data.summary.specifications, "Specifications"),
|
| 120 |
+
metric(state.data.summary.requirements, "Requirements"),
|
| 121 |
+
metric(state.data.summary.changes, "Active changes"));
|
| 122 |
+
$("rootPath").textContent = state.data.root;
|
| 123 |
+
$("specCount").textContent = specs.length;
|
| 124 |
+
$("changeCount").textContent = changes.length;
|
| 125 |
+
$("specNav").replaceChildren(...specs.map(item => navButton("spec", item, `${item.requirementCount} requirements`)));
|
| 126 |
+
$("changeNav").replaceChildren(...changes.map(item => navButton("change", item, `${item.completedTasks}/${item.totalTasks} tasks`)));
|
| 127 |
+
const found = specs.length + changes.length;
|
| 128 |
+
$("searchStatus").textContent = state.query ? `${found} document${found === 1 ? "" : "s"} match` : "";
|
| 129 |
+
const main = $("content");
|
| 130 |
+
main.replaceChildren();
|
| 131 |
+
let selected = state.selection?.kind === "spec" ? state.data.specs.find(item => item.id === state.selection.id)
|
| 132 |
+
: state.selection?.kind === "change" ? state.data.changes.find(item => item.id === state.selection.id) : null;
|
| 133 |
+
if (!selected || (state.query && !matches(selected))) {
|
| 134 |
+
selected = specs[0] || changes[0];
|
| 135 |
+
state.selection = selected ? {kind: specs.includes(selected) ? "spec" : "change", id: selected.id} : null;
|
| 136 |
+
}
|
| 137 |
+
if (!selected) main.append(node("p", "No specifications or changes match this search.", "no-results"));
|
| 138 |
+
else if (state.selection.kind === "spec") renderSpec(selected);
|
| 139 |
+
else renderChange(selected);
|
| 140 |
+
renderWarnings();
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
async function load() {
|
| 144 |
+
const refresh = $("refresh");
|
| 145 |
+
refresh.disabled = true;
|
| 146 |
+
refresh.textContent = "Refreshing…";
|
| 147 |
+
try {
|
| 148 |
+
const response = await fetch("/api/state", {cache: "no-store"});
|
| 149 |
+
const data = await response.json();
|
| 150 |
+
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
| 151 |
+
state.data = data;
|
| 152 |
+
render();
|
| 153 |
+
} catch (error) {
|
| 154 |
+
const main = $("content");
|
| 155 |
+
main.replaceChildren(node("h2", "Could not load OpenSpec"), node("p", error.message));
|
| 156 |
+
} finally {
|
| 157 |
+
refresh.disabled = false;
|
| 158 |
+
refresh.textContent = "Refresh files";
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
$("refresh").onclick = load;
|
| 163 |
+
$("search").addEventListener("input", event => {
|
| 164 |
+
state.query = event.target.value.trim().toLocaleLowerCase();
|
| 165 |
+
render();
|
| 166 |
+
});
|
| 167 |
+
load();
|
scripts/requirements_browser.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Local, read-only browser for this repository's OpenSpec requirements."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import subprocess
|
| 10 |
+
import sys
|
| 11 |
+
from http import HTTPStatus
|
| 12 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Callable
|
| 15 |
+
from urllib.parse import urlsplit
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
HOST = "127.0.0.1"
|
| 19 |
+
DEFAULT_PORT = 8765
|
| 20 |
+
PAGE = Path(__file__).with_name("requirements_browser.html")
|
| 21 |
+
STYLES = Path(__file__).with_name("requirements_browser.css")
|
| 22 |
+
SCRIPT = Path(__file__).with_name("requirements_browser.js")
|
| 23 |
+
Executor = Callable[[list[str], Path], subprocess.CompletedProcess[str]]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BrowserError(RuntimeError):
|
| 27 |
+
"""An actionable setup or OpenSpec discovery error."""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _execute(argv: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
| 31 |
+
return subprocess.run(argv, cwd=cwd, text=True, capture_output=True, check=False)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class OpenSpecClient:
|
| 35 |
+
def __init__(self, cwd: Path, executor: Executor = _execute):
|
| 36 |
+
self.cwd = cwd.resolve()
|
| 37 |
+
self.executor = executor
|
| 38 |
+
|
| 39 |
+
def json(self, *arguments: str) -> dict:
|
| 40 |
+
argv = ["openspec", *arguments]
|
| 41 |
+
try:
|
| 42 |
+
result = self.executor(argv, self.cwd)
|
| 43 |
+
except FileNotFoundError as exc:
|
| 44 |
+
raise BrowserError("OpenSpec is not installed or is not on PATH. Run `openspec --version` first.") from exc
|
| 45 |
+
if result.returncode:
|
| 46 |
+
detail = (result.stderr or result.stdout).strip()
|
| 47 |
+
raise BrowserError(f"`{' '.join(argv)}` failed: {detail or f'exit {result.returncode}'}")
|
| 48 |
+
try:
|
| 49 |
+
value = json.loads(result.stdout)
|
| 50 |
+
except json.JSONDecodeError as exc:
|
| 51 |
+
raise BrowserError(f"`{' '.join(argv)}` returned invalid JSON: {exc.msg}") from exc
|
| 52 |
+
if not isinstance(value, dict):
|
| 53 |
+
raise BrowserError(f"`{' '.join(argv)}` returned JSON that is not an object")
|
| 54 |
+
return value
|
| 55 |
+
|
| 56 |
+
def root(self) -> Path:
|
| 57 |
+
data = self.json("context", "--json")
|
| 58 |
+
raw = data.get("root")
|
| 59 |
+
path = raw.get("path") if isinstance(raw, dict) else None
|
| 60 |
+
if not isinstance(path, str) or not path:
|
| 61 |
+
raise BrowserError(
|
| 62 |
+
"No OpenSpec root was found. Run this command inside an initialized project or run `openspec init`."
|
| 63 |
+
)
|
| 64 |
+
root = Path(path).resolve()
|
| 65 |
+
if not (root / "openspec").is_dir():
|
| 66 |
+
raise BrowserError(f"OpenSpec reported a root without an openspec directory: {root}")
|
| 67 |
+
return root
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _inside(root: Path, candidate: Path) -> bool:
|
| 71 |
+
try:
|
| 72 |
+
return os.path.commonpath((str(root.resolve()), str(candidate.resolve()))) == str(root.resolve())
|
| 73 |
+
except (OSError, ValueError):
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def safe_read(root: Path, filename: str) -> str:
|
| 78 |
+
path = Path(filename)
|
| 79 |
+
if not path.is_absolute():
|
| 80 |
+
path = root / path
|
| 81 |
+
if not _inside(root, path):
|
| 82 |
+
raise BrowserError(f"Refused artifact outside the OpenSpec root: {filename}")
|
| 83 |
+
try:
|
| 84 |
+
return path.read_text(encoding="utf-8")
|
| 85 |
+
except (OSError, UnicodeError) as exc:
|
| 86 |
+
raise BrowserError(f"Could not read {path.relative_to(root)}: {exc}") from exc
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
TASK = re.compile(r"^- \[([ xX])\]\s+(.*)$")
|
| 90 |
+
HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def parse_markdown(text: str) -> list[dict]:
|
| 94 |
+
"""Extract readable structure without interpreting or trusting embedded HTML."""
|
| 95 |
+
blocks: list[dict] = []
|
| 96 |
+
paragraph: list[str] = []
|
| 97 |
+
|
| 98 |
+
def flush() -> None:
|
| 99 |
+
if paragraph:
|
| 100 |
+
blocks.append({"type": "paragraph", "text": " ".join(paragraph).strip()})
|
| 101 |
+
paragraph.clear()
|
| 102 |
+
|
| 103 |
+
for raw in text.splitlines():
|
| 104 |
+
line = raw.rstrip()
|
| 105 |
+
if not line:
|
| 106 |
+
flush()
|
| 107 |
+
continue
|
| 108 |
+
if match := HEADING.match(line):
|
| 109 |
+
flush()
|
| 110 |
+
blocks.append({"type": "heading", "level": len(match.group(1)), "text": match.group(2).strip()})
|
| 111 |
+
elif match := TASK.match(line):
|
| 112 |
+
flush()
|
| 113 |
+
blocks.append({"type": "task", "done": match.group(1).lower() == "x", "text": match.group(2).strip()})
|
| 114 |
+
elif line.startswith(('- ', '* ')):
|
| 115 |
+
flush()
|
| 116 |
+
blocks.append({"type": "list", "text": line[2:].strip()})
|
| 117 |
+
elif line.startswith("```"):
|
| 118 |
+
flush()
|
| 119 |
+
blocks.append({"type": "code-boundary", "text": line[3:].strip()})
|
| 120 |
+
else:
|
| 121 |
+
paragraph.append(line.strip())
|
| 122 |
+
flush()
|
| 123 |
+
return blocks
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _artifact_files(status: dict) -> list[tuple[str, str]]:
|
| 127 |
+
found: list[tuple[str, str]] = []
|
| 128 |
+
paths = status.get("artifactPaths")
|
| 129 |
+
if not isinstance(paths, dict):
|
| 130 |
+
return found
|
| 131 |
+
for artifact_id, details in paths.items():
|
| 132 |
+
if not isinstance(details, dict):
|
| 133 |
+
continue
|
| 134 |
+
existing = details.get("existingOutputPaths", [])
|
| 135 |
+
if isinstance(existing, list):
|
| 136 |
+
found.extend((str(artifact_id), item) for item in existing if isinstance(item, str))
|
| 137 |
+
return found
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def build_snapshot(client: OpenSpecClient, root: Path | None = None) -> dict:
|
| 141 |
+
root = (root or client.root()).resolve()
|
| 142 |
+
warnings: list[dict] = []
|
| 143 |
+
specs: list[dict] = []
|
| 144 |
+
changes: list[dict] = []
|
| 145 |
+
|
| 146 |
+
inventory = client.json("list", "--specs", "--json")
|
| 147 |
+
for item in inventory.get("specs", []):
|
| 148 |
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
| 149 |
+
warnings.append({"file": "spec inventory", "message": "Ignored a malformed specification entry"})
|
| 150 |
+
continue
|
| 151 |
+
spec_id = item["id"]
|
| 152 |
+
try:
|
| 153 |
+
detail = client.json("show", spec_id, "--type", "spec", "--json")
|
| 154 |
+
requirements = detail.get("requirements") if isinstance(detail.get("requirements"), list) else []
|
| 155 |
+
specs.append({
|
| 156 |
+
"id": spec_id,
|
| 157 |
+
"title": detail.get("title") or spec_id,
|
| 158 |
+
"purpose": detail.get("overview") or "",
|
| 159 |
+
"requirementCount": len(requirements),
|
| 160 |
+
"requirements": requirements,
|
| 161 |
+
})
|
| 162 |
+
except BrowserError as exc:
|
| 163 |
+
warnings.append({"file": f"spec:{spec_id}", "message": str(exc)})
|
| 164 |
+
|
| 165 |
+
change_inventory = client.json("list", "--json")
|
| 166 |
+
for item in change_inventory.get("changes", []):
|
| 167 |
+
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
| 168 |
+
warnings.append({"file": "change inventory", "message": "Ignored a malformed change entry"})
|
| 169 |
+
continue
|
| 170 |
+
name = item["name"]
|
| 171 |
+
change = {"id": name, "completedTasks": item.get("completedTasks", 0),
|
| 172 |
+
"totalTasks": item.get("totalTasks", 0), "artifacts": []}
|
| 173 |
+
try:
|
| 174 |
+
status = client.json("status", "--change", name, "--json")
|
| 175 |
+
change["schema"] = status.get("schemaName") or ""
|
| 176 |
+
for artifact_id, filename in _artifact_files(status):
|
| 177 |
+
try:
|
| 178 |
+
change["artifacts"].append({"id": artifact_id, "path": str(Path(filename).relative_to(root)),
|
| 179 |
+
"blocks": parse_markdown(safe_read(root, filename))})
|
| 180 |
+
except (BrowserError, ValueError) as exc:
|
| 181 |
+
warnings.append({"file": filename, "message": str(exc)})
|
| 182 |
+
except BrowserError as exc:
|
| 183 |
+
warnings.append({"file": f"change:{name}", "message": str(exc)})
|
| 184 |
+
changes.append(change)
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
"root": str(root),
|
| 188 |
+
"summary": {"specifications": len(specs),
|
| 189 |
+
"requirements": sum(spec["requirementCount"] for spec in specs),
|
| 190 |
+
"changes": len(changes)},
|
| 191 |
+
"specs": specs,
|
| 192 |
+
"changes": changes,
|
| 193 |
+
"warnings": warnings,
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def handler_for(snapshot: Callable[[], dict], page: Path = PAGE) -> type[BaseHTTPRequestHandler]:
|
| 198 |
+
assets = {"/": (page, "text/html; charset=utf-8"),
|
| 199 |
+
"/app.css": (STYLES, "text/css; charset=utf-8"),
|
| 200 |
+
"/app.js": (SCRIPT, "text/javascript; charset=utf-8")}
|
| 201 |
+
|
| 202 |
+
class Handler(BaseHTTPRequestHandler):
|
| 203 |
+
server_version = "OpenSpecRequirements/1"
|
| 204 |
+
|
| 205 |
+
def _send(self, status: int, body: bytes, content_type: str) -> None:
|
| 206 |
+
self.send_response(status)
|
| 207 |
+
self.send_header("Content-Type", content_type)
|
| 208 |
+
self.send_header("Content-Length", str(len(body)))
|
| 209 |
+
self.send_header("Cache-Control", "no-store")
|
| 210 |
+
self.send_header("X-Content-Type-Options", "nosniff")
|
| 211 |
+
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'")
|
| 212 |
+
self.end_headers()
|
| 213 |
+
self.wfile.write(body)
|
| 214 |
+
|
| 215 |
+
def do_GET(self) -> None:
|
| 216 |
+
route = urlsplit(self.path).path
|
| 217 |
+
if route in assets:
|
| 218 |
+
filename, content_type = assets[route]
|
| 219 |
+
try:
|
| 220 |
+
body = filename.read_bytes()
|
| 221 |
+
except OSError as exc:
|
| 222 |
+
self._send(HTTPStatus.INTERNAL_SERVER_ERROR,
|
| 223 |
+
json.dumps({"error": f"Could not load browser UI: {exc}"}).encode(),
|
| 224 |
+
"application/json; charset=utf-8")
|
| 225 |
+
return
|
| 226 |
+
self._send(HTTPStatus.OK, body, content_type)
|
| 227 |
+
elif route == "/api/state":
|
| 228 |
+
try:
|
| 229 |
+
body = json.dumps(snapshot(), ensure_ascii=False).encode("utf-8")
|
| 230 |
+
self._send(HTTPStatus.OK, body, "application/json; charset=utf-8")
|
| 231 |
+
except BrowserError as exc:
|
| 232 |
+
body = json.dumps({"error": str(exc)}, ensure_ascii=False).encode("utf-8")
|
| 233 |
+
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, body, "application/json; charset=utf-8")
|
| 234 |
+
else:
|
| 235 |
+
self._send(HTTPStatus.NOT_FOUND, b'{"error":"not found"}', "application/json; charset=utf-8")
|
| 236 |
+
|
| 237 |
+
def do_POST(self) -> None:
|
| 238 |
+
self._send(HTTPStatus.METHOD_NOT_ALLOWED, b'{"error":"read only"}', "application/json; charset=utf-8")
|
| 239 |
+
|
| 240 |
+
def log_message(self, format: str, *args: object) -> None:
|
| 241 |
+
sys.stderr.write(f"requirements-browser: {format % args}\n")
|
| 242 |
+
|
| 243 |
+
return Handler
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def create_server(snapshot: Callable[[], dict], port: int, page: Path = PAGE) -> ThreadingHTTPServer:
|
| 247 |
+
if not 0 <= port <= 65535:
|
| 248 |
+
raise BrowserError("Port must be between 0 and 65535.")
|
| 249 |
+
try:
|
| 250 |
+
return ThreadingHTTPServer((HOST, port), handler_for(snapshot, page))
|
| 251 |
+
except OSError as exc:
|
| 252 |
+
raise BrowserError(f"Could not bind {HOST}:{port}: {exc}. Choose another port with `--port PORT`.") from exc
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def parser() -> argparse.ArgumentParser:
|
| 256 |
+
out = argparse.ArgumentParser(description="Browse this project's OpenSpec requirements locally")
|
| 257 |
+
out.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"loopback port (default: {DEFAULT_PORT})")
|
| 258 |
+
return out
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def main(argv: list[str] | None = None) -> int:
|
| 262 |
+
args = parser().parse_args(argv)
|
| 263 |
+
client = OpenSpecClient(Path.cwd())
|
| 264 |
+
try:
|
| 265 |
+
root = client.root()
|
| 266 |
+
server = create_server(lambda: build_snapshot(client, root), args.port)
|
| 267 |
+
except BrowserError as exc:
|
| 268 |
+
print(f"requirements-browser: {exc}", file=sys.stderr)
|
| 269 |
+
return 2
|
| 270 |
+
print(f"OpenSpec requirements: http://localhost:{server.server_port}")
|
| 271 |
+
print(f"Reading: {root / 'openspec'}")
|
| 272 |
+
print("Press Ctrl+C to stop.")
|
| 273 |
+
try:
|
| 274 |
+
server.serve_forever()
|
| 275 |
+
except KeyboardInterrupt:
|
| 276 |
+
print("\nStopped.")
|
| 277 |
+
finally:
|
| 278 |
+
server.server_close()
|
| 279 |
+
return 0
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
if __name__ == "__main__":
|
| 283 |
+
raise SystemExit(main())
|
tests/test_requirements_browser.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import socket
|
| 5 |
+
import subprocess
|
| 6 |
+
import threading
|
| 7 |
+
import urllib.error
|
| 8 |
+
import urllib.request
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
from scripts.requirements_browser import (BrowserError, OpenSpecClient, build_snapshot, create_server,
|
| 14 |
+
parse_markdown, safe_read)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def completed(value, returncode=0, stderr=""):
|
| 18 |
+
stdout = value if isinstance(value, str) else json.dumps(value)
|
| 19 |
+
return subprocess.CompletedProcess([], returncode, stdout, stderr)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def fixture_root(tmp_path: Path) -> Path:
|
| 23 |
+
(tmp_path / "openspec" / "changes" / "demo").mkdir(parents=True)
|
| 24 |
+
(tmp_path / "openspec" / "config.yaml").write_text("schema: spec-driven\n", encoding="utf-8")
|
| 25 |
+
(tmp_path / "openspec" / "changes" / "demo" / "proposal.md").write_text(
|
| 26 |
+
"# Proposal\n\n## Why\n\nInspect <script>alert(1)</script>.\n\n- [ ] pending\n", encoding="utf-8")
|
| 27 |
+
return tmp_path
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def fake_executor(root: Path, broken_spec: bool = False):
|
| 31 |
+
def execute(argv: list[str], cwd: Path):
|
| 32 |
+
args = tuple(argv[1:])
|
| 33 |
+
values = {
|
| 34 |
+
("context", "--json"): {"root": {"path": str(root)}},
|
| 35 |
+
("list", "--specs", "--json"): {"specs": [{"id": "alpha", "requirementCount": 1}]},
|
| 36 |
+
("show", "alpha", "--type", "spec", "--json"): {
|
| 37 |
+
"title": "Alpha", "overview": "First capability", "requirements": [{
|
| 38 |
+
"name": "Visible", "text": "The tool SHALL show it.",
|
| 39 |
+
"scenarios": [{"name": "Open", "rawText": "- **WHEN** opened\n- **THEN** shown"}],
|
| 40 |
+
}],
|
| 41 |
+
},
|
| 42 |
+
("list", "--json"): {"changes": [{"name": "demo", "completedTasks": 0, "totalTasks": 1}]},
|
| 43 |
+
("status", "--change", "demo", "--json"): {
|
| 44 |
+
"schemaName": "spec-driven", "artifactPaths": {"proposal": {
|
| 45 |
+
"existingOutputPaths": [str(root / "openspec" / "changes" / "demo" / "proposal.md")]
|
| 46 |
+
}}
|
| 47 |
+
},
|
| 48 |
+
}
|
| 49 |
+
if broken_spec and args[:2] == ("show", "alpha"):
|
| 50 |
+
return completed("not json")
|
| 51 |
+
return completed(values[args])
|
| 52 |
+
return execute
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_client_resolves_root_and_reports_missing_cli(tmp_path):
|
| 56 |
+
root = fixture_root(tmp_path)
|
| 57 |
+
assert OpenSpecClient(root, fake_executor(root)).root() == root
|
| 58 |
+
|
| 59 |
+
def missing(argv, cwd):
|
| 60 |
+
raise FileNotFoundError
|
| 61 |
+
|
| 62 |
+
with pytest.raises(BrowserError, match="not installed or is not on PATH"):
|
| 63 |
+
OpenSpecClient(root, missing).root()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_client_reports_missing_root(tmp_path):
|
| 67 |
+
def no_root(argv, cwd):
|
| 68 |
+
return completed({"root": None})
|
| 69 |
+
|
| 70 |
+
with pytest.raises(BrowserError, match="No OpenSpec root"):
|
| 71 |
+
OpenSpecClient(tmp_path, no_root).root()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_snapshot_contains_specs_changes_tasks_and_inert_text(tmp_path):
|
| 75 |
+
root = fixture_root(tmp_path)
|
| 76 |
+
snapshot = build_snapshot(OpenSpecClient(root, fake_executor(root)))
|
| 77 |
+
assert snapshot["summary"] == {"specifications": 1, "requirements": 1, "changes": 1}
|
| 78 |
+
assert snapshot["specs"][0]["requirements"][0]["scenarios"][0]["name"] == "Open"
|
| 79 |
+
blocks = snapshot["changes"][0]["artifacts"][0]["blocks"]
|
| 80 |
+
assert any(block["type"] == "task" and not block["done"] for block in blocks)
|
| 81 |
+
assert any("<script>alert(1)</script>" in block.get("text", "") for block in blocks)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_snapshot_keeps_other_content_when_one_spec_is_malformed(tmp_path):
|
| 85 |
+
root = fixture_root(tmp_path)
|
| 86 |
+
snapshot = build_snapshot(OpenSpecClient(root, fake_executor(root, broken_spec=True)))
|
| 87 |
+
assert snapshot["changes"][0]["id"] == "demo"
|
| 88 |
+
assert snapshot["specs"] == []
|
| 89 |
+
assert "invalid JSON" in snapshot["warnings"][0]["message"]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_snapshot_keeps_specs_when_one_artifact_is_unreadable(tmp_path):
|
| 93 |
+
root = fixture_root(tmp_path)
|
| 94 |
+
(root / "openspec" / "changes" / "demo" / "proposal.md").unlink()
|
| 95 |
+
snapshot = build_snapshot(OpenSpecClient(root, fake_executor(root)))
|
| 96 |
+
assert snapshot["specs"][0]["id"] == "alpha"
|
| 97 |
+
assert snapshot["changes"][0]["artifacts"] == []
|
| 98 |
+
assert "Could not read" in snapshot["warnings"][0]["message"]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_safe_read_refuses_paths_and_symlinks_outside_root(tmp_path):
|
| 102 |
+
root = fixture_root(tmp_path / "project")
|
| 103 |
+
outside = tmp_path / "secret.md"
|
| 104 |
+
outside.write_text("secret", encoding="utf-8")
|
| 105 |
+
with pytest.raises(BrowserError, match="outside"):
|
| 106 |
+
safe_read(root, str(outside))
|
| 107 |
+
link = root / "openspec" / "linked.md"
|
| 108 |
+
link.symlink_to(outside)
|
| 109 |
+
with pytest.raises(BrowserError, match="outside"):
|
| 110 |
+
safe_read(root, str(link))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_parser_extracts_headings_lists_and_tasks():
|
| 114 |
+
assert parse_markdown("# A\n\nText\n\n- item\n- [x] done") == [
|
| 115 |
+
{"type": "heading", "level": 1, "text": "A"},
|
| 116 |
+
{"type": "paragraph", "text": "Text"},
|
| 117 |
+
{"type": "list", "text": "item"},
|
| 118 |
+
{"type": "task", "done": True, "text": "done"},
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_browser_client_uses_safe_dom_rendering():
|
| 123 |
+
script = Path("scripts/requirements_browser.js").read_text(encoding="utf-8")
|
| 124 |
+
assert "textContent" in script
|
| 125 |
+
assert "innerHTML" not in script
|
| 126 |
+
html = Path("scripts/requirements_browser.html").read_text(encoding="utf-8")
|
| 127 |
+
assert 'id="specNav"' in html
|
| 128 |
+
assert 'id="changeNav"' in html
|
| 129 |
+
assert 'id="search"' in html
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def running_server(tmp_path: Path):
|
| 133 |
+
page = tmp_path / "page.html"
|
| 134 |
+
page.write_text("<!doctype html><title>Requirements</title>", encoding="utf-8")
|
| 135 |
+
server = create_server(lambda: {"summary": {"specifications": 1}}, 0, page)
|
| 136 |
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
| 137 |
+
thread.start()
|
| 138 |
+
return server, thread
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_http_serves_page_snapshot_and_rejects_writes_or_paths(tmp_path):
|
| 142 |
+
server, thread = running_server(tmp_path)
|
| 143 |
+
base = f"http://127.0.0.1:{server.server_port}"
|
| 144 |
+
try:
|
| 145 |
+
with urllib.request.urlopen(base + "/") as response:
|
| 146 |
+
assert response.status == 200
|
| 147 |
+
assert b"Requirements" in response.read()
|
| 148 |
+
with urllib.request.urlopen(base + "/app.css") as response:
|
| 149 |
+
assert response.headers.get_content_type() == "text/css"
|
| 150 |
+
assert b":focus-visible" in response.read()
|
| 151 |
+
with urllib.request.urlopen(base + "/app.js") as response:
|
| 152 |
+
assert response.headers.get_content_type() == "text/javascript"
|
| 153 |
+
assert b"textContent" in response.read()
|
| 154 |
+
with urllib.request.urlopen(base + "/api/state") as response:
|
| 155 |
+
assert json.load(response)["summary"]["specifications"] == 1
|
| 156 |
+
for path in ("/etc/passwd", "/api/state?path=/etc/passwd"):
|
| 157 |
+
request = urllib.request.Request(base + path, method="POST" if path.startswith("/api") else "GET")
|
| 158 |
+
with pytest.raises(urllib.error.HTTPError) as error:
|
| 159 |
+
urllib.request.urlopen(request)
|
| 160 |
+
assert error.value.code in (404, 405)
|
| 161 |
+
finally:
|
| 162 |
+
server.shutdown()
|
| 163 |
+
thread.join()
|
| 164 |
+
server.server_close()
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_busy_port_has_actionable_error(tmp_path):
|
| 168 |
+
server, thread = running_server(tmp_path)
|
| 169 |
+
try:
|
| 170 |
+
with pytest.raises(BrowserError, match="--port PORT"):
|
| 171 |
+
create_server(lambda: {}, server.server_port, tmp_path / "page.html")
|
| 172 |
+
finally:
|
| 173 |
+
server.shutdown()
|
| 174 |
+
thread.join()
|
| 175 |
+
server.server_close()
|