Skip to content

[bc-linter] Add BC Linter configuration support #7016

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions tools/stronghold/docs/bc_linter_config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# BC Linter Configuration (beta)

This document describes the configuration format for the Stronghold BC linter.
The config enables repo‑specific path selection, rule suppression, and custom
annotations to include/exclude specific APIs.

### Config file location
- Place a YAML file named `.bc-linter.yml` at the repository root being linted
(the target repo).
- If the file is missing or empty, defaults are applied (see below).

### Schema (YAML)
```yml
version: 1

paths:
include:
- "**/*.py" # globs of files to consider (default)
exclude:
- "**/.*/**" # exclude hidden directories by default
- "**/.*" # exclude hidden files by default

scan:
functions: true # check free functions and methods
classes: true # check classes/dataclasses
public_only: true # ignore names starting with "_" at any level

annotations:
include: # decorators that force‑include a symbol
- name: "bc_linter_include" # matched by simple name or dotted suffix
propagate_to_members: false # for classes, include methods/inner classes
exclude: # decorators that force‑exclude a symbol
- name: "bc_linter_skip" # matched by simple name or dotted suffix
propagate_to_members: true # for classes, exclude methods/inner classes

excluded_violations: [] # e.g. ["ParameterRenamed", "FieldTypeChanged"]
```

### Behavior notes
- Paths precedence: `annotations.exclude` > `annotations.include` > `paths`.
Annotations can override file include/exclude rules.
- Name matching for annotations: A decorator matches if either its simple name
equals the configured `name` (e.g., `@bc_linter_skip`) or if its dotted
attribute ends with the configured `name` (e.g., `@proj.bc_linter_skip`).
- `public_only`: When true, any symbol whose qualified name contains a component
that starts with `_` is ignored (e.g., `module._Internal.func`, `Class._m`).
- Rule suppression: `excluded_violations` contains class names from
`api.violations` to omit from output (e.g., `FieldTypeChanged`).
- Invariants not affected by config:
- Deleted methods of a deleted class are not double‑reported (only the class).
- Nested class deletions collapse to the outermost deleted class.
- Dataclass detection and field inference are unchanged.

### Defaults
If `.bc-linter.yml` is missing or empty, the following defaults apply:

```
version: 1
paths:
include: ["**/*.py"]
exclude: ["**/.*/**", "**/.*"]
scan:
functions: true
classes: true
public_only: true
annotations:
include: []
exclude: []
excluded_violations: []
```
1 change: 1 addition & 0 deletions tools/stronghold/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ flake8==5.0.4
mypy==0.990
pip==23.3
pytest==7.2.0
PyYAML==6.0.2
4 changes: 4 additions & 0 deletions tools/stronghold/src/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class Parameters:
variadic_kwargs: bool
# The line where the function is defined.
line: int
# Decorator names applied to this function/method (simple or dotted form).
decorators: Sequence[str] = ()


@dataclasses.dataclass
Expand Down Expand Up @@ -62,6 +64,8 @@ class Class:
fields: Sequence[Field]
line: int
dataclass: bool = False
# Decorator names applied to the class (simple or dotted form).
decorators: Sequence[str] = ()


@dataclasses.dataclass
Expand Down
48 changes: 44 additions & 4 deletions tools/stronghold/src/api/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,13 @@ def _function_def_to_parameters(node: ast.FunctionDef) -> api.Parameters:
)
for i, arg in enumerate(args.kwonlyargs)
]
dec_names = tuple(_decorator_to_name(d) for d in node.decorator_list)
return api.Parameters(
parameters=params,
variadic_args=args.vararg is not None,
variadic_kwargs=args.kwarg is not None,
line=node.lineno,
decorators=dec_names,
)


Expand All @@ -106,10 +108,9 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
# class name pushed onto a new context.
if self._classes is not None:
name = ".".join(self._context + [node.name])
dec_names = [_decorator_to_name(d) for d in node.decorator_list]
is_dataclass = any(
(isinstance(dec, ast.Name) and dec.id == "dataclass")
or (isinstance(dec, ast.Attribute) and dec.attr == "dataclass")
for dec in node.decorator_list
(n == "dataclass" or n.endswith(".dataclass")) for n in dec_names
)
fields: list[api.Field] = []
for stmt in node.body:
Expand Down Expand Up @@ -180,7 +181,10 @@ def _is_field_func(f: ast.AST) -> bool:
)
)
self._classes[name] = api.Class(
fields=fields, line=node.lineno, dataclass=is_dataclass
fields=fields,
line=node.lineno,
dataclass=is_dataclass,
decorators=tuple(dec_names),
)

_ContextualNodeVisitor(
Expand All @@ -191,3 +195,39 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
# Records this function.
name = ".".join(self._context + [node.name])
self._functions[name] = node

def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
# Treat async functions similarly by normalizing to FunctionDef shape.
name = ".".join(self._context + [node.name])
fnode = ast.FunctionDef(
name=node.name,
args=node.args,
body=node.body,
decorator_list=node.decorator_list,
returns=node.returns,
type_comment=node.type_comment,
)
self._functions[name] = fnode


def _decorator_to_name(expr: ast.AST) -> str:
"""Extracts a dotted name for a decorator expression.
For calls like @dec(...), returns the callee name "dec".
For attributes like @pkg.mod.dec, returns "pkg.mod.dec".
For names like @dec, returns "dec".
"""

def _attr_chain(a: ast.AST) -> str | None:
if isinstance(a, ast.Name):
return a.id
if isinstance(a, ast.Attribute):
left = _attr_chain(a.value)
if left is None:
return a.attr
return f"{left}.{a.attr}"
return None

if isinstance(expr, ast.Call):
expr = expr.func
name = _attr_chain(expr)
return name or ""
Loading