From 9cb67e7ec15c606609981b080fc87d7e7ac58456 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:32:15 +0100 Subject: [PATCH] docs: describe project architecture rule API --- concepts/custom-rules.mdx | 65 +++++++++++++++++++++++++++------- reference/rule-context.mdx | 71 +++++++++++++++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 16 deletions(-) diff --git a/concepts/custom-rules.mdx b/concepts/custom-rules.mdx index d830624..5628f8c 100644 --- a/concepts/custom-rules.mdx +++ b/concepts/custom-rules.mdx @@ -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( @@ -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) @@ -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 @@ -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, @@ -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)] @@ -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. diff --git a/reference/rule-context.mdx b/reference/rule-context.mdx index 3b9509a..41e9584 100644 --- a/reference/rule-context.mdx +++ b/reference/rule-context.mdx @@ -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` @@ -75,6 +77,46 @@ whose result depends on the query as `requester`, normally `ctx.path`. [cacheability verification](/concepts/custom-rules#keeping-custom-rules-cacheable). +### `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 @@ -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 @@ -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 |