diff --git a/cli/check.mdx b/cli/check.mdx index e148b98..4e179b2 100644 --- a/cli/check.mdx +++ b/cli/check.mdx @@ -24,6 +24,8 @@ Fensu finds your [configuration](/concepts/configuration) by searching upward fr the current directory, sorts files into the product, test, and tooling [scopes](/concepts/architecture-model#scopes), and applies each scope's rules. An explicit multi-target configuration runs every target and produces one report. +Selected [repository rules](/concepts/repository-rules) then run exactly once over +that complete target registry. ## Arguments and options @@ -34,7 +36,7 @@ explicit multi-target configuration runs every target and produces one report. | `--no-color` | Disable ANSI color in the output. | | `--cache` / `--no-cache` | Enable or disable persistent result caching for this run. | | `--cache-stats` | Write cache operation counts to stderr. | -| `--target TARGET` | Evaluate only one named analyzer target. | +| `--target TARGET` | Evaluate only one named analyzer target; repository rules are skipped. | Passing paths replaces the configured roots for that run. The rest of the configuration, including [evaluation targeting](/concepts/configuration#evaluation-targeting), diff --git a/concepts/configuration.mdx b/concepts/configuration.mdx index 6e5eac9..26f7722 100644 --- a/concepts/configuration.mdx +++ b/concepts/configuration.mdx @@ -87,8 +87,10 @@ select = ["FPRS"] Each target requires `analyzer` and `roots`. `root` defaults to `.` and relocates that target's paths relative to the configuration directory. Explicit `targets` -cannot be mixed with legacy flat keys. `fensu check` evaluates every configured -target unless `--target NAME` selects one. +cannot be mixed with legacy flat keys; the top-level `repository_rules` table is +the one repository-owned policy exception. `fensu check` evaluates every configured +target unless `--target NAME` selects one. See +[Repository rules](/concepts/repository-rules) for aggregate cross-target policy. The registered analyzers are `python`, `rust`, `typescript`, and `svelte`. Web-only keys include `test_layout`, `ui_kit`, `shadcn`, `openapi`, and `generated`; the normal @@ -110,7 +112,8 @@ silently dropped key is how a typo quietly disables a rule. - An unknown top-level key is rejected: `Unknown config key(s): ...`. - A flat config requires `roots`; an explicit config requires one or more named targets, each with `analyzer` and `roots`. -- Explicit `targets` cannot be mixed with legacy top-level policy keys. +- Explicit `targets` cannot be mixed with legacy top-level policy keys. They may be + accompanied by top-level `repository_rules`. - `roots` entries must not be nested or overlapping. - Every configured root path must exist on disk. - A path may not belong to two scopes, and two scopes may not claim the same @@ -139,6 +142,7 @@ silently dropped key is how a typo quietly disables a rule. | Key | Type | Default | Purpose | | --- | --- | --- | --- | | `targets` | table of named targets | none | Explicit analyzer projects; mutually exclusive with all flat keys. | +| `repository_rules` | table | none | Aggregate custom-rule policy for explicit named targets. | | `roots` | list of strings | required in a flat config or target | Product code scopes. No nesting. | | `tests` | list of strings | `["tests"]` | Test scopes. | | `test_scopes` | list of strings | `["unit", "integration", "e2e"]` | Directory names allowed directly under a test root. | diff --git a/concepts/custom-rules.mdx b/concepts/custom-rules.mdx index 5628f8c..22d7cfd 100644 --- a/concepts/custom-rules.mdx +++ b/concepts/custom-rules.mdx @@ -134,7 +134,7 @@ under `XDB` and `XDJ`, `XDB` one namespace, `XDB001` one rule. ### Typed check subjects -Prefer one of the two typed callback shapes. A file rule runs once for each +Prefer a typed callback shape. A file rule runs once for each selected source file: ```python @@ -159,6 +159,22 @@ 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=...)`. +A repository rule runs once only during an aggregate all-target check and queries +explicit named target handles: + +```python +from fensu import Repository + + +def check(*, repository: Repository, ctx: RuleContext) -> list[Fault]: ... +``` + +Its analyzers declare the target capabilities it may read. Repository policy lives +once in top-level `[repository_rules]`; `fensu check --target NAME` skips these +rules rather than providing a partial target registry. See +[Repository rules](/concepts/repository-rules) for configuration, target-local +facts, diagnostic translation, and cache behavior. + 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. diff --git a/concepts/repository-rules.mdx b/concepts/repository-rules.mdx new file mode 100644 index 0000000..fe92ac0 --- /dev/null +++ b/concepts/repository-rules.mdx @@ -0,0 +1,217 @@ +--- +title: Repository Rules +description: "Author aggregate custom rules that compare explicit named Python, Rust, TypeScript, and Svelte targets." +--- + +Repository rules enforce contracts that span more than one configured analyzer +target. They are ordinary Python-authored custom rules, but receive one immutable +`Repository` subject and explicit named target handles instead of a current file or +one target-local project. + +Use them for policies such as a backend contract agreeing with a frontend client, +shared schema ownership, or repository-wide dependency direction. A matching name +in two targets is not evidence of a relationship: compare only facts and resolved +edges that Fensu exposes. + +## Execution model + +A repository rule runs exactly once during an aggregate all-target check: + +```bash +fensu check +``` + +It does not run during a target-scoped check: + +```bash +fensu check --target web +``` + +Skipping is deliberate. A repository rule never receives a partial target +universe. Target-local file and project rules continue to run normally in the same +aggregate check, and all findings are merged into one deterministic report. + +Fensu collects target facts only when a repository rule is selected. Native Rust, +TypeScript, and Svelte discovery, parsing, and built-in checks stay native; web +analysis remains Node-free. Fensu starts the bounded Python host only when an active +repository rule requires it. + +## Configure repository policy + +Repository rules require at least two explicit named targets. Configure their +policy once in the top-level `[repository_rules]` table, not inside any target: + +```toml +[targets.backend] +analyzer = "python" +root = "." +roots = ["src/server"] +tests = ["tests"] +tooling = [] +select = ["FF"] + +[targets.web] +analyzer = "typescript" +root = "frontend" +roots = ["src"] +tests = ["tests"] +tooling = [] +select = ["FPTS"] + +[repository_rules] +rule_paths = ["rules/repository.py"] +select = ["XREP001"] +``` + +The table supports the normal custom-rule `rule_paths`, `rule_modules`, `select`, +`warn`, `ignore`, `rule_options`, `rule_ignores`, `rule_exceptions`, `cache`, and +`skills` keys. Paths in repository exceptions and diagnostics are relative to the +repository configuration, not to an individual target. Repository exceptions are +file-level; they do not accept `symbols`. + +An active rule is applicable only when the configured registry contains every +analyzer declared by that rule. Inspect its resolved subject, analyzer capabilities, +options, and policy normally: + +```bash +fensu rule XREP001 +``` + +Generated project skills include the same repository-rule metadata and explain the +aggregate-only execution contract. + +## Author a repository rule + +Declare the `Repository` subject and all analyzer capabilities the rule reads: + +```python +from fensu import ( + AnalyzerId, + Family, + Fault, + ProjectPath, + Repository, + RuleContext, + rule, +) + + +@rule( + code="XREP001", + family=Family.CUSTOM, + slug="contract-version", + message="Backend and frontend contract versions differ", + analyzers=(AnalyzerId.PYTHON, AnalyzerId.TYPESCRIPT), + cacheable=True, +) +def contract_version( + *, repository: Repository, ctx: RuleContext +) -> list[Fault]: + del repository + backend = ctx.targets.named("backend") + web = ctx.targets.named("web") + if backend is None or web is None: + return [] + + contract = backend.python.file(ProjectPath("src/server/contracts.py")) + client = web.web.file(ProjectPath("src/client.ts")) + if contract is None or client is None: + return [] + + if "VERSION = 1" in contract.source and "version = 2" in client.source: + return [ctx.path_fault(path=web.repository_path(client.file))] + return [] +``` + +The callback shape is: + +```python +def check(*, repository: Repository, ctx: RuleContext) -> list[Fault]: ... +``` + +The `Repository` value is the stable identity of the aggregate subject. Read target +state only through `ctx.targets`; repository contexts do not expose a current file, +source, `ctx.project`, `ctx.graph`, or unqualified analyzer facts. + +## Target handles + +`ctx.targets.all()` returns every configured target in stable name order. +`ctx.targets.named(name)` returns one exact target or `None`. + +Each `RuleTargetFacts` handle exposes: + +| Member | Purpose | +| --- | --- | +| `identity` | A `Target` with stable `name`, `analyzer`, and repository-relative `root`. | +| `tree` | The target-local deterministic `ProjectTree`. | +| `graph` | The target-local `ArchitectureGraph`. | +| `python` | `PythonWorkspaceFacts` for a Python target. | +| `rust` | `RustWorkspaceFacts` for a Rust target. | +| `web` | `WebWorkspaceFacts` for a TypeScript or Svelte target. | +| `repository_path(value)` | Translate a target-local `File` or `ProjectPath` for a repository diagnostic. | +| `repository_location(value)` | Translate a target-local `SourceLocation` for `ctx.fault_at()`. | + +Target tree, graph, and analyzer facts use target-local paths. Always translate a +path or location before reporting it. Accessing facts for the wrong analyzer, or an +analyzer omitted from the rule's `analyzers`, fails closed rather than returning an +ambiguous value. + +Python targets expose `PythonWorkspaceFacts.files` and focused +`PythonWorkspaceFacts.file(...)` lookups. Each `PythonFileFacts` contains the +immutable file identity, source, and Fensu-owned fact, text, syntax, and relation +zones. Rust and web targets use the same workspace models described in the +[`RuleContext` reference](/reference/rule-context#ctxrust). + +## Diagnostics + +Repository findings are repository-relative. Use one of the translated values: + +```python +return [ctx.path_fault(path=web.repository_path(client.file))] +``` + +```python +return [ + ctx.fault_at(location=web.repository_location(import_fact.location)) +] +``` + +`ctx.path_fault()` without a path reports against the repository subject `"."`. +As with other custom rules, warnings do not affect the exit code, blocking findings +produce exit code `1`, and configuration or host errors produce exit code `2`. + +## Cache behavior + +Repository rules use a distinct cache namespace and requester. Cache identity +always includes the complete deterministic target registry: target names, +analyzers, and roots, together with rule implementation and repository policy. +Adding, removing, renaming, relocating, or changing the analyzer of a target +invalidates the result. + +Within a stable registry, invalidation follows the queries the rule actually made: + +- focused `python.file(...)`, `rust.file(...)`, `web.file(...)`, tree, and graph + lookups survive unrelated edits; +- broad `files`, `tree.paths`, `graph.nodes`, and similar inventory queries + invalidate when their complete answer changes. + +Cached and uncached diagnostics and exit codes are identical. `--cache-stats` +includes repository-rule hits, misses, invalidations, writes, and non-cacheable +results in the aggregate counters. + +## Related + + + + Rule metadata, options, loading, testing, and cacheability. + + + Target-local tree, graph, and analyzer fact contracts. + + + Aggregate checks, target selection, diagnostics, and exit codes. + + + Persistent cache correctness and invalidation. + + diff --git a/docs.json b/docs.json index eff12ec..a6ccfe8 100644 --- a/docs.json +++ b/docs.json @@ -48,6 +48,7 @@ ] }, "concepts/custom-rules", + "concepts/repository-rules", { "group": "Additional Analyzers", "pages": [ diff --git a/reference/rule-context.mdx b/reference/rule-context.mdx index 59d9bd4..bbd3e14 100644 --- a/reference/rule-context.mdx +++ b/reference/rule-context.mdx @@ -11,6 +11,7 @@ never from internal package paths. ```python def check(*, file: File, ctx: RuleContext) -> list[Fault]: ... def check(*, project: Project, ctx: RuleContext) -> list[Fault]: ... +def check(*, repository: Repository, ctx: RuleContext) -> list[Fault]: ... ``` The context groups its surface into current-file facts, project/tree facts, an @@ -173,6 +174,24 @@ Broad `files` and focused `file()` calls record their exact answers for subject-aware cache replay. Tree and graph calls made during the same invocation record their own query-specific dependencies. +## `ctx.targets` + +Repository rules use `ctx.targets` instead of an unqualified target context. +`all()` returns every configured `RuleTargetFacts` handle in target-name order; +`named(name)` returns one exact handle or `None`. + +Each handle exposes its stable `Target` identity, target-local `tree` and `graph`, +and the typed `python`, `rust`, or `web` workspace facts owned by its analyzer. +`repository_path(file_or_path)` and `repository_location(location)` translate +target-local evidence before constructing a repository-relative diagnostic. +Wrong-analyzer access and access to an analyzer omitted from the active rule's +`analyzers` fail closed. + +Repository contexts have no current file, source, `ctx.project`, or unqualified +analyzer zones. Their queries use a distinct repository requester for cache replay. +See [Repository rules](/concepts/repository-rules) for the complete execution and +cache model. + ## `ctx.text`, `ctx.syntax`, `ctx.relations` Source text, opaque syntax identities, and syntax relationships for the current @@ -219,6 +238,11 @@ path, and `fault_for()` requires an absolute path confined to the analyzed proje `fault_at()` accepts a `SourceLocation` or `SourceRange`; a bare `SyntaxHandle` cannot be resolved without a current file analysis. +During a repository-rule invocation, `path_fault()` accepts a repository-relative +path and may omit it to report against the repository subject `"."`. +`fault_at()` accepts a repository-relative `SourceLocation`; translate a +target-local location with `target.repository_location(...)` first. + ## Position and roles Location facts derived from configured roots and scopes, not hardcoded package @@ -343,6 +367,10 @@ are frozen dataclasses; inspect exact fields with your editor's type information | --- | --- | | `File` | Immutable identity of one discovered source file. | | `Project` | Immutable identity of one analyzed project. | +| `Repository` | Immutable identity of one configured multi-target repository. | +| `Target` | Stable name, analyzer, and repository-relative root for one target. | +| `RuleTargetFacts` | One named target's identity, tree, graph, typed facts, and path translators. | +| `RepositoryTargetFacts` | Deterministic `all()` and `named()` target lookup. | | `ProjectPath` | Normalized, confined, project-relative POSIX path. | | `ProjectTree` | Deterministic discovered paths, files, and positions. | | `FilePosition` | Complete analyzer-neutral layout position for one file. | @@ -364,6 +392,13 @@ are frozen dataclasses; inspect exact fields with your editor's type information | `RustItemFact` | Function, struct, enum, trait, implementation or module declaration. | | `RustUseFact` | Authored `use` path and explicit resolution evidence. | +**Python repository facts** + +| Model | Describes | +| --- | --- | +| `PythonWorkspaceFacts` | Versioned broad and focused Python file queries for one named target. | +| `PythonFileFacts` | One Python file's identity, source, and Fensu-owned fact, text, syntax, and relation zones. | + **Web facts** | Model | Describes |