Skip to content
Merged
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
65 changes: 53 additions & 12 deletions concepts/custom-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ from __future__ import annotations

import ast

from fensu import Family, Fault, RuleContext, rule
from fensu import Family, Fault, File, RuleContext, rule


@rule(
Expand All @@ -61,7 +61,7 @@ from fensu import Family, Fault, RuleContext, rule
message="GLOBAL_CLIENT hides ownership",
remediation="Move the value to the module that owns and produces it.",
)
def no_global_client(*, module: ast.Module, ctx: RuleContext) -> list[Fault]:
def no_global_client(*, file: File, ctx: RuleContext) -> list[Fault]:
return [
ctx.fault(node=node)
for node in ctx.nodes(ast.Name)
Expand Down Expand Up @@ -132,19 +132,37 @@ conventions package), and because [selectors](/concepts/configuration#selection)
are prefixes, namespaces nest: `X` selects every custom rule, `XD` everything
under `XDB` and `XDJ`, `XDB` one namespace, `XDB001` one rule.

### The check signature
### Typed check subjects

The signature is frozen. New capabilities arrive as new context methods, never as
signature changes.
Prefer one of the two typed callback shapes. A file rule runs once for each
selected source file:

```python
def check(*, module: ast.Module, ctx: RuleContext) -> list[Fault]: ...
def check(*, file: File, ctx: RuleContext) -> list[Fault]: ...
```

- `module` is the parsed `ast.Module` of the file being checked.
- `file` is an immutable project-relative identity for the current source.
- `ctx` is a [`RuleContext`](#the-rule-context).
- The return value is a list of [`Fault`](#the-fault) objects.

A project rule runs exactly once for the target, including when discovery finds no
source files:

```python
from fensu import Project


def check(*, project: Project, ctx: RuleContext) -> list[Fault]: ...
```

Project rules have no elected source anchor. They inspect `ctx.project.tree` and
`ctx.graph`, and report against an explicit confined path with
`ctx.path_fault(path=...)` or `ctx.fault_at(location=...)`.

The legacy `(*, module: ast.Module, ctx: RuleContext)` callback remains supported
for Python rules that need unrestricted Python AST access. New capabilities arrive
through typed subjects and context rather than additional callback parameters.

### The `Fault`

A `Fault` is a frozen dataclass. You usually construct one through the context so
Expand Down Expand Up @@ -185,11 +203,14 @@ override already applied. There is no string-keyed form, for the same reason
## The rule context

`RuleContext` is a convenience toolbox, not a cage. Its supported analysis surface
is split into five public, backend-neutral zones. You may still ignore them and walk
`module` yourself when a rule needs unrestricted Python AST access.
includes current-file facts, a deterministic project tree, a resolved architecture
graph, source text, and backend-neutral syntax. Legacy Python callbacks may still
walk `module` when a rule needs unrestricted Python AST access.

```python
functions = ctx.facts.functions() # current-file semantic facts
position = ctx.project.tree.position(file.path) # typed layout facts
imports = ctx.graph.imports(file) # resolved static imports
analysis = ctx.project.analysis( # cross-file analysis
requester=ctx.path,
path=other_path,
Expand All @@ -199,9 +220,21 @@ handles = ctx.syntax.handles(kind="Call") # opaque syntax identities
parent = ctx.relations.parent(handles[0]) # syntax relationships
```

Every `ctx.project` query records its input for cache invalidation. Pass the file
whose result depends on the query as `requester`, normally `ctx.path`. Construct
diagnostics through the context as well:
`ctx.project.tree` contains only the authoritative discovered inventory. Paths are
normalized project-relative POSIX `ProjectPath` values, and results are returned in
deterministic order. Its `paths`, `files`, `children()`, `descendants()`, `glob()`,
`files_under()`, and `position()` queries record the exact answer used by the active
rule.

For Python targets, `ctx.graph` exposes discovered module nodes, authored imports,
explicit resolved or unresolved edges, direct dependencies and dependents, and
deterministic static cycles. It resolves only imports that can be proven against
the discovered project; external, ambiguous, and dynamic imports are never guessed.

Every project-tree, graph, and `ctx.project` query records its input for cache
invalidation. The active requester is inferred for typed rules; legacy explicit
project queries may pass `requester=ctx.path`. Construct diagnostics through the
context as well:

```python
return [ctx.fault(node=node)]
Expand Down Expand Up @@ -424,6 +457,14 @@ reason. Unselected rules are never scanned. Repository-owned rule sources and
helpers are content-hashed, so editing a rule or its helpers invalidates the cache
correctly.

File rules retain per-file cache subjects and execute independently. Project rules
use a separate project cache subject with identity `.`; they are never attached to
an arbitrary source file. A cached project rule may own diagnostics and exceptions
at any confined repository-relative path. Query-specific tree and graph evidence
is replayed before reuse: broad inventory queries invalidate broadly, while focused
path, position, node, or edge queries can survive unrelated edits. Cached and
uncached diagnostics are rendered identically.

Resolved current values and complete option schemas participate in cache identity.
Changing an override, default, type, choice, or bound invalidates affected cached
results. `ctx.option()` is therefore safe in a cacheable rule.
Expand Down
71 changes: 67 additions & 4 deletions reference/rule-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ public protocols, handles, locations, facts, and dependency models from `fensu`,
never from internal package paths.

```python
def check(*, module: ast.Module, ctx: RuleContext) -> list[Fault]: ...
def check(*, file: File, ctx: RuleContext) -> list[Fault]: ...
def check(*, project: Project, ctx: RuleContext) -> list[Fault]: ...
```

The context groups its surface into five analysis zones plus fault construction,
position, AST helpers, and configuration. Every method is a convenience: a rule may
ignore the context and walk `module` directly when it needs unrestricted AST access.
The context groups its surface into current-file facts, project/tree facts, an
architecture graph, text and syntax analysis, fault construction, position helpers,
and configuration. Legacy Python callbacks may ignore the context and walk their
`ast.Module` directly when they need unrestricted Python AST access.

## `ctx.facts`

Expand Down Expand Up @@ -75,6 +77,46 @@ whose result depends on the query as `requester`, normally `ctx.path`.
[cacheability verification](/concepts/custom-rules#keeping-custom-rules-cacheable).
</Warning>

### `ctx.project.tree`

An immutable, deterministic view of the authoritative discovered project. It does
not perform arbitrary filesystem discovery. Every query records its exact answer
against the active file or project-rule requester.

| Call | Returns | Purpose |
| --- | --- | --- |
| `paths` | `tuple[ProjectPath, ...]` | All discovered files and directories. |
| `files` | `tuple[File, ...]` | All discovered source identities. |
| `children(path=".")` | `tuple[ProjectPath, ...]` | Immediate discovered children. |
| `descendants(path=".")` | `tuple[ProjectPath, ...]` | All discovered descendants. |
| `glob(pattern)` | `tuple[ProjectPath, ...]` | Discovered paths matching a confined POSIX glob. |
| `files_under(path=".")` | `tuple[File, ...]` | Discovered files at or beneath a path. |
| `position(path)` | `FilePosition \| None` | Analyzer, scope, module/package, domain, role, and entry-point facts for a file. |

`ProjectPath` rejects absolute paths, parent traversal, Windows path spellings, and
unsupported path objects. `FilePosition` includes `path`, `analyzer`, `source_kind`,
`scope`, `scope_root`, `module`, `package`, `domain_parts`, `role`, `role_depth`,
`is_entry_module`, and `is_main_module`.

## `ctx.graph`

For Python targets, an immutable architecture graph resolved from static imports
and authoritative discovered modules. Unresolved imports remain explicit and
dynamic imports are not represented.

| Call | Returns | Purpose |
| --- | --- | --- |
| `nodes` | `tuple[ModuleNode, ...]` | Every discovered graph node. |
| `node(file_or_path)` | `ModuleNode \| None` | One focused module lookup. |
| `imports(node_or_file_or_path)` | `tuple[ImportEdge, ...]` | Authored imports and their resolution status. |
| `dependencies(node_or_file_or_path)` | `tuple[ModuleNode, ...]` | Resolved direct targets. |
| `dependents(node_or_file_or_path)` | `tuple[ModuleNode, ...]` | Resolved direct importers. |
| `cycles()` | `tuple[ImportCycle, ...]` | Deterministic statically proven cycles. |

`ImportEdge` preserves the `AuthoredImport`, source location, resolved module name,
`ImportResolution`, and optional target node. Graph identities include analyzer and
source-kind ownership so they remain safe as additional analyzers gain graphs.

## `ctx.text`, `ctx.syntax`, `ctx.relations`

Source text, opaque syntax identities, and syntax relationships for the current
Expand Down Expand Up @@ -108,6 +150,12 @@ declared message and remediation, are wired automatically. Omitted `message` and
| `fault_for(path, line, column, message=None, remediation=None)` | A `Fault` at an explicit location. |
| `path_fault(path=None, message=None, remediation=None)` | A file-level `Fault` with no specific line. |

During a project-rule invocation there is no current file, source, text, syntax, or
file-fact object. `path_fault()` therefore requires an explicit project-relative
path, and `fault_for()` requires an absolute path confined to the analyzed project.
`fault_at()` accepts a `SourceLocation` or `SourceRange`; a bare `SyntaxHandle`
cannot be resolved without a current file analysis.

## Position and roles

Location facts derived from configured roots and scopes, not hardcoded package
Expand Down Expand Up @@ -226,6 +274,21 @@ are frozen dataclasses; inspect exact fields with your editor's type information
| `ProjectDependency` | One requester-to-path dependency observed by a query. |
| `StaticReferenceFact` | One expression resolved to an absolute imported symbol. |

**Subjects, project tree, and architecture graph**

| Model | Describes |
| --- | --- |
| `File` | Immutable identity of one discovered source file. |
| `Project` | Immutable identity of one analyzed project. |
| `ProjectPath` | Normalized, confined, project-relative POSIX path. |
| `ProjectTree` | Deterministic discovered paths, files, and positions. |
| `FilePosition` | Complete analyzer-neutral layout position for one file. |
| `ArchitectureGraph` | Resolved static project-import graph. |
| `ModuleNode` | Discovered module identity and ownership. |
| `AuthoredImport` | Import spelling before project resolution. |
| `ImportEdge` | Authored import plus explicit resolution result. |
| `ImportCycle` | One statically proven strongly connected component. |

**Test facts**

| Model | Describes |
Expand Down
Loading