diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7036b7..a036fbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: contents: read diff --git a/.gitignore b/.gitignore index 6bff469..755df94 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +coverage.json *.cover *.py.cover .hypothesis/ diff --git a/.opencode/commands/uf.finale.md b/.opencode/commands/uf.finale.md index b4f0232..04777b5 100644 --- a/.opencode/commands/uf.finale.md +++ b/.opencode/commands/uf.finale.md @@ -54,13 +54,13 @@ step. This checklist survives context compression and serves as the source of truth for workflow state. ``` -BRANCH= +BRANCH=opsx/taxonomy-and-discovery COMMIT= PR_NUMBER= PR_URL= CONFLICT_OPTION= -[ ] Step 1 — Branch Safety Gate +[x] Step 1 — Branch Safety Gate [ ] Step 2 — Check for Changes to Commit [ ] Step 3 — Generate and Confirm Commit Message [ ] Step 4 — Push to Remote diff --git a/.opencode/commands/uf.review-council.md b/.opencode/commands/uf.review-council.md index b8528d3..397a61c 100644 --- a/.opencode/commands/uf.review-council.md +++ b/.opencode/commands/uf.review-council.md @@ -21,21 +21,21 @@ description: Run the reviewer governance council to audit codebase or spec compl > **EXECUTION CHECKLIST** — Update each item using the > Edit tool as you complete it. Mark `[x]` when done. > -> - [ ] Phase 1a: Pre-flight checks -> - [ ] Phase 1b: Gaze quality analysis -> - [ ] Phase 1c: Review context discovery -> - [ ] Step 2: Divisor agent delegation (full branch diff) -> - [ ] Step 3: Finding consolidation -> - [ ] Step 4: Fix loop (iteration: _/3) -> - [ ] Step 5: Iteration limit check -> - [ ] Step 6: Final report -> - [ ] Step 7a: PR detection -> - [ ] Step 7b: Review state fetching -> - [ ] Step 7c: Pre-posting checks -> - [ ] Step 7d: Finding aggregation -> - [ ] Step 7e: Inline comment preparation -> - [ ] Step 7f: Human confirmation (MANDATORY GATE) -> - [ ] Step 7g: Post review +> - [x] Phase 1a: Pre-flight checks +> - [x] Phase 1b: Gaze quality analysis +> - [x] Phase 1c: Review context discovery +> - [x] Step 2: Divisor agent delegation (full branch diff) +> - [x] Step 3: Finding consolidation +> - [x] Step 4: Fix loop (iteration: 1/3) +> - [x] Step 5: Iteration limit check +> - [x] Step 6: Final report +> - [x] Step 7a: PR detection — no open PR found, skipping 7b-7g +> - [ ] Step 7b: Review state fetching (skipped) +> - [ ] Step 7c: Pre-posting checks (skipped) +> - [ ] Step 7d: Finding aggregation (skipped) +> - [ ] Step 7e: Inline comment preparation (skipped) +> - [ ] Step 7f: Human confirmation (MANDATORY GATE) (skipped) +> - [ ] Step 7g: Post review (skipped) ## User Input diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 7ae9876..d533667 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,5 +1,46 @@ + + # Snake Eyes Constitution +**parent_constitution**: unbound-force/unbound-force v1.2.0 + ## Core Principles ### I. Protocol Fidelity @@ -17,6 +58,12 @@ every language analyzer -- deviations break the platform. - When the protocol evolves (new methods, new fields), Snake Eyes MUST maintain backward compatibility with older Gaze versions through capability negotiation. +- Analysis of the same input tree with the same Snake Eyes + version and the same protocol version MUST produce + byte-identical JSON-RPC output. Responses MUST NOT + contain timestamps, random values, hostnames, or any + other environment-dependent data. JSON serialization + MUST be deterministic (stable key ordering). **Rationale**: Snake Eyes is not a standalone tool. It is one half of a two-process system. If the protocol contract @@ -103,6 +150,36 @@ a practical necessity: the JSON-RPC boundary provides a clean seam for integration testing without requiring a running Gaze process. +### V. Analysis Safety + +Snake Eyes analyzes arbitrary Python codebases. Analyzed +source code is untrusted input and MUST be treated as such. + +- Analysis MUST be strictly static. Snake Eyes MUST NOT + execute, import, or otherwise run analyzed code. All + inspection MUST use parse-level tools (`ast.parse`, + `symtable.symtable`, Astroid's AST inference) that do + not trigger code execution. +- Inputs MUST be validated and bounded. File paths MUST be + resolved and checked for traversal. Resource limits + (file size, AST depth, recursion budget) MUST prevent + analyzed code from causing denial of service. +- Every dependency is attack surface. The default answer + to adding a dependency is "do not add." Current + dependencies (astroid, radon, coverage.py) are justified + as established, maintained libraries that provide + capabilities impractical to reimplement. New dependencies + MUST be justified against this standard. +- CI actions MUST be pinned by commit SHA, not by mutable + tag. Supply-chain integrity is a structural property, + not a per-change review item. + +**Rationale**: Snake Eyes runs in developer and CI +environments on codebases it does not control. A +compromised or malicious project must not achieve code +execution through the analyzer. Static-only analysis is +not a limitation -- it is the security boundary. + ## Development Workflow - **Spec-First Development**: All changes that modify @@ -136,6 +213,11 @@ running Gaze process. ## Governance +This constitution extends the unbound-force org constitution +(v1.2.0). On matters where this document and the org +constitution conflict, the org constitution prevails and +this constitution MUST be amended to resolve the conflict. + This constitution is the highest-authority document for the Snake Eyes project. All development practices, pull request reviews, and architectural decisions MUST be consistent with @@ -155,9 +237,18 @@ the principles defined above. plan, tasks), the Constitution Check gate MUST verify that the proposed work aligns with all active principles. - **Upstream Alignment**: This constitution is subordinate - to Gaze's analyzer protocol specification. If a - constitutional principle conflicts with the protocol - spec, the protocol spec takes precedence and the - constitution MUST be amended. - -**Version**: 1.0.0 | **Ratified**: 2026-05-17 | **Last Amended**: 2026-05-17 + to Gaze's analyzer protocol specification. Snake Eyes + implements **protocol v1.1.0** (defined at + `unbound-force/gaze/docs/protocol.md`). Protocol + conformance MUST be verified by an automated conformance + suite (canned request/response pairs validated against + the protocol schema). When Gaze bumps the protocol + version, Snake Eyes MUST open an alignment issue within + one release cycle. +- **Conflict Resolution**: When two principles appear to + conflict in a specific scenario, the tradeoff MUST be + explicitly documented in the relevant spec or plan. No + principle has implicit priority over another; resolution + is context-dependent and requires written justification. + +**Version**: 1.1.0 | **Ratified**: 2026-05-17 | **Last Amended**: 2026-08-27 diff --git a/AGENTS.md b/AGENTS.md index ef87267..274a56c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,33 @@ when analyzing Python projects. - **Parent project**: [Gaze](https://github.com/unbound-force/gaze) (unbound-force) - **Organization**: [zero-dot-force](https://github.com/zero-dot-force) (labs incubator for unbound-force) +## Constitution (Highest Authority) + +The Snake Eyes constitution +(`.specify/memory/constitution.md`) is the highest-authority +document for this project. It extends the unbound-force org +constitution (v1.2.0) and pins Gaze protocol v1.1.0. +Constitution violations are CRITICAL severity and +non-negotiable. + +**Five principles:** + +1. **Protocol Fidelity** -- implement the Gaze analyzer + protocol precisely; deterministic output; deviations are + bugs +2. **Detection Accuracy** -- correctly identify all + observable side effects; ambiguity over omission; false + positives and false negatives are bugs +3. **Python-Native Analysis** -- use Python's own parsing + infrastructure (ast, symtable, astroid); do not + reimplement Python semantics +4. **Testability** -- every function testable in isolation; + coverage strategy required in every spec; protocol + conformance suites required +5. **Analysis Safety** -- analyzed source is untrusted + input; static analysis only; never execute analyzed + code; dependency necessity justified + ## Architecture Snake Eyes is a JSON-RPC server that Gaze spawns as a @@ -45,11 +72,11 @@ subprocess. The division of responsibility: AST analysis - **Scope analysis**: Python `symtable` module (stdlib) for global/nonlocal detection -- **Inference**: [Astroid](https://github.com/pylint-dev/astroid) +- **Inference** (planned): [Astroid](https://github.com/pylint-dev/astroid) for name resolution, type inference, cross-module imports -- **Complexity**: [radon](https://github.com/rubik/radon) +- **Complexity** (planned): [radon](https://github.com/rubik/radon) for cyclomatic complexity -- **Coverage**: [coverage.py](https://github.com/nedbat/coveragepy) +- **Coverage** (planned): [coverage.py](https://github.com/nedbat/coveragepy) for parsing coverage data - **Project management**: [uv](https://docs.astral.sh/uv/) - **Testing**: [pytest](https://docs.pytest.org/) @@ -62,7 +89,12 @@ snake-eyes/ │ ├── __init__.py │ ├── __main__.py # Entry point (snake-eyes --stdio) │ ├── server.py # JSON-RPC server (stdin/stdout) -│ └── protocol.py # Request/response types +│ ├── protocol.py # Request/response types +│ ├── discovery.py # File discovery (os.walk) +│ └── analysis/ +│ ├── __init__.py +│ ├── effects.py # 48-type SideEffectType taxonomy +│ └── models.py # Effect / FunctionRecord data models ├── tests/ ├── .github/workflows/ # CI: ruff, mypy, pytest gates ├── pyproject.toml @@ -71,33 +103,39 @@ snake-eyes/ ├── LICENSE └── NOTICE ``` -Planned later: `discovery.py`, `analysis/`, `complexity.py`, `coverage.py` (issues #3–#6). +Planned later: `complexity.py`, `coverage.py` (issues #5–#6). ## Shell Commands +Commands are derived from `.github/workflows/ci.yml`. +Do not rely on memory -- check the workflow file for the +current gates. + ```bash -# Install dependencies -uv sync +# Install dependencies (CI uses --locked) +uv sync --locked -# Run tests -uv run pytest +# Linting +uv run ruff check src/ tests/ -# Run tests with coverage -uv run pytest --cov=snake_eyes --cov-report=term-missing +# Format check (CI runs --check, not auto-format) +uv run ruff format --check src/ tests/ # Type checking uv run mypy src/ -# Linting -uv run ruff check src/ tests/ - -# Formatting -uv run ruff format src/ tests/ +# Run tests with coverage (85% is the protected gate) +uv run pytest --cov=snake_eyes --cov-report=term-missing --cov-fail-under=85 # Run snake-eyes in stdio mode (for testing with gaze) uv run snake-eyes --stdio ``` +**Protected gates** (agents MUST NOT lower these): +- `--cov-fail-under=85` -- minimum coverage percentage +- `ruff format --check` -- formatting must pass, not auto-fix +- `uv sync --locked` -- lockfile integrity + ## Core Mission - **Protocol fidelity**: Snake Eyes implements Gaze's @@ -165,9 +203,56 @@ Agents MUST NOT cross workflow phase boundaries: agents MUST run the same checks CI runs. Derive commands from workflow files, not memory. +## Spec Organization + +Snake Eyes uses two spec pipelines. Choose based on scope: + +| Criterion | Speckit (`specs/NNN-*/`) | OpenSpec (`openspec/changes/`) | +|---|---|---| +| **Scope** | Strategic: ≥3 tasks or cross-cutting | Tactical: 1–2 tasks, focused | +| **Artifacts** | spec, plan, tasks, checklists | proposal, design, tasks | +| **When to use** | New analysis capabilities, protocol changes, architecture | Bug fixes, small features, docs | +| **Example** | `specs/001-jsonrpc-prototype/` | `openspec/changes/fix-parse-error/` | + +**Ordering constraints**: spec artifacts MUST be committed +before implementation begins. Implementation commits MUST +NOT be in the same commit as spec changes. + +**Task Completion Bookkeeping**: When completing a task +from a tasks file, mark the checkbox `- [x]` immediately +-- not in a batch at the end. + +## Workflow Gates + +### Constitution Check + +Before implementation, verify alignment with all five +constitution principles. The check MUST name each principle +and give a PASS/FAIL verdict: + +1. Protocol Fidelity +2. Detection Accuracy +3. Python-Native Analysis +4. Testability +5. Analysis Safety + +### Review Council Gate + +Run `uf.review-council` before creating a PR. All +reviewers MUST APPROVE before the PR is eligible for +merge. Exempt: constitution amendments, docs-only changes, +emergency hotfixes. + +### CI Parity Gate + +Before marking any task complete, agents MUST run the same +checks CI runs. Derive commands from +`.github/workflows/ci.yml`, not from memory. + For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan +under `specs/` or `openspec/changes/`. ## Convention Packs @@ -181,3 +266,7 @@ before writing or reviewing code. - `.opencode/uf/packs/severity.md` - `.opencode/uf/packs/content.md` - `.opencode/uf/packs/content-custom.md` +- `.opencode/uf/packs/python.md` +- `.opencode/uf/packs/python-custom.md` +- `.opencode/uf/packs/ci.md` +- `.opencode/uf/packs/ci-custom.md` diff --git a/README.md b/README.md index 3b70da2..d954cf9 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ Snake Eyes is a Gaze-spawned subprocess. It speaks JSON-RPC 2.0 over stdin/stdou ## Current status (v0.1.0) -This release is the project scaffold and protocol lifecycle only: - | Method | Status | |--------|--------| | `initialize` | Implemented | | `shutdown` | Implemented | -| `discover`, `analyze`, `complexity`, `coverage` | Not implemented (`-32601`) | +| `discover` | Implemented | +| `analyze`, `complexity`, `coverage` | Not implemented (`-32601`) | -Capability flags advertised at handshake (`discover`, `test_mapping`, -`classify_signals`, `streaming`) are all `false`. Side-effect detection and -analysis dependencies (`astroid`, `radon`, `coverage.py`) are later issues. +Capability flags advertised at handshake: `discover` is `true`; +`test_mapping`, `classify_signals`, and `streaming` are `false`. +Side-effect detection and analysis dependencies (`astroid`, `radon`, +`coverage.py`) are later issues. ## Installation @@ -49,13 +49,18 @@ snake-eyes/ │ ├── __init__.py │ ├── __main__.py # Entry point (snake-eyes --stdio) │ ├── server.py # JSON-RPC server (stdin/stdout) -│ └── protocol.py # Request/response types +│ ├── protocol.py # Request/response types +│ ├── discovery.py # File discovery (os.walk) +│ └── analysis/ +│ ├── __init__.py +│ ├── effects.py # 48-type SideEffectType taxonomy +│ └── models.py # Effect / FunctionRecord data models ├── tests/ ├── pyproject.toml └── NOTICE ``` -Planned later: file discovery, analysis, complexity, and coverage (issues #3–#6). +Planned later: analysis engine, complexity, and coverage (issues #4–#6). ## License diff --git a/openspec/changes/taxonomy-and-discovery/.openspec.yaml b/openspec/changes/taxonomy-and-discovery/.openspec.yaml new file mode 100644 index 0000000..f05b045 --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-27 diff --git a/openspec/changes/taxonomy-and-discovery/design.md b/openspec/changes/taxonomy-and-discovery/design.md new file mode 100644 index 0000000..448c354 --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/design.md @@ -0,0 +1,55 @@ +## Context + +Gaze protocol v1.1.0 defines 48 canonical side-effect types (`internal/taxonomy/types.go`). The gaze-py reference implementation (Matt Peter, Apache 2.0) carries 38 of those 48 in `src/gaze_py/taxonomy/effects.py`. snake-eyes has permission to lift gaze-py code, and its `NOTICE` already attributes gaze-py. The scaffold change (unarchived) gives us `protocol.py` (`initialize_result`, `to_dict`, error codes) and `server.py` (`Server`, `DEFAULT_DISPATCH`, `RpcError`). + +This change adds the shared domain types every later analysis method needs, plus the optional `discover` method. No detection code is written — the 10 new enum members are vocabulary only. + +## Goals / Non-Goals + +**Goals:** +- Land the exact 48-type `SideEffectType` taxonomy with `TIER_MAP`, matching Gaze's canonical strings and tiers. +- Land protocol-shaped `Effect` and `FunctionRecord` dataclasses whose JSON keys match the v1.1.0 `analyze` payload. +- Land `discover()` for source/test file discovery with deterministic exclusion and classification rules. +- Wire the `discover` JSON-RPC method and flip `capabilities.discover` to `true`. + +**Non-Goals:** +- `analyze`, `complexity`, `coverage`, `test_mapping`, `classify_signals` implementations. +- Detection logic for any effect type (enum members and models only). +- gaze-py's internal `models.py` fields (`visibility`, `is_test`, `is_generator`, `complexity`, `id`, `Signal`, `Score`, `Summary`, quality types) — these are not part of the protocol payload. +- Go's full `./pkg/...` package-prefix semantics in `patterns`. + +## Decisions + +**D1 — Use `enum.StrEnum` for `SideEffectType`.** +Python 3.11+ `StrEnum` members are `str` subclasses, so `json.dumps` serializes them as bare strings without a custom encoder. This matches gaze-py's existing choice and satisfies "emit canonical names only". Alternative: `enum.Enum` with explicit `.value` coercion — rejected as needless friction. + +**D2 — Lift gaze-py `effects.py`, then add 10 members.** +Keep the 38 existing member names and `TIER_MAP` entries verbatim; append the 10 missing members with the exact names/tiers from the issue (`ErrorSignal` P0; `GeneratorYield`, `ContainerMutation`, `StreamOutput` P1; `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, `MonkeyPatch` P2). `TIER_MAP` is a gatekeeping value: existing tier assignments are not reclassified, and no local aliases (e.g. `ArgumentMutation`) are added as members — snake-eyes emits canonical names only. gaze-py's source files carry no per-file copyright header, so we add a provenance header to the lifted file crediting gaze-py / Matt Peter / Apache 2.0 (satisfying the "preserve copyright header" requirement; the `NOTICE` covers repository-level attribution). + +**D3 — Reshape `Effect` to the protocol payload, not gaze-py's `SideEffect`.** +gaze-py's `SideEffect` has `id`, `tier`, and `target` (qualified function name). The protocol wants `type` (canonical string), `description`, and optional `location`/`target`/`detail`. `Effect.type` is a `str` (not the enum) because the wire format is the canonical string; the detector (later) converts `SideEffectType` → `str` via the `StrEnum` value. `detail` is opaque `dict | None`. + +**D4 — Omit `None` optionals from JSON.** +`function_record_to_dict` drops `location`, `target`, and `detail` when `None` (and drops `side_effects` when empty is not needed since it defaults to `()` and always serializes as a list). Rationale: Gaze's optional fields stay optional; the protocol forbids emitting `null` for omitted fields (same rule already applied to error `data` in `protocol.py`). This keeps the analyzer's own `to_dict` independent of the RPC-envelope `to_dict`. + +**D5 — Discovery via `os.walk` with a fixed exclusion set and no symlink following.** +Walk with `followlinks=False` so directory symlinks are never descended (cycle safety); file symlinks are skipped entirely (simplest deterministic behavior). Only `.py` files are returned (`.pyi` stubs excluded). Paths are POSIX-relative to `root_path`. Test classification: filename starts with `test_`, ends with `_test.py`, or any path component is `tests`/`test`. A file matching test rules goes to `test_files` only. `patterns` follows Gaze's `["./..."]` convention: `None`/`[]`/`["..."]`/`["./..."]` → whole tree; a relative directory pattern walks that subtree; a glob (`**/*.py`) is resolved relative to root; Go's `./pkg/...` package semantics are reduced to "directory prefix + recursive". + +**D6 — `discover` handler maps `FileNotFoundError` to `-32602`.** +The `Server` already catches `RpcError` and routes to the matching JSON-RPC error. The `discover` handler validates params, calls `discover()`, catches `FileNotFoundError`, and raises `RpcError(INVALID_PARAMS, ...)`. The handler returns a plain dict `{"source_files": [...], "test_files": [...]}` (lists, not tuples), so `JsonRpcSuccess` serializes directly. `initialize_result` flips only `capabilities.discover` to `true`; the other three flags stay `false`. + +## Risks / Trade-offs + +- [Drift if Gaze adds a 49th type] → `TIER_MAP` is the single authoritative mapping and is tested to be complete (`len == 48` and every member has an entry); a future taxonomy bump is a one-file change. +- [Wrong tier for the 10 new types] → pinned by a table-driven test asserting each new type's exact tier. +- [Symlink/cycle edge cases in discovery] → deterministic "skip all symlinks" rule eliminates the class of bug; documented in spec. +- [Tuple vs list on the wire] → `discover` handler and `discovery.discover()` return tuples internally, but the RPC handler converts to lists so JSON is always arrays. +- [Copyright provenance] → gaze-py has no per-file headers; we add an explicit provenance header and rely on the existing `NOTICE`. If a future gaze-py release adds headers, re-lift and preserve them verbatim. + +## Migration Plan + +No runtime migration — this is additive. The only behavior change to existing code is `initialize` now reporting `"discover": true` (from `false`), which is forward-compatible with Gaze (an optional capability going from off to on). No rollback path beyond reverting the commit. + +## Open Questions + +None. Every previously-open decision (tier assignments, discovery exclusion set, symlink policy, `detail`/`target`/`location` omission) is resolved in the issue and codified in Decisions D1–D6. diff --git a/openspec/changes/taxonomy-and-discovery/proposal.md b/openspec/changes/taxonomy-and-discovery/proposal.md new file mode 100644 index 0000000..48dace0 --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/proposal.md @@ -0,0 +1,43 @@ +## Why + +snake-eyes currently only has the JSON-RPC scaffold (`initialize`/`shutdown`) and no domain types. Every later analysis method — `analyze`, `complexity`, `coverage`, `test_mapping` — depends on the shared side-effect taxonomy, the protocol data models, and file discovery, none of which exist yet. Gaze's canonical taxonomy is 48 types, but the gaze-py reference implementation carries only 38; the analyzer would emit non-canonical or missing effect names without this change. The optional `discover` protocol method is also still `false` in `initialize`. + +## What Changes + +- Add `src/snake_eyes/analysis/effects.py`: lift gaze-py's 38-value `SideEffectType` `StrEnum`, `Tier` enum, and `TIER_MAP`, then add the 10 missing canonical types to reach the full 48, with a provenance header crediting gaze-py (Matt Peter, Apache 2.0). +- Add `src/snake_eyes/analysis/models.py`: protocol-shaped `Effect` and `FunctionRecord` frozen dataclasses plus a `function_record_to_dict` serialization helper. +- Add `src/snake_eyes/discovery.py`: `discover()` and `DiscoveryResult` for source/test file discovery with directory exclusion and test classification. +- Add `src/snake_eyes/analysis/__init__.py` (thin re-export of `SideEffectType` and `TIER_MAP`). +- Wire the `discover` JSON-RPC method onto the server and flip `capabilities.discover` to `true` in `initialize`; invalid/missing `root_path` maps to `-32602`. +- Add `tests/test_effects.py`, `tests/test_models.py`, `tests/test_discovery.py`, and `tests/test_discover_method.py`. + +Out of scope (later issues): the `analyze`, `complexity`, `coverage`, `test_mapping`, and `classify_signals` methods; *detection* of any effect type (this change adds enum members and models only); gaze-py `models.py` fields that are not in the protocol payload. + +## Capabilities + +### New Capabilities +- `effects`: the 48-value `SideEffectType` `StrEnum`, the `Tier` enum, and the authoritative `TIER_MAP`. +- `models`: the `Effect` and `FunctionRecord` dataclasses and the `function_record_to_dict` serialization helper. +- `discovery`: the `discover()` function and `DiscoveryResult` (source/test file discovery rules). +- `discover-method`: the JSON-RPC `discover` method (params/result schema, error mapping) and the `initialize.capabilities.discover` flag. + +### Modified Capabilities +None — `openspec/specs/` has no archived capabilities; the `scaffold-and-protocol` change (which first defined `initialize_result` and the server dispatch) is still unarchived. The `discover` capability flag flip and the new method are captured under the new `discover-method` capability above. + +### Removed Capabilities +None. + +## Impact + +- New files: `src/snake_eyes/analysis/__init__.py`, `src/snake_eyes/analysis/effects.py`, `src/snake_eyes/analysis/models.py`, `src/snake_eyes/discovery.py`, and four test modules. +- Modified files: `src/snake_eyes/protocol.py` (flip `discover` to `true` in `initialize_result`), `src/snake_eyes/server.py` (add `discover` handler to `DEFAULT_DISPATCH`). +- No new runtime dependencies — stdlib only (`enum`, `dataclasses`, `os`, `fnmatch`, `pathlib`). The existing `NOTICE` already attributes gaze-py; the lifted file carries its own provenance header. +- The 10 new enum members carry no detection behavior; nothing in the existing scaffold changes semantics except the one capability flag. + +## Constitution Alignment + +- **I. Protocol Fidelity** — Emits only canonical Gaze type strings (`"ReturnValue"`, not aliases); `Effect`/`FunctionRecord` match the v1.1.0 `analyze` payload keys; `discover` params/result and `-32602` mapping follow the protocol; `initialize` flips exactly one flag. +- **II. Detection Accuracy** — Enum members only; no detection, so no false positives/negatives are introduced. Ambiguity is avoided by adding the full 48-type vocabulary now. +- **III. Python-Native Analysis** — Uses `enum.StrEnum`, `dataclasses`, and `os.walk` (stdlib); no reimplementation of Python semantics. +- **IV. Testability** — Coverage strategy is specified now (`effects.py` 100%, `models.py` 100%, `discovery.py` 95%+; the new `discover` handler in `server.py` and the `initialize_result` flag flip are exercised by the JSON-RPC roundtrip test in task 5.4, all under the unchanged 85% aggregate gate). +- **V. Analysis Safety** — Static analysis only; discovery walks the filesystem and never executes analyzed code. diff --git a/openspec/changes/taxonomy-and-discovery/specs/discover-method/spec.md b/openspec/changes/taxonomy-and-discovery/specs/discover-method/spec.md new file mode 100644 index 0000000..d0102e1 --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/specs/discover-method/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: discover method params +The `discover` method SHALL accept a `params` object with a required string `root_path` and an optional `patterns` array of strings. If `params` is absent, is not an object, or lacks a string `root_path`, the server SHALL respond with `-32602` (invalid params). + +#### Scenario: discover accepts root_path and patterns +- **WHEN** a `discover` request is sent with `params: {"root_path": "/abs/path", "patterns": ["./..."]}` +- **THEN** a valid result is returned + +#### Scenario: discover accepts missing patterns +- **WHEN** a `discover` request is sent with `params: {"root_path": "/abs/path"}` +- **THEN** a valid result is returned + +#### Scenario: discover rejects missing root_path +- **WHEN** a `discover` request is sent with `params: {}` +- **THEN** a `-32602` error is returned + +#### Scenario: discover rejects non-string root_path +- **WHEN** a `discover` request is sent with `params: {"root_path": 123}` +- **THEN** a `-32602` error is returned + +#### Scenario: discover rejects non-object params +- **WHEN** a `discover` request is sent with array `params` +- **THEN** a `-32602` error is returned + +#### Scenario: discover rejects non-array patterns +- **WHEN** a `discover` request is sent with `params: {"root_path": "/abs/path", "patterns": "src"}` +- **THEN** a `-32602` error is returned + +### Requirement: discover result schema +The `discover` method SHALL return a result object with exactly `source_files` (array of strings) and `test_files` (array of strings), each relative to `root_path`, and each list ordered deterministically (lexicographic by POSIX path). + +#### Scenario: result shape +- **WHEN** a `discover` request is answered against a project with `src/foo.py` and `tests/test_foo.py` +- **THEN** the result is `{"source_files": ["src/foo.py"], "test_files": ["tests/test_foo.py"]}` + +### Requirement: discover maps FileNotFoundError to -32602 +The `discover` method SHALL translate a missing or non-directory `root_path` into a `-32602` invalid params error. + +#### Scenario: nonexistent root via RPC +- **WHEN** a `discover` request is sent with a nonexistent `root_path` +- **THEN** a `-32602` error is returned + +### Requirement: initialize advertises discover capability +The `initialize` method SHALL report `capabilities.discover` as `true` and leave `test_mapping`, `classify_signals`, and `streaming` as `false`. + +#### Scenario: discover flag flipped +- **WHEN** `initialize` is called +- **THEN** `capabilities.discover` is `true` and the other three capability flags are `false` diff --git a/openspec/changes/taxonomy-and-discovery/specs/discovery/spec.md b/openspec/changes/taxonomy-and-discovery/specs/discovery/spec.md new file mode 100644 index 0000000..c445081 --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/specs/discovery/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: DiscoveryResult dataclass +The system SHALL define a frozen `DiscoveryResult` dataclass with fields `source_files: tuple[str, ...]` and `test_files: tuple[str, ...]`. + +#### Scenario: DiscoveryResult is frozen +- **WHEN** an attempt is made to assign to a field of a `DiscoveryResult` instance +- **THEN** a `FrozenInstanceError` (or equivalent) is raised + +### Requirement: discover only Python files +The `discover(root_path: str, patterns: list[str] | None = None)` function SHALL return only `.py` files, excluding `.pyi` stubs, and SHALL express returned paths relative to `root_path` with POSIX separators. + +#### Scenario: Non-Python files excluded +- **WHEN** `discover` runs over a tree containing `a.py`, `b.txt`, and `c.pyi` +- **THEN** only `a.py` appears in the results + +#### Scenario: Paths are relative and POSIX +- **WHEN** `discover` runs over a tree rooted at a directory containing `src/foo.py` +- **THEN** the result path is `src/foo.py` (no leading `./`, forward slashes) + +### Requirement: patterns follow Gaze ./... convention +The `discover` function SHALL treat `patterns` of `None`, `[]`, `["./..."]`, or `["..."]` as "walk the whole tree". A relative directory pattern (e.g. `src` or `src/`) SHALL walk that subtree. A glob pattern (e.g. `**/*.py`) SHALL be applied relative to root. Go's `./pkg/...` package semantics SHALL be reduced to "directory prefix + recursive". + +#### Scenario: Default patterns walk whole tree +- **WHEN** `discover` is called with `patterns=None` over a tree with files in `src/` and `tests/` +- **THEN** files from both subtrees are returned + +#### Scenario: Directory pattern restricts to subtree +- **WHEN** `discover` is called with `patterns=["src"]` +- **THEN** only files under `src/` are returned + +#### Scenario: Glob pattern matches relative to root +- **WHEN** `discover` is called with `patterns=["**/*.py"]` +- **THEN** all `.py` files under root are returned + +### Requirement: test file classification +The `discover` function SHALL classify a file as a test file if its filename starts with `test_`, or ends with `_test.py`, or any of its path components is `tests` or `test`. A file SHALL NOT appear in both lists; a file matching test rules goes to `test_files` only. + +#### Scenario: test_ prefix classified as test +- **WHEN** a tree contains `test_foo.py` at the root +- **THEN** `test_foo.py` appears in `test_files` and not `source_files` + +#### Scenario: _test suffix classified as test +- **WHEN** a tree contains `foo_test.py` +- **THEN** `foo_test.py` appears in `test_files` + +#### Scenario: tests directory component classified as test +- **WHEN** a tree contains `tests/test_foo.py` +- **THEN** `tests/test_foo.py` appears in `test_files` + +#### Scenario: No file in both lists +- **WHEN** `discover` runs over any tree +- **THEN** `source_files` and `test_files` are disjoint + +### Requirement: directory exclusion +The `discover` function SHALL not descend into a directory whose name is any of `.venv`, `venv`, `env`, `.env`, `__pycache__`, `.git`, `.hg`, `.svn`, `dist`, `build`, `.tox`, `.nox`, `.mypy_cache`, `.ruff_cache`, `.pytest_cache`, `node_modules`, `.eggs`, or any name ending in `.egg-info`. + +#### Scenario: .venv excluded +- **WHEN** a tree contains `.venv/lib/python3.12/site.py` +- **THEN** `site.py` does not appear in the results + +#### Scenario: __pycache__ excluded +- **WHEN** a tree contains `__pycache__/x.py` +- **THEN** `x.py` does not appear in the results + +### Requirement: symlink handling +The `discover` function SHALL not follow directory symlinks, and SHALL skip file symlinks entirely (do not follow them). + +#### Scenario: directory symlink not followed +- **WHEN** a tree contains a directory symlink pointing back to an ancestor +- **THEN** `discover` terminates and does not recurse into the symlink + +#### Scenario: file symlink skipped +- **WHEN** a tree contains a regular file `a.py` and a file symlink `link.py` pointing to `a.py` +- **THEN** only `a.py` appears in the results and `link.py` is not followed + +### Requirement: missing root_path raises FileNotFoundError +The `discover` function SHALL raise `FileNotFoundError` with the path in the message when `root_path` is missing or is not a directory. + +#### Scenario: missing root raises +- **WHEN** `discover` is called with a nonexistent `root_path` +- **THEN** a `FileNotFoundError` whose message contains the path is raised + +#### Scenario: non-directory root raises +- **WHEN** `discover` is called with a `root_path` that is a regular file +- **THEN** a `FileNotFoundError` is raised + +### Requirement: empty project returns empty result +The `discover` function SHALL return `DiscoveryResult((), ())` for an empty project without error. + +#### Scenario: empty project +- **WHEN** `discover` runs over an empty directory +- **THEN** both `source_files` and `test_files` are empty tuples + +### Requirement: deterministic output ordering +The `discover` function SHALL return `source_files` and `test_files` in deterministic sorted order, each list ordered lexicographically by POSIX path, so that discovering the same tree twice yields byte-identical results regardless of filesystem traversal order. + +#### Scenario: results are sorted lexicographically +- **WHEN** `discover` runs over a tree containing `b.py`, `a.py`, and `tests/c_test.py` +- **THEN** `source_files` is `("a.py", "b.py")` and `test_files` is `("tests/c_test.py",)`, each in lexicographic order diff --git a/openspec/changes/taxonomy-and-discovery/specs/effects/spec.md b/openspec/changes/taxonomy-and-discovery/specs/effects/spec.md new file mode 100644 index 0000000..0e764b9 --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/specs/effects/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: 48-value SideEffectType enum +The system SHALL define `SideEffectType` as an `enum.StrEnum` with exactly 48 members whose names and string values are the canonical Gaze type strings. The members SHALL be, by tier: P0 `ReturnValue`, `ErrorReturn`, `SentinelError`, `ReceiverMutation`, `PointerArgMutation`, `ErrorSignal`; P1 `SliceMutation`, `MapMutation`, `GlobalMutation`, `WriterOutput`, `HTTPResponseWrite`, `ChannelSend`, `ChannelClose`, `DeferredReturnMutation`, `GeneratorYield`, `ContainerMutation`, `StreamOutput`; P2 `FileSystemWrite`, `FileSystemDelete`, `FileSystemMeta`, `DatabaseWrite`, `DatabaseTransaction`, `GoroutineSpawn`, `Panic`, `CallbackInvocation`, `LogWrite`, `ContextCancellation`, `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, `MonkeyPatch`; P3 `StdoutWrite`, `StderrWrite`, `EnvVarMutation`, `MutexOp`, `WaitGroupOp`, `AtomicOp`, `TimeDependency`, `ProcessExit`, `RecoverBehavior`; P4 `ReflectionMutation`, `UnsafeMutation`, `CgoCall`, `FinalizerRegistration`, `SyncPoolOp`, `ClosureCaptureMutation`. + +#### Scenario: Enum has exactly 48 members +- **WHEN** `len(SideEffectType)` is computed +- **THEN** the result is 48 + +#### Scenario: Existing P0 members are retained +- **WHEN** the P0 tier members are inspected +- **THEN** `ReturnValue`, `ErrorReturn`, `SentinelError`, `ReceiverMutation`, and `PointerArgMutation` are present as members + +#### Scenario: The 10 added members are present +- **WHEN** the enum is inspected for the added types +- **THEN** `ErrorSignal`, `GeneratorYield`, `ContainerMutation`, `StreamOutput`, `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, and `MonkeyPatch` are present as members + +#### Scenario: Member values are canonical strings +- **WHEN** a member such as `SideEffectType.ReturnValue` is serialized +- **THEN** its value is the exact string `"ReturnValue"` with no language-neutral alias + +### Requirement: Tier enum +The system SHALL define a `Tier` enum with exactly five members `P0`, `P1`, `P2`, `P3`, `P4`, each with a string value equal to its name. + +#### Scenario: Five tiers exist +- **WHEN** `len(Tier)` is computed +- **THEN** the result is 5 + +#### Scenario: Tier values match names +- **WHEN** `Tier.P0.value` is evaluated +- **THEN** the result is `"P0"` + +### Requirement: TIER_MAP completeness and correctness +The system SHALL define `TIER_MAP: dict[SideEffectType, Tier]` mapping every `SideEffectType` member to exactly one `Tier`. Every member SHALL have an entry, and the 10 added members SHALL map to their specified tiers: `ErrorSignal` → P0; `GeneratorYield`, `ContainerMutation`, `StreamOutput` → P1; `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, `MonkeyPatch` → P2. + +#### Scenario: Every member has a tier entry +- **WHEN** `TIER_MAP` is compared against all `SideEffectType` members +- **THEN** every member is a key in `TIER_MAP` + +#### Scenario: Added members map to specified tiers +- **WHEN** the tier of each added member is looked up in `TIER_MAP` +- **THEN** `ErrorSignal` is P0, `GeneratorYield`/`ContainerMutation`/`StreamOutput` are P1, and `AsyncGeneratorYield`/`MetaprogrammingMutation`/`DescriptorEffect`/`ResourceManagement`/`ImportSideEffect`/`MonkeyPatch` are P2 + +#### Scenario: Existing tier assignments are unchanged +- **WHEN** the tier of each of the original 38 members is looked up in `TIER_MAP` +- **THEN** the assignment matches the lifted gaze-py mapping (e.g. `ReturnValue` → P0, `FileSystemWrite` → P2, `StdoutWrite` → P3, `ReflectionMutation` → P4) + +### Requirement: Provenance header on lifted file +The system SHALL retain provenance attribution on `effects.py` crediting gaze-py (Matt Peter) under Apache 2.0, since the taxonomy is lifted from gaze-py. + +#### Scenario: Attribution is present +- **WHEN** `src/snake_eyes/analysis/effects.py` is read +- **THEN** the file's header or module docstring references gaze-py and the Apache 2.0 license diff --git a/openspec/changes/taxonomy-and-discovery/specs/models/spec.md b/openspec/changes/taxonomy-and-discovery/specs/models/spec.md new file mode 100644 index 0000000..375a30e --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/specs/models/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Effect dataclass +The system SHALL define a frozen `Effect` dataclass with fields `type: str` (the canonical `SideEffectType` string value, e.g. `"ReturnValue"`), `description: str` (required human-readable text), `location: str | None = None` (as `"file.py:25:5"` relative to root), `target: str | None = None` (attribute/parameter/exception name), and `detail: dict[str, Any] | None = None` (opaque Python-specific metadata). + +#### Scenario: Effect is frozen +- **WHEN** an attempt is made to assign a new value to a field of an `Effect` instance +- **THEN** a `FrozenInstanceError` (or equivalent) is raised + +#### Scenario: type holds the canonical string +- **WHEN** an `Effect` is constructed with `type="ReturnValue"` +- **THEN** `effect.type` equals the exact string `"ReturnValue"` + +### Requirement: FunctionRecord dataclass +The system SHALL define a frozen `FunctionRecord` dataclass with fields `name: str`, `package: str` (dotted module path, e.g. `"snake_eyes.server"`), `file: str` (path relative to `root_path` with POSIX slashes), `line: int` (1-based def line), and `side_effects: tuple[Effect, ...] = ()`. It SHALL NOT include gaze-py-only fields (`visibility`, `is_test`, `is_generator`, `complexity`, `id`). + +#### Scenario: FunctionRecord is frozen +- **WHEN** an attempt is made to assign a new value to a field of a `FunctionRecord` instance +- **THEN** a `FrozenInstanceError` (or equivalent) is raised + +#### Scenario: side_effects defaults to empty tuple +- **WHEN** a `FunctionRecord` is constructed without `side_effects` +- **THEN** `record.side_effects` equals `()` + +#### Scenario: gaze-py-only fields are absent +- **WHEN** `dataclasses.fields(FunctionRecord)` is inspected +- **THEN** none of `visibility`, `is_test`, `is_generator`, `complexity`, or `id` are present as field names + +### Requirement: function_record_to_dict serialization +The system SHALL define `function_record_to_dict(record: FunctionRecord) -> dict[str, Any]` returning a dict with keys `name`, `package`, `file`, `line`, and `side_effects`. Each `Effect` in `side_effects` SHALL serialize with keys `type` and `description`, plus `location`, `target`, and `detail` only when non-`None`. + +#### Scenario: None optionals are omitted +- **WHEN** a record whose effects have `location`, `target`, and `detail` all `None` is serialized +- **THEN** the resulting dict contains no `location`, `target`, or `detail` keys + +#### Scenario: Present optionals are included +- **WHEN** a record whose effect has `location="file.py:25:5"`, `target="x"`, and `detail={"k": "v"}` is serialized +- **THEN** the effect dict contains `location` equal to `"file.py:25:5"`, `target` equal to `"x"`, and `detail` equal to `{"k": "v"}` + +#### Scenario: side_effects serializes as a list +- **WHEN** a record with two effects is serialized +- **THEN** the `side_effects` value is a list of length 2 + +#### Scenario: type serializes as a canonical string +- **WHEN** a record with a `ReturnValue` effect is serialized +- **THEN** the effect's `type` key equals the string `"ReturnValue"` diff --git a/openspec/changes/taxonomy-and-discovery/tasks.md b/openspec/changes/taxonomy-and-discovery/tasks.md new file mode 100644 index 0000000..cdccdc9 --- /dev/null +++ b/openspec/changes/taxonomy-and-discovery/tasks.md @@ -0,0 +1,39 @@ +## 1. Effect taxonomy + +- [x] 1.1 Create `src/snake_eyes/analysis/__init__.py` re-exporting `SideEffectType` and `TIER_MAP` (thin) +- [x] 1.2 Create `src/snake_eyes/analysis/effects.py` by lifting gaze-py's `effects.py` (38 `SideEffectType` members, `Tier` enum, `TIER_MAP`) with a provenance header crediting gaze-py / Matt Peter / Apache 2.0 +- [x] 1.3 Add the 10 missing members (`ErrorSignal` P0; `GeneratorYield`, `ContainerMutation`, `StreamOutput` P1; `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, `MonkeyPatch` P2) and their `TIER_MAP` entries + +## 2. Data models + +- [x] 2.1 Create `src/snake_eyes/analysis/models.py` with frozen `Effect` and `FunctionRecord` dataclasses (protocol-shaped, no gaze-py-only fields) +- [x] 2.2 Implement `function_record_to_dict` omitting `None` optionals (`location`, `target`, `detail`) + +## 3. File discovery + +- [x] 3.1 Create `src/snake_eyes/discovery.py` with frozen `DiscoveryResult` and `discover(root_path, patterns=None)` +- [x] 3.2 Implement `.py`-only discovery, POSIX-relative paths, `./...` pattern convention, directory exclusion list, and deterministic lexicographic sorting of both result lists +- [x] 3.3 Implement test classification (`test_` prefix, `_test.py` suffix, `tests`/`test` path component) with disjoint lists +- [x] 3.4 Implement symlink policy (skip directory and file symlinks) and `FileNotFoundError` on missing/non-directory root + +## 4. Wire discover method + +- [x] 4.1 Add a `discover` handler to `server.py` (`DEFAULT_DISPATCH`) that validates params and maps `FileNotFoundError` to `RpcError(INVALID_PARAMS)` +- [x] 4.2 Flip `capabilities.discover` to `true` in `protocol.py` `initialize_result` (leave other three flags `false`) + +## 5. Tests + +- [x] 5.1 Create `tests/test_effects.py` (48 members; every member in `TIER_MAP`; 10 new types' tiers; existing P0 set present) +- [x] 5.2 Create `tests/test_models.py` (`function_record_to_dict` omits None optionals; `type` canonical; frozen assignment raises) +- [x] 5.3 Create `tests/test_discovery.py` using `tmp_path` (src/tests split; `test_foo.py` root; `foo_test.py`; `.venv`/`__pycache__` excluded; sorted lexicographic output; empty project; missing root raises) +- [x] 5.4 Create `tests/test_discover_method.py` (JSON-RPC roundtrip against a temp project; `initialize` reports `"discover": true`) + +## 6. Verification + +- [x] 6.1 Run `uv run ruff check src/ tests/` and `uv run ruff format --check src/ tests/` +- [x] 6.2 Run `uv run mypy src/` +- [x] 6.3 Run `uv run pytest --cov=snake_eyes --cov-report=term-missing --cov-fail-under=85` +- [x] 6.4 Manually verify `uv run snake-eyes --stdio` answers `initialize` with `"discover": true` and a `discover` request against a temp project + + + diff --git a/src/snake_eyes/analysis/__init__.py b/src/snake_eyes/analysis/__init__.py new file mode 100644 index 0000000..20e77a8 --- /dev/null +++ b/src/snake_eyes/analysis/__init__.py @@ -0,0 +1,8 @@ +"""Analysis package: the effect taxonomy public API.""" + +from __future__ import annotations + +from .effects import TIER_MAP as TIER_MAP +from .effects import SideEffectType as SideEffectType + +__all__ = ["SideEffectType", "TIER_MAP"] diff --git a/src/snake_eyes/analysis/effects.py b/src/snake_eyes/analysis/effects.py new file mode 100644 index 0000000..fd9d388 --- /dev/null +++ b/src/snake_eyes/analysis/effects.py @@ -0,0 +1,156 @@ +"""Side effect type taxonomy for snake-eyes. + +Defines the canonical 48-value ``SideEffectType`` enum, the 5-tier ``Tier`` +enum, and the ``TIER_MAP`` mapping each effect type to its tier. + +Portions of this module are derived from gaze-py +(https://github.com/mpeter/gaze-py), Copyright Matt Peter, licensed under +Apache 2.0. The original 38-value taxonomy is extended with 10 additional +Python-specific effect types. Tier assignments are fixed by the Gaze +universal taxonomy and MUST NOT be configurable. +""" + +from __future__ import annotations + +import enum + + +class Tier(enum.Enum): + """Priority tier for a side effect type. + + Tiers determine the detection requirement level (P0 = zero false + negatives). The tier names and values are fixed by the Gaze universal + taxonomy. + """ + + P0 = "P0" + P1 = "P1" + P2 = "P2" + P3 = "P3" + P4 = "P4" + + +class SideEffectType(enum.StrEnum): + """Canonical 48-value side effect type taxonomy. + + Values are retained verbatim from the Go gaze taxonomy to preserve JSON + schema compatibility. Python-specific detection uses language-appropriate + patterns, but the type string remains unchanged. + """ + + # --- P0: Must Detect (6) --- + ReturnValue = "ReturnValue" + ErrorReturn = "ErrorReturn" + SentinelError = "SentinelError" + ReceiverMutation = "ReceiverMutation" + PointerArgMutation = "PointerArgMutation" + ErrorSignal = "ErrorSignal" + + # --- P1: High Value (11) --- + SliceMutation = "SliceMutation" + MapMutation = "MapMutation" + GlobalMutation = "GlobalMutation" + WriterOutput = "WriterOutput" + HTTPResponseWrite = "HTTPResponseWrite" + ChannelSend = "ChannelSend" + ChannelClose = "ChannelClose" + DeferredReturnMutation = "DeferredReturnMutation" + GeneratorYield = "GeneratorYield" + ContainerMutation = "ContainerMutation" + StreamOutput = "StreamOutput" + + # --- P2: Important (16) --- + FileSystemWrite = "FileSystemWrite" + FileSystemDelete = "FileSystemDelete" + FileSystemMeta = "FileSystemMeta" + DatabaseWrite = "DatabaseWrite" + DatabaseTransaction = "DatabaseTransaction" + GoroutineSpawn = "GoroutineSpawn" + Panic = "Panic" + CallbackInvocation = "CallbackInvocation" + LogWrite = "LogWrite" + ContextCancellation = "ContextCancellation" + AsyncGeneratorYield = "AsyncGeneratorYield" + MetaprogrammingMutation = "MetaprogrammingMutation" + DescriptorEffect = "DescriptorEffect" + ResourceManagement = "ResourceManagement" + ImportSideEffect = "ImportSideEffect" + MonkeyPatch = "MonkeyPatch" + + # --- P3: Nice to Have (9) --- + StdoutWrite = "StdoutWrite" + StderrWrite = "StderrWrite" + EnvVarMutation = "EnvVarMutation" + MutexOp = "MutexOp" + WaitGroupOp = "WaitGroupOp" + AtomicOp = "AtomicOp" + TimeDependency = "TimeDependency" + ProcessExit = "ProcessExit" + RecoverBehavior = "RecoverBehavior" + + # --- P4: Exotic (6) --- + ReflectionMutation = "ReflectionMutation" + UnsafeMutation = "UnsafeMutation" + CgoCall = "CgoCall" + FinalizerRegistration = "FinalizerRegistration" + SyncPoolOp = "SyncPoolOp" + ClosureCaptureMutation = "ClosureCaptureMutation" + + +# Mapping from each SideEffectType to its Tier. This is the authoritative +# source for tier lookups -- do not duplicate inline. +TIER_MAP: dict[SideEffectType, Tier] = { + # P0 -- 6 types + SideEffectType.ReturnValue: Tier.P0, + SideEffectType.ErrorReturn: Tier.P0, + SideEffectType.SentinelError: Tier.P0, + SideEffectType.ReceiverMutation: Tier.P0, + SideEffectType.PointerArgMutation: Tier.P0, + SideEffectType.ErrorSignal: Tier.P0, + # P1 -- 11 types + SideEffectType.SliceMutation: Tier.P1, + SideEffectType.MapMutation: Tier.P1, + SideEffectType.GlobalMutation: Tier.P1, + SideEffectType.WriterOutput: Tier.P1, + SideEffectType.HTTPResponseWrite: Tier.P1, + SideEffectType.ChannelSend: Tier.P1, + SideEffectType.ChannelClose: Tier.P1, + SideEffectType.DeferredReturnMutation: Tier.P1, + SideEffectType.GeneratorYield: Tier.P1, + SideEffectType.ContainerMutation: Tier.P1, + SideEffectType.StreamOutput: Tier.P1, + # P2 -- 16 types + SideEffectType.FileSystemWrite: Tier.P2, + SideEffectType.FileSystemDelete: Tier.P2, + SideEffectType.FileSystemMeta: Tier.P2, + SideEffectType.DatabaseWrite: Tier.P2, + SideEffectType.DatabaseTransaction: Tier.P2, + SideEffectType.GoroutineSpawn: Tier.P2, + SideEffectType.Panic: Tier.P2, + SideEffectType.CallbackInvocation: Tier.P2, + SideEffectType.LogWrite: Tier.P2, + SideEffectType.ContextCancellation: Tier.P2, + SideEffectType.AsyncGeneratorYield: Tier.P2, + SideEffectType.MetaprogrammingMutation: Tier.P2, + SideEffectType.DescriptorEffect: Tier.P2, + SideEffectType.ResourceManagement: Tier.P2, + SideEffectType.ImportSideEffect: Tier.P2, + SideEffectType.MonkeyPatch: Tier.P2, + # P3 -- 9 types + SideEffectType.StdoutWrite: Tier.P3, + SideEffectType.StderrWrite: Tier.P3, + SideEffectType.EnvVarMutation: Tier.P3, + SideEffectType.MutexOp: Tier.P3, + SideEffectType.WaitGroupOp: Tier.P3, + SideEffectType.AtomicOp: Tier.P3, + SideEffectType.TimeDependency: Tier.P3, + SideEffectType.ProcessExit: Tier.P3, + SideEffectType.RecoverBehavior: Tier.P3, + # P4 -- 6 types + SideEffectType.ReflectionMutation: Tier.P4, + SideEffectType.UnsafeMutation: Tier.P4, + SideEffectType.CgoCall: Tier.P4, + SideEffectType.FinalizerRegistration: Tier.P4, + SideEffectType.SyncPoolOp: Tier.P4, + SideEffectType.ClosureCaptureMutation: Tier.P4, +} diff --git a/src/snake_eyes/analysis/models.py b/src/snake_eyes/analysis/models.py new file mode 100644 index 0000000..83dc2ba --- /dev/null +++ b/src/snake_eyes/analysis/models.py @@ -0,0 +1,71 @@ +"""Protocol-shaped data models for the analyze payload. + +These dataclasses mirror the Gaze protocol v1.1.0 analyze payload shape, not +gaze-py's internal model. Optional fields are omitted from serialization when +unset so that Gaze-side optional fields remain genuinely optional. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class Effect: + """A single observable side effect. + + ``location`` is a ``"file.py:line:col"`` string relative to the analysis + root; ``target`` names the attribute/parameter/exception involved; and + ``detail`` carries opaque metadata. All three are optional. + """ + + type: str + description: str + location: str | None = None + target: str | None = None + detail: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class FunctionRecord: + """A function and its observed side effects. + + ``package`` is the dotted module path, ``file`` is the path relative to + ``root_path`` with POSIX separators, and ``line`` is the 1-based ``def`` + line number. + """ + + name: str + package: str + file: str + line: int + side_effects: tuple[Effect, ...] = () + + +def function_record_to_dict(record: FunctionRecord) -> dict[str, Any]: + """Serialize a ``FunctionRecord`` to a JSON-ready dict. + + Optional ``Effect`` fields (``location``, ``target``, ``detail``) are + omitted when ``None``. ``side_effects`` is serialized as a list. + """ + effects: list[dict[str, Any]] = [] + for effect in record.side_effects: + serialized: dict[str, Any] = { + "type": effect.type, + "description": effect.description, + } + if effect.location is not None: + serialized["location"] = effect.location + if effect.target is not None: + serialized["target"] = effect.target + if effect.detail is not None: + serialized["detail"] = effect.detail + effects.append(serialized) + return { + "name": record.name, + "package": record.package, + "file": record.file, + "line": record.line, + "side_effects": effects, + } diff --git a/src/snake_eyes/discovery.py b/src/snake_eyes/discovery.py new file mode 100644 index 0000000..1e9b121 --- /dev/null +++ b/src/snake_eyes/discovery.py @@ -0,0 +1,151 @@ +"""File and test discovery for snake-eyes. + +Discovers Python source and test files under a project root using the Gaze +``./...`` pattern convention. Results are deterministic: both lists are +returned in sorted lexicographic order by POSIX path. +""" + +from __future__ import annotations + +import fnmatch +import os +from dataclasses import dataclass +from pathlib import Path + +# Directory names that are never descended into. ``*.egg-info`` suffixes are +# handled separately in ``_should_prune``. +_EXCLUDE_DIRS = frozenset( + { + ".venv", + "venv", + "env", + ".env", + "__pycache__", + ".git", + ".hg", + ".svn", + "dist", + "build", + ".tox", + ".nox", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + "node_modules", + ".eggs", + } +) + +_WHOLE_TREE_PATTERNS = frozenset({"", ".", "...", "/", "./"}) + + +@dataclass(frozen=True) +class DiscoveryResult: + """The discovered source and test files, each sorted and POSIX-relative.""" + + source_files: tuple[str, ...] + test_files: tuple[str, ...] + + +def discover(root_path: str, patterns: list[str] | None = None) -> DiscoveryResult: + """Discover ``.py`` files under ``root_path`` and classify them. + + ``patterns`` of ``None``/``[]``/``["./..."]``/``["..."]`` walk the whole + tree. A relative directory pattern (``src`` or ``src/``) walks that + subtree. A glob pattern (``**/*.py``) is applied relative to root. Raises + ``FileNotFoundError`` when ``root_path`` is missing or not a directory. + """ + root = Path(root_path).resolve() + if not root.is_dir(): + raise FileNotFoundError(f"root_path is not a directory: {root_path}") + + files = _walk(root) + selected = _select(files, patterns) + sources, tests = _classify(selected) + return DiscoveryResult(tuple(sorted(sources)), tuple(sorted(tests))) + + +def _walk(root: Path) -> list[str]: + """Walk ``root`` collecting POSIX-relative ``.py`` paths (excluding ``.pyi``). + + Does not descend into excluded directories and skips all symlinks (both + directory and file). + """ + found: list[str] = [] + root_str = str(root) + for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): + dirnames[:] = [ + name for name in dirnames if not _should_prune(Path(dirpath) / name) + ] + rel_dir = os.path.relpath(dirpath, root_str) + for filename in filenames: + if filename.endswith(".pyi") or not filename.endswith(".py"): + continue + full = Path(dirpath) / filename + if full.is_symlink(): + continue + rel = filename if rel_dir == "." else os.path.join(rel_dir, filename) + found.append(rel.replace(os.sep, "/")) + return found + + +def _should_prune(path: Path) -> bool: + """Return ``True`` for excluded directories and directory symlinks.""" + if path.name in _EXCLUDE_DIRS: + return True + if path.name.endswith(".egg-info"): + return True + if path.is_symlink(): + return True + return False + + +def _select(files: list[str], patterns: list[str] | None) -> list[str]: + """Return the files matching the given patterns (or all files).""" + if not patterns: + return files + return [f for f in files if any(_matches(f, pattern) for pattern in patterns)] + + +def _matches(relpath: str, pattern: str) -> bool: + """Return ``True`` when ``relpath`` matches a single pattern. + + Reduces Go's ``./pkg/...`` semantics to "directory prefix + recursive": + ``...``/``./...`` matches everything; ``prefix/...`` matches the subtree; + a plain directory matches its subtree; a glob matches via ``fnmatch``. + """ + normalized = pattern[2:] if pattern.startswith("./") else pattern + if normalized in _WHOLE_TREE_PATTERNS: + return True + if normalized.endswith("/..."): + return relpath.startswith(normalized[:-4].rstrip("/") + "/") + if any(ch in normalized for ch in "*?["): + return fnmatch.fnmatchcase(relpath, normalized) + return relpath.startswith(normalized.rstrip("/") + "/") + + +def _classify(files: list[str]) -> tuple[list[str], list[str]]: + """Split files into source and test lists (disjoint; test wins).""" + sources: list[str] = [] + tests: list[str] = [] + for relpath in files: + if _is_test(relpath): + tests.append(relpath) + else: + sources.append(relpath) + return sources, tests + + +def _is_test(relpath: str) -> bool: + """Classify a POSIX-relative path as a test file. + + A file is a test if its filename starts with ``test_``, ends with + ``_test.py``, or any directory component is ``tests`` or ``test``. + """ + parts = relpath.split("/") + filename = parts[-1] + if filename.startswith("test_"): + return True + if filename.endswith("_test.py"): + return True + return any(part in ("tests", "test") for part in parts[:-1]) diff --git a/src/snake_eyes/protocol.py b/src/snake_eyes/protocol.py index bdd1b41..457bbec 100644 --- a/src/snake_eyes/protocol.py +++ b/src/snake_eyes/protocol.py @@ -75,8 +75,8 @@ def initialize_result() -> dict[str, Any]: Returns a plain dict with the exact keys ``analyzer_name``, ``language``, ``language_version`` (from ``sys.version_info``), ``protocol_version`` (``"1.1.0"``), and ``capabilities`` with all four - flags (``discover``, ``test_mapping``, ``classify_signals``, - ``streaming``) present and ``False``. + flags present (``discover`` ``True``; ``test_mapping``, + ``classify_signals``, and ``streaming`` ``False``). """ major, minor, micro = sys.version_info[:3] return { @@ -85,7 +85,7 @@ def initialize_result() -> dict[str, Any]: "language_version": f"{major}.{minor}.{micro}", "protocol_version": PROTOCOL_VERSION, "capabilities": { - "discover": False, + "discover": True, "test_mapping": False, "classify_signals": False, "streaming": False, @@ -126,4 +126,4 @@ def to_json(obj: Any) -> str: Returns the compact ``json.dumps`` rendering of ``to_dict(obj)`` -- no embedded newlines, suitable for the line-delimited stdio transport. """ - return json.dumps(to_dict(obj)) + return json.dumps(to_dict(obj), sort_keys=True) diff --git a/src/snake_eyes/server.py b/src/snake_eyes/server.py index fac24ce..f4d1d4d 100644 --- a/src/snake_eyes/server.py +++ b/src/snake_eyes/server.py @@ -9,6 +9,7 @@ from collections.abc import Callable, Mapping from typing import Any, TextIO +from .discovery import discover from .protocol import ( INTERNAL_ERROR, INVALID_PARAMS, @@ -57,9 +58,35 @@ def _shutdown(params: dict[str, Any] | None) -> dict[str, Any]: return shutdown_result() +def _discover(params: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(params, dict): + raise RpcError(INVALID_PARAMS, "Invalid params: params must be an object") + root_path = params.get("root_path") + if not isinstance(root_path, str): + raise RpcError(INVALID_PARAMS, "Invalid params: root_path must be a string") + patterns_raw = params.get("patterns") + if patterns_raw is not None and ( + not isinstance(patterns_raw, list) + or not all(isinstance(pattern, str) for pattern in patterns_raw) + ): + raise RpcError( + INVALID_PARAMS, "Invalid params: patterns must be an array of strings" + ) + patterns: list[str] | None = patterns_raw + try: + result = discover(root_path, patterns) + except FileNotFoundError as err: + raise RpcError(INVALID_PARAMS, str(err)) from err + return { + "source_files": list(result.source_files), + "test_files": list(result.test_files), + } + + DEFAULT_DISPATCH: Mapping[str, Handler] = { "initialize": _initialize, SHUTDOWN_METHOD: _shutdown, + "discover": _discover, } diff --git a/tests/test_discover_method.py b/tests/test_discover_method.py new file mode 100644 index 0000000..721c67c --- /dev/null +++ b/tests/test_discover_method.py @@ -0,0 +1,88 @@ +"""Tests for the discover JSON-RPC method.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest +from conftest import req, responses + +from snake_eyes.protocol import INVALID_PARAMS, initialize_result +from snake_eyes.server import Server + + +def _run(raw: str) -> str: + stdin = io.StringIO(raw) + stdout = io.StringIO() + server = Server(stdin, stdout, io.StringIO()) + with pytest.raises(SystemExit) as exc: + server.run() + assert exc.value.code == 0 + return stdout.getvalue() + + +def _write(path: Path, content: str = "") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def test_discover_roundtrip(tmp_path: Path) -> None: + _write(tmp_path / "src" / "foo.py") + _write(tmp_path / "tests" / "test_foo.py") + raw = req("discover", root_path=str(tmp_path), patterns=["./..."]) + "\n" + response = responses(_run(raw))[0] + assert response["result"] == { + "source_files": ["src/foo.py"], + "test_files": ["tests/test_foo.py"], + } + + +def test_discover_missing_patterns_ok(tmp_path: Path) -> None: + _write(tmp_path / "a.py") + raw = req("discover", root_path=str(tmp_path)) + "\n" + response = responses(_run(raw))[0] + assert response["result"] == {"source_files": ["a.py"], "test_files": []} + + +def test_discover_missing_root_path(tmp_path: Path) -> None: + raw = req("discover") + "\n" + response = responses(_run(raw))[0] + assert response["error"]["code"] == INVALID_PARAMS + + +def test_discover_non_object_params(tmp_path: Path) -> None: + body = {"jsonrpc": "2.0", "id": 1, "method": "discover", "params": []} + raw = json.dumps(body) + "\n" + response = responses(_run(raw))[0] + assert response["error"]["code"] == INVALID_PARAMS + + +def test_discover_nonexistent_root(tmp_path: Path) -> None: + raw = req("discover", root_path=str(tmp_path / "nope")) + "\n" + response = responses(_run(raw))[0] + assert response["error"]["code"] == INVALID_PARAMS + + +def test_discover_non_string_root(tmp_path: Path) -> None: + raw = req("discover", root_path=123) + "\n" + response = responses(_run(raw))[0] + assert response["error"]["code"] == INVALID_PARAMS + + +def test_discover_non_array_patterns(tmp_path: Path) -> None: + raw = req("discover", root_path=str(tmp_path), patterns="src") + "\n" + response = responses(_run(raw))[0] + assert response["error"]["code"] == INVALID_PARAMS + + +def test_initialize_reports_discover_true() -> None: + raw = req("initialize", root_path="/abs") + "\n" + response = responses(_run(raw))[0] + assert response["result"] == initialize_result() + capabilities = response["result"]["capabilities"] + assert capabilities["discover"] is True + assert capabilities["test_mapping"] is False + assert capabilities["classify_signals"] is False + assert capabilities["streaming"] is False diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..10dad90 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,154 @@ +"""Tests for file and test discovery.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from snake_eyes.discovery import DiscoveryResult, discover + + +def _write(path: Path, content: str = "") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def test_splits_source_and_test_files(tmp_path: Path) -> None: + _write(tmp_path / "src" / "foo.py") + _write(tmp_path / "tests" / "test_foo.py") + result = discover(str(tmp_path)) + assert result.source_files == ("src/foo.py",) + assert result.test_files == ("tests/test_foo.py",) + + +def test_test_prefix_at_root(tmp_path: Path) -> None: + _write(tmp_path / "test_foo.py") + result = discover(str(tmp_path)) + assert result.source_files == () + assert result.test_files == ("test_foo.py",) + + +def test_test_suffix(tmp_path: Path) -> None: + _write(tmp_path / "foo_test.py") + result = discover(str(tmp_path)) + assert result.source_files == () + assert result.test_files == ("foo_test.py",) + + +def test_test_directory_component(tmp_path: Path) -> None: + _write(tmp_path / "test" / "helper.py") + result = discover(str(tmp_path)) + assert result.source_files == () + assert result.test_files == ("test/helper.py",) + + +def test_venv_and_pycache_excluded(tmp_path: Path) -> None: + _write(tmp_path / ".venv" / "lib" / "site.py") + _write(tmp_path / "__pycache__" / "x.py") + _write(tmp_path / "a.py") + result = discover(str(tmp_path)) + assert result.source_files == ("a.py",) + assert result.test_files == () + + +def test_egg_info_excluded(tmp_path: Path) -> None: + _write(tmp_path / "myproj.egg-info" / "x.py") + _write(tmp_path / "a.py") + result = discover(str(tmp_path)) + assert result.source_files == ("a.py",) + + +def test_pyi_excluded(tmp_path: Path) -> None: + _write(tmp_path / "a.py") + _write(tmp_path / "a.pyi") + result = discover(str(tmp_path)) + assert result.source_files == ("a.py",) + + +def test_empty_project(tmp_path: Path) -> None: + result = discover(str(tmp_path)) + assert result == DiscoveryResult((), ()) + + +def test_missing_root_raises(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + discover(str(tmp_path / "nope")) + + +def test_non_directory_root_raises(tmp_path: Path) -> None: + target = tmp_path / "file.py" + target.write_text("") + with pytest.raises(FileNotFoundError): + discover(str(target)) + + +def test_directory_pattern_restricts_to_subtree(tmp_path: Path) -> None: + _write(tmp_path / "src" / "foo.py") + _write(tmp_path / "tests" / "test_foo.py") + result = discover(str(tmp_path), ["src"]) + assert result.source_files == ("src/foo.py",) + assert result.test_files == () + + +def test_trailing_slash_directory_pattern(tmp_path: Path) -> None: + _write(tmp_path / "src" / "foo.py") + _write(tmp_path / "tests" / "test_foo.py") + result = discover(str(tmp_path), ["src/"]) + assert result.source_files == ("src/foo.py",) + assert result.test_files == () + + +def test_glob_pattern_matches_relative_to_root(tmp_path: Path) -> None: + _write(tmp_path / "src" / "foo.py") + _write(tmp_path / "tests" / "test_foo.py") + result = discover(str(tmp_path), ["**/*.py"]) + assert result.source_files == ("src/foo.py",) + assert result.test_files == ("tests/test_foo.py",) + + +def test_whole_tree_patterns(tmp_path: Path) -> None: + _write(tmp_path / "src" / "a.py") + _write(tmp_path / "tests" / "test_a.py") + for pattern in (["./..."], ["..."]): + result = discover(str(tmp_path), pattern) + assert result.source_files == ("src/a.py",) + assert result.test_files == ("tests/test_a.py",) + + +def test_empty_patterns_walk_whole_tree(tmp_path: Path) -> None: + _write(tmp_path / "src" / "a.py") + result = discover(str(tmp_path), []) + assert result.source_files == ("src/a.py",) + + +def test_prefix_recursive_pattern(tmp_path: Path) -> None: + _write(tmp_path / "src" / "a.py") + _write(tmp_path / "tests" / "test_a.py") + result = discover(str(tmp_path), ["src/..."]) + assert result.source_files == ("src/a.py",) + assert result.test_files == () + + +def test_results_are_sorted(tmp_path: Path) -> None: + _write(tmp_path / "b.py") + _write(tmp_path / "a.py") + _write(tmp_path / "tests" / "c_test.py") + result = discover(str(tmp_path)) + assert result.source_files == ("a.py", "b.py") + assert result.test_files == ("tests/c_test.py",) + + +def test_file_symlink_skipped(tmp_path: Path) -> None: + _write(tmp_path / "a.py") + os.symlink(tmp_path / "a.py", tmp_path / "link.py") + result = discover(str(tmp_path)) + assert result.source_files == ("a.py",) + + +def test_directory_symlink_not_followed(tmp_path: Path) -> None: + _write(tmp_path / "real" / "a.py") + os.symlink(tmp_path / "real", tmp_path / "linkdir") + result = discover(str(tmp_path)) + assert result.source_files == ("real/a.py",) diff --git a/tests/test_effects.py b/tests/test_effects.py new file mode 100644 index 0000000..64acd99 --- /dev/null +++ b/tests/test_effects.py @@ -0,0 +1,57 @@ +"""Tests for the 48-type side effect taxonomy.""" + +from __future__ import annotations + +from snake_eyes.analysis.effects import TIER_MAP, SideEffectType, Tier + + +def test_48_members() -> None: + assert len(SideEffectType) == 48 + + +def test_every_member_in_tier_map() -> None: + for member in SideEffectType: + assert member in TIER_MAP + + +def test_tier_map_has_no_unknown_members() -> None: + assert set(TIER_MAP) == set(SideEffectType) + + +def test_new_types_have_correct_tiers() -> None: + assert TIER_MAP[SideEffectType.ErrorSignal] == Tier.P0 + assert TIER_MAP[SideEffectType.GeneratorYield] == Tier.P1 + assert TIER_MAP[SideEffectType.ContainerMutation] == Tier.P1 + assert TIER_MAP[SideEffectType.StreamOutput] == Tier.P1 + assert TIER_MAP[SideEffectType.AsyncGeneratorYield] == Tier.P2 + assert TIER_MAP[SideEffectType.MetaprogrammingMutation] == Tier.P2 + assert TIER_MAP[SideEffectType.DescriptorEffect] == Tier.P2 + assert TIER_MAP[SideEffectType.ResourceManagement] == Tier.P2 + assert TIER_MAP[SideEffectType.ImportSideEffect] == Tier.P2 + assert TIER_MAP[SideEffectType.MonkeyPatch] == Tier.P2 + + +def test_p0_set_complete() -> None: + p0 = {member for member in SideEffectType if TIER_MAP[member] == Tier.P0} + assert p0 == { + SideEffectType.ReturnValue, + SideEffectType.ErrorReturn, + SideEffectType.SentinelError, + SideEffectType.ReceiverMutation, + SideEffectType.PointerArgMutation, + SideEffectType.ErrorSignal, + } + + +def test_type_values_are_canonical_strings() -> None: + assert SideEffectType.ReturnValue.value == "ReturnValue" + assert SideEffectType.MonkeyPatch.value == "MonkeyPatch" + assert str(SideEffectType.ErrorSignal) == "ErrorSignal" + + +def test_package_reexports_taxonomy() -> None: + from snake_eyes.analysis import TIER_MAP as pkg_tier_map + from snake_eyes.analysis import SideEffectType as pkg_side_effect_type + + assert pkg_side_effect_type is SideEffectType + assert pkg_tier_map is TIER_MAP diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..d654a03 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,72 @@ +"""Tests for the protocol-shaped data models.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from snake_eyes.analysis.effects import SideEffectType +from snake_eyes.analysis.models import Effect, FunctionRecord, function_record_to_dict + + +def test_function_record_to_dict_omits_none_optionals() -> None: + effect = Effect(type="StdoutWrite", description="prints") + record = FunctionRecord( + name="f", package="pkg.mod", file="pkg/mod.py", line=3, side_effects=(effect,) + ) + result = function_record_to_dict(record) + assert result == { + "name": "f", + "package": "pkg.mod", + "file": "pkg/mod.py", + "line": 3, + "side_effects": [{"type": "StdoutWrite", "description": "prints"}], + } + serialized_effect = result["side_effects"][0] + assert "location" not in serialized_effect + assert "target" not in serialized_effect + assert "detail" not in serialized_effect + + +def test_function_record_to_dict_includes_optionals_when_set() -> None: + effect = Effect( + type="FileSystemWrite", + description="writes", + location="mod.py:10:1", + target="path", + detail={"mode": "w"}, + ) + record = FunctionRecord("f", "pkg", "pkg/mod.py", 10, (effect,)) + result = function_record_to_dict(record) + assert result["side_effects"] == [ + { + "type": "FileSystemWrite", + "description": "writes", + "location": "mod.py:10:1", + "target": "path", + "detail": {"mode": "w"}, + } + ] + + +def test_type_field_is_canonical_string() -> None: + effect = Effect(type=SideEffectType.StdoutWrite.value, description="x") + assert effect.type == "StdoutWrite" + + +def test_effect_is_frozen() -> None: + effect = Effect(type="StdoutWrite", description="x") + with pytest.raises(dataclasses.FrozenInstanceError): + effect.description = "changed" + + +def test_function_record_is_frozen() -> None: + record = FunctionRecord("f", "pkg", "pkg/mod.py", 1) + with pytest.raises(dataclasses.FrozenInstanceError): + record.name = "g" + + +def test_empty_side_effects_serialize_as_empty_list() -> None: + record = FunctionRecord("f", "pkg", "pkg/mod.py", 1) + assert function_record_to_dict(record)["side_effects"] == [] diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 3158457..ab65204 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -85,7 +85,7 @@ def test_initialize_result_schema() -> None: assert result["language_version"] == f"{major}.{minor}.{micro}" assert result["capabilities"] == { - "discover": False, + "discover": True, "test_mapping": False, "classify_signals": False, "streaming": False, diff --git a/tests/test_server.py b/tests/test_server.py index 13f8bc8..35360a2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -234,7 +234,6 @@ def test_invalid_params_root_path_wrong_type(root_path: object) -> None: "analyze", "complexity", "coverage", - "discover", "test_mapping", "classify_signals", "analyze/stream",