Skip to content

snake-eyes: side effect detector, complexity, and coverage — required analysis methods #4

Description

@jflowers

How to use this issue with /opsx-propose

/opsx-propose analysis-methods

Use this entire issue body as the change description. Do not ask clarifying questions. Every decision is already made below. If something is still unspecified, apply these defaults in order:

  1. Match Gaze protocol v1.1.0 request/response field names exactly.
  2. Lift named gaze-py files; preserve copyright headers; do not lift CLI, CRAP, or classify engine.
  3. analyze / complexity / coverage all take root_path + patterns, never a file list.
  4. Ambiguous effects are reported, never silently dropped (Constitution II).

OpenSpec change name: analysis-methods

Depends on: #3 (taxonomy-and-discovery) merged (48-type enum, FunctionRecord/Effect, discover() helper).

Do not implement classify_signals or test_mapping in this change.


Context

This issue implements the three remaining required protocol methods:

  • analyze — side-effect detection
  • complexity — cyclomatic complexity per function
  • coverage — parse coverage.py data (do not run tests)

Together with initialize / shutdown / discover, snake-eyes becomes a complete required-method Gaze backend.

Lift sources (mpeter/gaze-py, Apache 2.0, permission granted):

gaze-py path snake-eyes path Action
src/gaze_py/analysis/complexity.py src/snake_eyes/analysis/complexity.py Lift as-is
src/gaze_py/analysis/detector.py src/snake_eyes/analysis/detector.py Lift core; extend; drop Go-isms

Do not add radon. Use the lifted McCabe function. Runtime deps to add in this issue:

  • coverage>=7.0 — to read .coverage data files via coverage.py API (preferred over hand-rolling SQLite). If only coverage.json exists, parse JSON with stdlib.

Do not add astroid in this issue. Detection is ast + symtable only. Name inference via astroid is issue 4.


Protocol shapes (authoritative)

All three methods share request params:

{"root_path": "/absolute/path", "patterns": ["./..."]}

Use snake_eyes.discovery.discover() to get .py files, then operate on source_files ∪ test_files for analyze and complexity (tests are functions too). For coverage, map coverage data onto those functions; functions with no coverage data still appear with covered_stmts: 0 if the coverage file exists and lists the file, or omit functions that have no coverage records if the coverage file is missing entirely (see coverage section).

complexity result

{
  "functions": [
    {
      "name": "divide",
      "package": "math_utils",
      "file": "math_utils/ops.py",
      "line": 20,
      "complexity": 5
    }
  ]
}

package is the dotted module path derived from the file path relative to root (strip .py, replace / with ., drop trailing .__init__ so pkg/__init__.pypkg). Nested functions: name is the unqualified def name (same as Gaze Go analyzer — not outer.inner). If two nested functions share a name, emit both rows (same name, different line).

coverage result

{
  "functions": [
    {
      "file": "math_utils/ops.py",
      "function": "divide",
      "start_line": 20,
      "end_line": 30,
      "covered_stmts": 0,
      "total_stmts": 10,
      "percentage": 0.0
    }
  ]
}

Note the field is function not name. percentage is float 0.0–100.0, round(covered/total*100, 1) when total > 0, else 0.0.

analyze result

{
  "functions": [
    {
      "name": "divide",
      "package": "math_utils",
      "file": "math_utils/ops.py",
      "line": 20,
      "side_effects": [
        {
          "type": "ReturnValue",
          "description": "returns division result",
          "location": "math_utils/ops.py:25:5",
          "target": "result"
        }
      ]
    }
  ]
}

Do not include classification on effects. Gaze classifies. detail only when there is Python-specific metadata (e.g. {"exception_class": "ZeroDivisionError"} or {"confidence": "ambiguous"}).

Parse errors: skip the file, continue others. Include no functions from that file. Do not fail the whole request. Optional: later we may add an errors array; do not add one unless the protocol schema already has it (it does not — so just skip).

Missing root_path: -32602.

Syntax-error files: skip, continue.


What to build

1. Complexity

Lift cyclomatic_complexity from gaze-py as-is (preserve copyright).

Wire method complexity: discover files → ast.parse → for each FunctionDef / AsyncFunctionDef (including nested) compute complexity.

Nested classes' methods are included. Lambdas are not functions in the protocol response.

2. Detector

Public API:

def analyze_path(root_path: str, patterns: list[str]) -> list[FunctionRecord]: ...
def analyze_source(source: str, filename: str, package: str) -> list[FunctionRecord]: ...

The second is for unit tests on fixtures without a full tree.

Lift from gaze-py detector.py:

  • Pattern constant lists (_STDLIB_EXCEPTIONS, _SLICE_METHODS, _MAP_METHODS, _LOG_NAMES, _WRITE_MODES, and any FS/process/time/log call-name sets that apply to Python)
  • Module-level sentinel-exception class collection
  • _FunctionVisitor / equivalent per-function AST walk
  • Deterministic effect identity if present (se-XXXXXXXX) — store in detail["id"] if you keep it; Gaze does not require it. Decision: do not emit gaze-py effect IDs in protocol JSON (not in the schema). Keep internally only if tests need stability; prefer no ID field.

Detect all 38 original types that make sense in Python, mapping Go names onto Python AST:

Type Python detection rule (normative)
ReturnValue return with a non-None value (return / return None still counts as ReturnValue — Gaze P0 includes any return of a value; bare return and return None do count)
ErrorReturn raise of an instance/call/name
SentinelError class body that subclasses Exception/BaseException and is assigned a module-level alias used as a sentinel, or module-level class FooError(Exception): pass that is not a stdlib re-export. Match gaze-py's existing sentinel scan.
ReceiverMutation self.x = ... or self.x += ... or mutating call on self.x
PointerArgMutation mutating call or item/attr assign on a parameter other than self/cls
SliceMutation list/bytearray item assign or slice assign, or list mutating methods on a local list
MapMutation dict item assign or dict mutating methods
GlobalMutation global name assignment, or module-level name assign inside a function via global
WriterOutput .write( / .writelines( / .flush( on an object that is not clearly stdout/stderr (those are StdoutWrite/StderrWrite)
HTTPResponseWrite calls on names suggesting Flask/Django/FastAPI response write (response.write, make_response body, HttpResponse) — best-effort name match, same style as gaze-py
ChannelSend / ChannelClose queue.Queue.put / .put_nowait → ChannelSend; .task_done is not close; queue.Queue has no close — map multiprocessing.Queue.put similarly. ChannelClose: skip if no Python analogue, do not fake it
DeferredReturnMutation skip in v1 (gaze-py finally-block quirk). Do not emit.
FileSystemWrite open(..., "w"/"a"/"x"/"+"), Path.write_text/write_bytes, os.write, shutil.copy*
FileSystemDelete os.remove/unlink/rmdir, Path.unlink, shutil.rmtree
FileSystemMeta os.chmod/chown/rename/mkdir, Path.mkdir/rename/chmod
DatabaseWrite cursor.execute / .executemany / .commit name match
DatabaseTransaction .commit( / .rollback( on objects named conn/connection/session
GoroutineSpawn threading.Thread(...).start(), asyncio.create_task, loop.run_in_executor, multiprocessing.Process.start
Panic os.abort(), or raise SystemExit is ProcessExit not Panic. Panic: faulthandler or ctypes abort — if unsure, do not emit. assert False is not Panic.
CallbackInvocation calling a parameter that is callable (callback(), handler(), on_*()) — same heuristic as gaze-py if present
LogWrite logging.*, logger.info/debug/warning/error/critical/exception, structlog
ContextCancellation asyncio.CancelledError raise/handle, cancel() on a task
StdoutWrite print(...), sys.stdout.write
StderrWrite sys.stderr.write, print(..., file=sys.stderr)
EnvVarMutation os.environ[...] =, os.putenv, os.environ.update
MutexOp threading.Lock/RLock/Condition acquire/release, with lock
WaitGroupOp threading.Barrier, asyncio.gather — map Barrier to WaitGroupOp
AtomicOp skip unless atomic.* — do not fake
TimeDependency time.time/time.sleep/datetime.now/date.today
ProcessExit sys.exit, raise SystemExit, os._exit
RecoverBehavior bare except: or except Exception that swallows without re-raise
ReflectionMutation setattr on arbitrary objects, delattr, __dict__ writes
UnsafeMutation ctypes mutating calls
CgoCall ctypes.CDLL / cffi calls
FinalizerRegistration atexit.register, weakref.finalize
SyncPoolOp skip (no analogue) unless multiprocessing.Pool — map Pool to SyncPoolOp
ClosureCaptureMutation nonlocal assignment

Add detection for the 10 new types:

Type Rule
ErrorSignal Same AST as ErrorReturn (raise). Emit ErrorSignal in addition to ErrorReturn for every raise. (Gaze P0 includes both; dual emission is required so Gaze's alias layer is not the only path.)
GeneratorYield yield / yield from in a non-async function
ContainerMutation mutating methods on locals: append extend insert remove pop clear reverse sort add discard update — if the object is self.* prefer ReceiverMutation; if param prefer PointerArgMutation; ContainerMutation for locals and unknown
StreamOutput sys.stdout.write / sys.stderr.write / .write on a file-like. StdoutWrite/StderrWrite already cover print/stdout/stderr. Emit StreamOutput for .write on files opened in the function or passed in, not for print. Do not double-emit StreamOutput for print.
AsyncGeneratorYield yield inside async def
MetaprogrammingMutation type(name, bases, dict), types.new_class, __class__ =
DescriptorEffect class defines __get__ / __set__ / __delete__ / __set_name__. Emit on those methods themselves.
ResourceManagement __enter__/__exit__/__aenter__/__aexit__ definitions; @contextmanager / @asynccontextmanager decorated functions
ImportSideEffect inside a function: import / __import__ / importlib.import_module. Module-level imports are not attributed to a function; skip module-level (Gaze analyze is per-function).
MonkeyPatch setattr(module, ...), or imported_name.attr = ... where imported_name is in sys.modules / an import alias in the function's enclosing module

Ambiguity: eval, exec, getattr used as a call, obj.* where star is computed (getattr(obj, name)()): emit the most likely effect if a pattern still matches; otherwise emit CallbackInvocation with detail: {"confidence": "ambiguous"}. Never drop the call entirely if it is a call to a name that is not a known pure builtin.

Nested functions: each def/async def is its own FunctionRecord. Decorators are not themselves effects in v1 except @contextmanager as ResourceManagement on the wrapped function.

Do not emit DeferredReturnMutation, and do not emit ChannelClose/Panic/AtomicOp/CgoCall unless the rule above matched.

3. Coverage parser

src/snake_eyes/coverage.py

def parse_coverage(root_path: str, patterns: list[str]) -> list[dict]: ...

Lookup order:

  1. root_path/coverage.json (coverage.py JSON report)
  2. root_path/.coverage (coverage.py default data file)

If neither exists: return {"functions": []}not an error. Gaze treats missing coverage as empty.

If coverage.json exists, use its files.<path>.executed_lines / missing_lines (coverage.py JSON v1/v2 — support the format produced by coverage json). Map lines to functions via AST start/end (function body lines inclusive). total_stmts = count of lines in the coverage file that fall in [start_line, end_line] and are statement lines in the coverage data. covered_stmts = those listed as executed.

If only .coverage exists, use coverage.Coverage(data_file=...).load() and get_data().lines(filename).

Never run pytest. Never invoke coverage run.

4. Server wiring

Register analyze, complexity, coverage.

initialize capabilities stay:

{"discover": true, "test_mapping": false, "classify_signals": false, "streaming": false}

(discover already true from previous issue.)

5. Fixtures

tests/fixtures/effects/:

  • p0.py — return, raise, self.x =, param.append
  • p1.pyyield, xs.append, sys.stdout.write on a file write, global
  • p2.pyopen(..., "w"), setattr, class with __enter__/__exit__, class with __get__, importlib.import_module inside a function, other_module.foo = 1
  • p3.pyprint, os.environ["X"]=, sys.exit
  • syntax_error.py — invalid syntax, used to prove skip-and-continue
  • pure.pydef add(a,b): return a+b still has ReturnValue (P0). For zero effects, use def noop(): pass (no return). Assert side_effects == [] on noop.

tests/fixtures/coverage/coverage.json — canned file matching a tiny module also in fixtures.


Tests required

  • Complexity: fixture function with if/elif/else + and → exact integer (write the expected number in the test comment by computing McCabe by hand).
  • Detector: one test per new type (10) and one test per P0 type (6) at minimum. Assert type string, and location contains the fixture filename and a line number.
  • noop → no effects.
  • File with syntax error + a valid file in the same analyze request → valid file's functions present, no crash.
  • Coverage: canned JSON → expected covered/total for one function.
  • Coverage: missing files → functions: [].
  • JSON-RPC e2e for analyze, complexity, coverage against tmp_path copies of fixtures.

P0 false negatives in fixtures are release-blocking.


Coverage strategy (Constitution IV)

Layer Target
complexity.py 100%
detector.py 90%+ (visitor branches for rare Go-only names may be uncovered; do not keep dead Go-only branches — delete ChannelClose if unimplemented rather than leave dead code)
coverage.py 90%+
Project gate 85%

CS-002: do not leave unused Go-only pattern lists. If a pattern list has no Python detection using it, delete it.


Out of scope

  • classify_signals, test_mapping, analyze/stream
  • Running the user's test suite
  • CRAP / GazeCRAP / quadrants
  • Classification labels on effects

Done when

  • All three required methods respond with protocol-shaped JSON
  • P0 fixture effects have zero false negatives
  • 10 new types have at least one positive fixture test each
  • gaze-py copyright retained on lifted complexity and detector files

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions