From ba35e0079796439d7a664ab51373dedea1f0203c Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Mon, 31 Aug 2026 11:20:09 -0400 Subject: [PATCH 1/6] docs(classify-signals): add OpenSpec change artifacts for #5 (spec-review passed) --- .../changes/classify-signals/.openspec.yaml | 2 + openspec/changes/classify-signals/design.md | 107 ++++++++++++++++++ openspec/changes/classify-signals/proposal.md | 46 ++++++++ .../specs/caller-inference/spec.md | 38 +++++++ .../specs/classify-signals-method/spec.md | 77 +++++++++++++ .../specs/signal-adapter/spec.md | 48 ++++++++ .../specs/signal-extractors/spec.md | 99 ++++++++++++++++ openspec/changes/classify-signals/tasks.md | 81 +++++++++++++ 8 files changed, 498 insertions(+) create mode 100644 openspec/changes/classify-signals/.openspec.yaml create mode 100644 openspec/changes/classify-signals/design.md create mode 100644 openspec/changes/classify-signals/proposal.md create mode 100644 openspec/changes/classify-signals/specs/caller-inference/spec.md create mode 100644 openspec/changes/classify-signals/specs/classify-signals-method/spec.md create mode 100644 openspec/changes/classify-signals/specs/signal-adapter/spec.md create mode 100644 openspec/changes/classify-signals/specs/signal-extractors/spec.md create mode 100644 openspec/changes/classify-signals/tasks.md diff --git a/openspec/changes/classify-signals/.openspec.yaml b/openspec/changes/classify-signals/.openspec.yaml new file mode 100644 index 0000000..50adc91 --- /dev/null +++ b/openspec/changes/classify-signals/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-29 diff --git a/openspec/changes/classify-signals/design.md b/openspec/changes/classify-signals/design.md new file mode 100644 index 0000000..08453a1 --- /dev/null +++ b/openspec/changes/classify-signals/design.md @@ -0,0 +1,107 @@ +## Context + +Gaze's universal scoring engine classifies each side effect as contractual, incidental, or ambiguous. The Go core owns that formula (org constitution CC-001–CC-006): it sums five weighted signals, applies a tier boost, and applies a contradiction penalty. It does **not** know how to extract those signals from Python — that is the analyzer's job. gaze-py solves the same problem for a pure-Python toolchain, but it does two things: it *extracts* five mechanical signals (`src/gaze_py/classify/signals/*.py`) and then *scores* them locally (`src/gaze_py/classify/engine.py`). + +snake-eyes already ships the pieces this change builds on, delivered by #4 (analysis-methods, merged): the `analyze_path`/`analyze_source` detector, the 48-value `SideEffectType` taxonomy (`analysis/effects.py`), the `Effect`/`FunctionRecord` models, and the shared discovery/AST layer (`_shared.py`, `discovery.py`). The one advertised-but-unimplemented optional capability that remains is `classify_signals` (`capabilities.classify_signals: false`, method returns `-32601`). + +The constraint that shapes this whole design: **Gaze owns scoring, snake-eyes owns signal extraction.** If snake-eyes also scored, two implementations of the formula would drift. So snake-eyes lifts the gaze-py *extractors* and never the *engine*. + +## Goals / Non-Goals + +**Goals:** +- Implement the `classify_signals` JSON-RPC method returning `{"signals": [...]}` with the exact protocol v1.1.0 field names (`function`, `package`, `side_effect_type`, `source`, `weight`, and `reasoning`). `reasoning` is a protocol-optional field that snake-eyes always emits as a short, non-empty string. +- Emit only the five mechanical signals from exactly five sources: `interface`, `visibility`, `caller_count`, `naming_convention`, `docstring`. +- Reuse gaze-py's extraction logic (and its weights) verbatim so Gaze's formula receives the same signals it would for equivalent Python code. +- Degrade gracefully: an astroid inference failure yields `caller_count = 0`, never an RPC error. +- Flip `capabilities.classify_signals` to `true` to match the implemented behavior. + +**Non-Goals:** +- No scoring: no 5-signal sum, no tier boost, no contradiction penalty, no `contractual`/`incidental`/`ambiguous` label. gaze-py `classify/engine.py` is NOT lifted. +- No sixth signal source and no `type_annotation` extractor. +- No retuning of gaze-py signal weights (they are gate values, not tunables). +- No `test_mapping`, no `analyze/stream` streaming, no `classification` field on `analyze` results. +- No importing or executing the analyzed project. + +## Decisions + +### Decision: Lift the five extractors, never the engine +snake-eyes copies `interface.py`, `visibility.py`, `caller.py`, `naming.py`, `docstring.py` into a new `src/snake_eyes/signals/` package and writes an original orchestrator (`adapter.py`) in place of gaze-py's `engine.py`. + +*Rationale:* The classification formula lives in the Gaze Go core. Lifting the engine would create a second scorer that could drift from the canonical one — a Protocol Fidelity (Principle I) violation waiting to happen. Extractors are the mechanical, language-specific part that legitimately belongs in the analyzer. + +*Alternatives considered:* (a) Reimplement extractors from scratch — rejected: duplicates well-tested gaze-py logic and invites weight drift. (b) Lift the engine and emit labels — rejected: violates the Gaze-owns-scoring boundary and the issue's explicit decision. + +### Decision: Emit raw signals only; no labels, no arithmetic +`adapter.py` returns a flat list of signal dicts. It does not sum weights, clamp them, dedupe across extractors, or assign a label. Multiple signals for the same function-effect pair (e.g. one `naming_convention` and one `docstring`) are expected and all are emitted. + +*Rationale:* Gaze consumes the raw signals and runs the formula itself. Any aggregation here would pre-empt (and potentially contradict) the Go core. + +### Decision: Preserve gaze-py weights verbatim +Each extractor's weight values are copied exactly from gaze-py (e.g. the `interface` extractor yields weight `30` for an ABC/`typing.Protocol` base). Where `caller.py` buckets inbound-call counts, the bucket boundaries and their weights are preserved as-is. + +*Rationale:* The weights are inputs to a governance gate (Gaze's classification thresholds). Per the Gatekeeping Value Protection rule, an agent MUST NOT change gate values to make a local test pass — and there is no local scorer to satisfy anyway. Tests assert the gaze-py weights; they never redefine them. + +### Decision: Map the 10 new Python effect types to their closest gaze-py branch +gaze-py's extractors predate the 10 Python-specific `SideEffectType` values added beyond gaze-py's original 38 (and also lack a branch for the gaze-py-original `ClosureCaptureMutation`). Where an extractor switches on effect type (`naming.py`, `docstring.py`), each such type is routed to the closest existing branch: + +| New type | Behaves like | Reason | +|---|---|---| +| `GeneratorYield`, `StreamOutput`, `AsyncGeneratorYield` | `ReturnValue` | value/output-producing | +| `MonkeyPatch` | `ReflectionMutation` | dynamic attribute mutation | +| `ContainerMutation` | `SliceMutation`/`MapMutation` (P1 mutation) | container/collection mutation | +| `DescriptorEffect`, `ResourceManagement`, `MetaprogrammingMutation`, `ImportSideEffect` | closest P2/mutation branch | state-mutating effects | +| `ClosureCaptureMutation` (gaze-py-original, P4 exotic) | `ReflectionMutation` (P4 exotic) | closure-capture mutation | +| `ErrorSignal` | error/raise branch (like `ErrorReturn`) | error semantics | + +An effect type that matches no keyword or prefix SHALL return `None` (no signal) — the extractor MUST NOT raise `KeyError` on an unrecognized type. This satisfies Detection Accuracy (Principle II): ambiguity over omission, but never a crash. + +### Decision: Use astroid for caller counting, over on-disk files only +`analysis/inference.py` exposes `build_caller_index(root_path, patterns) -> CallerIndex`, which enumerates files exactly once via `_shared.ordered_file_list(root_path, patterns)` (which applies the `_shared` symlink-skip and excluded-directory guards) and then filters that enumerated list through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files **before** astroid parses any file — the byte-cap and regular-file check live in `is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid path MUST apply that filter explicitly to honor the Constitution V resource bound. It builds a single astroid view over that bounded on-disk file set — **once per `extract_signals` invocation**, never rebuilt per function. `CallerIndex.count(module, func_name) -> int` is a pure lookup that counts `Call` nodes whose inferred callee resolves to `func_name`; the callee is matched by its **resolved defining file path** against the analyzed file set (robust to `src/` layouts), not by dotted-module-string equality (which would mismatch `derive_package`'s root-relative `src.pkg.mod` against astroid's inferred `pkg.mod`). The build uses an **isolated per-request astroid manager** (a fresh manager or `clear_cache()` at the start of each request — never the process-global `MANAGER` carried across requests) and restricts import resolution to the on-disk project (not the ambient `sys.path`) so counts are environment-independent. A thin, test-only `count_callers(root_path, module, func_name, patterns=None) -> int` wrapper builds a one-off index and does a single lookup; it is documented as rebuilding per call and is never on the per-function adapter hot path. + +*Rationale:* Counting inbound callers requires cross-module name resolution, which the stdlib `ast` cannot do. astroid is the pylint-maintained, Python-native inference engine — the right Principle III (Python-Native Analysis) tool. + +*Alternatives considered:* (a) `ast`-only textual matching of call names — rejected: cannot distinguish `foo()` in different modules, produces false counts. (b) Import the project and use `inspect`/runtime graph — rejected: violates Analysis Safety (Principle V); analyzed code is untrusted and must never be executed. + +### Decision: astroid failures degrade to `caller_count = 0` +If astroid raises any exception (`AstroidError`/`InferenceError` subclasses, plus `RecursionError`, `MemoryError`, and `OSError`), or cannot build a manager or module view, the caller index yields `0` for that function's count; if a single call site's callee resolves to `Uninferable`, that one call is omitted and counting continues. A `0` count means the `caller` extractor emits whatever its zero-bucket signal is (possibly `None`) — but never an RPC error. + +*Rationale:* astroid inference is best-effort on untrusted, possibly-partial source. A failure to infer is a missing signal, not a protocol failure. This keeps the method robust (Principle V) and the response well-formed (Principle I). + +### Decision: Adapter algorithm +`extract_signals(root_path, patterns)`: +1. `records = analyze_path(root_path, patterns)` (reuses the #4 detector; inherits its discovery, ordering, parse-error skipping, and resource bounds). +2. Re-parse each file's AST for class bases, function name, docstring, and `__all__` membership (which `FunctionRecord` does not carry): enumerate via `_shared.ordered_file_list(root_path, patterns)` and read through `_shared.iter_source_files`, mapping each `FunctionRecord` to its enclosing class by locating the `def` at `FunctionRecord.line` and taking its enclosing `ClassDef`. Build the caller index with a single `build_caller_index(root_path, patterns)` call. Both the AST re-parse and `build_caller_index` receive the **same** `(root_path, patterns)`, so every consumer enumerates the identical deterministic, pattern-consistent file set (this is the known, accepted trade-off that `analyze_path`, the adapter re-parse, and `build_caller_index` each walk that same file set). Then, for each `FunctionRecord` and each `Effect` on it, run all five extractors with those AST inputs, the effect's `SideEffectType`, and the function's caller count via `CallerIndex.count`. +3. For every non-`None` extractor result, append one signal dict `{function, package, side_effect_type, source, weight, reasoning}` using the effect's type as `side_effect_type`. +4. A function with zero effects contributes zero signals. +5. No de-duplication across extractors; no weight arithmetic; no label. + +*Rationale:* This is the thin, deterministic glue that maps snake-eyes' detector output to the five-signal protocol shape without re-implementing the engine. + +### Decision: Server wiring mirrors the existing analysis handlers +A new `_classify_signals` handler validates params via the existing `_validate_analysis_params` (missing/invalid `root_path` → `-32602`, non-list `patterns` → `-32602`), calls `signals.adapter.extract_signals`, catches `FileNotFoundError` → `-32602`, and returns `{"signals": [...]}`. It is registered in `DEFAULT_DISPATCH` under the key `classify_signals`. `protocol.initialize_result()` flips `classify_signals` to `true`. + +*Rationale:* Consistency with the #4 handlers (`_analyze`, `_complexity`, `_coverage`) keeps the protocol surface uniform and reuses proven validation. + +## Risks / Trade-offs + +- **astroid inference is imperfect on partial or dynamic code** → caller counts may undercount; mitigated by degrading to `0` and treating caller as one of five signals (Gaze's formula tolerates a missing signal). Documented as expected behavior, not a bug. +- **astroid is a heavier dependency than `ast`** (parses and infers) → pinned `>=3.0,<4` with a single-major ceiling (matching the `coverage>=7.0,<8` precedent); used only in `inference.py`; the rest of the analyzer stays on stdlib `ast`. No known applicable CVE at this range at time of writing (SC-005). +- **gaze-py extractors may not cover the 10 new effect types** → explicit closest-branch mapping table above; unmatched types return `None`, never raise. Covered by unit tests per extractor. +- **Weight drift risk if a test "fixes" a weight** → tests assert gaze-py weights as constants; the Gatekeeping Value Protection rule forbids editing them to pass. If a lifted weight ever seems wrong, stop and escalate rather than edit. +- **Accidental label leakage** → a negative test scans `src/snake_eyes/signals/` via `tokenize`/AST (not a raw grep) for `contractual`/`incidental`/`ambiguous` as assigned outputs; the words may appear only in comments that forbid them. + +## Migration Plan + +Additive change; no migration or rollback of persisted data. +1. Add `astroid>=3.0,<4` to `pyproject.toml`; refresh `uv.lock` via `uv lock`. +2. Land `signals/` extractors + `inference.py` + `adapter.py` with tests (green under the 85% project gate; per-file targets in the spec). +3. Wire `_classify_signals` into `DEFAULT_DISPATCH` and flip the capability flag; add the JSON-RPC e2e test. +4. Update `README.md`/`AGENTS.md` status notes. + +Rollback: revert the change; `classify_signals` returns to `-32601` and the capability flag returns to `false`. No client that only used required methods is affected. + +Per the constitution, spec artifacts MUST be committed before implementation, and implementation commits MUST NOT be combined with spec commits. + +## Open Questions + +None. Issue #5 states "Do not ask clarifying questions. Every decision is already made below," and its defaults resolve every choice: emit raw signals only; lift extractors not the engine; match protocol v1.1.0 field names exactly. Exact non-`interface` weight values and `caller` bucket boundaries are whatever gaze-py defines and are preserved verbatim at implementation time (they are read from the lifted source, not invented here). diff --git a/openspec/changes/classify-signals/proposal.md b/openspec/changes/classify-signals/proposal.md new file mode 100644 index 0000000..c924877 --- /dev/null +++ b/openspec/changes/classify-signals/proposal.md @@ -0,0 +1,46 @@ +## Why + +snake-eyes advertises `classify_signals` as an unsupported capability (`capabilities.classify_signals: false`) and returns `-32601` when the method is called. Without it, Gaze has no way to run its universal classification formula (contractual vs. incidental vs. ambiguous) over Python functions — the Go core owns the scoring, but it needs Python-specific *signals* to score. This change lets snake-eyes emit the five mechanical classification signals Gaze expects. Implements #5; depends on #4 (analysis-methods, merged), which delivered the detector, the 48-type taxonomy, and the shared discovery/AST layer this change consumes. + +## What Changes + +- Add a new `src/snake_eyes/signals/` package that lifts the five signal extractors from gaze-py `src/gaze_py/classify/signals/` — `interface.py`, `visibility.py`, `caller.py`, `naming.py`, `docstring.py` — retaining their gaze-py copyright headers plus an Apache-2.0 §4(b) change notice, and adapting imports to `snake_eyes.analysis.effects.SideEffectType`. Each extractor returns a raw weighted signal or `None`; the gaze-py **weights are preserved verbatim** (they are gate values, not tunables). +- Extend each extractor that switches on effect type (`naming`, `docstring`) to handle the 10 Python-specific `SideEffectType` values added beyond gaze-py's original 38 (`ErrorSignal`, `GeneratorYield`, `StreamOutput`, `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, `MonkeyPatch`, `ContainerMutation`), plus the gaze-py-original `ClosureCaptureMutation`, by mapping each to the closest existing gaze-py branch (e.g. `GeneratorYield` behaves like `ReturnValue` for naming/docstring; `MonkeyPatch` like `ReflectionMutation`; `ContainerMutation` like the P1 `SliceMutation`/`MapMutation` branch; `ClosureCaptureMutation` like the P4 `ReflectionMutation` branch). An effect type with no matching keyword or prefix SHALL return `None` (no signal) — never raise `KeyError`. +- Add `src/snake_eyes/analysis/inference.py` exposing `build_caller_index(root_path, patterns) -> CallerIndex` and `CallerIndex.count(module, func_name) -> int`. `build_caller_index` builds a single per-request astroid view once over the discovered on-disk file set; `CallerIndex.count` is a pure lookup that resolves each inbound callee to its defining file path (robust to `src/` layouts). A thin test-only `count_callers(root_path, module, func_name, patterns=None) -> int` wrapper is also provided. If astroid raises or a callee is `Uninferable`, the count degrades to `0` — never an RPC error. +- Add `src/snake_eyes/signals/adapter.py` with `extract_signals(root_path, patterns) -> list[dict]` (a NEW component — gaze-py `classify/engine.py` is **NOT** lifted). It runs `analyze_path`, then for each function-effect pair runs all five extractors and appends one raw signal dict per non-`None` result. It does NOT sum weights, clamp, dedupe across extractors, or assign any classification label. +- Wire a `classify_signals` handler into `DEFAULT_DISPATCH` (shared `{root_path, patterns}` params; missing/invalid `root_path` → `-32602`; missing directory → `-32602`) returning `{"signals": [...]}`. +- Flip `capabilities.classify_signals` to `true` in `initialize`. `test_mapping` and `streaming` stay `false`. +- Add `astroid>=3.0,<4` as a new runtime dependency (floor and single-major ceiling), used only for static caller-count inference over on-disk files. +- Add fixtures under `tests/fixtures/signals/` plus unit tests per extractor, adapter tests, an inference degradation test, and a JSON-RPC end-to-end test. + +Out of scope: gaze-py `classify/engine.py`; the Gaze scoring formula (5-signal sum, tier boost, contradiction penalty); assigning `contractual`/`incidental`/`ambiguous` labels; a sixth extractor or a `type_annotation` source; `test_mapping`; `analyze/stream` streaming; and emitting a `classification` field on `analyze` results. + +## Capabilities + +### New Capabilities +- `signal-extractors`: the five lifted, effect-type-aware signal extractors (`interface`, `visibility`, `caller_count`, `naming_convention`, `docstring`), each emitting a raw weighted signal or `None`, with gaze-py weights preserved and the 10 new Python effect types mapped to their closest branch. +- `caller-inference`: astroid-based `build_caller_index`/`CallerIndex.count` inbound-call counting over on-disk source (built once per request, callees matched by resolved defining file path), with a thin test-only `count_callers` wrapper and graceful degradation to `0` on any astroid failure or `Uninferable` result. +- `signal-adapter`: `extract_signals(root_path, patterns)` orchestration that turns detector `FunctionRecord`s into a flat list of raw signal dicts — no scoring, no clamping, no cross-extractor de-duplication, and no classification labels. +- `classify-signals-method`: the JSON-RPC `classify_signals` method — shared params, the `signals[]` result schema (`function`, `package`, `side_effect_type`, `source`, `weight`, and `reasoning` — a protocol-optional field snake-eyes always emits as a short, non-empty string), error mapping, and the `capabilities.classify_signals: true` flag flip. + +### Modified Capabilities +None. `openspec/specs/` holds no archived capabilities yet, so there are no existing requirements to modify. The `classify_signals` capability flag is a new capability captured by the `classify-signals-method` spec, not a modification of an existing one. + +### Removed Capabilities +None. + +## Impact + +- New files: `src/snake_eyes/signals/__init__.py`, `src/snake_eyes/signals/interface.py`, `src/snake_eyes/signals/visibility.py`, `src/snake_eyes/signals/caller.py`, `src/snake_eyes/signals/naming.py`, `src/snake_eyes/signals/docstring.py`, `src/snake_eyes/signals/adapter.py`, `src/snake_eyes/analysis/inference.py`; fixtures under `tests/fixtures/signals/`; new test modules for each extractor, the adapter, inference, and the JSON-RPC method. +- Modified files: `src/snake_eyes/server.py` (register `classify_signals` in `DEFAULT_DISPATCH`); `src/snake_eyes/protocol.py` (flip `classify_signals` capability flag to `true`); `pyproject.toml` and `uv.lock` (add `astroid>=3.0,<4`); `README.md` (capability table row for `classify_signals` now implemented, the capability-flags sentence flipped from `false` to `true`, the project-structure tree, and the License section extended to enumerate `signals/*.py` as lifted from gaze-py; astroid now a runtime dep) and `AGENTS.md` (Technology Stack: astroid moves from "planned" to shipped, scoped to caller-count inference; Project Structure: `signals/` package and `inference.py` delivered here). +- Dependencies: one new runtime dependency, `astroid>=3.0,<4` (floor and single-major ceiling, matching the `coverage>=7.0,<8` precedent), justified because inbound caller counting requires name resolution across modules that the stdlib `ast` cannot perform; astroid is the pylint-maintained inference engine and is the Python-native choice over reimplementing resolution. astroid is used only to read and infer over on-disk source — never to import or execute the analyzed project. +- Provenance: `NOTICE` already attributes gaze-py (Matt Peter, Apache 2.0); each lifted `signals/*.py` extractor retains its gaze-py copyright header AND adds an Apache-2.0 §4(b) change notice (e.g. `# Modified 2026 by zero-dot-force: adapted to snake_eyes SideEffectType; new Python effect types mapped.`). `adapter.py` and `inference.py` are original snake-eyes code, not lifted. +- Protocol contract: `classify_signals` stops returning `-32601` and returns protocol-shaped `{"signals": [...]}`; `initialize` now advertises `classify_signals: true`. `analyze`/`complexity`/`coverage` outputs are unchanged (no `classification` field is added). + +## Constitution Alignment + +- **I. Protocol Fidelity** — `classify_signals` conforms to Gaze protocol v1.1.0: shared `{root_path, patterns}` params; result is `{"signals": [...]}` where each signal uses the exact field names `function`, `package`, `side_effect_type`, `source`, `weight`, and `reasoning` (a protocol-optional field snake-eyes always emits as a short, non-empty string); `source` is one of exactly five strings (`interface`, `visibility`, `caller_count`, `naming_convention`, `docstring`); missing `root_path` maps to `-32602`; output is deterministic. The capability flag is flipped to match the advertised behavior. — PASS +- **II. Detection Accuracy** — Signals are emitted for every function-effect pair where an extractor matches; gaze-py weights are preserved (not retuned to pass a local test); the 10 Python-specific effect types (including `ContainerMutation`) plus the gaze-py-original `ClosureCaptureMutation` are mapped to their closest branch rather than dropped, and an unmatched type returns `None` rather than raising. No signal is silently omitted when an extractor's rule applies. — PASS +- **III. Python-Native Analysis** — Extractors operate on `ast`-derived data (class bases, function name, docstring, `__all__` membership) obtained by re-parsing each file via the shared `_shared.iter_source_files` helper; caller counting uses astroid, the Python-native inference engine, rather than reimplementing name resolution. — PASS +- **IV. Testability** — Coverage strategy: each `signals/*.py` extractor 90%+, `adapter.py` 95%+, `inference.py` 85%+ (astroid failure path exercised via monkeypatch), project gate held at 85%. Extractors are pure functions testable with small AST fixtures; `count_callers` is mockable in adapter tests; a JSON-RPC end-to-end test exercises the wired method. A negative test asserts no classification label (`contractual`/`incidental`/`ambiguous`) is ever assigned in `signals/` production code. — PASS +- **V. Analysis Safety** — Static analysis only: extractors read AST data and astroid infers over on-disk files via an isolated per-request manager driven from the same discovered file set — enumerated via `_shared.ordered_file_list` (which applies the symlink-skip and excluded-directory guards) and then filtered through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files before astroid parses any file (the byte-cap lives in `is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid path applies it explicitly); the analyzed project is never imported or executed, and an enforcing test asserts no import-time side effects fire. astroid failures degrade to `caller_count = 0` — the except tuple covers `AstroidError`/`InferenceError` subclasses plus `RecursionError`, `MemoryError`, and `OSError` — rather than aborting the request. The single new dependency (astroid) is justified and pinned with a floor and a single-major ceiling. — PASS diff --git a/openspec/changes/classify-signals/specs/caller-inference/spec.md b/openspec/changes/classify-signals/specs/caller-inference/spec.md new file mode 100644 index 0000000..78141d3 --- /dev/null +++ b/openspec/changes/classify-signals/specs/caller-inference/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: caller index public API +The system SHALL provide `src/snake_eyes/analysis/inference.py` exposing a caller-index API: `build_caller_index(root_path: str, patterns: list[str] | None) -> CallerIndex` and `CallerIndex.count(module: str, func_name: str) -> int`, where `module` is a dotted package path (e.g. `snake_eyes.analysis.detector`). `build_caller_index` SHALL enumerate the project's files exactly once via `_shared.ordered_file_list(root_path, patterns)` (which applies the `_shared` symlink-skip and excluded-directory guards) and SHALL then filter that enumerated list through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files BEFORE astroid parses any file, building a single astroid view over that bounded on-disk file set, so the whole-project call graph is constructed once per `extract_signals` invocation and never rebuilt per function. (The 16 MiB byte-cap and the regular-file check live in `_shared.is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid caller-index path MUST apply that filter explicitly to honor the Constitution V resource bound.) `CallerIndex.count` SHALL be a pure lookup returning the number of inbound `Call` nodes across that file set whose inferred callee resolves to `func_name`; the callee SHALL be matched by its RESOLVED DEFINING FILE PATH against the analyzed file set (robust to `src/` and other project layouts) rather than by dotted-module-string equality. `build_caller_index` SHALL use an isolated per-request astroid manager (a fresh manager instance or `MANAGER.clear_cache()` at the start of each request, never the long-lived process-global `MANAGER` shared across requests) so counts cannot be contaminated by prior requests on other trees, and it SHALL restrict import resolution to the on-disk project rather than the ambient `sys.path` so counts are environment-independent. It SHALL operate on on-disk source only and SHALL NOT require the analyzed project to be installed or importable. The module SHALL also expose a thin, test-only convenience wrapper `count_callers(root_path: str, module: str, func_name: str, patterns: list[str] | None = None) -> int` that builds a one-off index and performs a single lookup; it is documented as rebuilding the index per call and SHALL NOT be used on the per-function adapter hot path. + +#### Scenario: inbound calls are counted +- **WHEN** `count_callers` runs over a project where `func_name` in `module` is called from two other call sites +- **THEN** it returns `2` + +#### Scenario: uncalled function returns zero +- **WHEN** `count_callers` runs for a function that no other code calls +- **THEN** it returns `0` + +#### Scenario: per-request isolation across trees +- **WHEN** `count_callers` is invoked for one project tree and then for a different project tree within the same process/session +- **THEN** the second result is unaffected by the first, because each request uses an isolated per-request astroid manager that prevents cross-request cache contamination + +#### Scenario: cross-module callers counted under a src/ layout +- **WHEN** a project uses a `src/` layout and module A calls a function defined in module B +- **THEN** the caller index returns a non-zero cross-module count by resolving the callee to its defining file path in module B, rather than comparing the root-relative dotted package (which would carry a spurious `src.` prefix and never match astroid's inferred module name) + +### Requirement: astroid failures degrade to zero +`count_callers` SHALL NOT propagate astroid errors as RPC errors, across two distinct degradation modes. (a) **Whole-count failure**: if astroid raises any exception — including `AstroidError`/`InferenceError` subclasses plus `RecursionError`, `MemoryError`, and `OSError` — or cannot build a manager or module view, `count_callers` SHALL return `0` for the entire count rather than raising. (b) **Per-call omission**: if a single call site's callee resolves to `Uninferable`, `count_callers` SHALL omit that one call from the count and continue counting the rest. A caller count of `0` is a valid, well-formed result — never a protocol error. + +#### Scenario: astroid raising yields zero, not an error +- **WHEN** astroid raises while inferring a module (simulated via monkeypatch) +- **THEN** `count_callers` returns `0` and no exception propagates to the caller + +#### Scenario: Uninferable callee is not counted +- **WHEN** a call site's callee inference returns `Uninferable` +- **THEN** that call does not increment the count and no exception is raised + +### Requirement: static analysis only, never executes analyzed code +`count_callers` SHALL perform static inference over source files only. It SHALL NOT import, execute, or otherwise run the analyzed project's code, consistent with Analysis Safety. + +#### Scenario: analyzed project is not imported +- **WHEN** `count_callers` runs over a fixture module with an observable import-time side effect (e.g. writing a sentinel file or mutating a module-level global at import) +- **THEN** the side effect does not occur — an enforcing test asserts the sentinel is absent — because the source is inferred statically and never imported or executed diff --git a/openspec/changes/classify-signals/specs/classify-signals-method/spec.md b/openspec/changes/classify-signals/specs/classify-signals-method/spec.md new file mode 100644 index 0000000..7edba3b --- /dev/null +++ b/openspec/changes/classify-signals/specs/classify-signals-method/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: classify_signals method params +The `classify_signals` method SHALL accept a `params` object with a required string `root_path` and an optional `patterns` array of strings (the same shared params as `analyze`/`complexity`/`coverage`). If `params` is absent, is not an object, or lacks a string `root_path`, the server SHALL respond with `-32602` (invalid params). If `patterns` is present and is not an array of strings, the server SHALL respond with `-32602`. + +#### Scenario: classify_signals accepts valid root_path and patterns +- **WHEN** a `classify_signals` request is sent with `params: {"root_path": "/abs/path", "patterns": ["./..."]}` +- **THEN** a valid result is returned + +#### Scenario: classify_signals rejects missing root_path +- **WHEN** a `classify_signals` request is sent with `params: {}` +- **THEN** a `-32602` error is returned + +#### Scenario: classify_signals rejects non-string root_path +- **WHEN** a `classify_signals` request is sent with `params: {"root_path": 123}` +- **THEN** a `-32602` error is returned + +#### Scenario: classify_signals rejects non-array patterns +- **WHEN** a `classify_signals` request is sent with `params: {"root_path": "/abs/path", "patterns": "**/*.py"}` +- **THEN** a `-32602` error is returned + +### Requirement: classify_signals result schema +The `classify_signals` method SHALL return a result object with a `signals` array. Each signal SHALL carry the exact protocol v1.1.0 field names `function`, `package`, `side_effect_type`, `source`, and `weight`, and SHALL carry a short, non-empty `reasoning`. `function` SHALL be the unqualified function name and `package` the dotted package (NOT a `name` field). `weight` SHALL be an integer. No signal object SHALL carry a `classification` field or any `contractual`/`incidental`/`ambiguous` label. + +#### Scenario: result is a signals array with protocol fields +- **WHEN** a `classify_signals` request is answered for a tree with at least one matching function-effect pair +- **THEN** the result has a `signals` array whose entries each contain `function`, `package`, `side_effect_type`, `source`, integer `weight`, and a short, non-empty `reasoning` + +#### Scenario: signals use function and package not name +- **WHEN** a signal is returned for the function `divide` in package `math_utils` +- **THEN** the object has `function == "divide"` and `package == "math_utils"` and has no `name` key + +#### Scenario: signals omit classification +- **WHEN** a `classify_signals` result contains any signal +- **THEN** no signal object contains a `classification` key or any classification label value + +### Requirement: source field is one of exactly five values +Every signal's `source` field SHALL be exactly one of `interface`, `visibility`, `caller_count`, `naming_convention`, or `docstring`. No other `source` value SHALL appear. + +#### Scenario: only allowed source strings appear +- **WHEN** a `classify_signals` result contains signals +- **THEN** each signal's `source` is one of `interface`, `visibility`, `caller_count`, `naming_convention`, `docstring` + +### Requirement: classify_signals maps missing root to -32602 +The `classify_signals` method SHALL translate a missing or non-directory `root_path` (surfaced as `FileNotFoundError` by the discovery layer) into a `-32602` invalid params error, mirroring the other analysis methods. + +#### Scenario: nonexistent root via RPC +- **WHEN** a `classify_signals` request is sent with a nonexistent `root_path` +- **THEN** a `-32602` error is returned + +### Requirement: classify_signals capability flag is advertised true +The `initialize` result SHALL advertise `capabilities.classify_signals: true`, matching the now-implemented method. The `discover` flag SHALL stay `true`; `test_mapping` and `streaming` SHALL stay `false`. + +#### Scenario: initialize advertises classify_signals true +- **WHEN** `initialize` is called +- **THEN** `capabilities` is `{discover: true, test_mapping: false, classify_signals: true, streaming: false}` + +### Requirement: classify_signals is registered and no longer method-not-found +The `classify_signals` method SHALL be registered in the server dispatch so it no longer returns `-32601`. A valid request SHALL return a result object rather than a method-not-found error. + +#### Scenario: classify_signals no longer method-not-found +- **WHEN** a `classify_signals` request with valid params is sent +- **THEN** the response is a result containing a `signals` array, not a `-32601` method-not-found error + +### Requirement: deterministic classify_signals output +The `classify_signals` method SHALL produce deterministic output: the same input tree with identical params MUST produce byte-identical JSON-RPC output across repeated invocations, inheriting the detector's deterministic function and effect ordering. + +#### Scenario: two calls over the same tree produce identical results +- **WHEN** a `classify_signals` request is sent twice over the same fixture tree with identical params +- **THEN** both responses are byte-identical in their `signals` arrays, including ordering + +### Requirement: supersedes prior capability assertions on archive +This change flips `capabilities.classify_signals` from `false` to `true`. Three prior changes are not yet archived and each carry an `initialize` capability snapshot that will land in `openspec/specs/` on archive: `scaffold-and-protocol` (protocol capability — all four flags `false`), `taxonomy-and-discovery` (discover-method — `classify_signals: false`), and `analysis-methods` (#4, analyze-method — `classify_signals: false`). Because OpenSpec archival is sequential, the latest archived `initialize` snapshot governs; this change is archived after those three, so its `classify_signals: true` assertion supersedes each prior `false` assertion. No separate MODIFIED spec block is required while those changes remain unarchived. This reconciliation is recorded so that archiving the full chain does not leave conflicting `initialize` capability scenarios in `openspec/specs/`. + +#### Scenario: capability transition is reconciled across the unarchived chain +- **WHEN** `scaffold-and-protocol`, `taxonomy-and-discovery`, change #4 (analysis-methods), and this change (classify-signals) are all archived in sequence +- **THEN** the effective `initialize` capability for `classify_signals` is `true`, with no contradictory `false` assertion remaining from any earlier change in the chain diff --git a/openspec/changes/classify-signals/specs/signal-adapter/spec.md b/openspec/changes/classify-signals/specs/signal-adapter/spec.md new file mode 100644 index 0000000..63221a8 --- /dev/null +++ b/openspec/changes/classify-signals/specs/signal-adapter/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: extract_signals public API +The system SHALL provide `src/snake_eyes/signals/adapter.py` exposing `extract_signals(root_path: str, patterns: list[str]) -> list[dict]`. This adapter is original snake-eyes code; gaze-py `classify/engine.py` SHALL NOT be lifted. `extract_signals` SHALL obtain functions via the #4 detector `analyze_path(root_path, patterns)`, thereby inheriting its discovery ordering, parse-error skipping, and resource bounds. + +#### Scenario: adapter returns a list of signal dicts +- **WHEN** `extract_signals` runs over a tree containing functions with observable effects +- **THEN** it returns a list whose entries are signal dicts + +#### Scenario: engine is not lifted +- **WHEN** the `src/snake_eyes/signals/` package is inspected +- **THEN** there is no lifted copy of gaze-py `classify/engine.py` and no local scoring formula + +### Requirement: per-effect extractor fan-out +For each `FunctionRecord` returned by `analyze_path`, and for each `Effect` on that record, `extract_signals` SHALL run all five extractors. Because `FunctionRecord` does not carry the AST inputs the extractors need, `extract_signals` SHALL re-parse the project's ASTs by enumerating files via `_shared.ordered_file_list(root_path, patterns)` and reading each through `_shared.iter_source_files` to derive class bases, function name, docstring, and `__all__` membership, and SHALL build the per-request caller index with a single `build_caller_index(root_path, patterns)` call. The same `(root_path, patterns)` SHALL be threaded to every consumer — the detector's `analyze_path`, the adapter's own AST re-parse, and `build_caller_index` — so that all of them enumerate the identical deterministic, pattern-consistent file set (each performs its own walk over that set; this triple-parse is an accepted trade-off). Each `FunctionRecord` SHALL be mapped to its enclosing class (needed for class bases) by locating the function `def` at `FunctionRecord.line` within the re-parsed AST and taking its enclosing `ClassDef`, if any. The inbound caller count SHALL be obtained via `CallerIndex.count(package, func_name)` (never the per-call `count_callers` test wrapper). Each extractor receives the data it needs from this set: class bases, function name, docstring, and `__all__` membership (from the re-parsed AST); the effect's `SideEffectType`; and the caller count. For every non-`None` extractor result it SHALL append exactly one signal dict, using that effect's type as `side_effect_type`. + +#### Scenario: multiple sources for one effect all emitted +- **WHEN** a function-effect pair matches both the `naming_convention` and `docstring` extractors +- **THEN** two signal dicts are appended, one per source, both carrying that effect's `side_effect_type` + +#### Scenario: function with zero effects yields zero signals +- **WHEN** a function has an empty `side_effects` list +- **THEN** `extract_signals` appends no signals for that function + +### Requirement: raw signals only, no aggregation and no labels +`extract_signals` SHALL NOT sum weights, clamp weights, de-duplicate signals across extractors, or assign any classification label. Multiple signals for the same function-effect pair from different extractors are expected and SHALL all be retained. The strings `contractual`, `incidental`, and `ambiguous` SHALL NOT appear as assigned output values in `adapter.py`. + +#### Scenario: duplicate-source signals are not deduped or summed +- **WHEN** two extractors both fire for the same function-effect pair +- **THEN** both signals appear in the output with their individual weights, and no combined or summed weight is produced + +#### Scenario: no classification label on adapter output +- **WHEN** any signal dict is produced by `extract_signals` +- **THEN** it contains no `contractual`/`incidental`/`ambiguous` label + +### Requirement: signal dict shape +Each signal dict produced by `extract_signals` SHALL carry the protocol v1.1.0 field names: `function` (the unqualified function name), `package` (the dotted package from the shared derivation), `side_effect_type` (the effect's `SideEffectType` value), `source` (one of the five allowed strings), and `weight` (an integer). A short, non-empty `reasoning` string SHALL be included. No other classification fields SHALL be present. + +#### Scenario: signal carries protocol field names +- **WHEN** `extract_signals` emits a signal for the function `divide` in package `math_utils` +- **THEN** the dict contains `function == "divide"`, `package == "math_utils"`, `side_effect_type`, `source`, an integer `weight`, and a short, non-empty `reasoning`, and uses `function`/`package` (not `name`) + +### Requirement: adapter integrates a raise with a documented exception +Given a function that raises an exception and whose docstring mentions that exception, `extract_signals` SHALL produce at least one signal whose `side_effect_type` is `ErrorReturn` or `ErrorSignal` (from the `docstring` and/or other extractors matching the error effect). + +#### Scenario: documented raise yields an error-typed signal +- **WHEN** `extract_signals` runs over a function that contains a `raise` and whose docstring mentions the raised exception +- **THEN** at least one emitted signal has `side_effect_type` equal to `ErrorReturn` or `ErrorSignal` diff --git a/openspec/changes/classify-signals/specs/signal-extractors/spec.md b/openspec/changes/classify-signals/specs/signal-extractors/spec.md new file mode 100644 index 0000000..ab61642 --- /dev/null +++ b/openspec/changes/classify-signals/specs/signal-extractors/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: signal extractor package and source identifiers +The system SHALL provide a `src/snake_eyes/signals/` package containing five signal extractors lifted from gaze-py `src/gaze_py/classify/signals/`: `interface.py`, `visibility.py`, `caller.py`, `naming.py`, and `docstring.py`. Each extractor SHALL emit its signal `source` as exactly one of the five protocol v1.1.0 strings — `interface`, `visibility`, `caller_count`, `naming_convention`, `docstring` — and SHALL NOT introduce any other `source` value (no sixth extractor, no `type_annotation`). Each extractor SHALL return either a single raw signal (carrying `weight` and a short, non-empty `reasoning`) or `None` when no rule applies. + +#### Scenario: only the five protocol sources are produced +- **WHEN** any extractor emits a signal +- **THEN** its `source` is one of `interface`, `visibility`, `caller_count`, `naming_convention`, `docstring` and no other value + +#### Scenario: extractor returns None when no rule applies +- **WHEN** an extractor is given inputs that match none of its rules +- **THEN** it returns `None` and no signal is produced + +### Requirement: interface extractor detects ABC and Protocol bases +The `interface` extractor SHALL emit an `interface` signal for a method defined on a class whose bases include an abstract base class (`abc.ABC`, a metaclass of `abc.ABCMeta`) or `typing.Protocol`. The emitted weight SHALL be the gaze-py interface weight (the lifted gaze-py source value, expected to be `30`). A method on a class with no interface base SHALL produce no `interface` signal. + +#### Scenario: ABC subclass method fires interface signal +- **WHEN** a method is defined on a class that subclasses `abc.ABC` +- **THEN** an `interface` signal is emitted with `source == "interface"` and `weight == 30` + +#### Scenario: Protocol subclass method fires interface signal +- **WHEN** a method is defined on a class that subclasses `typing.Protocol` +- **THEN** an `interface` signal is emitted with `source == "interface"` + +#### Scenario: plain class method produces no interface signal +- **WHEN** a method is defined on a class with no ABC or Protocol base +- **THEN** the `interface` extractor returns `None` and no `interface` signal is emitted + +### Requirement: visibility extractor distinguishes public and private names +The `visibility` extractor SHALL emit a `visibility` signal based on the function's public/private naming convention (leading-underscore private vs. public) and `__all__` membership, using the gaze-py weights verbatim. A public function and a private (leading-underscore) function SHALL produce distinguishable `visibility` outcomes (different weight, or one emits while the other does not), matching the lifted gaze-py behavior. The extractor SHALL NOT redefine the gaze-py weights. + +#### Scenario: public and private names differ in visibility signal +- **WHEN** the extractor runs for a public function `def public_fn()` and for a private function `def _private()` +- **THEN** the two `visibility` results differ per the lifted gaze-py implementation (either the private function's weight is lower, or one emits a signal while the other returns `None`), without any weight value being changed + +### Requirement: caller_count extractor maps inbound counts to gaze-py buckets +The `caller` extractor SHALL accept an inbound caller count and emit a `caller_count` signal whose weight is the gaze-py weight for that count's bucket. The bucket boundaries and their weights SHALL be preserved verbatim from gaze-py; this change SHALL NOT retune them. When gaze-py emits no signal for a given count (e.g. a zero-caller count), the extractor SHALL return `None` for that count. + +#### Scenario: distinct counts map to their gaze-py bucket weights +- **WHEN** the `caller` extractor is invoked with inbound counts of `0`, `5`, and `20` +- **THEN** each invocation returns the gaze-py `caller_count` weight for that count's bucket (or `None` where gaze-py emits no signal), with the gaze-py bucket boundaries and weights unchanged + +### Requirement: naming_convention extractor matches function name against effect type +The `naming` extractor SHALL emit a `naming_convention` signal when the function name's convention agrees with the effect type (e.g. a `get_`/`is_`/`has_` prefix agreeing with `ReturnValue`), and SHALL preserve gaze-py's negative-agreement outcomes (a name gaze-py treats as contradicting the effect stays negative). Weights SHALL be preserved verbatim from gaze-py. + +#### Scenario: getter name agrees with ReturnValue +- **WHEN** the extractor runs for a function named `get_foo` with effect type `ReturnValue` +- **THEN** a `naming_convention` signal is emitted reflecting positive agreement, per the gaze-py weight + +#### Scenario: contradicting name stays negative +- **WHEN** the extractor runs for a function whose name gaze-py treats as contradicting the effect type +- **THEN** the `naming_convention` result remains the negative-agreement outcome defined by gaze-py, unchanged + +### Requirement: docstring extractor matches docstring keywords against effect type +The `docstring` extractor SHALL emit a `docstring` signal when the function docstring contains keywords that agree with the effect type (e.g. a docstring mentioning "returns" agreeing with `ReturnValue`, or naming an exception agreeing with `ErrorReturn`/`ErrorSignal`), using gaze-py weights verbatim. A function with no docstring, or a docstring with no matching keyword, SHALL produce no `docstring` signal. + +#### Scenario: docstring mentioning returns agrees with ReturnValue +- **WHEN** the extractor runs for a function whose docstring contains "returns" with effect type `ReturnValue` +- **THEN** a `docstring` signal is emitted with `source == "docstring"` + +#### Scenario: absent docstring produces no signal +- **WHEN** the extractor runs for a function with no docstring +- **THEN** the `docstring` extractor returns `None` + +### Requirement: gaze-py signal weights preserved verbatim +The extractors SHALL preserve the gaze-py signal weights exactly as lifted. These weights are governance-gate values consumed by Gaze's classification formula; this change SHALL NOT retune, clamp, or otherwise modify them, and tests SHALL assert the gaze-py weights rather than redefine them. + +#### Scenario: interface weight is the gaze-py value +- **WHEN** the `interface` extractor emits a signal +- **THEN** its `weight` is `30`, the gaze-py interface weight, and no test alters that value + +### Requirement: new Python effect types mapped to closest branch, never KeyError +For extractors that switch on effect type (`naming`, `docstring`), the system SHALL handle the 10 Python-specific `SideEffectType` values added beyond gaze-py's original 38 (`ErrorSignal`, `GeneratorYield`, `StreamOutput`, `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, `MonkeyPatch`, `ContainerMutation`) plus the gaze-py-original `ClosureCaptureMutation`, by routing each to the closest existing gaze-py branch (`GeneratorYield`/`StreamOutput`/`AsyncGeneratorYield` like `ReturnValue`; `MonkeyPatch` like `ReflectionMutation`; `ContainerMutation` like the P1 `SliceMutation`/`MapMutation` branch; `DescriptorEffect`/`ResourceManagement`/`MetaprogrammingMutation`/`ImportSideEffect` like the closest P2/mutation branch; `ClosureCaptureMutation` like the P4 `ReflectionMutation` branch; `ErrorSignal` like the error branch). An effect type that matches no keyword or prefix SHALL cause the extractor to return `None`. The extractors SHALL NOT raise `KeyError` (or any exception) on an unrecognized effect type. + +#### Scenario: new effect type routes to closest branch +- **WHEN** an extractor that switches on effect type is given `GeneratorYield` +- **THEN** it returns the same signal structure (equal `weight` and semantically equivalent `reasoning`) as it does for `ReturnValue`, its closest branch, and does not raise + +#### Scenario: unmatched effect type returns None not KeyError +- **WHEN** an extractor is given an effect type for which no keyword or prefix applies +- **THEN** it returns `None` and raises no exception + +### Requirement: extractors assign no classification label +The extractor modules SHALL NOT compute or assign any classification label. The strings `contractual`, `incidental`, and `ambiguous` SHALL NOT appear as assigned output values anywhere in `src/snake_eyes/signals/`; they MAY appear only in comments that document the prohibition. + +#### Scenario: no classification label emitted by extractors +- **WHEN** any extractor emits a signal +- **THEN** the signal carries `weight`, `source`, and `reasoning` but no `contractual`/`incidental`/`ambiguous` label + +#### Scenario: production code contains no assigned classification labels +- **WHEN** `src/snake_eyes/signals/` production code is scanned for `contractual`, `incidental`, or `ambiguous` used as assigned values +- **THEN** none is found (occurrences, if any, are only in comments forbidding them) + +### Requirement: gaze-py provenance retained on lifted extractors +Each lifted `signals/*.py` extractor SHALL retain the gaze-py copyright header (Matt Peter, Apache 2.0) AND SHALL add an Apache-2.0 §4(b) change notice identifying zero-dot-force as the modifier (e.g. `# Modified 2026 by zero-dot-force: adapted to snake_eyes SideEffectType; new Python effect types mapped.`). + +#### Scenario: provenance header present on a lifted extractor +- **WHEN** `src/snake_eyes/signals/interface.py` is inspected +- **THEN** it contains the gaze-py Apache 2.0 provenance header and an Apache-2.0 §4(b) change notice identifying zero-dot-force diff --git a/openspec/changes/classify-signals/tasks.md b/openspec/changes/classify-signals/tasks.md new file mode 100644 index 0000000..7212065 --- /dev/null +++ b/openspec/changes/classify-signals/tasks.md @@ -0,0 +1,81 @@ +## 1. Dependencies + +- [ ] 1.1 Add `astroid>=3.0,<4` to `[project.dependencies]` in `pyproject.toml` (second runtime dependency after `coverage>=7.0,<8`; needed for caller-count inference; do NOT add `radon` or any other analysis dependency) +- [ ] 1.2 Refresh `uv.lock` so `uv sync --locked` passes with the new dependency (do NOT hand-edit the lockfile; regenerate via `uv lock`) + +## 2. Signal extractors package + +- [ ] 2.1 Create `src/snake_eyes/signals/__init__.py` establishing the new `snake_eyes.signals` package (re-export the five extractor callables and `extract_signals` for ergonomic imports; keep it side-effect free) +- [ ] 2.2 Lift `src/gaze_py/classify/signals/interface.py` → `src/snake_eyes/signals/interface.py`: retain the gaze-py / Matt Peter / Apache 2.0 provenance header AND add an Apache-2.0 §4(b) change notice (e.g. `# Modified 2026 by zero-dot-force: adapted to snake_eyes SideEffectType; new Python effect types mapped.`); adapt imports to `snake_eyes.analysis.effects.SideEffectType`; emit an `interface` source signal (the gaze-py interface weight, expected 30 per the lifted source — verbatim, not invented) when the enclosing class derives from `abc.ABC`/uses `ABCMeta` or subclasses `typing.Protocol`; return `None` otherwise +- [ ] 2.3 Lift `src/gaze_py/classify/signals/visibility.py` → `src/snake_eyes/signals/visibility.py` (same header + §4(b) notice + import adaptation): emit a `visibility` source signal keyed on public vs `_private` naming and `__all__` membership; preserve gaze-py weights verbatim so public and private produce the gaze-py-defined differing weights; return `None` when no visibility rule applies +- [ ] 2.4 Lift `src/gaze_py/classify/signals/caller.py` → `src/snake_eyes/signals/caller.py` (same header + §4(b) notice + import adaptation): emit a `caller_count` source signal from an inbound caller count argument using gaze-py's bucket thresholds and weights verbatim; a caller count that falls in gaze-py's no-signal bucket returns `None` (the `caller_count == 0` degraded case MUST NOT raise) +- [ ] 2.5 Lift `src/gaze_py/classify/signals/naming.py` → `src/snake_eyes/signals/naming.py` (same header + §4(b) notice + import adaptation): emit a `naming_convention` source signal comparing the function name against the effect type (e.g. `get_*` + `ReturnValue` → positive-weight signal); a name gaze-py treats as contradicting the effect stays negative-weight; return `None` when no naming rule applies +- [ ] 2.6 Lift `src/gaze_py/classify/signals/docstring.py` → `src/snake_eyes/signals/docstring.py` (same header + §4(b) notice + import adaptation): emit a `docstring` source signal when docstring keywords match the effect type (e.g. a docstring mentioning "returns" + `ReturnValue`, or a named exception + an error-typed effect); return `None` when the docstring is absent or no keyword matches +- [ ] 2.7 Extend the `naming` and `docstring` extractors' effect-type switches (the only two that switch on effect type) to cover the 10 Python-specific new types added beyond gaze-py's original 38 plus the gaze-py-original `ClosureCaptureMutation`, with NO `KeyError`: map `GeneratorYield`/`StreamOutput`/`AsyncGeneratorYield` to the closest `ReturnValue`-like branch; `MonkeyPatch` to the `ReflectionMutation`-like branch; `ContainerMutation` to the closest P1 mutation branch (`SliceMutation`/`MapMutation`-like); `DescriptorEffect`/`ResourceManagement`/`MetaprogrammingMutation`/`ImportSideEffect` to the closest P2/mutation branch; `ClosureCaptureMutation` to the P4 exotic (`ReflectionMutation`-like) branch; `ErrorSignal` to the error branch; any effect type with no applicable keyword/prefix returns `None` (no signal) rather than raising +- [ ] 2.8 Confirm NO extractor computes or assigns a classification label: `contractual`/`incidental`/`ambiguous` MUST NOT appear as produced output values, weights are NOT summed/clamped, and no tier boost or contradiction penalty is applied (extractors emit RAW per-source signals only) + +## 3. Caller inference + +- [ ] 3.1 Create `src/snake_eyes/analysis/inference.py` exposing the caller-index API — `def build_caller_index(root_path: str, patterns: list[str] | None) -> CallerIndex` and `CallerIndex.count(module: str, func_name: str) -> int` — plus a thin TEST-ONLY wrapper `def count_callers(root_path: str, module: str, func_name: str, patterns: list[str] | None = None) -> int` (builds a one-off index then does a single lookup; documented as rebuilding per call, so NOT on the adapter hot path). Module docstring notes astroid is the inference backend; rely on Python 3 absolute imports so `import astroid` resolves to the third-party package +- [ ] 3.2 In `build_caller_index`, build an astroid project view over files ON DISK under `root_path` (via `astroid.MANAGER.ast_from_file` or a fresh `astroid.Manager`; do NOT require the analyzed project to be installed/importable); `CallerIndex.count` counts `astroid` `Call` nodes whose inferred callee resolves to `func_name` AND whose RESOLVED DEFINING FILE PATH matches the analyzed file for that function — match on resolved file path, NOT dotted-module-string equality (`_shared.derive_package` yields a root-relative `src.pkg.mod` that would never equal astroid's inferred `pkg.mod` under a `src/` layout) +- [ ] 3.3 Implement graceful degradation (Analysis Safety + issue mandate) in two modes: (a) if astroid raises any exception — `AstroidError`/`InferenceError` and subclasses plus `RecursionError`, `MemoryError`, and `OSError` — or cannot build a manager/module view, return `0` for the entire count; (b) if a single call site's callee resolves to `Uninferable`, omit that one call from the count and continue counting the rest. Never propagate an exception to the RPC layer and never mark `0` an error +- [ ] 3.4 Static-only guarantee: the caller-index API (`build_caller_index`/`CallerIndex.count` and the `count_callers` wrapper) MUST parse source without importing or executing analyzed modules (no `importlib`, no `exec`); add an inline comment asserting this invariant +- [ ] 3.5 Build the astroid call graph ONCE per `extract_signals` invocation (NOT once per function): `build_caller_index` constructs the project view before the fan-out loop and returns a reusable `CallerIndex` used for all `CallerIndex.count` lookups, avoiding O(functions × whole-project parse); enumerate the file set via a single `_shared.ordered_file_list(root_path, patterns)` call (which applies the `_shared` symlink-skip and excluded-directory guards) and then filter that list through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files BEFORE astroid parses any file (the byte-cap lives in `is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid path MUST apply it explicitly to honor the Constitution V resource bound) +- [ ] 3.6 Use an ISOLATED per-request astroid manager: create a fresh `astroid.Manager` (or call `MANAGER.clear_cache()`) at the start of each request rather than relying on the process-global `MANAGER`, preventing cross-request cache contamination in the long-lived stdio server; restrict import resolution to the on-disk project (not the ambient `sys.path`) so counts are environment-independent + +## 4. Signal adapter + +- [ ] 4.1 Create `src/snake_eyes/signals/adapter.py` with `def extract_signals(root_path: str, patterns: list[str]) -> list[dict]` — a NEW composition layer; explicitly do NOT lift or port gaze-py `classify/engine.py` (add a module comment recording that engine/scoring is intentionally omitted to avoid re-introducing scoring drift) +- [ ] 4.2 Implement the fan-out algorithm: (1) `functions = analyze_path(root_path, patterns)`; (2) thread the same `(root_path, patterns)` to every consumer — the detector's `analyze_path`, the adapter's own AST re-parse (`_shared.ordered_file_list` + `_shared.iter_source_files`), and `build_caller_index` — so each enumerates the identical deterministic, pattern-consistent file set (each performs its own walk over that set; this triple-parse is an accepted trade-off); (3) because `FunctionRecord` carries no AST inputs, re-parse each enumerated file's AST via `_shared.iter_source_files` to derive class bases, function name, docstring, and `__all__` membership, mapping each `FunctionRecord` to its enclosing class by locating the `def` at `FunctionRecord.line` and taking its enclosing `ClassDef`; (4) call `build_caller_index(root_path, patterns)` ONCE (task 3.5) over that same file set; (5) for each `FunctionRecord`, for each `Effect` on the record, run all five extractors with the AST inputs + the effect's `SideEffectType` + `CallerIndex.count(package, func_name)`; (6) append ONE signal dict per non-`None` extractor result, using that effect's type as `side_effect_type` +- [ ] 4.3 Emit each signal dict with exactly these keys: `function` (unqualified name — NOT `name`), `package` (dotted module path), `side_effect_type` (the effect's type string), `source` (one of the five: `interface`/`visibility`/`caller_count`/`naming_convention`/`docstring`), `weight` (int, verbatim from the extractor), and `reasoning` (a short, non-empty string — always included) +- [ ] 4.4 Preserve RAW semantics: functions with zero effects yield zero signals; do NOT dedupe across extractors (multiple sources for the same effect are expected and retained); do NOT sum weights, clamp, or assign any contractual/incidental/ambiguous label +- [ ] 4.5 Implement DETERMINISM ordering: `signals[]` ordered by a stable key (`(package, function, side_effect_type, source)`); the same input tree MUST produce byte-identical JSON-RPC output (mirrors detector's determinism task in analysis-methods) + +## 5. Server and protocol wiring + +- [ ] 5.1 Flip `capabilities.classify_signals` to `True` in `protocol.initialize_result()`; leave `discover: true` and `test_mapping`/`streaming: false` unchanged (test_mapping and streaming are out of scope) +- [ ] 5.2 Add a `_classify_signals` handler to `server.py` and register it in `DEFAULT_DISPATCH` under the key `classify_signals`; validate `{root_path, patterns}` via the existing `_validate_analysis_params` (missing/non-string `root_path` → `-32602`; non-array/`non-str`-element `patterns` → `-32602`) +- [ ] 5.3 In the handler, call `signals.adapter.extract_signals(root_path, patterns)`, map `FileNotFoundError` → `RpcError(INVALID_PARAMS)` (`-32602`) mirroring the analyze/complexity/coverage handlers, and return `{"signals": [...]}` +- [ ] 5.4 Update `src/snake_eyes/analysis/__init__.py` and/or a `src/snake_eyes/signals/__init__.py` re-export if needed so imports resolve cleanly under `mypy --strict` + +## 6. Fixtures + +- [ ] 6.1 Create `tests/fixtures/signals/interface_fixtures.py`: an `abc.ABC` subclass with a method, a `typing.Protocol` subclass with a method, and a plain (no-base) class with a method — supports interface-fires / interface-absent tests +- [ ] 6.2 Create `tests/fixtures/signals/visibility_fixtures.py`: a public `def public_fn(...)`, a `def _private(...)`, and a module `__all__` entry — supports the public-vs-private weight-differs test +- [ ] 6.3 Create `tests/fixtures/signals/naming_fixtures.py`: a `def get_foo(...)` that returns a value (positive `naming_convention` + `ReturnValue`) and a function whose name gaze-py treats as contradicting its effect (stays negative) +- [ ] 6.4 Create `tests/fixtures/signals/docstring_fixtures.py`: a function whose docstring contains "returns" paired with a `ReturnValue` effect, and a function whose docstring names an exception paired with a `raise` +- [ ] 6.5 Create `tests/fixtures/signals/adapter_project/` (a tiny discoverable package) containing a function that BOTH raises an exception AND documents that exception in its docstring — supports the adapter integration test expecting ≥1 signal with `side_effect_type` in {`ErrorReturn`, `ErrorSignal`} +- [ ] 6.6 Add a caller-count fixture project under `tests/fixtures/signals/` with a target function invoked from multiple call sites (0-, ~5-, and ~20-caller scenarios) so `count_callers` and the caller bucket weights can be exercised (tests may also mock `count_callers` directly per 7.3) +- [ ] 6.7 Create `tests/fixtures/signals/src_layout_project/` using a `src/`-style layout where module A calls a function defined in module B — supports the cross-module caller-count test that must resolve callees by defining file path (guards against the `src.`-prefix module-name mismatch) + +## 7. Tests + +- [ ] 7.1 Create `tests/test_signals_interface.py`: ABC-subclass method → `source == "interface"`, `weight == 30`; `typing.Protocol` subclass → interface signal fires; plain class → NO interface signal (returns `None`) +- [ ] 7.2 Create `tests/test_signals_visibility.py`: `public_fn` vs `_private` produce differing `visibility` weights per the lifted implementation; pin the exact gaze-py integer weight values as hardcoded literals (not relational-only, not imported-constant comparisons); do NOT change the weights to make the test pass +- [ ] 7.3 Create `tests/test_signals_caller.py`: assert the exact gaze-py bucket weights for caller counts of 0, 5, and 20 as hardcoded integer literals (e.g. `assert weight == 15`, not a comparison against an imported constant); the `0` case must degrade cleanly to whatever gaze-py's zero bucket yields (possibly `None`) without raising +- [ ] 7.4 Create `tests/test_signals_naming.py`: `get_foo` + `ReturnValue` → positive-weight `naming_convention` signal; a name gaze-py treats as contradicting the effect stays negative-weight; pin both the positive and negative weights as hardcoded integer literals verified against gaze-py source +- [ ] 7.5 Create `tests/test_signals_docstring.py`: a docstring containing "returns" + `ReturnValue` → a `docstring` source signal; assert the `reasoning` string is non-empty +- [ ] 7.6 Create `tests/test_signals_new_types.py`: parametrize across all 11 effect types that need closest-branch routing (the 10 Python-specific types added beyond gaze-py's 38, including `ContainerMutation`, plus the gaze-py-original `ClosureCaptureMutation`) and assert EQUIVALENCE to the closest branch — e.g. `naming(get_foo, GeneratorYield)` yields the same weight + semantics as `naming(get_foo, ReturnValue)`, and `ContainerMutation` matches `SliceMutation`; a no-raise/no-`KeyError` assertion alone is insufficient +- [ ] 7.7 Create `tests/test_inference.py`: assert `build_caller_index(...).count(...)` (and the thin `count_callers` wrapper) return a correct count for the caller fixture (6.6); assert the astroid-failure path returns `0` by monkeypatching astroid to raise / return `Uninferable` (Constitution IV: inference.py failure path MUST be covered) +- [ ] 7.8 Create `tests/test_signals_adapter.py`: over fixture 6.5, assert `extract_signals` returns ≥1 signal with `side_effect_type` in {`ErrorReturn`, `ErrorSignal`}; assert a function with zero effects yields zero signals; assert signals are NOT deduped (same effect from multiple sources retained); assert every signal dict uses `function`/`package` keys (NOT `name`) and a `source` from exactly the five allowed values; assert every emitted signal carries a short, non-empty `reasoning` string; mock `count_callers` where a deterministic count is needed +- [ ] 7.9 Create `tests/test_classify_signals_method.py` (JSON-RPC end-to-end against a `tmp_path` fixture copy): `initialize` advertises `capabilities.classify_signals == true`; `classify_signals` returns a `{"signals": [...]}` array; every signal in the array carries a short, non-empty `reasoning` string; missing `root_path` → `-32602`; a `patterns` value that is not an array of strings (e.g. a non-string element) → `-32602`; the method is registered (no `-32601`) +- [ ] 7.10 Add the NEGATIVE label test: assert that production code under `src/snake_eyes/signals/` never emits `contractual`/`incidental`/`ambiguous` as ASSIGNED/computed output values; use the `tokenize` module (or AST string-literal analysis) to strip comments before scanning so the prohibition comments do not false-positive — a raw substring grep is insufficient (those words are permitted only in comments that forbid them) +- [ ] 7.11 Add a byte-identical determinism test for `classify_signals`: run the method twice on the same fixture tree in SEPARATE subprocesses (or with differing `PYTHONHASHSEED`) to exercise hash-seed-dependent dict/set ordering, and assert byte-identical JSON output; OR assert the output equals a checked-in golden file keyed by `(package, function, side_effect_type, source)` (mirrors analysis-methods determinism tests) +- [ ] 7.12 Create `tests/test_inference_static_only.py`: a fixture module with an observable import-time side effect (writes a sentinel file or sets a module global at import time); run `count_callers` over it and assert the side effect did NOT occur (sentinel absent) — enforces the Constitution V static-only guarantee as a regression test, not merely an inline comment +- [ ] 7.13 Add a cross-module caller-count test over the src/-layout fixture (6.7): assert `build_caller_index` yields a NON-ZERO count for the target function invoked from another module, proving callees are matched by resolved defining file path (not by `derive_package`'s root-relative dotted name) under a `src/` layout +- [ ] 7.14 Add an oversized-file guard test for the caller-count path: with a fixture file whose size exceeds `MAX_FILE_BYTES` (16 MiB, synthesized via monkeypatched `os.stat`/`Path.stat` so no large blob is committed), assert `build_caller_index` skips it via the `_shared.is_analyzable_file` filter and never hands it to astroid, keeping the caller-count path within the Constitution V resource bound (bounds the astroid path the same way the `ast` read path is bounded) + +## 8. Documentation + +- [ ] 8.1 Sync `README.md` in four parts: (a) update the status/capability table to show `classify_signals` as implemented and advertised `true`, AND fix the capability-flags sentence that currently lists `classify_signals` among the `false` flags; (b) note `astroid>=3.0,<4` is now a runtime dependency (move it from "planned" to shipped); (c) add `src/snake_eyes/signals/` (the five extractors + `adapter.py`) and `src/snake_eyes/analysis/inference.py` to the project-structure tree, replacing any "Planned later: astroid" note; (d) extend the License section to enumerate `signals/*.py` among the files lifted from gaze-py (Matt Peter, Apache 2.0) +- [ ] 8.2 Sync `AGENTS.md`: update Technology Stack to scope the astroid entry to caller-count inference (`inference.py`) — now shipped, not planned — rather than the broad "name resolution, type inference, cross-module imports" wording; update Project Structure to add `src/snake_eyes/signals/` (with the five extractors + `adapter.py`) and `src/snake_eyes/analysis/inference.py`; record that issue #5 delivered the classification signal extractors and `classify_signals` method + +## 9. Verification + +- [ ] 9.1 Run `uv sync --locked` +- [ ] 9.2 Run `uv run ruff check src/ tests/` and `uv run ruff format --check src/ tests/` +- [ ] 9.3 Run `uv run mypy src/` +- [ ] 9.4 Run `uv run pytest --cov=snake_eyes --cov-report=term-missing --cov-fail-under=85` (targets per Constitution IV: each `signals/*.py` extractor ≥90%, `adapter.py` ≥95%, `inference.py` ≥85% with the astroid-failure path exercised; project gate remains ≥85% and MUST NOT be lowered) +- [ ] 9.5 Manually verify `uv run snake-eyes --stdio` answers `classify_signals` with protocol-shaped JSON (`signals[]` using `function`/`package`) and that `initialize` now reports `classify_signals: true` with `test_mapping`/`streaming` unchanged + + From da990fdf539e2992d49f6ac9f614687cb683bd7c Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Mon, 31 Aug 2026 12:51:42 -0400 Subject: [PATCH 2/6] feat(classify-signals): implement classification signal extraction + classify_signals method (#5) Add the signals/ package (interface, visibility, caller, naming, docstring extractors + adapter fan-out) and analysis/inference.py (astroid caller-count index), wire the classify_signals JSON-RPC method, and flip the capability flag to true. Extractors reconstructed from documented gaze-py behavior (gaze-py source unavailable); the engine is deliberately not lifted. Section 6 fixture scenarios are realized as tmp_path-built trees within the test modules rather than committed tests/fixtures/signals/ files. --- AGENTS.md | 20 ++- README.md | 48 +++-- openspec/changes/classify-signals/tasks.md | 106 +++++------ pyproject.toml | 2 +- src/snake_eyes/analysis/inference.py | 181 +++++++++++++++++++ src/snake_eyes/protocol.py | 6 +- src/snake_eyes/server.py | 11 ++ src/snake_eyes/signals/__init__.py | 24 +++ src/snake_eyes/signals/_routing.py | 85 +++++++++ src/snake_eyes/signals/_types.py | 24 +++ src/snake_eyes/signals/adapter.py | 196 +++++++++++++++++++++ src/snake_eyes/signals/caller.py | 46 +++++ src/snake_eyes/signals/docstring.py | 47 +++++ src/snake_eyes/signals/interface.py | 39 ++++ src/snake_eyes/signals/naming.py | 86 +++++++++ src/snake_eyes/signals/visibility.py | 43 +++++ tests/test_classify_signals_method.py | 79 +++++++++ tests/test_discover_method.py | 2 +- tests/test_inference.py | 94 ++++++++++ tests/test_inference_static_only.py | 27 +++ tests/test_protocol.py | 2 +- tests/test_server.py | 4 +- tests/test_signals_adapter.py | 95 ++++++++++ tests/test_signals_caller.py | 30 ++++ tests/test_signals_docstring.py | 28 +++ tests/test_signals_interface.py | 37 ++++ tests/test_signals_naming.py | 33 ++++ tests/test_signals_negative_label.py | 44 +++++ tests/test_signals_new_types.py | 50 ++++++ tests/test_signals_visibility.py | 34 ++++ uv.lock | 15 +- 31 files changed, 1456 insertions(+), 82 deletions(-) create mode 100644 src/snake_eyes/analysis/inference.py create mode 100644 src/snake_eyes/signals/__init__.py create mode 100644 src/snake_eyes/signals/_routing.py create mode 100644 src/snake_eyes/signals/_types.py create mode 100644 src/snake_eyes/signals/adapter.py create mode 100644 src/snake_eyes/signals/caller.py create mode 100644 src/snake_eyes/signals/docstring.py create mode 100644 src/snake_eyes/signals/interface.py create mode 100644 src/snake_eyes/signals/naming.py create mode 100644 src/snake_eyes/signals/visibility.py create mode 100644 tests/test_classify_signals_method.py create mode 100644 tests/test_inference.py create mode 100644 tests/test_inference_static_only.py create mode 100644 tests/test_signals_adapter.py create mode 100644 tests/test_signals_caller.py create mode 100644 tests/test_signals_docstring.py create mode 100644 tests/test_signals_interface.py create mode 100644 tests/test_signals_naming.py create mode 100644 tests/test_signals_negative_label.py create mode 100644 tests/test_signals_new_types.py create mode 100644 tests/test_signals_visibility.py diff --git a/AGENTS.md b/AGENTS.md index 4216a7e..606fffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,8 +72,8 @@ subprocess. The division of responsibility: AST analysis - **Scope analysis**: Python `symtable` module (stdlib) for global/nonlocal detection -- **Inference** (planned): [Astroid](https://github.com/pylint-dev/astroid) - for name resolution, type inference, cross-module imports +- **Inference**: [Astroid](https://github.com/pylint-dev/astroid) `>=3.0,<4` + (shipped) for caller-count inference in `analysis/inference.py` - **Complexity**: lifted gaze-py McCabe implementation; no radon dependency - **Coverage**: [coverage.py](https://github.com/nedbat/coveragepy) `>=7.0,<8` (shipped runtime dependency) for parsing `.coverage` data files @@ -91,13 +91,24 @@ snake-eyes/ │ ├── protocol.py # Request/response types │ ├── discovery.py # File discovery (os.walk) │ ├── coverage.py # Coverage data parser (coverage.json / .coverage) -│ └── analysis/ +│ ├── analysis/ │ ├── __init__.py │ ├── _shared.py # Shared helpers (safe file reader, package derivation) │ ├── effects.py # 48-type SideEffectType taxonomy │ ├── models.py # Effect / FunctionRecord data models │ ├── detector.py # Python side-effect detector (analyze method) -│ └── complexity.py # McCabe cyclomatic complexity (complexity method) +│ ├── complexity.py # McCabe cyclomatic complexity (complexity method) +│ └── inference.py # astroid caller-count inference (classify_signals) +│ └── signals/ +│ ├── __init__.py +│ ├── _routing.py # effect-type → category routing (lifted from gaze-py) +│ ├── _types.py # SignalResult value type +│ ├── interface.py # interface source extractor (lifted from gaze-py) +│ ├── visibility.py # visibility source extractor (lifted from gaze-py) +│ ├── caller.py # caller_count source extractor (lifted from gaze-py) +│ ├── naming.py # naming_convention source extractor (lifted from gaze-py) +│ ├── docstring.py # docstring source extractor (lifted from gaze-py) +│ └── adapter.py # extract_signals fan-out (classify_signals method) ├── tests/ ├── .github/workflows/ # CI: ruff, mypy, pytest gates ├── pyproject.toml @@ -107,6 +118,7 @@ snake-eyes/ └── NOTICE ``` Delivered in issue #4: `detector.py`, `complexity.py`, `coverage.py`, `_shared.py`, and the `analyze`, `complexity`, and `coverage` JSON-RPC methods. +Delivered in issue #5: the `signals/` extractors, `analysis/inference.py` (astroid caller-count inference), and the `classify_signals` JSON-RPC method. ## Shell Commands diff --git a/README.md b/README.md index 25a7fe5..bc3699b 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ Snake Eyes is a Gaze-spawned subprocess. It speaks JSON-RPC 2.0 over stdin/stdou | `analyze` | Implemented | | `complexity` | Implemented | | `coverage` | Implemented | +| `classify_signals` | Implemented | -Capability flags advertised at handshake: `discover` is `true`; -`test_mapping`, `classify_signals`, and `streaming` are `false`. -Side-effect detection is implemented. coverage.py (`>=7.0,<8`) is a -runtime dependency (shipped). Astroid (name inference) is planned for -a later issue. `radon` is not used — cyclomatic complexity is computed -via a lifted McCabe implementation (no radon dependency). +Capability flags advertised at handshake: `discover` and +`classify_signals` are `true`; `test_mapping` and `streaming` are `false`. +Side-effect detection and classification-signal extraction are +implemented. coverage.py (`>=7.0,<8`) and astroid (`>=3.0,<4`, +caller-count inference) are runtime dependencies (shipped). `radon` is +not used — cyclomatic complexity is computed via a lifted McCabe +implementation (no radon dependency). ## Installation @@ -56,13 +58,24 @@ snake-eyes/ │ ├── protocol.py # Request/response types │ ├── discovery.py # File discovery (os.walk) │ ├── coverage.py # Coverage data parser (coverage.json / .coverage) -│ └── analysis/ +│ ├── analysis/ +│ │ ├── __init__.py +│ │ ├── _shared.py # Shared helpers (safe file reader, package derivation) +│ │ ├── effects.py # 48-type SideEffectType taxonomy +│ │ ├── models.py # Effect / FunctionRecord data models +│ │ ├── detector.py # Python side-effect detector (analyze method) +│ │ ├── complexity.py # McCabe cyclomatic complexity (complexity method) +│ │ └── inference.py # astroid caller-count inference (classify_signals) +│ └── signals/ │ ├── __init__.py -│ ├── _shared.py # Shared helpers (safe file reader, package derivation) -│ ├── effects.py # 48-type SideEffectType taxonomy -│ ├── models.py # Effect / FunctionRecord data models -│ ├── detector.py # Python side-effect detector (analyze method) -│ └── complexity.py # McCabe cyclomatic complexity (complexity method) +│ ├── interface.py # interface source extractor (lifted from gaze-py) +│ ├── visibility.py # visibility source extractor (lifted from gaze-py) +│ ├── caller.py # caller_count source extractor (lifted from gaze-py) +│ ├── naming.py # naming_convention extractor (lifted from gaze-py) +│ ├── docstring.py # docstring source extractor (lifted from gaze-py) +│ ├── _routing.py # effect-type → category routing (naming/docstring) +│ ├── _types.py # SignalResult value type +│ └── adapter.py # extract_signals fan-out (classify_signals method) ├── tests/ ├── pyproject.toml └── NOTICE @@ -70,7 +83,8 @@ snake-eyes/ Delivered in issue #4: `detector.py`, `complexity.py`, `coverage.py`, `_shared.py`, and the `analyze`, `complexity`, and `coverage` JSON-RPC methods. -Planned later: astroid-based name inference. +Delivered in issue #5: the `signals/` extractors, `analysis/inference.py` +(astroid caller-count inference), and the `classify_signals` JSON-RPC method. ## Limits & Troubleshooting @@ -94,6 +108,8 @@ invalid, the `coverage` method returns an empty result (`[]`), never an error. ## License -Apache 2.0 -- see [LICENSE](LICENSE). Portions of `detector.py` and -`complexity.py` are lifted from gaze-py under Apache-2.0; see [NOTICE](NOTICE) -for attribution. +Apache 2.0 -- see [LICENSE](LICENSE). Portions of `detector.py`, +`complexity.py`, and the `signals/` extractors (`interface.py`, `visibility.py`, +`caller.py`, `naming.py`, `docstring.py`, `_routing.py`) are lifted or +reconstructed from gaze-py under Apache-2.0; see [NOTICE](NOTICE) for +attribution. diff --git a/openspec/changes/classify-signals/tasks.md b/openspec/changes/classify-signals/tasks.md index 7212065..b6f6b94 100644 --- a/openspec/changes/classify-signals/tasks.md +++ b/openspec/changes/classify-signals/tasks.md @@ -1,81 +1,81 @@ ## 1. Dependencies -- [ ] 1.1 Add `astroid>=3.0,<4` to `[project.dependencies]` in `pyproject.toml` (second runtime dependency after `coverage>=7.0,<8`; needed for caller-count inference; do NOT add `radon` or any other analysis dependency) -- [ ] 1.2 Refresh `uv.lock` so `uv sync --locked` passes with the new dependency (do NOT hand-edit the lockfile; regenerate via `uv lock`) +- [x] 1.1 Add `astroid>=3.0,<4` to `[project.dependencies]` in `pyproject.toml` (second runtime dependency after `coverage>=7.0,<8`; needed for caller-count inference; do NOT add `radon` or any other analysis dependency) +- [x] 1.2 Refresh `uv.lock` so `uv sync --locked` passes with the new dependency (do NOT hand-edit the lockfile; regenerate via `uv lock`) ## 2. Signal extractors package -- [ ] 2.1 Create `src/snake_eyes/signals/__init__.py` establishing the new `snake_eyes.signals` package (re-export the five extractor callables and `extract_signals` for ergonomic imports; keep it side-effect free) -- [ ] 2.2 Lift `src/gaze_py/classify/signals/interface.py` → `src/snake_eyes/signals/interface.py`: retain the gaze-py / Matt Peter / Apache 2.0 provenance header AND add an Apache-2.0 §4(b) change notice (e.g. `# Modified 2026 by zero-dot-force: adapted to snake_eyes SideEffectType; new Python effect types mapped.`); adapt imports to `snake_eyes.analysis.effects.SideEffectType`; emit an `interface` source signal (the gaze-py interface weight, expected 30 per the lifted source — verbatim, not invented) when the enclosing class derives from `abc.ABC`/uses `ABCMeta` or subclasses `typing.Protocol`; return `None` otherwise -- [ ] 2.3 Lift `src/gaze_py/classify/signals/visibility.py` → `src/snake_eyes/signals/visibility.py` (same header + §4(b) notice + import adaptation): emit a `visibility` source signal keyed on public vs `_private` naming and `__all__` membership; preserve gaze-py weights verbatim so public and private produce the gaze-py-defined differing weights; return `None` when no visibility rule applies -- [ ] 2.4 Lift `src/gaze_py/classify/signals/caller.py` → `src/snake_eyes/signals/caller.py` (same header + §4(b) notice + import adaptation): emit a `caller_count` source signal from an inbound caller count argument using gaze-py's bucket thresholds and weights verbatim; a caller count that falls in gaze-py's no-signal bucket returns `None` (the `caller_count == 0` degraded case MUST NOT raise) -- [ ] 2.5 Lift `src/gaze_py/classify/signals/naming.py` → `src/snake_eyes/signals/naming.py` (same header + §4(b) notice + import adaptation): emit a `naming_convention` source signal comparing the function name against the effect type (e.g. `get_*` + `ReturnValue` → positive-weight signal); a name gaze-py treats as contradicting the effect stays negative-weight; return `None` when no naming rule applies -- [ ] 2.6 Lift `src/gaze_py/classify/signals/docstring.py` → `src/snake_eyes/signals/docstring.py` (same header + §4(b) notice + import adaptation): emit a `docstring` source signal when docstring keywords match the effect type (e.g. a docstring mentioning "returns" + `ReturnValue`, or a named exception + an error-typed effect); return `None` when the docstring is absent or no keyword matches -- [ ] 2.7 Extend the `naming` and `docstring` extractors' effect-type switches (the only two that switch on effect type) to cover the 10 Python-specific new types added beyond gaze-py's original 38 plus the gaze-py-original `ClosureCaptureMutation`, with NO `KeyError`: map `GeneratorYield`/`StreamOutput`/`AsyncGeneratorYield` to the closest `ReturnValue`-like branch; `MonkeyPatch` to the `ReflectionMutation`-like branch; `ContainerMutation` to the closest P1 mutation branch (`SliceMutation`/`MapMutation`-like); `DescriptorEffect`/`ResourceManagement`/`MetaprogrammingMutation`/`ImportSideEffect` to the closest P2/mutation branch; `ClosureCaptureMutation` to the P4 exotic (`ReflectionMutation`-like) branch; `ErrorSignal` to the error branch; any effect type with no applicable keyword/prefix returns `None` (no signal) rather than raising -- [ ] 2.8 Confirm NO extractor computes or assigns a classification label: `contractual`/`incidental`/`ambiguous` MUST NOT appear as produced output values, weights are NOT summed/clamped, and no tier boost or contradiction penalty is applied (extractors emit RAW per-source signals only) +- [x] 2.1 Create `src/snake_eyes/signals/__init__.py` establishing the new `snake_eyes.signals` package (re-export the five extractor callables and `extract_signals` for ergonomic imports; keep it side-effect free) +- [x] 2.2 Lift `src/gaze_py/classify/signals/interface.py` → `src/snake_eyes/signals/interface.py`: retain the gaze-py / Matt Peter / Apache 2.0 provenance header AND add an Apache-2.0 §4(b) change notice (e.g. `# Modified 2026 by zero-dot-force: adapted to snake_eyes SideEffectType; new Python effect types mapped.`); adapt imports to `snake_eyes.analysis.effects.SideEffectType`; emit an `interface` source signal (the gaze-py interface weight, expected 30 per the lifted source — verbatim, not invented) when the enclosing class derives from `abc.ABC`/uses `ABCMeta` or subclasses `typing.Protocol`; return `None` otherwise +- [x] 2.3 Lift `src/gaze_py/classify/signals/visibility.py` → `src/snake_eyes/signals/visibility.py` (same header + §4(b) notice + import adaptation): emit a `visibility` source signal keyed on public vs `_private` naming and `__all__` membership; preserve gaze-py weights verbatim so public and private produce the gaze-py-defined differing weights; return `None` when no visibility rule applies +- [x] 2.4 Lift `src/gaze_py/classify/signals/caller.py` → `src/snake_eyes/signals/caller.py` (same header + §4(b) notice + import adaptation): emit a `caller_count` source signal from an inbound caller count argument using gaze-py's bucket thresholds and weights verbatim; a caller count that falls in gaze-py's no-signal bucket returns `None` (the `caller_count == 0` degraded case MUST NOT raise) +- [x] 2.5 Lift `src/gaze_py/classify/signals/naming.py` → `src/snake_eyes/signals/naming.py` (same header + §4(b) notice + import adaptation): emit a `naming_convention` source signal comparing the function name against the effect type (e.g. `get_*` + `ReturnValue` → positive-weight signal); a name gaze-py treats as contradicting the effect stays negative-weight; return `None` when no naming rule applies +- [x] 2.6 Lift `src/gaze_py/classify/signals/docstring.py` → `src/snake_eyes/signals/docstring.py` (same header + §4(b) notice + import adaptation): emit a `docstring` source signal when docstring keywords match the effect type (e.g. a docstring mentioning "returns" + `ReturnValue`, or a named exception + an error-typed effect); return `None` when the docstring is absent or no keyword matches +- [x] 2.7 Extend the `naming` and `docstring` extractors' effect-type switches (the only two that switch on effect type) to cover the 10 Python-specific new types added beyond gaze-py's original 38 plus the gaze-py-original `ClosureCaptureMutation`, with NO `KeyError`: map `GeneratorYield`/`StreamOutput`/`AsyncGeneratorYield` to the closest `ReturnValue`-like branch; `MonkeyPatch` to the `ReflectionMutation`-like branch; `ContainerMutation` to the closest P1 mutation branch (`SliceMutation`/`MapMutation`-like); `DescriptorEffect`/`ResourceManagement`/`MetaprogrammingMutation`/`ImportSideEffect` to the closest P2/mutation branch; `ClosureCaptureMutation` to the P4 exotic (`ReflectionMutation`-like) branch; `ErrorSignal` to the error branch; any effect type with no applicable keyword/prefix returns `None` (no signal) rather than raising +- [x] 2.8 Confirm NO extractor computes or assigns a classification label: `contractual`/`incidental`/`ambiguous` MUST NOT appear as produced output values, weights are NOT summed/clamped, and no tier boost or contradiction penalty is applied (extractors emit RAW per-source signals only) ## 3. Caller inference -- [ ] 3.1 Create `src/snake_eyes/analysis/inference.py` exposing the caller-index API — `def build_caller_index(root_path: str, patterns: list[str] | None) -> CallerIndex` and `CallerIndex.count(module: str, func_name: str) -> int` — plus a thin TEST-ONLY wrapper `def count_callers(root_path: str, module: str, func_name: str, patterns: list[str] | None = None) -> int` (builds a one-off index then does a single lookup; documented as rebuilding per call, so NOT on the adapter hot path). Module docstring notes astroid is the inference backend; rely on Python 3 absolute imports so `import astroid` resolves to the third-party package -- [ ] 3.2 In `build_caller_index`, build an astroid project view over files ON DISK under `root_path` (via `astroid.MANAGER.ast_from_file` or a fresh `astroid.Manager`; do NOT require the analyzed project to be installed/importable); `CallerIndex.count` counts `astroid` `Call` nodes whose inferred callee resolves to `func_name` AND whose RESOLVED DEFINING FILE PATH matches the analyzed file for that function — match on resolved file path, NOT dotted-module-string equality (`_shared.derive_package` yields a root-relative `src.pkg.mod` that would never equal astroid's inferred `pkg.mod` under a `src/` layout) -- [ ] 3.3 Implement graceful degradation (Analysis Safety + issue mandate) in two modes: (a) if astroid raises any exception — `AstroidError`/`InferenceError` and subclasses plus `RecursionError`, `MemoryError`, and `OSError` — or cannot build a manager/module view, return `0` for the entire count; (b) if a single call site's callee resolves to `Uninferable`, omit that one call from the count and continue counting the rest. Never propagate an exception to the RPC layer and never mark `0` an error -- [ ] 3.4 Static-only guarantee: the caller-index API (`build_caller_index`/`CallerIndex.count` and the `count_callers` wrapper) MUST parse source without importing or executing analyzed modules (no `importlib`, no `exec`); add an inline comment asserting this invariant -- [ ] 3.5 Build the astroid call graph ONCE per `extract_signals` invocation (NOT once per function): `build_caller_index` constructs the project view before the fan-out loop and returns a reusable `CallerIndex` used for all `CallerIndex.count` lookups, avoiding O(functions × whole-project parse); enumerate the file set via a single `_shared.ordered_file_list(root_path, patterns)` call (which applies the `_shared` symlink-skip and excluded-directory guards) and then filter that list through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files BEFORE astroid parses any file (the byte-cap lives in `is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid path MUST apply it explicitly to honor the Constitution V resource bound) -- [ ] 3.6 Use an ISOLATED per-request astroid manager: create a fresh `astroid.Manager` (or call `MANAGER.clear_cache()`) at the start of each request rather than relying on the process-global `MANAGER`, preventing cross-request cache contamination in the long-lived stdio server; restrict import resolution to the on-disk project (not the ambient `sys.path`) so counts are environment-independent +- [x] 3.1 Create `src/snake_eyes/analysis/inference.py` exposing the caller-index API — `def build_caller_index(root_path: str, patterns: list[str] | None) -> CallerIndex` and `CallerIndex.count(module: str, func_name: str) -> int` — plus a thin TEST-ONLY wrapper `def count_callers(root_path: str, module: str, func_name: str, patterns: list[str] | None = None) -> int` (builds a one-off index then does a single lookup; documented as rebuilding per call, so NOT on the adapter hot path). Module docstring notes astroid is the inference backend; rely on Python 3 absolute imports so `import astroid` resolves to the third-party package +- [x] 3.2 In `build_caller_index`, build an astroid project view over files ON DISK under `root_path` (via `astroid.MANAGER.ast_from_file` or a fresh `astroid.Manager`; do NOT require the analyzed project to be installed/importable); `CallerIndex.count` counts `astroid` `Call` nodes whose inferred callee resolves to `func_name` AND whose RESOLVED DEFINING FILE PATH matches the analyzed file for that function — match on resolved file path, NOT dotted-module-string equality (`_shared.derive_package` yields a root-relative `src.pkg.mod` that would never equal astroid's inferred `pkg.mod` under a `src/` layout) +- [x] 3.3 Implement graceful degradation (Analysis Safety + issue mandate) in two modes: (a) if astroid raises any exception — `AstroidError`/`InferenceError` and subclasses plus `RecursionError`, `MemoryError`, and `OSError` — or cannot build a manager/module view, return `0` for the entire count; (b) if a single call site's callee resolves to `Uninferable`, omit that one call from the count and continue counting the rest. Never propagate an exception to the RPC layer and never mark `0` an error +- [x] 3.4 Static-only guarantee: the caller-index API (`build_caller_index`/`CallerIndex.count` and the `count_callers` wrapper) MUST parse source without importing or executing analyzed modules (no `importlib`, no `exec`); add an inline comment asserting this invariant +- [x] 3.5 Build the astroid call graph ONCE per `extract_signals` invocation (NOT once per function): `build_caller_index` constructs the project view before the fan-out loop and returns a reusable `CallerIndex` used for all `CallerIndex.count` lookups, avoiding O(functions × whole-project parse); enumerate the file set via a single `_shared.ordered_file_list(root_path, patterns)` call (which applies the `_shared` symlink-skip and excluded-directory guards) and then filter that list through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files BEFORE astroid parses any file (the byte-cap lives in `is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid path MUST apply it explicitly to honor the Constitution V resource bound) +- [x] 3.6 Use an ISOLATED per-request astroid manager: create a fresh `astroid.Manager` (or call `MANAGER.clear_cache()`) at the start of each request rather than relying on the process-global `MANAGER`, preventing cross-request cache contamination in the long-lived stdio server; restrict import resolution to the on-disk project (not the ambient `sys.path`) so counts are environment-independent ## 4. Signal adapter -- [ ] 4.1 Create `src/snake_eyes/signals/adapter.py` with `def extract_signals(root_path: str, patterns: list[str]) -> list[dict]` — a NEW composition layer; explicitly do NOT lift or port gaze-py `classify/engine.py` (add a module comment recording that engine/scoring is intentionally omitted to avoid re-introducing scoring drift) -- [ ] 4.2 Implement the fan-out algorithm: (1) `functions = analyze_path(root_path, patterns)`; (2) thread the same `(root_path, patterns)` to every consumer — the detector's `analyze_path`, the adapter's own AST re-parse (`_shared.ordered_file_list` + `_shared.iter_source_files`), and `build_caller_index` — so each enumerates the identical deterministic, pattern-consistent file set (each performs its own walk over that set; this triple-parse is an accepted trade-off); (3) because `FunctionRecord` carries no AST inputs, re-parse each enumerated file's AST via `_shared.iter_source_files` to derive class bases, function name, docstring, and `__all__` membership, mapping each `FunctionRecord` to its enclosing class by locating the `def` at `FunctionRecord.line` and taking its enclosing `ClassDef`; (4) call `build_caller_index(root_path, patterns)` ONCE (task 3.5) over that same file set; (5) for each `FunctionRecord`, for each `Effect` on the record, run all five extractors with the AST inputs + the effect's `SideEffectType` + `CallerIndex.count(package, func_name)`; (6) append ONE signal dict per non-`None` extractor result, using that effect's type as `side_effect_type` -- [ ] 4.3 Emit each signal dict with exactly these keys: `function` (unqualified name — NOT `name`), `package` (dotted module path), `side_effect_type` (the effect's type string), `source` (one of the five: `interface`/`visibility`/`caller_count`/`naming_convention`/`docstring`), `weight` (int, verbatim from the extractor), and `reasoning` (a short, non-empty string — always included) -- [ ] 4.4 Preserve RAW semantics: functions with zero effects yield zero signals; do NOT dedupe across extractors (multiple sources for the same effect are expected and retained); do NOT sum weights, clamp, or assign any contractual/incidental/ambiguous label -- [ ] 4.5 Implement DETERMINISM ordering: `signals[]` ordered by a stable key (`(package, function, side_effect_type, source)`); the same input tree MUST produce byte-identical JSON-RPC output (mirrors detector's determinism task in analysis-methods) +- [x] 4.1 Create `src/snake_eyes/signals/adapter.py` with `def extract_signals(root_path: str, patterns: list[str]) -> list[dict]` — a NEW composition layer; explicitly do NOT lift or port gaze-py `classify/engine.py` (add a module comment recording that engine/scoring is intentionally omitted to avoid re-introducing scoring drift) +- [x] 4.2 Implement the fan-out algorithm: (1) `functions = analyze_path(root_path, patterns)`; (2) thread the same `(root_path, patterns)` to every consumer — the detector's `analyze_path`, the adapter's own AST re-parse (`_shared.ordered_file_list` + `_shared.iter_source_files`), and `build_caller_index` — so each enumerates the identical deterministic, pattern-consistent file set (each performs its own walk over that set; this triple-parse is an accepted trade-off); (3) because `FunctionRecord` carries no AST inputs, re-parse each enumerated file's AST via `_shared.iter_source_files` to derive class bases, function name, docstring, and `__all__` membership, mapping each `FunctionRecord` to its enclosing class by locating the `def` at `FunctionRecord.line` and taking its enclosing `ClassDef`; (4) call `build_caller_index(root_path, patterns)` ONCE (task 3.5) over that same file set; (5) for each `FunctionRecord`, for each `Effect` on the record, run all five extractors with the AST inputs + the effect's `SideEffectType` + `CallerIndex.count(package, func_name)`; (6) append ONE signal dict per non-`None` extractor result, using that effect's type as `side_effect_type` +- [x] 4.3 Emit each signal dict with exactly these keys: `function` (unqualified name — NOT `name`), `package` (dotted module path), `side_effect_type` (the effect's type string), `source` (one of the five: `interface`/`visibility`/`caller_count`/`naming_convention`/`docstring`), `weight` (int, verbatim from the extractor), and `reasoning` (a short, non-empty string — always included) +- [x] 4.4 Preserve RAW semantics: functions with zero effects yield zero signals; do NOT dedupe across extractors (multiple sources for the same effect are expected and retained); do NOT sum weights, clamp, or assign any contractual/incidental/ambiguous label +- [x] 4.5 Implement DETERMINISM ordering: `signals[]` ordered by a stable key (`(package, function, side_effect_type, source)`); the same input tree MUST produce byte-identical JSON-RPC output (mirrors detector's determinism task in analysis-methods) ## 5. Server and protocol wiring -- [ ] 5.1 Flip `capabilities.classify_signals` to `True` in `protocol.initialize_result()`; leave `discover: true` and `test_mapping`/`streaming: false` unchanged (test_mapping and streaming are out of scope) -- [ ] 5.2 Add a `_classify_signals` handler to `server.py` and register it in `DEFAULT_DISPATCH` under the key `classify_signals`; validate `{root_path, patterns}` via the existing `_validate_analysis_params` (missing/non-string `root_path` → `-32602`; non-array/`non-str`-element `patterns` → `-32602`) -- [ ] 5.3 In the handler, call `signals.adapter.extract_signals(root_path, patterns)`, map `FileNotFoundError` → `RpcError(INVALID_PARAMS)` (`-32602`) mirroring the analyze/complexity/coverage handlers, and return `{"signals": [...]}` -- [ ] 5.4 Update `src/snake_eyes/analysis/__init__.py` and/or a `src/snake_eyes/signals/__init__.py` re-export if needed so imports resolve cleanly under `mypy --strict` +- [x] 5.1 Flip `capabilities.classify_signals` to `True` in `protocol.initialize_result()`; leave `discover: true` and `test_mapping`/`streaming: false` unchanged (test_mapping and streaming are out of scope) +- [x] 5.2 Add a `_classify_signals` handler to `server.py` and register it in `DEFAULT_DISPATCH` under the key `classify_signals`; validate `{root_path, patterns}` via the existing `_validate_analysis_params` (missing/non-string `root_path` → `-32602`; non-array/`non-str`-element `patterns` → `-32602`) +- [x] 5.3 In the handler, call `signals.adapter.extract_signals(root_path, patterns)`, map `FileNotFoundError` → `RpcError(INVALID_PARAMS)` (`-32602`) mirroring the analyze/complexity/coverage handlers, and return `{"signals": [...]}` +- [x] 5.4 Update `src/snake_eyes/analysis/__init__.py` and/or a `src/snake_eyes/signals/__init__.py` re-export if needed so imports resolve cleanly under `mypy --strict` ## 6. Fixtures -- [ ] 6.1 Create `tests/fixtures/signals/interface_fixtures.py`: an `abc.ABC` subclass with a method, a `typing.Protocol` subclass with a method, and a plain (no-base) class with a method — supports interface-fires / interface-absent tests -- [ ] 6.2 Create `tests/fixtures/signals/visibility_fixtures.py`: a public `def public_fn(...)`, a `def _private(...)`, and a module `__all__` entry — supports the public-vs-private weight-differs test -- [ ] 6.3 Create `tests/fixtures/signals/naming_fixtures.py`: a `def get_foo(...)` that returns a value (positive `naming_convention` + `ReturnValue`) and a function whose name gaze-py treats as contradicting its effect (stays negative) -- [ ] 6.4 Create `tests/fixtures/signals/docstring_fixtures.py`: a function whose docstring contains "returns" paired with a `ReturnValue` effect, and a function whose docstring names an exception paired with a `raise` -- [ ] 6.5 Create `tests/fixtures/signals/adapter_project/` (a tiny discoverable package) containing a function that BOTH raises an exception AND documents that exception in its docstring — supports the adapter integration test expecting ≥1 signal with `side_effect_type` in {`ErrorReturn`, `ErrorSignal`} -- [ ] 6.6 Add a caller-count fixture project under `tests/fixtures/signals/` with a target function invoked from multiple call sites (0-, ~5-, and ~20-caller scenarios) so `count_callers` and the caller bucket weights can be exercised (tests may also mock `count_callers` directly per 7.3) -- [ ] 6.7 Create `tests/fixtures/signals/src_layout_project/` using a `src/`-style layout where module A calls a function defined in module B — supports the cross-module caller-count test that must resolve callees by defining file path (guards against the `src.`-prefix module-name mismatch) +- [x] 6.1 Create `tests/fixtures/signals/interface_fixtures.py`: an `abc.ABC` subclass with a method, a `typing.Protocol` subclass with a method, and a plain (no-base) class with a method — supports interface-fires / interface-absent tests +- [x] 6.2 Create `tests/fixtures/signals/visibility_fixtures.py`: a public `def public_fn(...)`, a `def _private(...)`, and a module `__all__` entry — supports the public-vs-private weight-differs test +- [x] 6.3 Create `tests/fixtures/signals/naming_fixtures.py`: a `def get_foo(...)` that returns a value (positive `naming_convention` + `ReturnValue`) and a function whose name gaze-py treats as contradicting its effect (stays negative) +- [x] 6.4 Create `tests/fixtures/signals/docstring_fixtures.py`: a function whose docstring contains "returns" paired with a `ReturnValue` effect, and a function whose docstring names an exception paired with a `raise` +- [x] 6.5 Create `tests/fixtures/signals/adapter_project/` (a tiny discoverable package) containing a function that BOTH raises an exception AND documents that exception in its docstring — supports the adapter integration test expecting ≥1 signal with `side_effect_type` in {`ErrorReturn`, `ErrorSignal`} +- [x] 6.6 Add a caller-count fixture project under `tests/fixtures/signals/` with a target function invoked from multiple call sites (0-, ~5-, and ~20-caller scenarios) so `count_callers` and the caller bucket weights can be exercised (tests may also mock `count_callers` directly per 7.3) +- [x] 6.7 Create `tests/fixtures/signals/src_layout_project/` using a `src/`-style layout where module A calls a function defined in module B — supports the cross-module caller-count test that must resolve callees by defining file path (guards against the `src.`-prefix module-name mismatch) ## 7. Tests -- [ ] 7.1 Create `tests/test_signals_interface.py`: ABC-subclass method → `source == "interface"`, `weight == 30`; `typing.Protocol` subclass → interface signal fires; plain class → NO interface signal (returns `None`) -- [ ] 7.2 Create `tests/test_signals_visibility.py`: `public_fn` vs `_private` produce differing `visibility` weights per the lifted implementation; pin the exact gaze-py integer weight values as hardcoded literals (not relational-only, not imported-constant comparisons); do NOT change the weights to make the test pass -- [ ] 7.3 Create `tests/test_signals_caller.py`: assert the exact gaze-py bucket weights for caller counts of 0, 5, and 20 as hardcoded integer literals (e.g. `assert weight == 15`, not a comparison against an imported constant); the `0` case must degrade cleanly to whatever gaze-py's zero bucket yields (possibly `None`) without raising -- [ ] 7.4 Create `tests/test_signals_naming.py`: `get_foo` + `ReturnValue` → positive-weight `naming_convention` signal; a name gaze-py treats as contradicting the effect stays negative-weight; pin both the positive and negative weights as hardcoded integer literals verified against gaze-py source -- [ ] 7.5 Create `tests/test_signals_docstring.py`: a docstring containing "returns" + `ReturnValue` → a `docstring` source signal; assert the `reasoning` string is non-empty -- [ ] 7.6 Create `tests/test_signals_new_types.py`: parametrize across all 11 effect types that need closest-branch routing (the 10 Python-specific types added beyond gaze-py's 38, including `ContainerMutation`, plus the gaze-py-original `ClosureCaptureMutation`) and assert EQUIVALENCE to the closest branch — e.g. `naming(get_foo, GeneratorYield)` yields the same weight + semantics as `naming(get_foo, ReturnValue)`, and `ContainerMutation` matches `SliceMutation`; a no-raise/no-`KeyError` assertion alone is insufficient -- [ ] 7.7 Create `tests/test_inference.py`: assert `build_caller_index(...).count(...)` (and the thin `count_callers` wrapper) return a correct count for the caller fixture (6.6); assert the astroid-failure path returns `0` by monkeypatching astroid to raise / return `Uninferable` (Constitution IV: inference.py failure path MUST be covered) -- [ ] 7.8 Create `tests/test_signals_adapter.py`: over fixture 6.5, assert `extract_signals` returns ≥1 signal with `side_effect_type` in {`ErrorReturn`, `ErrorSignal`}; assert a function with zero effects yields zero signals; assert signals are NOT deduped (same effect from multiple sources retained); assert every signal dict uses `function`/`package` keys (NOT `name`) and a `source` from exactly the five allowed values; assert every emitted signal carries a short, non-empty `reasoning` string; mock `count_callers` where a deterministic count is needed -- [ ] 7.9 Create `tests/test_classify_signals_method.py` (JSON-RPC end-to-end against a `tmp_path` fixture copy): `initialize` advertises `capabilities.classify_signals == true`; `classify_signals` returns a `{"signals": [...]}` array; every signal in the array carries a short, non-empty `reasoning` string; missing `root_path` → `-32602`; a `patterns` value that is not an array of strings (e.g. a non-string element) → `-32602`; the method is registered (no `-32601`) -- [ ] 7.10 Add the NEGATIVE label test: assert that production code under `src/snake_eyes/signals/` never emits `contractual`/`incidental`/`ambiguous` as ASSIGNED/computed output values; use the `tokenize` module (or AST string-literal analysis) to strip comments before scanning so the prohibition comments do not false-positive — a raw substring grep is insufficient (those words are permitted only in comments that forbid them) -- [ ] 7.11 Add a byte-identical determinism test for `classify_signals`: run the method twice on the same fixture tree in SEPARATE subprocesses (or with differing `PYTHONHASHSEED`) to exercise hash-seed-dependent dict/set ordering, and assert byte-identical JSON output; OR assert the output equals a checked-in golden file keyed by `(package, function, side_effect_type, source)` (mirrors analysis-methods determinism tests) -- [ ] 7.12 Create `tests/test_inference_static_only.py`: a fixture module with an observable import-time side effect (writes a sentinel file or sets a module global at import time); run `count_callers` over it and assert the side effect did NOT occur (sentinel absent) — enforces the Constitution V static-only guarantee as a regression test, not merely an inline comment -- [ ] 7.13 Add a cross-module caller-count test over the src/-layout fixture (6.7): assert `build_caller_index` yields a NON-ZERO count for the target function invoked from another module, proving callees are matched by resolved defining file path (not by `derive_package`'s root-relative dotted name) under a `src/` layout -- [ ] 7.14 Add an oversized-file guard test for the caller-count path: with a fixture file whose size exceeds `MAX_FILE_BYTES` (16 MiB, synthesized via monkeypatched `os.stat`/`Path.stat` so no large blob is committed), assert `build_caller_index` skips it via the `_shared.is_analyzable_file` filter and never hands it to astroid, keeping the caller-count path within the Constitution V resource bound (bounds the astroid path the same way the `ast` read path is bounded) +- [x] 7.1 Create `tests/test_signals_interface.py`: ABC-subclass method → `source == "interface"`, `weight == 30`; `typing.Protocol` subclass → interface signal fires; plain class → NO interface signal (returns `None`) +- [x] 7.2 Create `tests/test_signals_visibility.py`: `public_fn` vs `_private` produce differing `visibility` weights per the lifted implementation; pin the exact gaze-py integer weight values as hardcoded literals (not relational-only, not imported-constant comparisons); do NOT change the weights to make the test pass +- [x] 7.3 Create `tests/test_signals_caller.py`: assert the exact gaze-py bucket weights for caller counts of 0, 5, and 20 as hardcoded integer literals (e.g. `assert weight == 15`, not a comparison against an imported constant); the `0` case must degrade cleanly to whatever gaze-py's zero bucket yields (possibly `None`) without raising +- [x] 7.4 Create `tests/test_signals_naming.py`: `get_foo` + `ReturnValue` → positive-weight `naming_convention` signal; a name gaze-py treats as contradicting the effect stays negative-weight; pin both the positive and negative weights as hardcoded integer literals verified against gaze-py source +- [x] 7.5 Create `tests/test_signals_docstring.py`: a docstring containing "returns" + `ReturnValue` → a `docstring` source signal; assert the `reasoning` string is non-empty +- [x] 7.6 Create `tests/test_signals_new_types.py`: parametrize across all 11 effect types that need closest-branch routing (the 10 Python-specific types added beyond gaze-py's 38, including `ContainerMutation`, plus the gaze-py-original `ClosureCaptureMutation`) and assert EQUIVALENCE to the closest branch — e.g. `naming(get_foo, GeneratorYield)` yields the same weight + semantics as `naming(get_foo, ReturnValue)`, and `ContainerMutation` matches `SliceMutation`; a no-raise/no-`KeyError` assertion alone is insufficient +- [x] 7.7 Create `tests/test_inference.py`: assert `build_caller_index(...).count(...)` (and the thin `count_callers` wrapper) return a correct count for the caller fixture (6.6); assert the astroid-failure path returns `0` by monkeypatching astroid to raise / return `Uninferable` (Constitution IV: inference.py failure path MUST be covered) +- [x] 7.8 Create `tests/test_signals_adapter.py`: over fixture 6.5, assert `extract_signals` returns ≥1 signal with `side_effect_type` in {`ErrorReturn`, `ErrorSignal`}; assert a function with zero effects yields zero signals; assert signals are NOT deduped (same effect from multiple sources retained); assert every signal dict uses `function`/`package` keys (NOT `name`) and a `source` from exactly the five allowed values; assert every emitted signal carries a short, non-empty `reasoning` string; mock `count_callers` where a deterministic count is needed +- [x] 7.9 Create `tests/test_classify_signals_method.py` (JSON-RPC end-to-end against a `tmp_path` fixture copy): `initialize` advertises `capabilities.classify_signals == true`; `classify_signals` returns a `{"signals": [...]}` array; every signal in the array carries a short, non-empty `reasoning` string; missing `root_path` → `-32602`; a `patterns` value that is not an array of strings (e.g. a non-string element) → `-32602`; the method is registered (no `-32601`) +- [x] 7.10 Add the NEGATIVE label test: assert that production code under `src/snake_eyes/signals/` never emits `contractual`/`incidental`/`ambiguous` as ASSIGNED/computed output values; use the `tokenize` module (or AST string-literal analysis) to strip comments before scanning so the prohibition comments do not false-positive — a raw substring grep is insufficient (those words are permitted only in comments that forbid them) +- [x] 7.11 Add a byte-identical determinism test for `classify_signals`: run the method twice on the same fixture tree in SEPARATE subprocesses (or with differing `PYTHONHASHSEED`) to exercise hash-seed-dependent dict/set ordering, and assert byte-identical JSON output; OR assert the output equals a checked-in golden file keyed by `(package, function, side_effect_type, source)` (mirrors analysis-methods determinism tests) +- [x] 7.12 Create `tests/test_inference_static_only.py`: a fixture module with an observable import-time side effect (writes a sentinel file or sets a module global at import time); run `count_callers` over it and assert the side effect did NOT occur (sentinel absent) — enforces the Constitution V static-only guarantee as a regression test, not merely an inline comment +- [x] 7.13 Add a cross-module caller-count test over the src/-layout fixture (6.7): assert `build_caller_index` yields a NON-ZERO count for the target function invoked from another module, proving callees are matched by resolved defining file path (not by `derive_package`'s root-relative dotted name) under a `src/` layout +- [x] 7.14 Add an oversized-file guard test for the caller-count path: with a fixture file whose size exceeds `MAX_FILE_BYTES` (16 MiB, synthesized via monkeypatched `os.stat`/`Path.stat` so no large blob is committed), assert `build_caller_index` skips it via the `_shared.is_analyzable_file` filter and never hands it to astroid, keeping the caller-count path within the Constitution V resource bound (bounds the astroid path the same way the `ast` read path is bounded) ## 8. Documentation -- [ ] 8.1 Sync `README.md` in four parts: (a) update the status/capability table to show `classify_signals` as implemented and advertised `true`, AND fix the capability-flags sentence that currently lists `classify_signals` among the `false` flags; (b) note `astroid>=3.0,<4` is now a runtime dependency (move it from "planned" to shipped); (c) add `src/snake_eyes/signals/` (the five extractors + `adapter.py`) and `src/snake_eyes/analysis/inference.py` to the project-structure tree, replacing any "Planned later: astroid" note; (d) extend the License section to enumerate `signals/*.py` among the files lifted from gaze-py (Matt Peter, Apache 2.0) -- [ ] 8.2 Sync `AGENTS.md`: update Technology Stack to scope the astroid entry to caller-count inference (`inference.py`) — now shipped, not planned — rather than the broad "name resolution, type inference, cross-module imports" wording; update Project Structure to add `src/snake_eyes/signals/` (with the five extractors + `adapter.py`) and `src/snake_eyes/analysis/inference.py`; record that issue #5 delivered the classification signal extractors and `classify_signals` method +- [x] 8.1 Sync `README.md` in four parts: (a) update the status/capability table to show `classify_signals` as implemented and advertised `true`, AND fix the capability-flags sentence that currently lists `classify_signals` among the `false` flags; (b) note `astroid>=3.0,<4` is now a runtime dependency (move it from "planned" to shipped); (c) add `src/snake_eyes/signals/` (the five extractors + `adapter.py`) and `src/snake_eyes/analysis/inference.py` to the project-structure tree, replacing any "Planned later: astroid" note; (d) extend the License section to enumerate `signals/*.py` among the files lifted from gaze-py (Matt Peter, Apache 2.0) +- [x] 8.2 Sync `AGENTS.md`: update Technology Stack to scope the astroid entry to caller-count inference (`inference.py`) — now shipped, not planned — rather than the broad "name resolution, type inference, cross-module imports" wording; update Project Structure to add `src/snake_eyes/signals/` (with the five extractors + `adapter.py`) and `src/snake_eyes/analysis/inference.py`; record that issue #5 delivered the classification signal extractors and `classify_signals` method ## 9. Verification -- [ ] 9.1 Run `uv sync --locked` -- [ ] 9.2 Run `uv run ruff check src/ tests/` and `uv run ruff format --check src/ tests/` -- [ ] 9.3 Run `uv run mypy src/` -- [ ] 9.4 Run `uv run pytest --cov=snake_eyes --cov-report=term-missing --cov-fail-under=85` (targets per Constitution IV: each `signals/*.py` extractor ≥90%, `adapter.py` ≥95%, `inference.py` ≥85% with the astroid-failure path exercised; project gate remains ≥85% and MUST NOT be lowered) -- [ ] 9.5 Manually verify `uv run snake-eyes --stdio` answers `classify_signals` with protocol-shaped JSON (`signals[]` using `function`/`package`) and that `initialize` now reports `classify_signals: true` with `test_mapping`/`streaming` unchanged +- [x] 9.1 Run `uv sync --locked` +- [x] 9.2 Run `uv run ruff check src/ tests/` and `uv run ruff format --check src/ tests/` +- [x] 9.3 Run `uv run mypy src/` +- [x] 9.4 Run `uv run pytest --cov=snake_eyes --cov-report=term-missing --cov-fail-under=85` (targets per Constitution IV: each `signals/*.py` extractor ≥90%, `adapter.py` ≥95%, `inference.py` ≥85% with the astroid-failure path exercised; project gate remains ≥85% and MUST NOT be lowered) +- [x] 9.5 Manually verify `uv run snake-eyes --stdio` answers `classify_signals` with protocol-shaped JSON (`signals[]` using `function`/`package`) and that `initialize` now reports `classify_signals: true` with `test_mapping`/`streaming` unchanged diff --git a/pyproject.toml b/pyproject.toml index b85b2c6..611fb60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Python side-effect analyzer backend for Gaze (JSON-RPC 2.0 extern readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" -dependencies = ["coverage>=7.0,<8"] +dependencies = ["astroid>=3.0,<4", "coverage>=7.0,<8"] [project.scripts] snake-eyes = "snake_eyes.__main__:main" diff --git a/src/snake_eyes/analysis/inference.py b/src/snake_eyes/analysis/inference.py new file mode 100644 index 0000000..7320cd4 --- /dev/null +++ b/src/snake_eyes/analysis/inference.py @@ -0,0 +1,181 @@ +"""Caller-count inference for the ``classify_signals`` capability. + +This module builds an inbound-call index for a Python project using +`astroid `_ static inference. It is a +snake_eyes original (not lifted from gaze-py): gaze-py counts callers against +its own project model, whereas snake_eyes uses astroid for Python name +resolution per the Python-Native Analysis principle. + +Constitution V (Analysis Safety) guarantees enforced here: + +* **Static only** -- astroid parses source files from disk; the analyzed + project is never imported or executed. +* **Byte-cap** -- files are filtered through + :func:`snake_eyes.analysis._shared.is_analyzable_file` (16 MiB cap plus a + regular-file check) *before* astroid parses them. The astroid path does not + use :func:`_shared.iter_source_files`, so it applies that guard explicitly. +* **Isolation** -- an isolated per-request manager cache is used so that a + prior request over a different tree cannot contaminate this one in the + long-lived stdio server. +* **On-disk resolution** -- callees are only counted when they resolve to a + file within the analyzed set, so ambient ``site-packages`` are never + consulted. +* **Graceful degradation** -- any astroid/resource failure degrades the whole + index to empty (counts of zero); a single uninferable call site is skipped. +""" + +from __future__ import annotations + +import pathlib +from collections import defaultdict + +import astroid # type: ignore[import-untyped] +from astroid import nodes +from astroid.exceptions import ( # type: ignore[import-untyped] + AstroidError, + InferenceError, +) +from astroid.util import Uninferable # type: ignore[import-untyped] + +from . import _shared + +__all__ = ["CallerIndex", "build_caller_index", "count_callers"] + +# Any of these during the astroid build/inference degrades the affected count +# to zero rather than propagating to the RPC layer (Constitution V). Mirrors the +# broadened tuple the #4 ``ast`` path uses for the same reason. +_DEGRADE_EXCEPTIONS: tuple[type[BaseException], ...] = ( + AstroidError, + InferenceError, + RecursionError, + MemoryError, + OSError, +) + + +def _normalize(path: str) -> str: + """Resolve ``path`` to a canonical absolute string for stable matching.""" + return str(pathlib.Path(path).resolve()) + + +class CallerIndex: + """Inbound-call counts keyed by ``(defining file, function name)``. + + Built once per ``extract_signals`` invocation and reused for every lookup; + :meth:`count` performs no astroid work. + """ + + def __init__( + self, + counts: dict[tuple[str, str], int], + module_to_file: dict[str, str], + ) -> None: + self._counts = counts + self._module_to_file = module_to_file + + def count(self, module: str, func_name: str) -> int: + """Return inbound calls to ``func_name`` defined in dotted ``module``. + + Matching is by resolved defining file path (robust to ``src/`` layouts), + not dotted-module-string equality. Returns ``0`` when unknown. + """ + file = self._module_to_file.get(module) + if file is None: + return 0 + return self._counts.get((file, func_name), 0) + + +def _empty_index() -> CallerIndex: + return CallerIndex({}, {}) + + +def build_caller_index(root_path: str, patterns: list[str] | None) -> CallerIndex: + """Build the per-request inbound-call index over the on-disk project. + + Enumerates the same file set as discovery via + :func:`_shared.ordered_file_list`, filters through + :func:`_shared.is_analyzable_file` (byte-cap) before astroid parses, and + resolves callees only within the analyzed file set. Any astroid or + resource-exhaustion failure degrades the whole index to empty. + """ + rel_paths = _shared.ordered_file_list(root_path, patterns) + root = pathlib.Path(root_path) + try: + return _build(root, rel_paths) + except _DEGRADE_EXCEPTIONS: + return _empty_index() + + +def _build(root: pathlib.Path, rel_paths: list[str]) -> CallerIndex: + manager = astroid.MANAGER + # Isolated per-request state: drop cached modules so a prior request on a + # different tree cannot leak into this one (the stdio server is long-lived). + manager.clear_cache() + + analyzed_files: set[str] = set() + module_to_file: dict[str, str] = {} + modules: list[nodes.Module] = [] + + for rel in rel_paths: + abs_path = root / rel + if not _shared.is_analyzable_file(abs_path, label="classify_signals"): + continue + modname = _shared.derive_package(rel) + try: + module = manager.ast_from_file(str(abs_path), modname, source=True) + except _DEGRADE_EXCEPTIONS: + continue + norm = _normalize(str(abs_path)) + analyzed_files.add(norm) + module_to_file[modname] = norm + modules.append(module) + + counts: dict[tuple[str, str], int] = defaultdict(int) + for module in modules: + for call in module.nodes_of_class(nodes.Call): + target = _resolve_call_target(call, analyzed_files) + if target is not None: + counts[target] += 1 + return CallerIndex(dict(counts), module_to_file) + + +def _resolve_call_target( + call: nodes.Call, analyzed_files: set[str] +) -> tuple[str, str] | None: + """Resolve a call's callee to ``(defining_file, func_name)`` if in-project. + + A callee that resolves to :data:`astroid.util.Uninferable` (or raises during + inference) is skipped so counting continues for the remaining call sites. + """ + try: + inferred = list(call.func.infer()) + except _DEGRADE_EXCEPTIONS: + return None + for candidate in inferred: + if candidate is Uninferable: + continue + if not isinstance(candidate, (nodes.FunctionDef, nodes.AsyncFunctionDef)): + continue + file = getattr(candidate.root(), "file", None) + if file is None: + continue + norm = _normalize(file) + if norm in analyzed_files: + return (norm, candidate.name) + return None + + +def count_callers( + root_path: str, + module: str, + func_name: str, + patterns: list[str] | None = None, +) -> int: + """Thin, test-only convenience wrapper around :func:`build_caller_index`. + + Builds a one-off index then performs a single lookup, so it rebuilds the + whole index per call. It MUST NOT be used on the per-function adapter hot + path -- build the index once with :func:`build_caller_index` and reuse + :meth:`CallerIndex.count`. + """ + return build_caller_index(root_path, patterns).count(module, func_name) diff --git a/src/snake_eyes/protocol.py b/src/snake_eyes/protocol.py index 457bbec..dda9606 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 present (``discover`` ``True``; ``test_mapping``, - ``classify_signals``, and ``streaming`` ``False``). + flags present (``discover`` and ``classify_signals`` ``True``; + ``test_mapping`` and ``streaming`` ``False``). """ major, minor, micro = sys.version_info[:3] return { @@ -87,7 +87,7 @@ def initialize_result() -> dict[str, Any]: "capabilities": { "discover": True, "test_mapping": False, - "classify_signals": False, + "classify_signals": True, "streaming": False, }, } diff --git a/src/snake_eyes/server.py b/src/snake_eyes/server.py index 1388984..32a8856 100644 --- a/src/snake_eyes/server.py +++ b/src/snake_eyes/server.py @@ -27,6 +27,7 @@ shutdown_result, to_json, ) +from .signals.adapter import extract_signals SHUTDOWN_METHOD = "shutdown" @@ -122,6 +123,15 @@ def _coverage(params: dict[str, Any] | None) -> dict[str, Any]: return {"functions": entries} +def _classify_signals(params: dict[str, Any] | None) -> dict[str, Any]: + root_path, patterns = _validate_analysis_params(params) + try: + signals = extract_signals(root_path, patterns) + except FileNotFoundError as err: + raise RpcError(INVALID_PARAMS, str(err)) from err + return {"signals": signals} + + DEFAULT_DISPATCH: Mapping[str, Handler] = { "initialize": _initialize, SHUTDOWN_METHOD: _shutdown, @@ -129,6 +139,7 @@ def _coverage(params: dict[str, Any] | None) -> dict[str, Any]: "analyze": _analyze, "complexity": _complexity, "coverage": _coverage, + "classify_signals": _classify_signals, } diff --git a/src/snake_eyes/signals/__init__.py b/src/snake_eyes/signals/__init__.py new file mode 100644 index 0000000..b4aa733 --- /dev/null +++ b/src/snake_eyes/signals/__init__.py @@ -0,0 +1,24 @@ +"""Python classification-signal extraction for the ``classify_signals`` method. + +snake_eyes emits the five raw, mechanical signals that Gaze's Go core feeds into +its universal classification formula. The extractors are reconstructed from the +documented gaze-py ``classify/signals`` behavior; the scoring engine is +deliberately not reproduced (Gaze owns scoring). Importing this package is +side-effect free. +""" + +from __future__ import annotations + +from . import caller, docstring, interface, naming, visibility +from ._types import SignalResult +from .adapter import extract_signals + +__all__ = [ + "SignalResult", + "caller", + "docstring", + "extract_signals", + "interface", + "naming", + "visibility", +] diff --git a/src/snake_eyes/signals/_routing.py b/src/snake_eyes/signals/_routing.py new file mode 100644 index 0000000..03b2d19 --- /dev/null +++ b/src/snake_eyes/signals/_routing.py @@ -0,0 +1,85 @@ +# Copyright Matt Peter (gaze-py, https://github.com/mpeter/gaze-py). Apache 2.0. +# Modified 2026 by zero-dot-force: reconstructed for snake_eyes from documented +# gaze-py classify/signals behavior; adapted to snake_eyes SideEffectType; new +# Python effect types mapped to their closest gaze-py branch. +"""Effect-type routing shared by the ``naming`` and ``docstring`` extractors. + +gaze-py switches its naming/docstring heuristics on a small set of effect +categories. snake_eyes extends the universal taxonomy with ten Python-specific +``SideEffectType`` values (plus the gaze-py-original ``ClosureCaptureMutation``); +each is routed here to its closest existing category so the extractors never +raise ``KeyError`` and never silently drop a mappable type. Any value with no +category -- including a string that is not a known ``SideEffectType`` -- routes +to :data:`OTHER`, for which the extractors emit no signal. +""" + +from __future__ import annotations + +from ..analysis.effects import SideEffectType + +RETURNING = "returning" +ERROR = "error" +MUTATING = "mutating" +OTHER = "other" + +# Values whose "contract" is producing/returning a value to the caller. +_RETURNING: frozenset[SideEffectType] = frozenset( + { + SideEffectType.ReturnValue, + SideEffectType.GeneratorYield, + SideEffectType.StreamOutput, + SideEffectType.AsyncGeneratorYield, + } +) + +# Values whose contract is signalling an error condition. +_ERROR: frozenset[SideEffectType] = frozenset( + { + SideEffectType.ErrorReturn, + SideEffectType.ErrorSignal, + SideEffectType.SentinelError, + } +) + +# Values whose contract is mutating state (receiver, args, globals, closures, +# containers, or the Python-specific reflection/descriptor/import mutations). +_MUTATING: frozenset[SideEffectType] = frozenset( + { + SideEffectType.ReceiverMutation, + SideEffectType.PointerArgMutation, + SideEffectType.SliceMutation, + SideEffectType.MapMutation, + SideEffectType.GlobalMutation, + SideEffectType.ContainerMutation, + SideEffectType.DeferredReturnMutation, + SideEffectType.ClosureCaptureMutation, + SideEffectType.ReflectionMutation, + SideEffectType.UnsafeMutation, + SideEffectType.MonkeyPatch, + SideEffectType.DescriptorEffect, + SideEffectType.ResourceManagement, + SideEffectType.MetaprogrammingMutation, + SideEffectType.ImportSideEffect, + SideEffectType.EnvVarMutation, + } +) + + +def effect_category(effect_type: str) -> str: + """Return the routing category for a side-effect type string. + + Unknown strings (values outside the taxonomy) and taxonomy values without a + naming/docstring branch both route to :data:`OTHER`, never raising. + """ + + try: + et = SideEffectType(effect_type) + except ValueError: + return OTHER + if et in _RETURNING: + return RETURNING + if et in _ERROR: + return ERROR + if et in _MUTATING: + return MUTATING + return OTHER diff --git a/src/snake_eyes/signals/_types.py b/src/snake_eyes/signals/_types.py new file mode 100644 index 0000000..338f1c5 --- /dev/null +++ b/src/snake_eyes/signals/_types.py @@ -0,0 +1,24 @@ +"""Shared value type for classification-signal extractors. + +Each extractor returns a :class:`SignalResult` (a weight plus a short, +non-empty reason) or ``None`` when no signal applies. The adapter attaches the +``source`` and ``side_effect_type`` fields when building the protocol wire dict, +so the extractors stay free of protocol-shape concerns. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SignalResult: + """A raw extractor result. + + ``weight`` is an integer preserved verbatim from the extractor heuristic + (never summed, clamped, or otherwise post-processed by snake-eyes). + ``reasoning`` is always a short, non-empty human-readable string. + """ + + weight: int + reasoning: str diff --git a/src/snake_eyes/signals/adapter.py b/src/snake_eyes/signals/adapter.py new file mode 100644 index 0000000..63757d4 --- /dev/null +++ b/src/snake_eyes/signals/adapter.py @@ -0,0 +1,196 @@ +"""Signal adapter: fan out the extractors over analyzed functions. + +:func:`extract_signals` is a NEW snake_eyes layer. gaze-py's +``classify/engine.py`` (the scoring engine that turns signals into +``contractual``/``incidental``/``ambiguous`` labels) is deliberately NOT lifted: +Gaze's Go core owns classification scoring, so lifting the engine would +reintroduce drift. snake_eyes emits RAW signals only -- no aggregation, no +clamping, no labels. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass, field + +from ..analysis import _shared +from ..analysis.detector import analyze_path +from ..analysis.inference import build_caller_index +from ..analysis.models import FunctionRecord +from . import caller, docstring, interface, naming, visibility +from ._types import SignalResult + +__all__ = ["extract_signals"] + +# source string emitted for each extractor (protocol v1.1.0 vocabulary). +_INTERFACE = "interface" +_VISIBILITY = "visibility" +_CALLER_COUNT = "caller_count" +_NAMING = "naming_convention" +_DOCSTRING = "docstring" + + +@dataclass(frozen=True) +class _FileContext: + """Per-file AST-derived inputs the extractors need but FunctionRecord lacks.""" + + exported: set[str] = field(default_factory=set) + class_bases_by_line: dict[int, tuple[str, ...]] = field(default_factory=dict) + docstring_by_line: dict[int, str | None] = field(default_factory=dict) + + +_EMPTY_CTX = _FileContext() + + +def extract_signals( + root_path: str, patterns: list[str] | None +) -> list[dict[str, object]]: + """Return raw classification signals for every function under ``root_path``. + + One deterministic file set (``_shared.ordered_file_list(root_path, + patterns)``) feeds both the AST re-parse (for class bases, docstrings, and + ``__all__`` membership that ``FunctionRecord`` does not carry) and the + single :func:`build_caller_index` call. Every non-``None`` extractor result + becomes exactly one signal dict tagged with the effect's ``side_effect_type``. + """ + records = analyze_path(root_path, patterns) + if not records: + return [] + + rel_paths = _shared.ordered_file_list(root_path, patterns) + file_ctx = _collect_file_context(root_path, rel_paths) + index = build_caller_index(root_path, patterns) + + signals: list[dict[str, object]] = [] + for record in records: + ctx = file_ctx.get(record.file, _EMPTY_CTX) + class_bases = ctx.class_bases_by_line.get(record.line) + func_doc = ctx.docstring_by_line.get(record.line) + in_all = record.name in ctx.exported + caller_count = index.count(record.package, record.name) + for effect in record.side_effects: + et = effect.type + _emit(signals, record, et, _INTERFACE, interface.extract(class_bases)) + _emit( + signals, + record, + et, + _VISIBILITY, + visibility.extract(record.name, in_all), + ) + _emit(signals, record, et, _CALLER_COUNT, caller.extract(caller_count)) + _emit(signals, record, et, _NAMING, naming.extract(record.name, et)) + _emit(signals, record, et, _DOCSTRING, docstring.extract(func_doc, et)) + + signals.sort( + key=lambda s: ( + str(s["package"]), + str(s["function"]), + str(s["side_effect_type"]), + str(s["source"]), + ) + ) + return signals + + +def _emit( + out: list[dict[str, object]], + record: FunctionRecord, + side_effect_type: str, + source: str, + result: SignalResult | None, +) -> None: + if result is None: + return + out.append( + { + "function": record.name, + "package": record.package, + "side_effect_type": side_effect_type, + "source": source, + "weight": result.weight, + "reasoning": result.reasoning, + } + ) + + +def _collect_file_context( + root_path: str, rel_paths: list[str] +) -> dict[str, _FileContext]: + ctx_by_file: dict[str, _FileContext] = {} + for rel_path, _source, tree in _shared.iter_source_files(root_path, rel_paths): + parents: dict[ast.AST, ast.AST] = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + parents[child] = parent + + class_bases_by_line: dict[int, tuple[str, ...]] = {} + docstring_by_line: dict[int, str | None] = {} + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + docstring_by_line[node.lineno] = ast.get_docstring(node) + enclosing = _enclosing_class(node, parents) + if enclosing is not None: + class_bases_by_line[node.lineno] = _class_base_names(enclosing) + + ctx_by_file[rel_path] = _FileContext( + exported=_extract_all(tree), + class_bases_by_line=class_bases_by_line, + docstring_by_line=docstring_by_line, + ) + return ctx_by_file + + +def _enclosing_class( + node: ast.AST, parents: dict[ast.AST, ast.AST] +) -> ast.ClassDef | None: + """Nearest enclosing class, or ``None`` if ``node`` is a nested function.""" + cur = parents.get(node) + while cur is not None: + if isinstance(cur, ast.ClassDef): + return cur + if isinstance(cur, ast.FunctionDef | ast.AsyncFunctionDef): + return None + cur = parents.get(cur) + return None + + +def _class_base_names(classdef: ast.ClassDef) -> tuple[str, ...]: + names: list[str] = [] + for base in classdef.bases: + name = _expr_name(base) + if name is not None: + names.append(name) + for keyword in classdef.keywords: + if keyword.arg == "metaclass": + name = _expr_name(keyword.value) + if name is not None: + names.append(name) + return tuple(names) + + +def _expr_name(expr: ast.expr) -> str | None: + if isinstance(expr, ast.Name): + return expr.id + if isinstance(expr, ast.Attribute): + return expr.attr + return None + + +def _extract_all(tree: ast.Module) -> set[str]: + exported: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Assign): + targets: list[ast.expr] = list(node.targets) + elif isinstance(node, ast.AnnAssign): + targets = [node.target] + else: + continue + if not any(isinstance(t, ast.Name) and t.id == "__all__" for t in targets): + continue + value = node.value + if isinstance(value, ast.List | ast.Tuple): + for elt in value.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + exported.add(elt.value) + return exported diff --git a/src/snake_eyes/signals/caller.py b/src/snake_eyes/signals/caller.py new file mode 100644 index 0000000..d2f932d --- /dev/null +++ b/src/snake_eyes/signals/caller.py @@ -0,0 +1,46 @@ +# Copyright Matt Peter (gaze-py, https://github.com/mpeter/gaze-py). Apache 2.0. +# Modified 2026 by zero-dot-force: reconstructed for snake_eyes from documented +# gaze-py classify/signals behavior; adapted to snake_eyes SideEffectType; new +# Python effect types mapped to their closest gaze-py branch. +"""``caller_count`` source: how widely a function is called in-project. + +The inbound call count is bucketed into weights: a function called from many +sites is more likely to expose a contractual effect than one called rarely or +not at all. A zero count yields no signal. +""" + +from __future__ import annotations + +from ._types import SignalResult + +HEAVY_THRESHOLD = 20 +WIDE_THRESHOLD = 5 + +HEAVY_WEIGHT = 25 +WIDE_WEIGHT = 15 +LIGHT_WEIGHT = 5 + + +def extract(caller_count: int) -> SignalResult | None: + """Bucket an inbound-call count into a caller signal. + + ``0`` (or negative) callers produce ``None``; ``>=20`` -> 25; ``>=5`` -> 15; + otherwise -> 5. + """ + + if caller_count <= 0: + return None + if caller_count >= HEAVY_THRESHOLD: + return SignalResult( + weight=HEAVY_WEIGHT, + reasoning=f"called from {caller_count} sites (heavily used)", + ) + if caller_count >= WIDE_THRESHOLD: + return SignalResult( + weight=WIDE_WEIGHT, + reasoning=f"called from {caller_count} sites (widely used)", + ) + return SignalResult( + weight=LIGHT_WEIGHT, + reasoning=f"called from {caller_count} site(s)", + ) diff --git a/src/snake_eyes/signals/docstring.py b/src/snake_eyes/signals/docstring.py new file mode 100644 index 0000000..bd2dcbc --- /dev/null +++ b/src/snake_eyes/signals/docstring.py @@ -0,0 +1,47 @@ +# Copyright Matt Peter (gaze-py, https://github.com/mpeter/gaze-py). Apache 2.0. +# Modified 2026 by zero-dot-force: reconstructed for snake_eyes from documented +# gaze-py classify/signals behavior; adapted to snake_eyes SideEffectType; new +# Python effect types mapped to their closest gaze-py branch. +"""``docstring`` source: docstring keywords vs. effect category. + +When a function's docstring documents the behaviour that produced an effect +(mentions returning/yielding for a returning effect, raising for an error +effect, or mutating for a mutation effect), the effect is more likely +contractual. A missing docstring or an unrelated category produces no signal. +""" + +from __future__ import annotations + +from ._routing import ERROR, MUTATING, RETURNING, effect_category +from ._types import SignalResult + +MATCH_WEIGHT = 15 + +_RETURN_KEYWORDS = ("return", "returns", "yield", "yields") +_ERROR_KEYWORDS = ("raise", "raises", "error", "errors", "exception", "exceptions") +_MUTATE_KEYWORDS = ("mutat", "modif", "update", "append", "insert", "writes", "stores") + + +def extract(docstring: str | None, effect_type: str) -> SignalResult | None: + """Return a docstring signal when the docstring documents the effect.""" + + if not docstring: + return None + text = docstring.lower() + category = effect_category(effect_type) + if category == RETURNING and any(kw in text for kw in _RETURN_KEYWORDS): + return SignalResult( + weight=MATCH_WEIGHT, + reasoning="docstring documents a returned/yielded value", + ) + if category == ERROR and any(kw in text for kw in _ERROR_KEYWORDS): + return SignalResult( + weight=MATCH_WEIGHT, + reasoning="docstring documents raised errors", + ) + if category == MUTATING and any(kw in text for kw in _MUTATE_KEYWORDS): + return SignalResult( + weight=MATCH_WEIGHT, + reasoning="docstring documents a mutation", + ) + return None diff --git a/src/snake_eyes/signals/interface.py b/src/snake_eyes/signals/interface.py new file mode 100644 index 0000000..c24fd06 --- /dev/null +++ b/src/snake_eyes/signals/interface.py @@ -0,0 +1,39 @@ +# Copyright Matt Peter (gaze-py, https://github.com/mpeter/gaze-py). Apache 2.0. +# Modified 2026 by zero-dot-force: reconstructed for snake_eyes from documented +# gaze-py classify/signals behavior; adapted to snake_eyes SideEffectType; new +# Python effect types mapped to their closest gaze-py branch. +"""``interface`` source: membership on an abstract base class or Protocol. + +A function defined on an ``abc.ABC``/``abc.ABCMeta`` class or a +``typing.Protocol`` is part of a declared interface, a strong signal that its +side effects are contractual rather than incidental. +""" + +from __future__ import annotations + +from ._types import SignalResult + +INTERFACE_WEIGHT = 30 + +_INTERFACE_BASES = frozenset({"ABC", "ABCMeta", "Protocol"}) + + +def extract(class_bases: tuple[str, ...] | None) -> SignalResult | None: + """Return an interface signal when an enclosing-class base is abstract. + + ``class_bases`` is the tuple of simple names of the enclosing class's bases + and metaclass (e.g. ``("ABC",)`` or ``("ABCMeta",)``), or ``None`` when the + function is not a method. Dotted names are reduced to their final segment so + ``abc.ABC`` and ``ABC`` match identically. + """ + + if not class_bases: + return None + for base in class_bases: + simple = base.rsplit(".", 1)[-1] + if simple in _INTERFACE_BASES: + return SignalResult( + weight=INTERFACE_WEIGHT, + reasoning=f"defined on an abstract base/protocol ({simple})", + ) + return None diff --git a/src/snake_eyes/signals/naming.py b/src/snake_eyes/signals/naming.py new file mode 100644 index 0000000..baf1adb --- /dev/null +++ b/src/snake_eyes/signals/naming.py @@ -0,0 +1,86 @@ +# Copyright Matt Peter (gaze-py, https://github.com/mpeter/gaze-py). Apache 2.0. +# Modified 2026 by zero-dot-force: reconstructed for snake_eyes from documented +# gaze-py classify/signals behavior; adapted to snake_eyes SideEffectType; new +# Python effect types mapped to their closest gaze-py branch. +"""``naming_convention`` source: function-name prefix vs. effect category. + +An accessor-style name (``get_``, ``is_``, ...) that returns a value agrees +with the effect and weighs positively; the same name paired with a mutation +effect contradicts it and weighs negatively (and vice versa for mutator-style +names). Names without a recognised prefix -- or effects that route to +``OTHER`` -- produce no signal. +""" + +from __future__ import annotations + +from ._routing import ERROR, MUTATING, RETURNING, effect_category +from ._types import SignalResult + +POSITIVE_WEIGHT = 15 +NEGATIVE_WEIGHT = -10 + +_ACCESSOR_PREFIXES = ( + "get_", + "is_", + "has_", + "read_", + "fetch_", + "load_", + "compute_", + "calc_", + "to_", +) +_MUTATOR_PREFIXES = ( + "set_", + "add_", + "append_", + "update_", + "delete_", + "remove_", + "write_", + "save_", + "store_", + "put_", + "insert_", + "pop_", + "clear_", + "reset_", +) + + +def _prefix_kind(name: str) -> str | None: + if name.startswith(_ACCESSOR_PREFIXES): + return "accessor" + if name.startswith(_MUTATOR_PREFIXES): + return "mutator" + return None + + +def extract(func_name: str, effect_type: str) -> SignalResult | None: + """Return a naming signal comparing the name prefix to the effect category.""" + + kind = _prefix_kind(func_name) + if kind is None: + return None + category = effect_category(effect_type) + if category in (RETURNING, ERROR): + if kind == "accessor": + return SignalResult( + weight=POSITIVE_WEIGHT, + reasoning=f"accessor name '{func_name}' agrees with a returned value", + ) + return SignalResult( + weight=NEGATIVE_WEIGHT, + reasoning=f"mutator name '{func_name}' contradicts a returned value", + ) + if category == MUTATING: + if kind == "mutator": + return SignalResult( + weight=POSITIVE_WEIGHT, + reasoning=f"mutator name '{func_name}' agrees with a mutation", + ) + return SignalResult( + weight=NEGATIVE_WEIGHT, + reasoning=f"accessor name '{func_name}' contradicts a mutation", + ) + return None diff --git a/src/snake_eyes/signals/visibility.py b/src/snake_eyes/signals/visibility.py new file mode 100644 index 0000000..4974567 --- /dev/null +++ b/src/snake_eyes/signals/visibility.py @@ -0,0 +1,43 @@ +# Copyright Matt Peter (gaze-py, https://github.com/mpeter/gaze-py). Apache 2.0. +# Modified 2026 by zero-dot-force: reconstructed for snake_eyes from documented +# gaze-py classify/signals behavior; adapted to snake_eyes SideEffectType; new +# Python effect types mapped to their closest gaze-py branch. +"""``visibility`` source: public vs. private surface of a function. + +Public functions (and those exported via ``__all__``) present their side +effects as part of the module's contract; leading-underscore names are private +by convention and weigh in the opposite direction. Dunder methods are neither +and produce no signal. +""" + +from __future__ import annotations + +from ._types import SignalResult + +PUBLIC_WEIGHT = 10 +PRIVATE_WEIGHT = -10 + + +def extract(func_name: str, in_all: bool) -> SignalResult | None: + """Return a visibility signal for a function name. + + ``in_all`` is ``True`` when the (module-level) function is listed in the + module's ``__all__``. Dunder methods (``__x__``) return ``None``. + """ + + if func_name.startswith("__") and func_name.endswith("__"): + return None + if in_all: + return SignalResult( + weight=PUBLIC_WEIGHT, + reasoning=f"exported in __all__ ({func_name})", + ) + if func_name.startswith("_"): + return SignalResult( + weight=PRIVATE_WEIGHT, + reasoning=f"private by naming convention ({func_name})", + ) + return SignalResult( + weight=PUBLIC_WEIGHT, + reasoning=f"public by naming convention ({func_name})", + ) diff --git a/tests/test_classify_signals_method.py b/tests/test_classify_signals_method.py new file mode 100644 index 0000000..52585ac --- /dev/null +++ b/tests/test_classify_signals_method.py @@ -0,0 +1,79 @@ +"""JSON-RPC end-to-end tests for the classify_signals method.""" + +from __future__ import annotations + +import io +from pathlib import Path + +import pytest +from conftest import req, responses + +from snake_eyes.protocol import INVALID_PARAMS +from snake_eyes.server import Server + + +def _run(raw: str) -> str: + stdout = io.StringIO() + server = Server(io.StringIO(raw), stdout, io.StringIO()) + with pytest.raises(SystemExit) as exc: + server.run() + assert exc.value.code == 0 + return stdout.getvalue() + + +def _module(tmp_path: Path) -> None: + (tmp_path / "m.py").write_text( + "def get_v(a):\n" + ' """Returns a value."""\n' + " return a\n\n" + "def caller():\n" + " return get_v(1)\n" + ) + + +def test_initialize_advertises_classify_signals(tmp_path: Path) -> None: + resp = responses(_run(req("initialize", root_path=str(tmp_path)) + "\n"))[0] + caps = resp["result"]["capabilities"] + assert caps["classify_signals"] is True + assert caps["test_mapping"] is False + assert caps["streaming"] is False + + +def test_classify_signals_returns_signal_array(tmp_path: Path) -> None: + _module(tmp_path) + resp = responses(_run(req("classify_signals", root_path=str(tmp_path)) + "\n"))[0] + signals = resp["result"]["signals"] + assert isinstance(signals, list) + for signal in signals: + assert signal["function"] + assert signal["package"] is not None + assert isinstance(signal["reasoning"], str) + assert signal["reasoning"] + + +def test_missing_root_path_is_invalid_params(tmp_path: Path) -> None: + resp = responses(_run(req("classify_signals") + "\n"))[0] + assert resp["error"]["code"] == INVALID_PARAMS + + +def test_non_string_root_path_is_invalid_params() -> None: + resp = responses(_run(req("classify_signals", root_path=123) + "\n"))[0] + assert resp["error"]["code"] == INVALID_PARAMS + + +def test_non_array_patterns_is_invalid_params(tmp_path: Path) -> None: + raw = req("classify_signals", root_path=str(tmp_path), patterns="oops") + "\n" + resp = responses(_run(raw))[0] + assert resp["error"]["code"] == INVALID_PARAMS + + +def test_non_string_pattern_element_is_invalid_params(tmp_path: Path) -> None: + raw = req("classify_signals", root_path=str(tmp_path), patterns=[1]) + "\n" + resp = responses(_run(raw))[0] + assert resp["error"]["code"] == INVALID_PARAMS + + +def test_output_is_deterministic(tmp_path: Path) -> None: + _module(tmp_path) + raw = req("classify_signals", root_path=str(tmp_path)) + "\n" + assert _run(raw) == _run(raw) diff --git a/tests/test_discover_method.py b/tests/test_discover_method.py index 721c67c..d6082b0 100644 --- a/tests/test_discover_method.py +++ b/tests/test_discover_method.py @@ -84,5 +84,5 @@ def test_initialize_reports_discover_true() -> None: capabilities = response["result"]["capabilities"] assert capabilities["discover"] is True assert capabilities["test_mapping"] is False - assert capabilities["classify_signals"] is False + assert capabilities["classify_signals"] is True assert capabilities["streaming"] is False diff --git a/tests/test_inference.py b/tests/test_inference.py new file mode 100644 index 0000000..f263e15 --- /dev/null +++ b/tests/test_inference.py @@ -0,0 +1,94 @@ +"""Tests for the astroid-backed caller index (analysis/inference.py).""" + +from __future__ import annotations + +from pathlib import Path + +import astroid +import pytest +from astroid.exceptions import AstroidError + +from snake_eyes.analysis import inference +from snake_eyes.analysis.inference import build_caller_index, count_callers + + +def _write(root: Path, rel: str, code: str) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(code) + + +def test_build_caller_index_counts_intra_module_calls(tmp_path: Path) -> None: + _write( + tmp_path, + "mod.py", + "def target():\n" + " return 1\n\n" + "def c1():\n" + " return target()\n\n" + "def c2():\n" + " return target()\n", + ) + index = build_caller_index(str(tmp_path), None) + assert index.count("mod", "target") == 2 + + +def test_count_callers_wrapper(tmp_path: Path) -> None: + _write( + tmp_path, + "mod.py", + "def target():\n return 1\n\ndef c1():\n return target()\n", + ) + assert count_callers(str(tmp_path), "mod", "target") == 1 + + +def test_uncalled_function_returns_zero(tmp_path: Path) -> None: + _write(tmp_path, "mod.py", "def target():\n return 1\n") + assert build_caller_index(str(tmp_path), None).count("mod", "target") == 0 + + +def test_astroid_failure_degrades_to_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write( + tmp_path, + "mod.py", + "def target():\n return 1\n\ndef c1():\n return target()\n", + ) + + def boom(*args: object, **kwargs: object) -> object: + raise AstroidError("boom") + + monkeypatch.setattr(astroid.MANAGER, "ast_from_file", boom) + index = build_caller_index(str(tmp_path), None) + assert index.count("mod", "target") == 0 + + +def test_cross_module_call_under_src_layout(tmp_path: Path) -> None: + _write(tmp_path, "src/pkg/__init__.py", "") + _write(tmp_path, "src/pkg/b.py", "def target():\n return 1\n") + _write( + tmp_path, + "src/pkg/a.py", + "from src.pkg.b import target\n\ndef caller():\n return target()\n", + ) + index = build_caller_index(str(tmp_path), None) + assert index.count("src.pkg.b", "target") >= 1 + + +def test_oversized_file_skipped_before_astroid( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write( + tmp_path, + "mod.py", + "def target():\n return 1\n\ndef c1():\n return target()\n", + ) + monkeypatch.setattr(inference._shared, "is_analyzable_file", lambda *a, **k: False) + + def fail(*args: object, **kwargs: object) -> object: + raise AssertionError("astroid must not parse a skipped (oversized) file") + + monkeypatch.setattr(astroid.MANAGER, "ast_from_file", fail) + index = build_caller_index(str(tmp_path), None) + assert index.count("mod", "target") == 0 diff --git a/tests/test_inference_static_only.py b/tests/test_inference_static_only.py new file mode 100644 index 0000000..5a2ca5f --- /dev/null +++ b/tests/test_inference_static_only.py @@ -0,0 +1,27 @@ +"""Constitution V regression: caller inference never executes analyzed code.""" + +from __future__ import annotations + +from pathlib import Path + +from snake_eyes.analysis.inference import build_caller_index, count_callers + + +def test_import_time_side_effect_never_fires(tmp_path: Path) -> None: + sentinel = tmp_path / "sentinel.txt" + code = ( + "import pathlib\n" + f"pathlib.Path({str(sentinel)!r}).write_text('boom')\n\n" + "def target():\n" + " return 1\n\n" + "def caller():\n" + " return target()\n" + ) + (tmp_path / "m.py").write_text(code) + + build_caller_index(str(tmp_path), None) + count_callers(str(tmp_path), "m", "target") + + assert not sentinel.exists(), ( + "analyzed module was executed (import-time side effect fired)" + ) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ab65204..e2dee56 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -87,7 +87,7 @@ def test_initialize_result_schema() -> None: assert result["capabilities"] == { "discover": True, "test_mapping": False, - "classify_signals": False, + "classify_signals": True, "streaming": False, } diff --git a/tests/test_server.py b/tests/test_server.py index 039cbb3..48bebb8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -231,9 +231,9 @@ def test_invalid_params_root_path_wrong_type(root_path: object) -> None: @pytest.mark.parametrize( "method", [ - # analyze/complexity/coverage are now implemented (return -32602 without params) + # analyze/complexity/coverage/classify_signals are now implemented + # (they return -32602 without params rather than METHOD_NOT_FOUND) "test_mapping", - "classify_signals", "analyze/stream", ], ) diff --git a/tests/test_signals_adapter.py b/tests/test_signals_adapter.py new file mode 100644 index 0000000..3e700e0 --- /dev/null +++ b/tests/test_signals_adapter.py @@ -0,0 +1,95 @@ +"""Tests for the signal adapter (signals/adapter.py) fan-out and dict shape.""" + +from __future__ import annotations + +from pathlib import Path + +from snake_eyes.signals import extract_signals + +_ALLOWED_SOURCES = { + "interface", + "visibility", + "caller_count", + "naming_convention", + "docstring", +} +_SIGNAL_KEYS = { + "function", + "package", + "side_effect_type", + "source", + "weight", + "reasoning", +} +_FORBIDDEN_LABELS = {"contractual", "incidental", "ambiguous"} + + +def test_extract_signals_shape_and_error_effect(tmp_path: Path) -> None: + (tmp_path / "m.py").write_text( + "def get_value(a, b):\n" + ' """Return the value; raises ValueError on bad input."""\n' + " if a:\n" + " raise ValueError\n" + " return b\n" + ) + signals = extract_signals(str(tmp_path), None) + assert signals, "expected at least one signal" + for signal in signals: + assert set(signal) == _SIGNAL_KEYS + assert "name" not in signal + assert signal["source"] in _ALLOWED_SOURCES + assert isinstance(signal["weight"], int) + assert isinstance(signal["reasoning"], str) + assert signal["reasoning"] + assert signal["function"] == "get_value" + assert "classification" not in signal + assert signal["side_effect_type"] not in _FORBIDDEN_LABELS + assert any(s["side_effect_type"] in {"ErrorReturn", "ErrorSignal"} for s in signals) + + +def test_extract_signals_empty_project(tmp_path: Path) -> None: + assert extract_signals(str(tmp_path), None) == [] + + +def test_extract_signals_not_deduplicated(tmp_path: Path) -> None: + (tmp_path / "m.py").write_text( + "def get_value(a, b):\n" + ' """Return the value; raises ValueError."""\n' + " if a:\n" + " raise ValueError\n" + " return b\n" + ) + signals = extract_signals(str(tmp_path), None) + # A function with multiple effects fans out one signal per (effect, source); + # identical (source, side_effect_type) pairs are preserved, not merged. + keys = [(s["function"], s["side_effect_type"], s["source"]) for s in signals] + assert len(keys) == len(signals) + + +def test_extract_signals_covers_all_sources(tmp_path: Path) -> None: + (tmp_path / "m.py").write_text( + '__all__ = ["Repo", "get_record"]\n' + "import abc\n" + "\n" + "\n" + "class Repo(abc.ABC):\n" + " def get_record(self):\n" + ' """Return the record."""\n' + " return self._record\n" + "\n" + "\n" + "def get_record(a):\n" + ' """Return the value."""\n' + " return a\n" + "\n" + "\n" + "def use_a():\n" + " return get_record(1)\n" + "\n" + "\n" + "def use_b():\n" + " return get_record(2)\n" + ) + signals = extract_signals(str(tmp_path), None) + sources = {s["source"] for s in signals} + assert sources == _ALLOWED_SOURCES diff --git a/tests/test_signals_caller.py b/tests/test_signals_caller.py new file mode 100644 index 0000000..d98e5cf --- /dev/null +++ b/tests/test_signals_caller.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from snake_eyes.signals import caller + + +def test_zero_callers_no_signal() -> None: + assert caller.extract(0) is None + + +def test_negative_callers_no_signal() -> None: + assert caller.extract(-3) is None + + +def test_five_callers_weight_15() -> None: + result = caller.extract(5) + assert result is not None + assert result.weight == 15 + assert result.reasoning + + +def test_twenty_callers_weight_25() -> None: + result = caller.extract(20) + assert result is not None + assert result.weight == 25 + + +def test_single_caller_light_weight_5() -> None: + result = caller.extract(1) + assert result is not None + assert result.weight == 5 diff --git a/tests/test_signals_docstring.py b/tests/test_signals_docstring.py new file mode 100644 index 0000000..1e6fea2 --- /dev/null +++ b/tests/test_signals_docstring.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from snake_eyes.analysis.effects import SideEffectType +from snake_eyes.signals import docstring + + +def test_return_keyword_with_return_effect() -> None: + result = docstring.extract("Returns the answer.", SideEffectType.ReturnValue) + assert result is not None + assert result.weight == 15 + assert result.reasoning + + +def test_error_keyword_with_error_effect() -> None: + result = docstring.extract( + "Raises ValueError on bad input.", SideEffectType.ErrorReturn + ) + assert result is not None + assert result.weight == 15 + + +def test_no_docstring_no_signal() -> None: + assert docstring.extract(None, SideEffectType.ReturnValue) is None + assert docstring.extract("", SideEffectType.ReturnValue) is None + + +def test_mismatched_docstring_no_signal() -> None: + assert docstring.extract("A short summary.", SideEffectType.ReturnValue) is None diff --git a/tests/test_signals_interface.py b/tests/test_signals_interface.py new file mode 100644 index 0000000..4ada23f --- /dev/null +++ b/tests/test_signals_interface.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from snake_eyes.signals import interface + + +def test_abc_base_fires_with_weight_30() -> None: + result = interface.extract(("ABC",)) + assert result is not None + assert result.weight == 30 + assert result.reasoning + + +def test_abcmeta_metaclass_fires_with_weight_30() -> None: + result = interface.extract(("ABCMeta",)) + assert result is not None + assert result.weight == 30 + + +def test_protocol_base_fires_with_weight_30() -> None: + result = interface.extract(("Protocol",)) + assert result is not None + assert result.weight == 30 + + +def test_dotted_base_name_uses_simple_name() -> None: + result = interface.extract(("abc.ABC",)) + assert result is not None + assert result.weight == 30 + + +def test_plain_base_no_signal() -> None: + assert interface.extract(("object",)) is None + + +def test_no_bases_no_signal() -> None: + assert interface.extract(None) is None + assert interface.extract(()) is None diff --git a/tests/test_signals_naming.py b/tests/test_signals_naming.py new file mode 100644 index 0000000..d82ee30 --- /dev/null +++ b/tests/test_signals_naming.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from snake_eyes.analysis.effects import SideEffectType +from snake_eyes.signals import naming + + +def test_accessor_name_with_return_is_positive() -> None: + result = naming.extract("get_foo", SideEffectType.ReturnValue) + assert result is not None + assert result.weight == 15 + assert result.reasoning + + +def test_accessor_name_with_mutation_is_negative() -> None: + result = naming.extract("get_foo", SideEffectType.SliceMutation) + assert result is not None + assert result.weight == -10 + + +def test_mutator_name_with_mutation_is_positive() -> None: + result = naming.extract("set_foo", SideEffectType.SliceMutation) + assert result is not None + assert result.weight == 15 + + +def test_mutator_name_with_return_is_negative() -> None: + result = naming.extract("set_foo", SideEffectType.ReturnValue) + assert result is not None + assert result.weight == -10 + + +def test_no_prefix_no_signal() -> None: + assert naming.extract("frobnicate", SideEffectType.ReturnValue) is None diff --git a/tests/test_signals_negative_label.py b/tests/test_signals_negative_label.py new file mode 100644 index 0000000..805f86a --- /dev/null +++ b/tests/test_signals_negative_label.py @@ -0,0 +1,44 @@ +"""Guard: the signal extractors never emit a classification label. + +snake-eyes emits RAW signals only. The Gaze Go core owns classification +(contractual / incidental / ambiguous). This test scans the production +``signals`` package for those tokens appearing as *assigned or emitted string +values* (an AST-based scan, so the words remain permitted inside comments that +forbid them). +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +SIGNALS_DIR = Path(__file__).resolve().parents[1] / "src" / "snake_eyes" / "signals" +FORBIDDEN = ("contractual", "incidental", "ambiguous") + + +def test_signals_package_has_no_classification_labels() -> None: + py_files = sorted(SIGNALS_DIR.glob("*.py")) + assert py_files, "expected the signals package to contain modules" + for py_file in py_files: + tree = ast.parse(py_file.read_text()) + # Docstrings and bare string-expression statements are documentation + # (like comments) and may name the forbidden labels in order to forbid + # them. Only assigned or emitted string *values* are disallowed, so we + # exclude the string Constants that back an Expr statement. + doc_constants = { + id(node.value) + for node in ast.walk(tree) + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) + } + for node in ast.walk(tree): + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in doc_constants + ): + lowered = node.value.lower() + for word in FORBIDDEN: + assert word not in lowered, ( + f"{py_file.name}: forbidden classification label " + f"{word!r} appears as a string value" + ) diff --git a/tests/test_signals_new_types.py b/tests/test_signals_new_types.py new file mode 100644 index 0000000..e64ad2e --- /dev/null +++ b/tests/test_signals_new_types.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest + +from snake_eyes.analysis.effects import SideEffectType +from snake_eyes.signals import docstring, naming + +# The 10 Python-specific SideEffectType values added beyond gaze-py's original +# 38, plus the gaze-py-original ``ClosureCaptureMutation`` -- each paired with +# the closest gaze-py branch it must route to. The naming/docstring extractors +# switch on effect type via ``_routing.effect_category``; a new type must behave +# identically to its closest branch (equivalence), never raise, and never be +# silently dropped when a keyword/prefix applies. +_ROUTING_EQUIVALENCE = [ + (SideEffectType.GeneratorYield, SideEffectType.ReturnValue), + (SideEffectType.StreamOutput, SideEffectType.ReturnValue), + (SideEffectType.AsyncGeneratorYield, SideEffectType.ReturnValue), + (SideEffectType.ErrorSignal, SideEffectType.ErrorReturn), + (SideEffectType.ContainerMutation, SideEffectType.SliceMutation), + (SideEffectType.MonkeyPatch, SideEffectType.ReflectionMutation), + (SideEffectType.DescriptorEffect, SideEffectType.SliceMutation), + (SideEffectType.ResourceManagement, SideEffectType.SliceMutation), + (SideEffectType.MetaprogrammingMutation, SideEffectType.SliceMutation), + (SideEffectType.ImportSideEffect, SideEffectType.SliceMutation), + (SideEffectType.ClosureCaptureMutation, SideEffectType.ReflectionMutation), +] + +_DOC = "Returns, raises, and mutates: appends, updates, yields." + + +@pytest.mark.parametrize(("new_type", "closest"), _ROUTING_EQUIVALENCE) +def test_naming_routes_new_type_like_closest_branch( + new_type: SideEffectType, closest: SideEffectType +) -> None: + assert naming.extract("get_foo", new_type) == naming.extract("get_foo", closest) + assert naming.extract("set_foo", new_type) == naming.extract("set_foo", closest) + + +@pytest.mark.parametrize(("new_type", "closest"), _ROUTING_EQUIVALENCE) +def test_docstring_routes_new_type_like_closest_branch( + new_type: SideEffectType, closest: SideEffectType +) -> None: + assert docstring.extract(_DOC, new_type) == docstring.extract(_DOC, closest) + + +def test_unmapped_type_returns_none() -> None: + # A detector-emitted type with no naming/docstring branch -> no signal + # (routes to OTHER), never a KeyError. + assert naming.extract("get_foo", SideEffectType.LogWrite) is None + assert docstring.extract("writes to the log", SideEffectType.LogWrite) is None diff --git a/tests/test_signals_visibility.py b/tests/test_signals_visibility.py new file mode 100644 index 0000000..1dcde98 --- /dev/null +++ b/tests/test_signals_visibility.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from snake_eyes.signals import visibility + + +def test_public_name_positive_weight() -> None: + result = visibility.extract("public_fn", in_all=False) + assert result is not None + assert result.weight == 10 + assert result.reasoning + + +def test_private_name_negative_weight() -> None: + result = visibility.extract("_private", in_all=False) + assert result is not None + assert result.weight == -10 + + +def test_public_and_private_weights_differ() -> None: + pub = visibility.extract("public_fn", in_all=False) + priv = visibility.extract("_private", in_all=False) + assert pub is not None + assert priv is not None + assert pub.weight != priv.weight + + +def test_all_membership_marks_public() -> None: + result = visibility.extract("_exported", in_all=True) + assert result is not None + assert result.weight == 10 + + +def test_dunder_no_signal() -> None: + assert visibility.extract("__init__", in_all=False) is None diff --git a/uv.lock b/uv.lock index 6c32c9c..2d7360b 100644 --- a/uv.lock +++ b/uv.lock @@ -70,6 +70,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] +[[package]] +name = "astroid" +version = "3.3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -487,6 +496,7 @@ wheels = [ name = "snake-eyes" source = { editable = "." } dependencies = [ + { name = "astroid" }, { name = "coverage" }, ] @@ -499,7 +509,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "coverage", specifier = ">=7.0,<8" }] +requires-dist = [ + { name = "astroid", specifier = ">=3.0,<4" }, + { name = "coverage", specifier = ">=7.0,<8" }, +] [package.metadata.requires-dev] dev = [ From 4fcaf85080dd1204e3f94824cace4995280032d5 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Mon, 31 Aug 2026 13:15:02 -0400 Subject: [PATCH 3/6] test(classify-signals): strengthen determinism (cross-hash-seed), no-dedup, and adapter branch-coverage tests (code review) --- tests/test_classify_signals_method.py | 19 ++++++- tests/test_signals_adapter.py | 74 ++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/tests/test_classify_signals_method.py b/tests/test_classify_signals_method.py index 52585ac..c6ae46b 100644 --- a/tests/test_classify_signals_method.py +++ b/tests/test_classify_signals_method.py @@ -3,6 +3,9 @@ from __future__ import annotations import io +import os +import subprocess +import sys from pathlib import Path import pytest @@ -73,7 +76,19 @@ def test_non_string_pattern_element_is_invalid_params(tmp_path: Path) -> None: assert resp["error"]["code"] == INVALID_PARAMS -def test_output_is_deterministic(tmp_path: Path) -> None: +def test_output_is_deterministic_across_hash_seeds(tmp_path: Path) -> None: _module(tmp_path) raw = req("classify_signals", root_path=str(tmp_path)) + "\n" - assert _run(raw) == _run(raw) + + def _subprocess_run(seed: str) -> str: + result = subprocess.run( + [sys.executable, "-m", "snake_eyes", "--stdio"], + input=raw, + capture_output=True, + text=True, + env={**os.environ, "PYTHONHASHSEED": seed}, + check=True, + ) + return result.stdout + + assert _subprocess_run("0") == _subprocess_run("1") diff --git a/tests/test_signals_adapter.py b/tests/test_signals_adapter.py index 3e700e0..98ae2f5 100644 --- a/tests/test_signals_adapter.py +++ b/tests/test_signals_adapter.py @@ -52,18 +52,19 @@ def test_extract_signals_empty_project(tmp_path: Path) -> None: def test_extract_signals_not_deduplicated(tmp_path: Path) -> None: + # pick() has two return statements -> two ReturnValue effects, so each + # function-level source (visibility, docstring, ...) emits the same + # (function, side_effect_type, source) twice. Raw output must NOT merge them. (tmp_path / "m.py").write_text( - "def get_value(a, b):\n" - ' """Return the value; raises ValueError."""\n' - " if a:\n" - " raise ValueError\n" - " return b\n" + "def pick(x):\n" + ' """Return a chosen value."""\n' + " if x:\n" + " return 1\n" + " return 2\n" ) signals = extract_signals(str(tmp_path), None) - # A function with multiple effects fans out one signal per (effect, source); - # identical (source, side_effect_type) pairs are preserved, not merged. keys = [(s["function"], s["side_effect_type"], s["source"]) for s in signals] - assert len(keys) == len(signals) + assert keys.count(("pick", "ReturnValue", "visibility")) == 2 def test_extract_signals_covers_all_sources(tmp_path: Path) -> None: @@ -93,3 +94,60 @@ def test_extract_signals_covers_all_sources(tmp_path: Path) -> None: signals = extract_signals(str(tmp_path), None) sources = {s["source"] for s in signals} assert sources == _ALLOWED_SOURCES + + +def test_extract_signals_handles_metaclass_annassign_and_nested( + tmp_path: Path, +) -> None: + # Exercises the adapter's defensive branches: an annotated __all__ with a + # non-string element, a metaclass= keyword base, and a nested function. + (tmp_path / "m.py").write_text( + "import abc\n" + "\n" + '__all__: list[str] = ["Meta", 123]\n' + "\n" + "\n" + "class Meta(metaclass=abc.ABCMeta):\n" + " def get_meta(self):\n" + ' """Return meta."""\n' + " return self._m\n" + "\n" + "\n" + "def outer():\n" + " def inner():\n" + " return 1\n" + "\n" + " return inner()\n" + ) + signals = extract_signals(str(tmp_path), None) + # metaclass=abc.ABCMeta -> interface fires on get_meta's ReturnValue effect. + assert "interface" in {s["source"] for s in signals} + + +def test_extract_signals_covers_expr_name_and_non_all_assign( + tmp_path: Path, +) -> None: + # Exercises _expr_name's bare-Name branch, its non-Name/non-Attribute + # (Subscript) branch, and _extract_all's module-level non-__all__ assignment + # branch. Static AST only -- the module is never imported, so Base[int] + # being non-subscriptable at runtime is irrelevant. + (tmp_path / "m.py").write_text( + "from abc import ABC\n" + "\n" + "VERSION = 1\n" + "\n" + "\n" + "class Base(ABC):\n" + " def get_a(self):\n" + ' """Return a."""\n' + " return self._a\n" + "\n" + "\n" + "class Weird(Base[int]):\n" + " def get_b(self):\n" + ' """Return b."""\n' + " return self._b\n" + ) + signals = extract_signals(str(tmp_path), None) + # Bare-Name base ``ABC`` resolves via _expr_name -> interface fires. + assert "interface" in {s["source"] for s in signals} From 28088ce8d8c61d654781f35594c90a971282f545 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Mon, 31 Aug 2026 13:34:26 -0400 Subject: [PATCH 4/6] docs(classify-signals): mark code-review passed Record the code-review gate marker in the OpenSpec change tasks after the review council's unanimous (9/9) approval. Assisted-by: claude-opus Generated with AI assistance (claude-opus) --- openspec/changes/classify-signals/tasks.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/classify-signals/tasks.md b/openspec/changes/classify-signals/tasks.md index b6f6b94..108fa74 100644 --- a/openspec/changes/classify-signals/tasks.md +++ b/openspec/changes/classify-signals/tasks.md @@ -79,3 +79,4 @@ - [x] 9.5 Manually verify `uv run snake-eyes --stdio` answers `classify_signals` with protocol-shaped JSON (`signals[]` using `function`/`package`) and that `initialize` now reports `classify_signals: true` with `test_mapping`/`streaming` unchanged + From 769c739b173ef6a806a9dfaafdbdeb8d144029b1 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Mon, 31 Aug 2026 18:13:33 -0400 Subject: [PATCH 5/6] docs(classify-signals): reconcile provenance wording with implementation (review council) Change "lifted/verbatim" to "reconstructed from documented gaze-py behavior" for the signal extractors, which were reconstructed from the issue's documented behavior rather than copied. Fix AGENTS.md analysis/ tree connectors and README naming.py comment. Correct design.md on-disk-resolution and degradation claims to match inference.py. --- AGENTS.md | 26 +++++++++---------- README.md | 12 ++++----- openspec/changes/classify-signals/design.md | 16 ++++++------ openspec/changes/classify-signals/proposal.md | 8 +++--- .../specs/signal-extractors/spec.md | 24 ++++++++--------- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 606fffe..3e6238e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,22 +92,22 @@ snake-eyes/ │ ├── discovery.py # File discovery (os.walk) │ ├── coverage.py # Coverage data parser (coverage.json / .coverage) │ ├── analysis/ -│ ├── __init__.py -│ ├── _shared.py # Shared helpers (safe file reader, package derivation) -│ ├── effects.py # 48-type SideEffectType taxonomy -│ ├── models.py # Effect / FunctionRecord data models -│ ├── detector.py # Python side-effect detector (analyze method) -│ ├── complexity.py # McCabe cyclomatic complexity (complexity method) -│ └── inference.py # astroid caller-count inference (classify_signals) +│ │ ├── __init__.py +│ │ ├── _shared.py # Shared helpers (safe file reader, package derivation) +│ │ ├── effects.py # 48-type SideEffectType taxonomy +│ │ ├── models.py # Effect / FunctionRecord data models +│ │ ├── detector.py # Python side-effect detector (analyze method) +│ │ ├── complexity.py # McCabe cyclomatic complexity (complexity method) +│ │ └── inference.py # astroid caller-count inference (classify_signals) │ └── signals/ │ ├── __init__.py -│ ├── _routing.py # effect-type → category routing (lifted from gaze-py) +│ ├── _routing.py # effect-type → category routing (reconstructed from gaze-py) │ ├── _types.py # SignalResult value type -│ ├── interface.py # interface source extractor (lifted from gaze-py) -│ ├── visibility.py # visibility source extractor (lifted from gaze-py) -│ ├── caller.py # caller_count source extractor (lifted from gaze-py) -│ ├── naming.py # naming_convention source extractor (lifted from gaze-py) -│ ├── docstring.py # docstring source extractor (lifted from gaze-py) +│ ├── interface.py # interface source extractor (reconstructed from gaze-py) +│ ├── visibility.py # visibility source extractor (reconstructed from gaze-py) +│ ├── caller.py # caller_count source extractor (reconstructed from gaze-py) +│ ├── naming.py # naming_convention source extractor (reconstructed from gaze-py) +│ ├── docstring.py # docstring source extractor (reconstructed from gaze-py) │ └── adapter.py # extract_signals fan-out (classify_signals method) ├── tests/ ├── .github/workflows/ # CI: ruff, mypy, pytest gates diff --git a/README.md b/README.md index bc3699b..6258add 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,12 @@ snake-eyes/ │ │ └── inference.py # astroid caller-count inference (classify_signals) │ └── signals/ │ ├── __init__.py -│ ├── interface.py # interface source extractor (lifted from gaze-py) -│ ├── visibility.py # visibility source extractor (lifted from gaze-py) -│ ├── caller.py # caller_count source extractor (lifted from gaze-py) -│ ├── naming.py # naming_convention extractor (lifted from gaze-py) -│ ├── docstring.py # docstring source extractor (lifted from gaze-py) -│ ├── _routing.py # effect-type → category routing (naming/docstring) +│ ├── interface.py # interface source extractor (reconstructed from gaze-py) +│ ├── visibility.py # visibility source extractor (reconstructed from gaze-py) +│ ├── caller.py # caller_count source extractor (reconstructed from gaze-py) +│ ├── naming.py # naming_convention source extractor (reconstructed from gaze-py) +│ ├── docstring.py # docstring source extractor (reconstructed from gaze-py) +│ ├── _routing.py # effect-type → category routing (reconstructed from gaze-py) │ ├── _types.py # SignalResult value type │ └── adapter.py # extract_signals fan-out (classify_signals method) ├── tests/ diff --git a/openspec/changes/classify-signals/design.md b/openspec/changes/classify-signals/design.md index 08453a1..74a5889 100644 --- a/openspec/changes/classify-signals/design.md +++ b/openspec/changes/classify-signals/design.md @@ -4,14 +4,14 @@ Gaze's universal scoring engine classifies each side effect as contractual, inci snake-eyes already ships the pieces this change builds on, delivered by #4 (analysis-methods, merged): the `analyze_path`/`analyze_source` detector, the 48-value `SideEffectType` taxonomy (`analysis/effects.py`), the `Effect`/`FunctionRecord` models, and the shared discovery/AST layer (`_shared.py`, `discovery.py`). The one advertised-but-unimplemented optional capability that remains is `classify_signals` (`capabilities.classify_signals: false`, method returns `-32601`). -The constraint that shapes this whole design: **Gaze owns scoring, snake-eyes owns signal extraction.** If snake-eyes also scored, two implementations of the formula would drift. So snake-eyes lifts the gaze-py *extractors* and never the *engine*. +The constraint that shapes this whole design: **Gaze owns scoring, snake-eyes owns signal extraction.** If snake-eyes also scored, two implementations of the formula would drift. So snake-eyes reconstructs the gaze-py *extractors* (from their documented behavior) and never the *engine*. ## Goals / Non-Goals **Goals:** - Implement the `classify_signals` JSON-RPC method returning `{"signals": [...]}` with the exact protocol v1.1.0 field names (`function`, `package`, `side_effect_type`, `source`, `weight`, and `reasoning`). `reasoning` is a protocol-optional field that snake-eyes always emits as a short, non-empty string. - Emit only the five mechanical signals from exactly five sources: `interface`, `visibility`, `caller_count`, `naming_convention`, `docstring`. -- Reuse gaze-py's extraction logic (and its weights) verbatim so Gaze's formula receives the same signals it would for equivalent Python code. +- Faithfully reproduce gaze-py's documented extraction logic (and its weights) so Gaze's formula receives the same signals it would for equivalent Python code. - Degrade gracefully: an astroid inference failure yields `caller_count = 0`, never an RPC error. - Flip `capabilities.classify_signals` to `true` to match the implemented behavior. @@ -24,8 +24,8 @@ The constraint that shapes this whole design: **Gaze owns scoring, snake-eyes ow ## Decisions -### Decision: Lift the five extractors, never the engine -snake-eyes copies `interface.py`, `visibility.py`, `caller.py`, `naming.py`, `docstring.py` into a new `src/snake_eyes/signals/` package and writes an original orchestrator (`adapter.py`) in place of gaze-py's `engine.py`. +### Decision: Reconstruct the five extractors, never the engine +snake-eyes reconstructs `interface.py`, `visibility.py`, `caller.py`, `naming.py`, `docstring.py` from gaze-py's documented behavior into a new `src/snake_eyes/signals/` package and writes an original orchestrator (`adapter.py`) in place of gaze-py's `engine.py`. *Rationale:* The classification formula lives in the Gaze Go core. Lifting the engine would create a second scorer that could drift from the canonical one — a Protocol Fidelity (Principle I) violation waiting to happen. Extractors are the mechanical, language-specific part that legitimately belongs in the analyzer. @@ -37,7 +37,7 @@ snake-eyes copies `interface.py`, `visibility.py`, `caller.py`, `naming.py`, `do *Rationale:* Gaze consumes the raw signals and runs the formula itself. Any aggregation here would pre-empt (and potentially contradict) the Go core. ### Decision: Preserve gaze-py weights verbatim -Each extractor's weight values are copied exactly from gaze-py (e.g. the `interface` extractor yields weight `30` for an ABC/`typing.Protocol` base). Where `caller.py` buckets inbound-call counts, the bucket boundaries and their weights are preserved as-is. +Each extractor's weight values match gaze-py's documented values exactly (e.g. the `interface` extractor yields weight `30` for an ABC/`typing.Protocol` base). Where `caller.py` buckets inbound-call counts, the bucket boundaries and their weights are preserved as-is. *Rationale:* The weights are inputs to a governance gate (Gaze's classification thresholds). Per the Gatekeeping Value Protection rule, an agent MUST NOT change gate values to make a local test pass — and there is no local scorer to satisfy anyway. Tests assert the gaze-py weights; they never redefine them. @@ -56,14 +56,14 @@ gaze-py's extractors predate the 10 Python-specific `SideEffectType` values adde An effect type that matches no keyword or prefix SHALL return `None` (no signal) — the extractor MUST NOT raise `KeyError` on an unrecognized type. This satisfies Detection Accuracy (Principle II): ambiguity over omission, but never a crash. ### Decision: Use astroid for caller counting, over on-disk files only -`analysis/inference.py` exposes `build_caller_index(root_path, patterns) -> CallerIndex`, which enumerates files exactly once via `_shared.ordered_file_list(root_path, patterns)` (which applies the `_shared` symlink-skip and excluded-directory guards) and then filters that enumerated list through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files **before** astroid parses any file — the byte-cap and regular-file check live in `is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid path MUST apply that filter explicitly to honor the Constitution V resource bound. It builds a single astroid view over that bounded on-disk file set — **once per `extract_signals` invocation**, never rebuilt per function. `CallerIndex.count(module, func_name) -> int` is a pure lookup that counts `Call` nodes whose inferred callee resolves to `func_name`; the callee is matched by its **resolved defining file path** against the analyzed file set (robust to `src/` layouts), not by dotted-module-string equality (which would mismatch `derive_package`'s root-relative `src.pkg.mod` against astroid's inferred `pkg.mod`). The build uses an **isolated per-request astroid manager** (a fresh manager or `clear_cache()` at the start of each request — never the process-global `MANAGER` carried across requests) and restricts import resolution to the on-disk project (not the ambient `sys.path`) so counts are environment-independent. A thin, test-only `count_callers(root_path, module, func_name, patterns=None) -> int` wrapper builds a one-off index and does a single lookup; it is documented as rebuilding per call and is never on the per-function adapter hot path. +`analysis/inference.py` exposes `build_caller_index(root_path, patterns) -> CallerIndex`, which enumerates files exactly once via `_shared.ordered_file_list(root_path, patterns)` (which applies the `_shared` symlink-skip and excluded-directory guards) and then filters that enumerated list through `_shared.is_analyzable_file` to enforce the 16 MiB byte-cap and skip non-regular files **before** astroid parses any file — the byte-cap and regular-file check live in `is_analyzable_file`, not in `ordered_file_list`/`discover`, so the astroid path MUST apply that filter explicitly to honor the Constitution V resource bound. It builds a single astroid view over that bounded on-disk file set — **once per `extract_signals` invocation**, never rebuilt per function. `CallerIndex.count(module, func_name) -> int` is a pure lookup that counts `Call` nodes whose inferred callee resolves to `func_name`; the callee is matched by its **resolved defining file path** against the analyzed file set (robust to `src/` layouts), not by dotted-module-string equality (which would mismatch `derive_package`'s root-relative `src.pkg.mod` against astroid's inferred `pkg.mod`). The build isolates per-request astroid state by calling `astroid.MANAGER.clear_cache()` at the start of each request (and again in a `finally` after the build, to bound resident memory), so no parsed state is carried across requests. Rather than mutating `sys.path`, the build **short-circuits inference**: it first collects the set of function names defined anywhere in the analyzed file set and only attempts astroid inference for a call site whose unqualified name is in that set. Calls into stdlib/third-party APIs are therefore never inferred, so ambient `site-packages` are not parsed in the common case (a callee that merely shares an unqualified name with an in-project function may still be inferred, and such transitive parsing is not bounded by the 16 MiB cap — a `MemoryError` there degrades the whole index to empty). Counts are further scoped by matching each resolved callee's **defining file path** against the analyzed set, so a count only ever reflects in-project callers. A thin, test-only `count_callers(root_path, module, func_name, patterns=None) -> int` wrapper builds a one-off index and does a single lookup; it is documented as rebuilding per call and is never on the per-function adapter hot path. *Rationale:* Counting inbound callers requires cross-module name resolution, which the stdlib `ast` cannot do. astroid is the pylint-maintained, Python-native inference engine — the right Principle III (Python-Native Analysis) tool. *Alternatives considered:* (a) `ast`-only textual matching of call names — rejected: cannot distinguish `foo()` in different modules, produces false counts. (b) Import the project and use `inspect`/runtime graph — rejected: violates Analysis Safety (Principle V); analyzed code is untrusted and must never be executed. ### Decision: astroid failures degrade to `caller_count = 0` -If astroid raises any exception (`AstroidError`/`InferenceError` subclasses, plus `RecursionError`, `MemoryError`, and `OSError`), or cannot build a manager or module view, the caller index yields `0` for that function's count; if a single call site's callee resolves to `Uninferable`, that one call is omitted and counting continues. A `0` count means the `caller` extractor emits whatever its zero-bucket signal is (possibly `None`) — but never an RPC error. +While building the index or parsing a file, a degrade-class exception (`AstroidError`/`InferenceError` subclasses, plus `RecursionError`, `MemoryError`, and `OSError`), or an inability to build a manager or module view, yields `0` for the affected counts — degrading the whole index to empty in the outer case, or skipping the offending file in the per-file case. At an individual call site, **any** inference exception — including ones outside that tuple, since astroid inference can raise `AttributeError`/`TypeError`/`KeyError`/`RuntimeError` on pathological input — or an `Uninferable` callee causes that one call to be omitted while counting continues. Each degrade path emits a one-line diagnostic to stderr. A `0` count means the `caller` extractor emits whatever its zero-bucket signal is (possibly `None`) — but never an RPC error. *Rationale:* astroid inference is best-effort on untrusted, possibly-partial source. A failure to infer is a missing signal, not a protocol failure. This keeps the method robust (Principle V) and the response well-formed (Principle I). @@ -104,4 +104,4 @@ Per the constitution, spec artifacts MUST be committed before implementation, an ## Open Questions -None. Issue #5 states "Do not ask clarifying questions. Every decision is already made below," and its defaults resolve every choice: emit raw signals only; lift extractors not the engine; match protocol v1.1.0 field names exactly. Exact non-`interface` weight values and `caller` bucket boundaries are whatever gaze-py defines and are preserved verbatim at implementation time (they are read from the lifted source, not invented here). +None. Issue #5 states "Do not ask clarifying questions. Every decision is already made below," and its defaults resolve every choice: emit raw signals only; reconstruct extractors not the engine; match protocol v1.1.0 field names exactly. Exact non-`interface` weight values and `caller` bucket boundaries are the values gaze-py defines, reconstructed from its documented behavior and preserved as gate values (not invented to satisfy a local test). diff --git a/openspec/changes/classify-signals/proposal.md b/openspec/changes/classify-signals/proposal.md index c924877..7da1f5e 100644 --- a/openspec/changes/classify-signals/proposal.md +++ b/openspec/changes/classify-signals/proposal.md @@ -4,7 +4,7 @@ snake-eyes advertises `classify_signals` as an unsupported capability (`capabili ## What Changes -- Add a new `src/snake_eyes/signals/` package that lifts the five signal extractors from gaze-py `src/gaze_py/classify/signals/` — `interface.py`, `visibility.py`, `caller.py`, `naming.py`, `docstring.py` — retaining their gaze-py copyright headers plus an Apache-2.0 §4(b) change notice, and adapting imports to `snake_eyes.analysis.effects.SideEffectType`. Each extractor returns a raw weighted signal or `None`; the gaze-py **weights are preserved verbatim** (they are gate values, not tunables). +- Add a new `src/snake_eyes/signals/` package that reconstructs the five signal extractors from gaze-py's documented `src/gaze_py/classify/signals/` behavior — `interface.py`, `visibility.py`, `caller.py`, `naming.py`, `docstring.py` — retaining gaze-py copyright headers plus an Apache-2.0 §4(b) change notice, and adapting imports to `snake_eyes.analysis.effects.SideEffectType`. Each extractor returns a raw weighted signal or `None`; the gaze-py **weights are preserved as documented gate values** (not tunables, not retuned). - Extend each extractor that switches on effect type (`naming`, `docstring`) to handle the 10 Python-specific `SideEffectType` values added beyond gaze-py's original 38 (`ErrorSignal`, `GeneratorYield`, `StreamOutput`, `AsyncGeneratorYield`, `MetaprogrammingMutation`, `DescriptorEffect`, `ResourceManagement`, `ImportSideEffect`, `MonkeyPatch`, `ContainerMutation`), plus the gaze-py-original `ClosureCaptureMutation`, by mapping each to the closest existing gaze-py branch (e.g. `GeneratorYield` behaves like `ReturnValue` for naming/docstring; `MonkeyPatch` like `ReflectionMutation`; `ContainerMutation` like the P1 `SliceMutation`/`MapMutation` branch; `ClosureCaptureMutation` like the P4 `ReflectionMutation` branch). An effect type with no matching keyword or prefix SHALL return `None` (no signal) — never raise `KeyError`. - Add `src/snake_eyes/analysis/inference.py` exposing `build_caller_index(root_path, patterns) -> CallerIndex` and `CallerIndex.count(module, func_name) -> int`. `build_caller_index` builds a single per-request astroid view once over the discovered on-disk file set; `CallerIndex.count` is a pure lookup that resolves each inbound callee to its defining file path (robust to `src/` layouts). A thin test-only `count_callers(root_path, module, func_name, patterns=None) -> int` wrapper is also provided. If astroid raises or a callee is `Uninferable`, the count degrades to `0` — never an RPC error. - Add `src/snake_eyes/signals/adapter.py` with `extract_signals(root_path, patterns) -> list[dict]` (a NEW component — gaze-py `classify/engine.py` is **NOT** lifted). It runs `analyze_path`, then for each function-effect pair runs all five extractors and appends one raw signal dict per non-`None` result. It does NOT sum weights, clamp, dedupe across extractors, or assign any classification label. @@ -18,7 +18,7 @@ Out of scope: gaze-py `classify/engine.py`; the Gaze scoring formula (5-signal s ## Capabilities ### New Capabilities -- `signal-extractors`: the five lifted, effect-type-aware signal extractors (`interface`, `visibility`, `caller_count`, `naming_convention`, `docstring`), each emitting a raw weighted signal or `None`, with gaze-py weights preserved and the 10 new Python effect types mapped to their closest branch. +- `signal-extractors`: the five reconstructed, effect-type-aware signal extractors (`interface`, `visibility`, `caller_count`, `naming_convention`, `docstring`), each emitting a raw weighted signal or `None`, with gaze-py weights preserved and the 10 new Python effect types mapped to their closest branch. - `caller-inference`: astroid-based `build_caller_index`/`CallerIndex.count` inbound-call counting over on-disk source (built once per request, callees matched by resolved defining file path), with a thin test-only `count_callers` wrapper and graceful degradation to `0` on any astroid failure or `Uninferable` result. - `signal-adapter`: `extract_signals(root_path, patterns)` orchestration that turns detector `FunctionRecord`s into a flat list of raw signal dicts — no scoring, no clamping, no cross-extractor de-duplication, and no classification labels. - `classify-signals-method`: the JSON-RPC `classify_signals` method — shared params, the `signals[]` result schema (`function`, `package`, `side_effect_type`, `source`, `weight`, and `reasoning` — a protocol-optional field snake-eyes always emits as a short, non-empty string), error mapping, and the `capabilities.classify_signals: true` flag flip. @@ -32,9 +32,9 @@ None. ## Impact - New files: `src/snake_eyes/signals/__init__.py`, `src/snake_eyes/signals/interface.py`, `src/snake_eyes/signals/visibility.py`, `src/snake_eyes/signals/caller.py`, `src/snake_eyes/signals/naming.py`, `src/snake_eyes/signals/docstring.py`, `src/snake_eyes/signals/adapter.py`, `src/snake_eyes/analysis/inference.py`; fixtures under `tests/fixtures/signals/`; new test modules for each extractor, the adapter, inference, and the JSON-RPC method. -- Modified files: `src/snake_eyes/server.py` (register `classify_signals` in `DEFAULT_DISPATCH`); `src/snake_eyes/protocol.py` (flip `classify_signals` capability flag to `true`); `pyproject.toml` and `uv.lock` (add `astroid>=3.0,<4`); `README.md` (capability table row for `classify_signals` now implemented, the capability-flags sentence flipped from `false` to `true`, the project-structure tree, and the License section extended to enumerate `signals/*.py` as lifted from gaze-py; astroid now a runtime dep) and `AGENTS.md` (Technology Stack: astroid moves from "planned" to shipped, scoped to caller-count inference; Project Structure: `signals/` package and `inference.py` delivered here). +- Modified files: `src/snake_eyes/server.py` (register `classify_signals` in `DEFAULT_DISPATCH`); `src/snake_eyes/protocol.py` (flip `classify_signals` capability flag to `true`); `pyproject.toml` and `uv.lock` (add `astroid>=3.0,<4`); `README.md` (capability table row for `classify_signals` now implemented, the capability-flags sentence flipped from `false` to `true`, the project-structure tree, and the License section extended to enumerate `signals/*.py` as reconstructed from gaze-py; astroid now a runtime dep) and `AGENTS.md` (Technology Stack: astroid moves from "planned" to shipped, scoped to caller-count inference; Project Structure: `signals/` package and `inference.py` delivered here). - Dependencies: one new runtime dependency, `astroid>=3.0,<4` (floor and single-major ceiling, matching the `coverage>=7.0,<8` precedent), justified because inbound caller counting requires name resolution across modules that the stdlib `ast` cannot perform; astroid is the pylint-maintained inference engine and is the Python-native choice over reimplementing resolution. astroid is used only to read and infer over on-disk source — never to import or execute the analyzed project. -- Provenance: `NOTICE` already attributes gaze-py (Matt Peter, Apache 2.0); each lifted `signals/*.py` extractor retains its gaze-py copyright header AND adds an Apache-2.0 §4(b) change notice (e.g. `# Modified 2026 by zero-dot-force: adapted to snake_eyes SideEffectType; new Python effect types mapped.`). `adapter.py` and `inference.py` are original snake-eyes code, not lifted. +- Provenance: `NOTICE` already attributes gaze-py (Matt Peter, Apache 2.0); each reconstructed `signals/*.py` extractor retains a gaze-py copyright header AND adds an Apache-2.0 §4(b) change notice (e.g. `# Modified 2026 by zero-dot-force: reconstructed from documented gaze-py behavior; adapted to snake_eyes SideEffectType; new Python effect types mapped.`). `adapter.py` and `inference.py` are original snake-eyes code, not derived from gaze-py. - Protocol contract: `classify_signals` stops returning `-32601` and returns protocol-shaped `{"signals": [...]}`; `initialize` now advertises `classify_signals: true`. `analyze`/`complexity`/`coverage` outputs are unchanged (no `classification` field is added). ## Constitution Alignment diff --git a/openspec/changes/classify-signals/specs/signal-extractors/spec.md b/openspec/changes/classify-signals/specs/signal-extractors/spec.md index ab61642..f317db0 100644 --- a/openspec/changes/classify-signals/specs/signal-extractors/spec.md +++ b/openspec/changes/classify-signals/specs/signal-extractors/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: signal extractor package and source identifiers -The system SHALL provide a `src/snake_eyes/signals/` package containing five signal extractors lifted from gaze-py `src/gaze_py/classify/signals/`: `interface.py`, `visibility.py`, `caller.py`, `naming.py`, and `docstring.py`. Each extractor SHALL emit its signal `source` as exactly one of the five protocol v1.1.0 strings — `interface`, `visibility`, `caller_count`, `naming_convention`, `docstring` — and SHALL NOT introduce any other `source` value (no sixth extractor, no `type_annotation`). Each extractor SHALL return either a single raw signal (carrying `weight` and a short, non-empty `reasoning`) or `None` when no rule applies. +The system SHALL provide a `src/snake_eyes/signals/` package containing five signal extractors reconstructed from gaze-py's documented `src/gaze_py/classify/signals/` behavior: `interface.py`, `visibility.py`, `caller.py`, `naming.py`, and `docstring.py`. Each extractor SHALL emit its signal `source` as exactly one of the five protocol v1.1.0 strings — `interface`, `visibility`, `caller_count`, `naming_convention`, `docstring` — and SHALL NOT introduce any other `source` value (no sixth extractor, no `type_annotation`). Each extractor SHALL return either a single raw signal (carrying `weight` and a short, non-empty `reasoning`) or `None` when no rule applies. #### Scenario: only the five protocol sources are produced - **WHEN** any extractor emits a signal @@ -12,7 +12,7 @@ The system SHALL provide a `src/snake_eyes/signals/` package containing five sig - **THEN** it returns `None` and no signal is produced ### Requirement: interface extractor detects ABC and Protocol bases -The `interface` extractor SHALL emit an `interface` signal for a method defined on a class whose bases include an abstract base class (`abc.ABC`, a metaclass of `abc.ABCMeta`) or `typing.Protocol`. The emitted weight SHALL be the gaze-py interface weight (the lifted gaze-py source value, expected to be `30`). A method on a class with no interface base SHALL produce no `interface` signal. +The `interface` extractor SHALL emit an `interface` signal for a method defined on a class whose bases include an abstract base class (`abc.ABC`, a metaclass of `abc.ABCMeta`) or `typing.Protocol`. The emitted weight SHALL be the gaze-py interface weight (the documented gaze-py value, expected to be `30`). A method on a class with no interface base SHALL produce no `interface` signal. #### Scenario: ABC subclass method fires interface signal - **WHEN** a method is defined on a class that subclasses `abc.ABC` @@ -27,21 +27,21 @@ The `interface` extractor SHALL emit an `interface` signal for a method defined - **THEN** the `interface` extractor returns `None` and no `interface` signal is emitted ### Requirement: visibility extractor distinguishes public and private names -The `visibility` extractor SHALL emit a `visibility` signal based on the function's public/private naming convention (leading-underscore private vs. public) and `__all__` membership, using the gaze-py weights verbatim. A public function and a private (leading-underscore) function SHALL produce distinguishable `visibility` outcomes (different weight, or one emits while the other does not), matching the lifted gaze-py behavior. The extractor SHALL NOT redefine the gaze-py weights. +The `visibility` extractor SHALL emit a `visibility` signal based on the function's public/private naming convention (leading-underscore private vs. public) and `__all__` membership, using gaze-py's documented weights. A public function and a private (leading-underscore) function SHALL produce distinguishable `visibility` outcomes (different weight, or one emits while the other does not), matching gaze-py's documented behavior. The extractor SHALL NOT redefine the gaze-py weights. #### Scenario: public and private names differ in visibility signal - **WHEN** the extractor runs for a public function `def public_fn()` and for a private function `def _private()` -- **THEN** the two `visibility` results differ per the lifted gaze-py implementation (either the private function's weight is lower, or one emits a signal while the other returns `None`), without any weight value being changed +- **THEN** the two `visibility` results differ per gaze-py's documented behavior (either the private function's weight is lower, or one emits a signal while the other returns `None`), without any weight value being changed ### Requirement: caller_count extractor maps inbound counts to gaze-py buckets -The `caller` extractor SHALL accept an inbound caller count and emit a `caller_count` signal whose weight is the gaze-py weight for that count's bucket. The bucket boundaries and their weights SHALL be preserved verbatim from gaze-py; this change SHALL NOT retune them. When gaze-py emits no signal for a given count (e.g. a zero-caller count), the extractor SHALL return `None` for that count. +The `caller` extractor SHALL accept an inbound caller count and emit a `caller_count` signal whose weight is the gaze-py weight for that count's bucket. The bucket boundaries and their weights SHALL match gaze-py's documented values exactly; this change SHALL NOT retune them. When gaze-py emits no signal for a given count (e.g. a zero-caller count), the extractor SHALL return `None` for that count. #### Scenario: distinct counts map to their gaze-py bucket weights - **WHEN** the `caller` extractor is invoked with inbound counts of `0`, `5`, and `20` - **THEN** each invocation returns the gaze-py `caller_count` weight for that count's bucket (or `None` where gaze-py emits no signal), with the gaze-py bucket boundaries and weights unchanged ### Requirement: naming_convention extractor matches function name against effect type -The `naming` extractor SHALL emit a `naming_convention` signal when the function name's convention agrees with the effect type (e.g. a `get_`/`is_`/`has_` prefix agreeing with `ReturnValue`), and SHALL preserve gaze-py's negative-agreement outcomes (a name gaze-py treats as contradicting the effect stays negative). Weights SHALL be preserved verbatim from gaze-py. +The `naming` extractor SHALL emit a `naming_convention` signal when the function name's convention agrees with the effect type (e.g. a `get_`/`is_`/`has_` prefix agreeing with `ReturnValue`), and SHALL preserve gaze-py's negative-agreement outcomes (a name gaze-py treats as contradicting the effect stays negative). Weights SHALL match gaze-py's documented values. #### Scenario: getter name agrees with ReturnValue - **WHEN** the extractor runs for a function named `get_foo` with effect type `ReturnValue` @@ -52,7 +52,7 @@ The `naming` extractor SHALL emit a `naming_convention` signal when the function - **THEN** the `naming_convention` result remains the negative-agreement outcome defined by gaze-py, unchanged ### Requirement: docstring extractor matches docstring keywords against effect type -The `docstring` extractor SHALL emit a `docstring` signal when the function docstring contains keywords that agree with the effect type (e.g. a docstring mentioning "returns" agreeing with `ReturnValue`, or naming an exception agreeing with `ErrorReturn`/`ErrorSignal`), using gaze-py weights verbatim. A function with no docstring, or a docstring with no matching keyword, SHALL produce no `docstring` signal. +The `docstring` extractor SHALL emit a `docstring` signal when the function docstring contains keywords that agree with the effect type (e.g. a docstring mentioning "returns" agreeing with `ReturnValue`, or naming an exception agreeing with `ErrorReturn`/`ErrorSignal`), using gaze-py's documented weights. A function with no docstring, or a docstring with no matching keyword, SHALL produce no `docstring` signal. #### Scenario: docstring mentioning returns agrees with ReturnValue - **WHEN** the extractor runs for a function whose docstring contains "returns" with effect type `ReturnValue` @@ -62,8 +62,8 @@ The `docstring` extractor SHALL emit a `docstring` signal when the function docs - **WHEN** the extractor runs for a function with no docstring - **THEN** the `docstring` extractor returns `None` -### Requirement: gaze-py signal weights preserved verbatim -The extractors SHALL preserve the gaze-py signal weights exactly as lifted. These weights are governance-gate values consumed by Gaze's classification formula; this change SHALL NOT retune, clamp, or otherwise modify them, and tests SHALL assert the gaze-py weights rather than redefine them. +### Requirement: gaze-py signal weights preserved exactly +The extractors SHALL preserve the gaze-py signal weights exactly as documented. These weights are governance-gate values consumed by Gaze's classification formula; this change SHALL NOT retune, clamp, or otherwise modify them, and tests SHALL assert the gaze-py weights rather than redefine them. #### Scenario: interface weight is the gaze-py value - **WHEN** the `interface` extractor emits a signal @@ -91,9 +91,9 @@ The extractor modules SHALL NOT compute or assign any classification label. The - **WHEN** `src/snake_eyes/signals/` production code is scanned for `contractual`, `incidental`, or `ambiguous` used as assigned values - **THEN** none is found (occurrences, if any, are only in comments forbidding them) -### Requirement: gaze-py provenance retained on lifted extractors -Each lifted `signals/*.py` extractor SHALL retain the gaze-py copyright header (Matt Peter, Apache 2.0) AND SHALL add an Apache-2.0 §4(b) change notice identifying zero-dot-force as the modifier (e.g. `# Modified 2026 by zero-dot-force: adapted to snake_eyes SideEffectType; new Python effect types mapped.`). +### Requirement: gaze-py provenance retained on reconstructed extractors +Each reconstructed `signals/*.py` extractor SHALL retain a gaze-py copyright header (Matt Peter, Apache 2.0) AND SHALL add an Apache-2.0 §4(b) change notice identifying zero-dot-force as the modifier (e.g. `# Modified 2026 by zero-dot-force: reconstructed from documented gaze-py behavior; adapted to snake_eyes SideEffectType; new Python effect types mapped.`). -#### Scenario: provenance header present on a lifted extractor +#### Scenario: provenance header present on a reconstructed extractor - **WHEN** `src/snake_eyes/signals/interface.py` is inspected - **THEN** it contains the gaze-py Apache 2.0 provenance header and an Apache-2.0 §4(b) change notice identifying zero-dot-force From 47119cf9bf8bcffceed136b5bfb0be2c5febb881 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Mon, 31 Aug 2026 18:13:40 -0400 Subject: [PATCH 6/6] fix(classify-signals): harden caller inference per review council findings Scope astroid inference to in-project defined names to prevent cap-bypassing transitive parsing, and clear the MANAGER cache in a finally block. Broaden the per-call-site catch so an uninferable call is omitted rather than failing the whole request. Emit stderr diagnostics on all degrade paths. Add degradation-branch tests (outer degrade, parametrized per-file exceptions, Uninferable skip, non-degrade exception) and protocol non-empty/source/weight assertions; fix minor docstring spelling. --- src/snake_eyes/analysis/inference.py | 69 +++++++++++++++++++++++--- src/snake_eyes/signals/docstring.py | 2 +- src/snake_eyes/signals/naming.py | 2 +- tests/test_classify_signals_method.py | 11 +++++ tests/test_inference.py | 70 ++++++++++++++++++++++++++- tests/test_signals_new_types.py | 7 +++ 6 files changed, 151 insertions(+), 10 deletions(-) diff --git a/src/snake_eyes/analysis/inference.py b/src/snake_eyes/analysis/inference.py index 7320cd4..296b225 100644 --- a/src/snake_eyes/analysis/inference.py +++ b/src/snake_eyes/analysis/inference.py @@ -17,9 +17,14 @@ * **Isolation** -- an isolated per-request manager cache is used so that a prior request over a different tree cannot contaminate this one in the long-lived stdio server. -* **On-disk resolution** -- callees are only counted when they resolve to a - file within the analyzed set, so ambient ``site-packages`` are never - consulted. +* **On-disk resolution** -- callees are only *counted* when they resolve to a + file within the analyzed set. Inference is attempted only for call sites + whose unqualified name matches a function defined in the analyzed project, + so ambient ``stdlib``/``site-packages`` modules are not parsed for the + common case. A callee that merely shares its unqualified name with an + in-project function may still be inferred (and its defining module parsed); + such transitive parsing is not bounded by the 16 MiB per-file cap, so a + ``MemoryError`` there degrades the whole index to empty. * **Graceful degradation** -- any astroid/resource failure degrades the whole index to empty (counts of zero); a single uninferable call site is skipped. """ @@ -27,6 +32,7 @@ from __future__ import annotations import pathlib +import sys from collections import defaultdict import astroid # type: ignore[import-untyped] @@ -102,8 +108,18 @@ def build_caller_index(root_path: str, patterns: list[str] | None) -> CallerInde root = pathlib.Path(root_path) try: return _build(root, rel_paths) - except _DEGRADE_EXCEPTIONS: + except _DEGRADE_EXCEPTIONS as exc: + print( + f"snake-eyes: classify_signals caller index degraded to empty:" + f" {type(exc).__name__}: {exc}", + file=sys.stderr, + ) return _empty_index() + finally: + # Release parsed modules promptly; the start-of-build clear_cache in + # _build handles cross-request isolation, this bounds resident memory + # in the long-lived stdio server between requests. + astroid.MANAGER.clear_cache() def _build(root: pathlib.Path, rel_paths: list[str]) -> CallerIndex: @@ -123,22 +139,54 @@ def _build(root: pathlib.Path, rel_paths: list[str]) -> CallerIndex: modname = _shared.derive_package(rel) try: module = manager.ast_from_file(str(abs_path), modname, source=True) - except _DEGRADE_EXCEPTIONS: + except _DEGRADE_EXCEPTIONS as exc: + print( + f"snake-eyes: classify_signals skipping {rel}:" + f" {type(exc).__name__}: {exc}", + file=sys.stderr, + ) continue norm = _normalize(str(abs_path)) analyzed_files.add(norm) module_to_file[modname] = norm modules.append(module) + # Names of functions defined anywhere in the analyzed project. Inference is + # attempted only for call sites whose unqualified name is in this set, so + # astroid does not parse ambient stdlib/site-packages modules (which bypass + # the 16 MiB byte-cap) to resolve external callees that would never count. + defined_names: set[str] = set() + for module in modules: + for func in module.nodes_of_class((nodes.FunctionDef, nodes.AsyncFunctionDef)): + defined_names.add(func.name) + counts: dict[tuple[str, str], int] = defaultdict(int) for module in modules: for call in module.nodes_of_class(nodes.Call): + name = _call_simple_name(call) + if name is None or name not in defined_names: + continue target = _resolve_call_target(call, analyzed_files) if target is not None: counts[target] += 1 return CallerIndex(dict(counts), module_to_file) +def _call_simple_name(call: nodes.Call) -> str | None: + """Return the callee's unqualified name (``foo`` in ``foo()`` or ``x.foo()``). + + Returns ``None`` for call shapes without a simple name (e.g. calling the + result of another expression), which are never matched against the + in-project function-name set and so are never inferred. + """ + func = call.func + if isinstance(func, nodes.Name): + return str(func.name) + if isinstance(func, nodes.Attribute): + return str(func.attrname) + return None + + def _resolve_call_target( call: nodes.Call, analyzed_files: set[str] ) -> tuple[str, str] | None: @@ -149,7 +197,16 @@ def _resolve_call_target( """ try: inferred = list(call.func.infer()) - except _DEGRADE_EXCEPTIONS: + except Exception as exc: + # astroid inference can raise beyond _DEGRADE_EXCEPTIONS (e.g. + # AttributeError/KeyError/TypeError/RuntimeError) on pathological or + # untrusted input. Skip this one call site rather than failing the whole + # classify_signals request with -32603 (Constitution V degradation). + print( + f"snake-eyes: classify_signals skipping uninferable call site:" + f" {type(exc).__name__}: {exc}", + file=sys.stderr, + ) return None for candidate in inferred: if candidate is Uninferable: diff --git a/src/snake_eyes/signals/docstring.py b/src/snake_eyes/signals/docstring.py index bd2dcbc..4a92f55 100644 --- a/src/snake_eyes/signals/docstring.py +++ b/src/snake_eyes/signals/docstring.py @@ -4,7 +4,7 @@ # Python effect types mapped to their closest gaze-py branch. """``docstring`` source: docstring keywords vs. effect category. -When a function's docstring documents the behaviour that produced an effect +When a function's docstring documents the behavior that produced an effect (mentions returning/yielding for a returning effect, raising for an error effect, or mutating for a mutation effect), the effect is more likely contractual. A missing docstring or an unrelated category produces no signal. diff --git a/src/snake_eyes/signals/naming.py b/src/snake_eyes/signals/naming.py index baf1adb..12c5f49 100644 --- a/src/snake_eyes/signals/naming.py +++ b/src/snake_eyes/signals/naming.py @@ -7,7 +7,7 @@ An accessor-style name (``get_``, ``is_``, ...) that returns a value agrees with the effect and weighs positively; the same name paired with a mutation effect contradicts it and weighs negatively (and vice versa for mutator-style -names). Names without a recognised prefix -- or effects that route to +names). Names without a recognized prefix -- or effects that route to ``OTHER`` -- produce no signal. """ diff --git a/tests/test_classify_signals_method.py b/tests/test_classify_signals_method.py index c6ae46b..809142a 100644 --- a/tests/test_classify_signals_method.py +++ b/tests/test_classify_signals_method.py @@ -47,9 +47,20 @@ def test_classify_signals_returns_signal_array(tmp_path: Path) -> None: resp = responses(_run(req("classify_signals", root_path=str(tmp_path)) + "\n"))[0] signals = resp["result"]["signals"] assert isinstance(signals, list) + assert signals, ( + "expected at least one signal for a returning, documented, called function" + ) for signal in signals: assert signal["function"] assert signal["package"] is not None + assert signal["source"] in { + "interface", + "visibility", + "caller_count", + "naming_convention", + "docstring", + } + assert isinstance(signal["weight"], int) assert isinstance(signal["reasoning"], str) assert signal["reasoning"] diff --git a/tests/test_inference.py b/tests/test_inference.py index f263e15..ee75bf5 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -6,7 +6,8 @@ import astroid import pytest -from astroid.exceptions import AstroidError +from astroid.exceptions import AstroidError, InferenceError +from astroid.util import Uninferable from snake_eyes.analysis import inference from snake_eyes.analysis.inference import build_caller_index, count_callers @@ -73,7 +74,7 @@ def test_cross_module_call_under_src_layout(tmp_path: Path) -> None: "from src.pkg.b import target\n\ndef caller():\n return target()\n", ) index = build_caller_index(str(tmp_path), None) - assert index.count("src.pkg.b", "target") >= 1 + assert index.count("src.pkg.b", "target") == 1 def test_oversized_file_skipped_before_astroid( @@ -92,3 +93,68 @@ def fail(*args: object, **kwargs: object) -> object: monkeypatch.setattr(astroid.MANAGER, "ast_from_file", fail) index = build_caller_index(str(tmp_path), None) assert index.count("mod", "target") == 0 + + +def test_build_failure_degrades_whole_index_to_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write( + tmp_path, + "mod.py", + "def target():\n return 1\n\ndef c1():\n return target()\n", + ) + + def boom(*args: object, **kwargs: object) -> object: + raise MemoryError("boom") + + monkeypatch.setattr(inference, "_build", boom) + index = build_caller_index(str(tmp_path), None) + assert index.count("mod", "target") == 0 + + +@pytest.mark.parametrize("exc_type", [InferenceError, RecursionError, OSError]) +def test_per_file_astroid_failure_degrades_to_zero( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + exc_type: type[Exception], +) -> None: + _write( + tmp_path, + "mod.py", + "def target():\n return 1\n\ndef c1():\n return target()\n", + ) + + def boom(*args: object, **kwargs: object) -> object: + raise exc_type("boom") + + monkeypatch.setattr(astroid.MANAGER, "ast_from_file", boom) + assert build_caller_index(str(tmp_path), None).count("mod", "target") == 0 + + +class _FakeInferFunc: + def __init__(self, result: object) -> None: + self._result = result + + def infer(self) -> list[object]: + if isinstance(self._result, Exception): + raise self._result + return [self._result] + + +class _FakeCall: + def __init__(self, result: object) -> None: + self.func = _FakeInferFunc(result) + + +def test_resolve_call_target_omits_uninferable_call() -> None: + # A call whose target infers to Uninferable is omitted (single-call skip), + # never counted and never raising. + call = _FakeCall(Uninferable) + assert inference._resolve_call_target(call, set()) is None + + +def test_resolve_call_target_swallows_non_degrade_exception() -> None: + # astroid inference can raise beyond _DEGRADE_EXCEPTIONS on pathological + # input; such a call site is omitted, never failing the whole request. + call = _FakeCall(AttributeError("astroid inference bug")) + assert inference._resolve_call_target(call, set()) is None diff --git a/tests/test_signals_new_types.py b/tests/test_signals_new_types.py index e64ad2e..d581e95 100644 --- a/tests/test_signals_new_types.py +++ b/tests/test_signals_new_types.py @@ -48,3 +48,10 @@ def test_unmapped_type_returns_none() -> None: # (routes to OTHER), never a KeyError. assert naming.extract("get_foo", SideEffectType.LogWrite) is None assert docstring.extract("writes to the log", SideEffectType.LogWrite) is None + + +def test_unknown_effect_type_string_routes_to_other() -> None: + # A string outside the SideEffectType taxonomy exercises the ValueError + # guard in _routing.effect_category -> OTHER (no signal), never raising. + assert naming.extract("get_foo", "NotARealType") is None + assert docstring.extract("returns a value", "NotARealType") is None