Repository: CoinFabrik/stacy (language: python)

Issue:
Missing whitespace in Note message
https://github.com/CoinFabrik/stacy/blob/be66718bcfdc86ad514386fe057f35cca8ed443d/stacks_analyzer/print_message.py#L49

On that branch, the message is missing the white-space after the colon.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 5baa91a..e48532d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ package*.json
 node_modules
 report.out
 .idea
+dist/
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
index e66ea90..a7bb33a 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
-[submodule "stacks_analyzer/tree-sitter-clarity"]
-	path = stacks_analyzer/tree-sitter-clarity
+[submodule "src/stacy_analyzer/tree-sitter-clarity"]
+	path = src/stacy_analyzer/tree-sitter-clarity
 	url = https://github.com/xlittlerag/tree-sitter-clarity
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..b1dbe7e
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,4 @@
+include pyproject.toml
+include *.md
+include LICENSE
+recursive-include tests test*.py
diff --git a/Makefile b/Makefile
index f79550e..74ae882 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-PATH1 = stacks_analyzer/tree-sitter-clarity
+PATH1 = src/stacy_analyzer/tree-sitter-clarity
 PATH2 = .
 
 .PHONY: venv fish bash ps1 csh install
@@ -34,6 +34,8 @@ action: venv
 
 
 install: venv
+	@echo -e  "${GREEN}======== Removing previous install of Stacy ========${NC}"
+	./venv/bin/pip uninstall stacy-analyzer -y
 	@echo -e  "${GREEN}======== Cloning tree-sitter grammar for Clarity ========${NC}"
 	git submodule update --recursive
 	@echo -e "${GREEN}======== Installing tree-sitter grammar for Clarity ========${NC}"
@@ -60,6 +62,9 @@ test_ci: venv
 	@echo -e  "${GREEN}======== Testing detectors ========${NC}"
 	./tests.sh > $(GITHUB_WORKSPACE)/test.out
 
+unittest: venv install
+	cd tests/ && python3 -m unittest test_module1 && cd ..
+
 fish: venv
 	@echo -e "${BLUE}======== Using Fish shell ========${NC}"
 	. venv/bin/activate.fish && make install
diff --git a/README.md b/README.md
index 952244f..f82393a 100644
--- a/README.md
+++ b/README.md
@@ -6,59 +6,13 @@ Stacy is an open-source static analyzer for Clarity smart contracts. It is inten
 
 This tool will help developers write secure and more robust smart contracts.
 
-
 ## Install
 
-### HTTPS
-
-```shell
-git clone --recurse-submodules -j8 https://github.com/CoinFabrik/stacy.git
-cd stacy
-make
-```
-
-### SSH
-
-```shell
-git clone --recurse-submodules -j8 git@github.com:CoinFabrik/stacy.git
-cd stacy
-make
-```
-
-If you already have an initialized python venv, use `make install`
-
-If you have a shell other than `bash`, use `make <shell>`
-
-## Run
-
-```shell
-stacy-analyzer lint <path/to/.clar>
-```
-
-You can run recursively over all `.clar` files in a directory. For this, run
-
-```shell
-stacy-analyzer lint <path/to/contract/folder>
-```
-
-## Tests
-
-To run tests, run
 ```shell
-make test
+pip install git+https://github.com/xlittlerag/tree-sitter-clarity
+pip install stacy-analyzer
 ```
 
-## Common issues
-
-### `tree-sitter-clar` installation fails
-If the installation of `tree-sitter-clar` fails, go into `stacks_analyzer/tree-sitter-clarity` and run
-
-```shell
-tree-sitter generate && tree-sitter-build
-```
-
-Ensure that the submodule is loaded.
-
 ## Documentation
 
 - [Vulnerabilities](https://github.com/CoinFabrik/stacy/tree/main/docs/vulnerabilities)
diff --git a/pyproject.toml b/pyproject.toml
index b07c25b..ead8162 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,36 +1,3 @@
 [build-system]
-requires = ["setuptools>=42", "wheel"]
+requires = ["setuptools>=46.4.0", "wheel"]
 build-backend = "setuptools.build_meta"
-
-[project]
-name = "stacy-analyzer"
-description = "Clarity Static Analyzer"
-version = "0.0.1"
-keywords = ["stacks", "auditor", "security", "clarity", "smart-contracts"]
-classifiers = [
-    "Intended Audience :: Developers",
-    "Development Status :: 3 - Alpha",
-    "Environment :: Console",
-    "License :: OSI Approved :: MIT License",
-    "Natural Language :: English",
-    "Programming Language :: Clarity",
-    "Topic :: Software Development",
-    "Topic :: Utilities"
-]
-requires-python = ">=3.8"
-license.text = "MIT"
-readme = "README.md"
-dependencies = [
-    "tree-sitter~=0.21",
-]
-
-[project.urls]
-Homepage = "https://github.com/coinfabrik/stacy-clarity"
-
-
-[tool.cibuildwheel]
-build = "cp38-*"
-build-frontend = "build"
-
-[project.scripts]
-stacy-analyzer = "stacks_analyzer.stacks_analyzer:main"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index de0a1a4..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-pip~=23.2.1
-attrs~=23.2.0
-distro~=1.9.0
-wheel~=0.43.0
-cryptography~=41.0.7
-pillow~=10.3.0
-setuptools~=69.0.3
-packaging~=23.2
-platformdirs~=4.2.1
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..9840bc7
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,3 @@
+[metadata]
+version = attr: stacy_analyzer.__version__
+license_files = LICENSE
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..7b038b1
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,41 @@
+import setuptools
+
+with open('README.md', 'r', encoding='utf-8') as fh:
+    long_description = fh.read()
+
+if __name__=="__main__":
+    setuptools.setup(
+        name='stacy-analyzer',
+        author='CoinFabrik',
+        author_email='',
+        description='Clarity Static Analyzer',
+        keywords='stacks, auditor, security, clarity, smart-contracts',
+        long_description=long_description,
+        long_description_content_type='text/markdown',
+        url='https://github.com/coinfabrik/stacy',
+        project_urls={
+            'Documentation': 'https://github.com/coinfabrik/stacy',
+            'Bug Reports':
+                'https://github.com/coinfabrik/stacy/issues',
+            'Source Code': 'https://github.com/coinfabrik/stacy',
+
+        },
+        package_dir={'': 'src'},
+        packages=setuptools.find_packages(where='src'),
+        classifiers=[
+            "Intended Audience :: Developers",
+            "Development Status :: 3 - Alpha",
+            "Environment :: Console",
+            "License :: OSI Approved :: MIT License",
+            "Natural Language :: English",
+            "Topic :: Software Development",
+            "Topic :: Utilities"
+        ],
+        python_requires='>=3.6',
+        install_requires=['tree-sitter', 'tree-sitter-clarity'],
+        entry_points={
+            'console_scripts': [  # This can provide executable scripts
+                'stacy-analyzer=stacy_analyzer:main',
+            ],
+        },
+    )
diff --git a/src/stacy_analyzer/__init__.py b/src/stacy_analyzer/__init__.py
new file mode 100644
index 0000000..59ab738
--- /dev/null
+++ b/src/stacy_analyzer/__init__.py
@@ -0,0 +1,7 @@
+__version__ = "0.1.0"
+
+from .analyzer import *
+
+
+def main():
+    Analyzer().main()
diff --git a/src/stacy_analyzer/__main__.py b/src/stacy_analyzer/__main__.py
new file mode 100644
index 0000000..8b6bd9d
--- /dev/null
+++ b/src/stacy_analyzer/__main__.py
@@ -0,0 +1,3 @@
+from stacy_analyzer import main
+
+main()
diff --git a/src/stacy_analyzer/analyzer.py b/src/stacy_analyzer/analyzer.py
new file mode 100644
index 0000000..1dac82f
--- /dev/null
+++ b/src/stacy_analyzer/analyzer.py
@@ -0,0 +1,143 @@
+import argparse
+import inspect
+import os
+import sys
+from importlib.util import spec_from_file_location, module_from_spec
+
+from stacy_analyzer.print_message import TerminalColors
+from stacy_analyzer.visitor import LinterRunner, Visitor, Finding
+
+
+class Number(object):
+    def __init__(self, n):
+        self.value = n
+
+    def val(self):
+        return self.value
+
+    def add(self, n2):
+        self.value += n2.val()
+
+    def __add__(self, n2):
+        return self.__class__(self.value + n2.val())
+
+    def __str__(self):
+        return str(self.val())
+
+    @classmethod
+    def addall(cls, number_obj_iter):
+        cls(sum(n.val() for n in number_obj_iter))
+
+
+class Analyzer:
+    DETECTOR_MAP = {}
+
+    @staticmethod
+    def find_detectors():
+        found = []
+        current_dir = os.path.dirname(os.path.abspath(__file__))
+        detectors_dir = os.path.join(current_dir, 'detectors')
+
+        for fname in os.listdir(detectors_dir):
+            if not fname.endswith('.py') or fname == '__init__.py':
+                continue
+
+            module_name = fname[:-3]
+            module_path = os.path.join(detectors_dir, fname)
+
+            spec = spec_from_file_location(module_name, module_path)
+            mod = module_from_spec(spec)
+            spec.loader.exec_module(mod)
+
+            for attr in dir(mod):
+                if attr == 'Visitor':
+                    continue
+                obj = getattr(mod, attr)
+                if inspect.isclass(obj) and issubclass(obj, Visitor):
+                    found.append(obj)
+
+        return {obj.__name__: obj for obj in found}
+
+    def __init__(self):
+        self.DETECTOR_MAP = self.find_detectors()
+        self.isatty = sys.stdout.isatty()
+
+    def main(self):
+        arg_parser = argparse.ArgumentParser(description='Static Analyzer for the Clarity language from Stacks')
+        subparsers = arg_parser.add_subparsers(dest="command", help="Commands")
+
+        lint_parser = subparsers.add_parser("lint", help="Run detectors in a given contract or contracts directory")
+        lint_parser.add_argument("path", type=str, help="Path")
+        lint_parser.add_argument("--filter", nargs="+", type=str, help="Comma-separated list of detector names to use")
+        lint_parser.add_argument("--exclude", nargs="+", type=str,
+                                 help="Comma-separated list of detector names to exclude")
+        list_detectors = subparsers.add_parser("detectors", help="List detectors")
+
+        user_args = arg_parser.parse_args()
+        if user_args.command == "lint":
+            filters = list(self.DETECTOR_MAP.keys()) if user_args.filter is None else user_args.filter.split(',')
+            excludes = [] if user_args.exclude is None else user_args.exclude.split(',')
+            detectors = self.get_detectors(filters, excludes)
+            path = user_args.path
+            if path.endswith(".clar"):
+                self.lint_file(path, detectors)
+            else:
+                for root, _, files in os.walk(path):
+                    for file in files:
+                        if file.endswith(".clar"):
+                            self.lint_file(os.path.join(root, file), detectors)
+
+        if user_args.command == "detectors":
+            convert_camel_case = lambda s: s[0] + ''.join(' ' + c if c.isupper() else c for c in s[1:])
+            detectors = list(map(convert_camel_case, self.DETECTOR_MAP.keys()))
+
+            max_length = max(len(st) for st in detectors)
+            s = max_length // 2 - 4
+
+            if self.isatty:
+                color = TerminalColors.OKCYAN
+                end = TerminalColors.ENDC
+            else:
+                color = ""
+                end = ""
+            print(f" {color}┌" + "─" * s + " Detectors " + "─" * s + f"┐{end}")
+            for file in detectors:
+                print(
+                    f" {color}|{end} {file.ljust(max_length + 1)}{color}|{end}")
+            print(f" {color}└" + "─" * (max_length + 2) + f"┘{end}")
+
+    def get_detectors(self, filters: str, excludes: str):
+        all_detectors = set(self.DETECTOR_MAP.keys())
+        filtered_names = set()
+        for fil in filters:
+            if fil not in all_detectors:
+                print(f"{fil} is not a detector to filter.", file=sys.stderr)
+            else:
+                filtered_names.add(fil)
+
+        for exc in excludes:
+            if exc not in all_detectors:
+                print(f"{exc} is not a detector to exclude.", file=sys.stderr)
+            if exc in filtered_names:
+                filtered_names.remove(exc)
+
+        return [self.DETECTOR_MAP[name] for name in filtered_names]
+
+    def lint_file(self, filename, lints: [Visitor]):
+        if self.isatty:
+            print(f"{TerminalColors.HEADER}====== Linting {filename}... ======{TerminalColors.ENDC}")
+        else:
+            print(f"====== Linting {filename}... ======")
+        with open(filename, 'r') as file:
+            source = file.read()
+
+        runner: LinterRunner = LinterRunner(source, filename)
+        runner.add_lints(lints)
+
+        findings: [Finding] = runner.run()
+
+        return findings
+
+
+if __name__ == '__main__':
+    Analyzer().main()
diff --git a/src/stacy_analyzer/detectors/AssertBlockHeight.py b/src/stacy_analyzer/detectors/AssertBlockHeight.py
new file mode 100644
index 0000000..a8e5863
--- /dev/null
+++ b/src/stacy_analyzer/detectors/AssertBlockHeight.py
@@ -0,0 +1,21 @@
+from tree_sitter import Node
+
+from stacy_analyzer.visitor import Visitor, NodeIterator
+
+
+class AssertBlockHeight(Visitor):
+
+    def __init__(self):
+        super().__init__()
+        self.MSG = "Use of block-height inside a assert."
+        self.HELP = None
+        self.FOOTNOTE = "Consider using burn-block-height."
+
+    def visit_node(self, node: Node, i):
+        if i > 1:
+            return
+        if str(node.text, "utf8") == "asserts!":
+            for n in NodeIterator(node.parent):
+                if str(n.text, "utf8") == "block-height" and n.grammar_name == "global":
+                    self.add_finding(node.parent, node)
+                    break
diff --git a/stacks_analyzer/detectors/CallInsideAsContract.py b/src/stacy_analyzer/detectors/CallInsideAsContract.py
similarity index 66%
rename from stacks_analyzer/detectors/CallInsideAsContract.py
rename to src/stacy_analyzer/detectors/CallInsideAsContract.py
index 69cd7d3..e4cf4ff 100644
--- a/stacks_analyzer/detectors/CallInsideAsContract.py
+++ b/src/stacy_analyzer/detectors/CallInsideAsContract.py
@@ -1,17 +1,18 @@
 from tree_sitter import Node
 
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor, NodeIterator
+from stacy_analyzer.visitor import Visitor, NodeIterator
 
 
 class CallInsideAsContract(Visitor):
-    MSG = "Use of call-contract? inside an as-contract context."
 
     def __init__(self):
         super().__init__()
         self.call = False
         self.lit = False
         self.checked = []
+        self.MSG = "Use of call-contract? inside an as-contract context."
+        self.HELP = None
+        self.FOOTNOTE = None
 
     def visit_node(self, node: Node, i):
         if i > 1:
@@ -24,14 +25,7 @@ def visit_node(self, node: Node, i):
                     self.lit = True
 
             if (self.call and not self.lit) and node not in self.checked:
-                pretty_print_warn(
-                    self,
-                    node.parent,
-                    node,
-                    self.MSG,
-                    None,
-                    None
-                )
+                self.add_finding(node.parent, node)
                 self.checked.append(node)
 
         self.call = False
diff --git a/src/stacy_analyzer/detectors/DivideBeforeMultiply.py b/src/stacy_analyzer/detectors/DivideBeforeMultiply.py
new file mode 100644
index 0000000..f4247ba
--- /dev/null
+++ b/src/stacy_analyzer/detectors/DivideBeforeMultiply.py
@@ -0,0 +1,20 @@
+from tree_sitter import Node
+
+from stacy_analyzer.visitor import Visitor, NodeIterator
+
+
+class DivideBeforeMultiply(Visitor):
+
+    def __init__(self):
+        super().__init__()
+        self.MSG = "Use of divide inside a multiplication. This could result in a precision loss."
+        self.FOOTNOTE = "Try multiplication before division."
+        self.HELP = None
+
+    def visit_node(self, node: Node, i):
+        if i > 1:
+            return
+        if node.grammar_name == "native_identifier" and str(node.text, "utf8") == "*":
+            for n in NodeIterator(node.parent):
+                if str(n.text, "utf8") == "/" and n.grammar_name == "native_identifier":
+                    self.add_finding(node.parent, node)
diff --git a/stacks_analyzer/detectors/PrivateFunctionNotUsed.py b/src/stacy_analyzer/detectors/PrivateFunctionNotUsed.py
similarity index 78%
rename from stacks_analyzer/detectors/PrivateFunctionNotUsed.py
rename to src/stacy_analyzer/detectors/PrivateFunctionNotUsed.py
index f079663..656a78c 100644
--- a/stacks_analyzer/detectors/PrivateFunctionNotUsed.py
+++ b/src/stacy_analyzer/detectors/PrivateFunctionNotUsed.py
@@ -1,17 +1,17 @@
-
 from tree_sitter import Node
 
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor, NodeIterator
+from stacy_analyzer.visitor import Visitor
 
 
 class PrivateFunctionNotUsed(Visitor):
-    MSG = "This private function is not used."
     private_fns_names: [Node] = []
 
     def __init__(self):
         super().__init__()
         self.private_fns_names: [Node] = []
+        self.MSG = "This private function is not used."
+        self.FOOTNOTE = "Consider removing it."
+        self.HELP = None
 
     def visit_node(self, node: Node, run_number: int):
         if run_number == 1 and node.grammar_name == "private_function":
@@ -34,12 +34,5 @@ def visit_node(self, node: Node, run_number: int):
 
         if run_number == 3:
             for n in self.private_fns_names:
-                pretty_print_warn(
-                    self,
-                    n,
-                    n,
-                    self.MSG,
-                    None,
-                    "Consider removing it."
-                )
+                self.add_finding(n, n)
             self.private_fns_names = []
diff --git a/src/stacy_analyzer/detectors/ToDoComment.py b/src/stacy_analyzer/detectors/ToDoComment.py
new file mode 100644
index 0000000..10e7fa7
--- /dev/null
+++ b/src/stacy_analyzer/detectors/ToDoComment.py
@@ -0,0 +1,18 @@
+from tree_sitter import Node
+
+from stacy_analyzer.visitor import Visitor
+
+
+class ToDoComment(Visitor):
+
+    def __init__(self):
+        super().__init__()
+        self.MSG = "Remove TODO: comment before deploying contract"
+        self.HELP = None
+        self.FOOTNOTE = None
+
+    def visit_node(self, node: Node, run_number: int):
+
+        if run_number == 1 and node.grammar_name == "comment":
+            if "todo" in node.text.decode("utf-8").lower():
+                self.add_finding(node, node)
diff --git a/src/stacy_analyzer/detectors/TxSenderInAssert.py b/src/stacy_analyzer/detectors/TxSenderInAssert.py
new file mode 100644
index 0000000..27e6277
--- /dev/null
+++ b/src/stacy_analyzer/detectors/TxSenderInAssert.py
@@ -0,0 +1,21 @@
+from tree_sitter import Node
+
+from stacy_analyzer.visitor import Visitor, NodeIterator
+
+
+class TxSenderInAssert(Visitor):
+
+    def __init__(self):
+        super().__init__()
+        self.MSG = "Use of tx-sender inside an assert"
+        self.FOOTNOTE = "Consider using contract-caller."
+        self.HELP = None
+
+    def visit_node(self, node: Node, i):
+        if i > 1:
+            return
+        if str(node.text, "utf8") == "asserts!":
+            for n in NodeIterator(node.parent):
+                if str(n.text, "utf8") == "tx-sender" and n.grammar_name == "global":
+                    self.add_finding(node.parent, node)
+                    break
diff --git a/src/stacy_analyzer/detectors/UnwrapPanicUsage.py b/src/stacy_analyzer/detectors/UnwrapPanicUsage.py
new file mode 100644
index 0000000..a59b86b
--- /dev/null
+++ b/src/stacy_analyzer/detectors/UnwrapPanicUsage.py
@@ -0,0 +1,18 @@
+from tree_sitter import Node
+
+from stacy_analyzer.visitor import Visitor
+
+
+class UnwrapPanicUsage(Visitor):
+
+    def __init__(self):
+        super().__init__()
+        self.MSG = "Use of unwrap-panic."
+        self.FOOTNOTE = "Use unwrap! and handle the error."
+        self.HELP = None
+
+    def visit_node(self, node: Node, i):
+        if i > 1:
+            return
+        if node.grammar_name == "unwrap-panic":
+            self.add_finding(node.parent, node)
diff --git a/src/stacy_analyzer/detectors/UpdatedFunctions.py b/src/stacy_analyzer/detectors/UpdatedFunctions.py
new file mode 100644
index 0000000..19ef247
--- /dev/null
+++ b/src/stacy_analyzer/detectors/UpdatedFunctions.py
@@ -0,0 +1,22 @@
+from tree_sitter import Node
+
+from stacy_analyzer.visitor import Visitor
+
+
+class UpdatedFunctions(Visitor):
+    functions_updated = ["element-at", "index-of"]
+
+    def __init__(self):
+        super().__init__()
+        self.HELP = None
+
+    def visit_node(self, node: Node, i):
+        if i > 1:
+            return
+
+        fun = str(node.text, "utf-8")
+
+        if fun in self.functions_updated:
+            self.MSG = f"Behavior of '{fun}' changed from Clarity1 to Clarity2, now outputs optional value."
+            self.FOOTNOTE = f"Suggestion: use '{fun}?' to make this behavior explicit."
+            self.add_finding(node.parent, node)
diff --git a/stacks_analyzer/detectors/VarCouldBeConstant.py b/src/stacy_analyzer/detectors/VarCouldBeConstant.py
similarity index 61%
rename from stacks_analyzer/detectors/VarCouldBeConstant.py
rename to src/stacy_analyzer/detectors/VarCouldBeConstant.py
index f53a77a..bedfd70 100644
--- a/stacks_analyzer/detectors/VarCouldBeConstant.py
+++ b/src/stacy_analyzer/detectors/VarCouldBeConstant.py
@@ -1,16 +1,17 @@
 from tree_sitter import Node
 
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor, NodeIterator
+from stacy_analyzer.visitor import Visitor
 
 
 class VarCouldBeConstant(Visitor):
-    MSG = "This can be a define-constant because it's never set"
     data_vars: []
 
     def __init__(self):
         super().__init__()
         self.data_vars = []
+        self.MSG = "This can be a define-constant because it's never set"
+        self.HELP = None
+        self.FOOTNOTE = None
 
     def visit_node(self, node: Node, i):
         if i == 1:
@@ -25,13 +26,6 @@ def visit_node(self, node: Node, i):
                         self.data_vars.remove(n)
 
         if i == 3:
-            for n in self.data_vars:
-                pretty_print_warn(
-                    self,
-                    n,
-                    n,
-                    self.MSG,
-                    None,
-                    None
-                )
+            for node in self.data_vars:
+                self.add_finding(node, node)
             self.data_vars = []
diff --git a/stacks_analyzer/__init__.py b/src/stacy_analyzer/detectors/__init__.py
similarity index 100%
rename from stacks_analyzer/__init__.py
rename to src/stacy_analyzer/detectors/__init__.py
diff --git a/stacks_analyzer/print_message.py b/src/stacy_analyzer/print_message.py
similarity index 89%
rename from stacks_analyzer/print_message.py
rename to src/stacy_analyzer/print_message.py
index ddcc0e9..dd79826 100644
--- a/stacks_analyzer/print_message.py
+++ b/src/stacy_analyzer/print_message.py
@@ -3,8 +3,6 @@
 
 from tree_sitter import Node
 
-from .visitor import Visitor
-
 tty = sys.stdout.isatty()
 
 
@@ -14,7 +12,7 @@ class TerminalColors:
     ENDC = '\033[0m' if tty else ''
 
 
-def pretty_print_warn(visitor: Visitor, parent: Node, specific_node: Node, msg: str, help_msg: str | None,
+def pretty_print_warn(visitor, parent: Node, specific_node: Node, msg: str, help_msg: str | None,
                       footnote: str | None):
     line_number = parent.start_point.row + 1
     num_size_spaces = " " * (int(math.log10(line_number)) + 2)
diff --git a/stacks_analyzer/tree-sitter-clarity b/src/stacy_analyzer/tree-sitter-clarity
similarity index 100%
rename from stacks_analyzer/tree-sitter-clarity
rename to src/stacy_analyzer/tree-sitter-clarity
diff --git a/stacks_analyzer/visitor.py b/src/stacy_analyzer/visitor.py
similarity index 51%
rename from stacks_analyzer/visitor.py
rename to src/stacy_analyzer/visitor.py
index 9a313f3..c1ae55d 100644
--- a/stacks_analyzer/visitor.py
+++ b/src/stacy_analyzer/visitor.py
@@ -1,21 +1,77 @@
+import sys
+from dataclasses import dataclass
+from typing import Optional
+
 import tree_sitter_clarity
 from tree_sitter import Language, TreeCursor, Parser, Tree, Node
 
 __CLARITY__ = Language(tree_sitter_clarity.language())
 __TIMES__ = 3
 
+from stacy_analyzer.print_message import pretty_print_warn
+
+
+@dataclass
+class Location:
+    lineno: int
+    start_tabs: int
+    span: tuple[int, int]
+    line_code: str
+
+
+@dataclass
+class Finding:
+    marked_nodes: (Node, Node)
+    visitor: str
+    source: str
+    msg: str
+    help_msg: Optional[str]
+    footnote: Optional[str]
+    location: Optional[Location]
+
 
 class Visitor:
     source: str | None
+    MSG: str
+    HELP: str | None
+    FOOTNOTE: str | None
 
     def __init__(self):
-        self.source = None
+        self.source = self.src_name = None
+        self.findings = []
 
-    def add_source(self, source: str):
-        self.source = source
+    def add_source(self, src: str, src_name: str = None):
+        self.source = src
+        self.src_name = src_name
+
+    # noinspection PyShadowingBuiltins
+    def visit_node(self, node: Node, round: int):
+        sys.exit("visit_node not implemented")
+
+    def get_contract_code_lines(self):
+        return self.source.split('\n')
+
+    def add_finding(self, node: Node, specific_node: Node):
+        pretty_print_warn(self, node, specific_node, self.MSG, self.HELP, self.FOOTNOTE)
 
-    def visit_node(self, node: Node, i: int):
-        pass
+        parent = node.parent
+        line_number = parent.start_point.row + 1
+        line_code = self.get_contract_code_lines()[line_number - 1]
+        location = Location(parent.start_point.row + 1,
+                            line_code.count('\t') + 1,
+                            (node.start_point.column, node.end_point.column),
+                            self.get_contract_code_lines()[line_number - 1])
+        finding = Finding((node, specific_node), self.Name, self.src_name, self.MSG, self.HELP, self.FOOTNOTE, location)
+        self.findings.append(finding)
+        return sorted(self.findings, key=lambda f: f.location.lineno)
+
+    def get_findings(self):
+        return self.findings
+
+    @classmethod
+    @property
+    def Name(cls):
+        return cls.__name__
 
 
 class NodeIterator:
@@ -70,10 +126,11 @@ class LinterRunner:
     tree: Tree
     root_node: Node
     iterator: NodeIterator
-    lints: []  # lo que vaya acá adentro REQUIERE tener el metodo visit_node (at least)
+    lints: []  # lo que vaya acá adentro REQUIERE tener el metodo visit_node (at least) # XXX Happens to be a visitor=)
     round_number: int
 
-    def __init__(self, source: str):
+    def __init__(self, source: str, src_name: str = None):
+        self.src_name = src_name
         self.source = source
         parser = Parser(__CLARITY__)
         self.tree = parser.parse(bytes(self.source, "utf8"))
@@ -90,10 +147,11 @@ def add_lint(self, lint):
         self.lints.append(lint)
         return self
 
-    def add_lints(self, lints):
-        for lint in lints:
-            lint.add_source(self.source)
-        self.lints.extend(lints)
+    def add_lints(self, lint_classes: [Visitor]):
+        for lint_class in lint_classes:
+            lint = lint_class()
+            lint.add_source(self.source, self.src_name)
+            self.lints.append(lint)
 
     def reset_cursor(self):
         self.iterator = NodeIterator(self.root_node)
@@ -107,3 +165,6 @@ def run(self):
                     break
                 self.run_lints(v)
             self.reset_cursor()
+
+        return [finding for lint in self.lints
+                for finding in lint.get_findings()]
diff --git a/stacks_analyzer/detectors/AssertBlockHeight.py b/stacks_analyzer/detectors/AssertBlockHeight.py
deleted file mode 100644
index b07e360..0000000
--- a/stacks_analyzer/detectors/AssertBlockHeight.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from tree_sitter import Node
-
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor, NodeIterator
-
-
-class AssertBlockHeight(Visitor):
-    MSG = "Use of block-height inside a assert."
-    HELP = "Consider using burn-block-height."
-
-    def __init__(self):
-        super().__init__()
-
-    def visit_node(self, node: Node, i):
-        if i > 1:
-            return
-        if str(node.text, "utf8") == "asserts!":
-            for n in NodeIterator(node.parent):
-                if str(n.text, "utf8") == "block-height" and n.grammar_name == "global":
-                    pretty_print_warn(
-                        self,
-                        node.parent,
-                        node,
-                        self.MSG,
-                        None,
-                        self.HELP
-                    )
-                    break
diff --git a/stacks_analyzer/detectors/DivideBeforeMultiply.py b/stacks_analyzer/detectors/DivideBeforeMultiply.py
deleted file mode 100644
index ae841fd..0000000
--- a/stacks_analyzer/detectors/DivideBeforeMultiply.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from tree_sitter import Node
-
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor, NodeIterator
-
-
-class DivideBeforeMultiply(Visitor):
-    MSG = "Use of divide inside a multiplication. This could result in a precision loss."
-    NOTE = "Try multiplication before division."
-
-    def __init__(self):
-        super().__init__()
-
-    def visit_node(self, node: Node, i):
-        if i > 1:
-            return
-        if node.grammar_name == "native_identifier" and str(node.text, "utf8") == "*":
-            for n in NodeIterator(node.parent):
-                if str(n.text, "utf8") == "/" and n.grammar_name == "native_identifier":
-                    pretty_print_warn(
-                        self,
-                        node.parent,
-                        node,
-                        self.MSG,
-                        None,
-                        self.NOTE,
-                    )
\ No newline at end of file
diff --git a/stacks_analyzer/detectors/ToDoComment.py b/stacks_analyzer/detectors/ToDoComment.py
deleted file mode 100644
index 2e6e938..0000000
--- a/stacks_analyzer/detectors/ToDoComment.py
+++ /dev/null
@@ -1,25 +0,0 @@
-
-from tree_sitter import Node
-
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor
-
-class ToDoComment(Visitor):
-    MSG = "Remove TODO: comment before deploying contract"
-
-    def __init__(self):
-        super().__init__()
-
-    def visit_node(self, node: Node, run_number: int):
-
-        if run_number == 1 and node.grammar_name == "comment":
-            if "todo" in node.text.decode("utf-8").lower():
-                pretty_print_warn(
-                    self,
-                    node,
-                    node,
-                    self.MSG,
-                    None,
-                    None
-                )
-
diff --git a/stacks_analyzer/detectors/TxSenderInAssert.py b/stacks_analyzer/detectors/TxSenderInAssert.py
deleted file mode 100644
index 0b8af02..0000000
--- a/stacks_analyzer/detectors/TxSenderInAssert.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from tree_sitter import Node
-
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor, NodeIterator
-
-
-class TxSenderInAssert(Visitor):
-    MSG = "Use of tx-sender inside an assert"
-
-    def __init__(self):
-        super().__init__()
-
-    def visit_node(self, node: Node, i):
-        if i > 1:
-            return
-        if str(node.text, "utf8") == "asserts!":
-            for n in NodeIterator(node.parent):
-                if str(n.text, "utf8") == "tx-sender" and n.grammar_name == "global":
-                    pretty_print_warn(
-                        self,
-                        node.parent,
-                        node,
-                        self.MSG,
-                        None,
-                        "Consider using contract-caller"
-                    )
-                    break
-
diff --git a/stacks_analyzer/detectors/UnwrapPanicUsage.py b/stacks_analyzer/detectors/UnwrapPanicUsage.py
deleted file mode 100644
index 41e3846..0000000
--- a/stacks_analyzer/detectors/UnwrapPanicUsage.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from tree_sitter import Node
-
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor, NodeIterator
-
-
-class UnwrapPanicUsage(Visitor):
-    MSG = "Use of unwrap-panic."
-
-    def __init__(self):
-        super().__init__()
-
-    def visit_node(self, node: Node, i):
-        if i > 1:
-            return
-        if node.grammar_name == "unwrap-panic":
-            pretty_print_warn(
-                self,
-                node.parent,
-                node,
-                self.MSG,
-                None,
-                "Use unwrap! and handle the error."
-            )
-
diff --git a/stacks_analyzer/detectors/UpdatedFunctionsDetector.py b/stacks_analyzer/detectors/UpdatedFunctionsDetector.py
deleted file mode 100644
index 2eff6a2..0000000
--- a/stacks_analyzer/detectors/UpdatedFunctionsDetector.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from tree_sitter import Node
-
-from ..print_message import pretty_print_warn
-from ..visitor import Visitor
-
-
-class UpdatedFunctionsDetector(Visitor):
-    functions_updated = ["element-at", "index-of"]
-
-    def __init__(self):
-        super().__init__()
-
-    def visit_node(self, node: Node, i):
-        if i > 1:
-            return
-        
-        fun = str(node.text, "utf-8")
-
-        if fun in self.functions_updated:
-            MSG = f"Behavior of '{fun}' changed from Clarity1 to Clarity2, now outputs optional value."
-            pretty_print_warn(
-                self,
-                node.parent,
-                node,
-                MSG,
-                None,
-                f"Suggestion: use '{fun}?' to make this behavior explicit."
-            )
-
diff --git a/stacks_analyzer/stacks_analyzer.py b/stacks_analyzer/stacks_analyzer.py
deleted file mode 100644
index 91f3bfb..0000000
--- a/stacks_analyzer/stacks_analyzer.py
+++ /dev/null
@@ -1,105 +0,0 @@
-import os
-import argparse
-import sys
-
-from .detectors.AssertBlockHeight import AssertBlockHeight
-from .detectors.CallInsideAsContract import CallInsideAsContract
-from .detectors.DivideBeforeMultiply import DivideBeforeMultiply
-from .detectors.PrivateFunctionNotUsed import PrivateFunctionNotUsed
-from .detectors.ToDoComment import ToDoComment
-from .detectors.TxSenderInAssert import TxSenderInAssert
-from .detectors.UnwrapPanicUsage import UnwrapPanicUsage
-from .detectors.UpdatedFunctionsDetector import UpdatedFunctionsDetector
-from .detectors.VarCouldBeConstant import VarCouldBeConstant
-from .print_message import TerminalColors
-from .visitor import LinterRunner, Visitor
-
-DETECTOR_MAP = {
-    "AssertBlockHeight": AssertBlockHeight(),
-    "CallInsideAsContract": CallInsideAsContract(),
-    "DivideBeforeMultiply": DivideBeforeMultiply(),
-    "PrivateFunctionNotUsed": PrivateFunctionNotUsed(),
-    "ToDoComment": ToDoComment(),
-    "TxSenderInAssert": TxSenderInAssert(),
-    "UnwrapPanicUsage": UnwrapPanicUsage(),
-    "UpdatedFunctionsDetector": UpdatedFunctionsDetector(),
-    "VarCouldBeConstant": VarCouldBeConstant(),
-}
-
-
-def main():
-    arg_parser = argparse.ArgumentParser(description='Static Analyzer for the Clarity language from Stacks')
-    subparsers = arg_parser.add_subparsers(dest="command", help="Commands")
-
-    lint_parser = subparsers.add_parser("lint", help="Run detectors in a given contract or contracts directory")
-    lint_parser.add_argument("path", type=str, help="Path")
-    lint_parser.add_argument("--filter", nargs="+", type=str, help="Comma-separated list of detector names to use")
-    lint_parser.add_argument("--exclude", nargs="+", type=str, help="Comma-separated list of detector names to exclude")
-    list_detectors = subparsers.add_parser("detectors", help="List detectors")
-
-    user_args = arg_parser.parse_args()
-    if user_args.command == "lint":
-        filters = user_args.filter or list(DETECTOR_MAP.keys())
-        excludes = user_args.exclude or []
-        detectors = get_detectors(filters, excludes)
-        path = user_args.path
-        if path.endswith(".clar"):
-            lint_file(path, detectors)
-        else:
-            for root, _, files in os.walk(path):
-                for file in files:
-                    if file.endswith(".clar"):
-                        lint_file(os.path.join(root, file), detectors)
-
-    if user_args.command == "detectors":
-        detectors = list(DETECTOR_MAP.keys())
-
-        max_length = max(len(st) for st in detectors)
-        s = max_length // 2 - 4
-
-        print(f" {TerminalColors.OKCYAN}┌" + "─" * (s - 1) + " Detectors " + "─" * s + f"┐{TerminalColors.ENDC}")
-        for file in detectors:
-            print(
-                f" {TerminalColors.OKCYAN}|{TerminalColors.ENDC} {file.ljust(max_length + 1)}{TerminalColors.OKCYAN}|{TerminalColors.ENDC}")
-        print(f" {TerminalColors.OKCYAN}└" + "─" * (max_length + 2) + f"┘{TerminalColors.ENDC}")
-
-
-def get_detectors(filters: str, excludes):
-    all_detectors = list(DETECTOR_MAP.keys())
-    if len(filters) != len(all_detectors):
-        filters = filters[0].split(',')
-    if excludes:
-        excludes = excludes[0].split(',')
-    filtered_names = []
-    for fil in filters:
-        if fil not in all_detectors:
-            print(f"{fil} is not a detector to filter.", file=sys.stderr)
-        else:
-            filtered_names.append(fil)
-
-    for exc in excludes:
-        if exc not in all_detectors:
-            print(f"{exc} is not a detector to exclude.", file=sys.stderr)
-        if exc in filtered_names:
-            filtered_names.remove(exc)
-
-    detectors = []
-    for name in filtered_names:
-        detectors.append(DETECTOR_MAP[name])
-    return detectors
-
-
-def lint_file(path, lints: [Visitor]):
-    print(f"{TerminalColors.HEADER}====== Linting {path}... ======{TerminalColors.ENDC}")
-
-    with open(path, 'r') as file:
-        source = file.read()
-
-    runner: LinterRunner = LinterRunner(source)
-
-    runner.add_lints(lints)
-    runner.run()
-
-
-if __name__ == '__main__':
-    main()
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..1174245
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,13 @@
+[tox]
+envlist = py{36,37,38,39}
+minversion = 3.3.0
+isolated_build = true
+
+[testenv]
+deps =
+    check-manifest >= 0.42
+    pytest
+commands =
+    check-manifest --ignore 'tox.ini,tests/**,.editorconfig,vscode.env,.vscode/**'
+    python setup.py check -m -s
+    pytest tests {posargs}


Test patch:
diff --git a/tests.sh b/tests.sh
index 1006d45..2422c8a 100755
--- a/tests.sh
+++ b/tests.sh
@@ -21,10 +21,16 @@ base_dir="tests"
 
 for test_case in "$base_dir"/*; do
     for example in "$test_case"/*; do
-        if [[ -t 1 ]]; then
-          echo "Testing detectors in" "$example"
+        if [[ "$example" == *.py ]]; then
+            continue
+        fi
+
+        if [[ -d "$example" ]]; then
+            if [[ -t 1 ]]; then
+                echo "Testing detectors in" "$example"
+            fi
+            process_example "$example"
         fi
-        process_example "$example"
     done
 done
 
diff --git a/stacks_analyzer/detectors/__init__.py b/tests/__init__.py
similarity index 100%
rename from stacks_analyzer/detectors/__init__.py
rename to tests/__init__.py
diff --git a/tests/test_module1.py b/tests/test_module1.py
new file mode 100644
index 0000000..8a27710
--- /dev/null
+++ b/tests/test_module1.py
@@ -0,0 +1,54 @@
+import os
+import time
+import unittest
+
+from stacy_analyzer.analyzer import Analyzer
+
+__DETECTOR_QUANTITY__ = 9
+
+
+class TestFind1Detector(unittest.TestCase):
+    def test_add(self):
+        a = Analyzer()
+        self.assertTrue(len(a.DETECTOR_MAP) == __DETECTOR_QUANTITY__, f"{a.DETECTOR_MAP}")
+
+    def test_detector_file(self):
+        p = '.'
+        for root, dirs, files in os.walk(p):
+            for name in files:
+                if not name.endswith('.clar'):
+                    continue
+                filename = os.path.join(root, name)
+                a = Analyzer()
+                for detector in a.DETECTOR_MAP.values():
+                    a = Analyzer()
+                    ret = a.lint_file(filename, [detector])
+                    is_vulnerable_path = "vulnerable" in filename
+                    is_correct_detector = detector.Name.lower() in filename.replace('_', '')
+
+                    expected = "at least one" if (is_vulnerable_path and is_correct_detector) else "zero"
+                    actual = len(ret)
+
+                    self.assertTrue(
+                        (actual >= 1 if (is_vulnerable_path and is_correct_detector) else actual == 0),
+                        f"Test failed for: {detector.Name} @ {filename}. \n"
+                        f"Expected {expected} lint(s), but found {actual}. \n"
+                        f"Lints: {ret}\n"
+                    )
+
+
+
+    def test_profile_time(self):
+        filename = 'tx_sender_in_assert/vulnerable-example/tx_sender.clar'
+        a = Analyzer()
+        lints = [detectorKlass for detectorKlass in a.DETECTOR_MAP.values()]
+        start = time.time()
+        for _ in range(1000):
+            a = Analyzer()
+            a.lint_file(filename, [detectorKlass for detectorKlass in lints])
+        end = time.time()
+        print(f'took: {end - start:f}s')
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/tx_sender_in_assert/vulnerable-example/stdout b/tests/tx_sender_in_assert/vulnerable-example/stdout
index 05bfb37..3b9bc5b 100644
--- a/tests/tx_sender_in_assert/vulnerable-example/stdout
+++ b/tests/tx_sender_in_assert/vulnerable-example/stdout
@@ -3,5 +3,5 @@ Warning: Use of tx-sender inside an assert
    |
  5 |         (asserts! (is-eq tx-sender contract-owner) err-owner-only)
    |          ^^^^^^^^
-   Note: Consider using contract-caller
+   Note: Consider using contract-caller.