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:
- Match Gaze protocol v1.1.0 request/response field names exactly.
- Lift named gaze-py files; preserve copyright headers; do not lift CLI, CRAP, or classify engine.
analyze / complexity / coverage all take root_path + patterns, never a file list.
- 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__.py → pkg). 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:
root_path/coverage.json (coverage.py JSON report)
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.py — yield, xs.append, sys.stdout.write on a file write, global
p2.py — open(..., "w"), setattr, class with __enter__/__exit__, class with __get__, importlib.import_module inside a function, other_module.foo = 1
p3.py — print, os.environ["X"]=, sys.exit
syntax_error.py — invalid syntax, used to prove skip-and-continue
pure.py — def 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
How to use this issue with
/opsx-proposeUse 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:
analyze/complexity/coverageall takeroot_path+patterns, never a file list.OpenSpec change name:
analysis-methodsDepends on: #3 (
taxonomy-and-discovery) merged (48-type enum, FunctionRecord/Effect, discover() helper).Do not implement
classify_signalsortest_mappingin this change.Context
This issue implements the three remaining required protocol methods:
analyze— side-effect detectioncomplexity— cyclomatic complexity per functioncoverage— 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):
src/gaze_py/analysis/complexity.pysrc/snake_eyes/analysis/complexity.pysrc/gaze_py/analysis/detector.pysrc/snake_eyes/analysis/detector.pyDo not add
radon. Use the lifted McCabe function. Runtime deps to add in this issue:coverage>=7.0— to read.coveragedata files via coverage.py API (preferred over hand-rolling SQLite). If onlycoverage.jsonexists, parse JSON with stdlib.Do not add astroid in this issue. Detection is
ast+symtableonly. 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.pyfiles, then operate on source_files ∪ test_files foranalyzeandcomplexity(tests are functions too). Forcoverage, map coverage data onto those functions; functions with no coverage data still appear withcovered_stmts: 0if 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).complexityresult{ "functions": [ { "name": "divide", "package": "math_utils", "file": "math_utils/ops.py", "line": 20, "complexity": 5 } ] }packageis the dotted module path derived from the file path relative to root (strip.py, replace/with., drop trailing.__init__sopkg/__init__.py→pkg). Nested functions:nameis the unqualifieddefname (same as Gaze Go analyzer — notouter.inner). If two nested functions share a name, emit both rows (same name, differentline).coverageresult{ "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
functionnotname.percentageis float 0.0–100.0,round(covered/total*100, 1)when total > 0, else0.0.analyzeresult{ "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
classificationon effects. Gaze classifies.detailonly 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_complexityfrom gaze-py as-is (preserve copyright).Wire method
complexity: discover files →ast.parse→ for eachFunctionDef/AsyncFunctionDef(including nested) compute complexity.Nested classes' methods are included. Lambdas are not functions in the protocol response.
2. Detector
Public API:
The second is for unit tests on fixtures without a full tree.
Lift from gaze-py detector.py:
_STDLIB_EXCEPTIONS,_SLICE_METHODS,_MAP_METHODS,_LOG_NAMES,_WRITE_MODES, and any FS/process/time/log call-name sets that apply to Python)_FunctionVisitor/ equivalent per-function AST walkse-XXXXXXXX) — store indetail["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:
ReturnValuereturnwith a non-None value (return/return Nonestill counts as ReturnValue — Gaze P0 includes any return of a value; barereturnandreturn Nonedo count)ErrorReturnraiseof an instance/call/nameSentinelErrorException/BaseExceptionand is assigned a module-level alias used as a sentinel, or module-levelclass FooError(Exception): passthat is not a stdlib re-export. Match gaze-py's existing sentinel scan.ReceiverMutationself.x = ...orself.x += ...or mutating call onself.xPointerArgMutationself/clsSliceMutationlist/bytearrayitem assign or slice assign, or list mutating methods on a local listMapMutationdictitem assign or dict mutating methodsGlobalMutationglobalname assignment, or module-level name assign inside a function viaglobalWriterOutput.write(/.writelines(/.flush(on an object that is not clearly stdout/stderr (those are StdoutWrite/StderrWrite)HTTPResponseWriteresponse.write,make_responsebody,HttpResponse) — best-effort name match, same style as gaze-pyChannelSend/ChannelClosequeue.Queue.put/.put_nowait→ ChannelSend;.task_doneis not close;queue.Queuehas no close — mapmultiprocessing.Queue.putsimilarly. ChannelClose: skip if no Python analogue, do not fake itDeferredReturnMutationFileSystemWriteopen(..., "w"/"a"/"x"/"+"),Path.write_text/write_bytes,os.write,shutil.copy*FileSystemDeleteos.remove/unlink/rmdir,Path.unlink,shutil.rmtreeFileSystemMetaos.chmod/chown/rename/mkdir,Path.mkdir/rename/chmodDatabaseWritecursor.execute/.executemany/.commitname matchDatabaseTransaction.commit(/.rollback(on objects namedconn/connection/sessionGoroutineSpawnthreading.Thread(...).start(),asyncio.create_task,loop.run_in_executor,multiprocessing.Process.startPanicos.abort(), orraise SystemExitis ProcessExit not Panic. Panic:faulthandlerorctypesabort — if unsure, do not emit.assert Falseis not Panic.CallbackInvocationcallback(),handler(),on_*()) — same heuristic as gaze-py if presentLogWritelogging.*,logger.info/debug/warning/error/critical/exception,structlogContextCancellationasyncio.CancelledErrorraise/handle,cancel()on a taskStdoutWriteprint(...),sys.stdout.writeStderrWritesys.stderr.write,print(..., file=sys.stderr)EnvVarMutationos.environ[...] =,os.putenv,os.environ.updateMutexOpthreading.Lock/RLock/Conditionacquire/release,with lockWaitGroupOpthreading.Barrier,asyncio.gather— map Barrier to WaitGroupOpAtomicOpatomic.*— do not fakeTimeDependencytime.time/time.sleep/datetime.now/date.todayProcessExitsys.exit,raise SystemExit,os._exitRecoverBehaviorexcept:orexcept Exceptionthat swallows without re-raiseReflectionMutationsetattron arbitrary objects,delattr,__dict__writesUnsafeMutationctypesmutating callsCgoCallctypes.CDLL/cfficallsFinalizerRegistrationatexit.register,weakref.finalizeSyncPoolOpmultiprocessing.Pool— map Pool to SyncPoolOpClosureCaptureMutationnonlocalassignmentAdd detection for the 10 new types:
ErrorSignalErrorReturn(raise). Emit ErrorSignal in addition to ErrorReturn for everyraise. (Gaze P0 includes both; dual emission is required so Gaze's alias layer is not the only path.)GeneratorYieldyield/yield fromin a non-async functionContainerMutationappend extend insert remove pop clear reverse sort add discard update— if the object isself.*prefer ReceiverMutation; if param prefer PointerArgMutation; ContainerMutation for locals and unknownStreamOutputsys.stdout.write/sys.stderr.write/.writeon a file-like. StdoutWrite/StderrWrite already cover print/stdout/stderr. EmitStreamOutputfor.writeon files opened in the function or passed in, not forprint. Do not double-emit StreamOutput forprint.AsyncGeneratorYieldyieldinsideasync defMetaprogrammingMutationtype(name, bases, dict),types.new_class,__class__ =DescriptorEffect__get__/__set__/__delete__/__set_name__. Emit on those methods themselves.ResourceManagement__enter__/__exit__/__aenter__/__aexit__definitions;@contextmanager/@asynccontextmanagerdecorated functionsImportSideEffectimport/__import__/importlib.import_module. Module-level imports are not attributed to a function; skip module-level (Gaze analyze is per-function).MonkeyPatchsetattr(module, ...), orimported_name.attr = ...whereimported_nameis insys.modules/ an import alias in the function's enclosing moduleAmbiguity:
eval,exec,getattrused as a call,obj.*where star is computed (getattr(obj, name)()): emit the most likely effect if a pattern still matches; otherwise emitCallbackInvocationwithdetail: {"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 defis its ownFunctionRecord. Decorators are not themselves effects in v1 except@contextmanageras 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.pyLookup order:
root_path/coverage.json(coverage.py JSON report)root_path/.coverage(coverage.py default data file)If neither exists: return
{"functions": []}— not an error. Gaze treats missing coverage as empty.If
coverage.jsonexists, use itsfiles.<path>.executed_lines/missing_lines(coverage.py JSON v1/v2 — support the format produced bycoverage 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
.coverageexists, usecoverage.Coverage(data_file=...).load()andget_data().lines(filename).Never run pytest. Never invoke
coverage run.4. Server wiring
Register
analyze,complexity,coverage.initializecapabilities stay:{"discover": true, "test_mapping": false, "classify_signals": false, "streaming": false}(
discoveralready true from previous issue.)5. Fixtures
tests/fixtures/effects/:p0.py— return, raise,self.x =,param.appendp1.py—yield,xs.append,sys.stdout.writeon a file write,globalp2.py—open(..., "w"),setattr, class with__enter__/__exit__, class with__get__,importlib.import_moduleinside a function,other_module.foo = 1p3.py—print,os.environ["X"]=,sys.exitsyntax_error.py— invalid syntax, used to prove skip-and-continuepure.py—def add(a,b): return a+bstill has ReturnValue (P0). For zero effects, usedef noop(): pass(no return). Assertside_effects == []onnoop.tests/fixtures/coverage/coverage.json— canned file matching a tiny module also in fixtures.Tests required
if/elif/else+and→ exact integer (write the expected number in the test comment by computing McCabe by hand).typestring, andlocationcontains the fixture filename and a line number.noop→ no effects.analyzerequest → valid file's functions present, no crash.functions: [].analyze,complexity,coverageagainsttmp_pathcopies of fixtures.P0 false negatives in fixtures are release-blocking.
Coverage strategy (Constitution IV)
complexity.pydetector.pycoverage.pyCS-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/streamDone when