Metadata-Version: 2.5
Name: enact-compliance
Version: 0.1.4
Summary: The Enact enforcement package: registry rows, checks, verdicts.
License-File: LICENSE
Requires-Python: >=3.14
Requires-Dist: pydantic>=2.9
Requires-Dist: pyyaml>=6.0.2
Description-Content-Type: text/markdown

# enact-compliance

The enforcement package, as a Python package.

A check is a pure function over the set of files it is handed:

```python
findings = Check().run(context, file_set)
```

That signature is the design. A check cannot walk a tree (there is nothing to
walk from), cannot call git, cannot locate itself, and cannot take an argument —
not because a rule forbids it, but because it is handed a `Context` and a
`FileSet` and has nowhere to put any of it.

## Why this exists

The shell tree it replaces wrote the literal string `compliance/` **1071 times
across 248 files**. That is not a variable set wrong; it is a path typed out
once per use. Here the interpreter resolves the package and
`importlib.resources` resolves its data, so the string does not appear at all.

Measured against `compliance_new/` at the time of the port:

| | shell | here |
|---|---|---|
| `compliance/` path literals | 1071 | 0 |
| places that read the environment | many | one (`cli.py`) |
| checks spawning `yq`/`jq` | 48 | 0 (stdlib) |
| checks driving `ast-grep` | 9 | still a subprocess, correctly; the svelte-tailwind analyzer also embeds `@ast-grep/napi` in-process (below) |

## Layout

Three layers, and the dependencies only point one way: `checks` → `core` →
`policy`. Nothing in `core` or `policy` imports a check, and no check imports
another.

| module | owns |
|---|---|
| `core/models.py` | `StrictModel` / `OpenModel` — `extra="forbid"`, `strict=True`, `frozen=True` |
| `core/context.py` | `Context` and `FileSet` — the only two things a check may read |
| `core/check.py` | the `Check` base class: the `run` template plus its shared services |
| `core/finding.py` | `Finding`, `Advisory`, and the one text shape |
| `core/errors.py` | `CouldNotRun` and `SchemaInvalid`, kept apart on purpose |
| `core/scope.py` | the applicable-scope formula, and nothing else implements it |
| `core/verdict.py` | grants arithmetic and the 0/1/2 exit contract |
| `policy/registry.py` | the registry schema, enforced on load |
| `policy/grants.py` | `enact.exception-catalog/v1` — owner, reason and expiry all required |
| `policy/*.yaml` | the policy documents, carried over one-to-one |
| `checks/<domain>/<name>.py` | one module per gate, under the domain its row declares |
| `runner.py` | dispatch: row → check → scope → findings → verdict |
| `cli.py` | the argv surface — the only reader of `os.environ` and the only caller of `git` |

## Writing a check

`run` is a template method. A subclass never overrides it; it fills in two
hooks, and that split is the distinction this estate keeps having to re-explain:

```python
class AdoptionCoverage(Check):
    id = "TS-001"

    def structural(self, ctx):          # rules that probe NAMED PATHS
        ...                             # an absent file is in no file set

    def graded(self, ctx, files):       # rules that read the GIVEN SET
        ...                             # which files is engine.scope's question
```

Inherited from the base, because two or more checks each needed it: `report` /
`advise`, `text` (None means absent, never a silent empty string), `state`
(missing / empty / present), `missing_markers` / `present_markers`, `scan`
(line-numbered regex hits), and `parse` (JSON → validated model, raising
`SchemaInvalid` with the offending field named).

## Strict validation

Every document is parsed into a pydantic model with `extra="forbid"` and
`strict=True`. The forbid is the one that earns its place: the shell tree read
the registry with `yq -r '.rows[].engine.script'`, so a row that misspelled the
key got an empty string, and an empty string was a gate that ran nothing and
reported clean.

`policy/gate-registry.schema.yaml`'s constraints are now enforced by the loader
rather than by a driver script in a consumer tree — every pattern, the 80-char
title cap, ROW-09, and the three uniqueness rules including the
`<domain>.<name>` one that schema file called "not expressible". ROW-01/06/07/08
are gone, not merely relocated: they constrained `engine.script` and the
row-level `test:` field, both deleted outright (owner ruling 2026-09-15) — see
`INVARIANTS.md`.

Two things opt out, and both are representation rather than value: a YAML array
materialising as a `tuple`, and an enum arriving as its own member value.
Documents this package reads but does not own (`package.json`, ast-grep's JSON
stream) use `OpenModel` — strict about the fields they declare, indifferent to
the rest, so ast-grep's next release does not fail a gate.

## The consumer's layout arrives

A row names a logical tree; `policy/config.toml` says where it currently lives.

```yaml
scope:
  - <rust>/crates/**/src/*.rs        # backend/crates/**/src/*.rs
  - <compliance>/policy/inputs/rust-policy.json
```

260 of the registry's 330 scope entries are placeholders, and named-input
defaults and `grants:` paths resolve through the same table — so no consumer path
is written in this package's rows, and moving a tree is one line. `<name>`, not
`{name}`: braces are already glob alternation and eleven rows use it. An unknown
tree is a load failure, never a pattern that survives unexpanded.

## Applicable scope

```
applicable scope = (row's engine.scope  ∩  the caller's set)  −  what git cannot see
```

Three terms, three owners, none of them the check. The third is the CLI's: it
runs `git ls-files --cached --others --exclude-standard`, so gitignored paths
never enter a set and no row needs to exclude `node_modules/`.

## Running

```bash
uv run enact-compliance run rust.acp-boundary
uv run enact-compliance run RST-001 --files changed.list
uv run enact-compliance run                   # every row
enact-compliance                              # the same — `run` is implicit
```

`enact-compliance` reads `[controls.compliance]` from `enact-config.toml`
at the repo root, so a consumer invocation only needs to name the repo
(`--repo .`, defaulting to the enclosing checkout). Explicit `--tree`,
`--input`, `--declare`, and `--only` flags override the config on a
per-key basis.

Exit `0` clean, `1` findings, `2` could not run. A tool failure is never a
finding.

## Testing

```bash
uv run pytest
```

A fixture is a committed directory holding only the files the rule is about, and
a corpus is pointed at by passing it as the root. Nothing is copied to `$TMPDIR`
and nothing is exported into the process.

Every gate is a folder: `src/enact_compliance/checks/<domain>/<name>/` holds
`check.py`, `test_check.py`, and `fixtures/` together — no second tree mirroring
it. `tests/core/` is the one exception, for the spine (`Check`, `scope`,
`Registry`, `runner`, `cli`, `Constants`, the tree map) that no single gate owns.

## Status

117 of the registry's 117 rows are ported — counted by importing
`enact_compliance.checks` and measuring `len(CHECKS)`, not by re-reading
this paragraph.

33 rows were cut on 2026-10-01 because `adl-azdo` already covers them:
13 byte-identical (under rename), 16 rename-only diffs, and 4 cases where
adl is stricter (DOC-009 freshness, LAY-013 root-shape allowlist, LAY-016
cap of zero open plans, LFS-001 expanded-LFS pointer). The 117 rows that
remain are the product gates: 58 svelte-tailwind (53 prior + UI-054
proportions, UI-055 semantic-tokens, UI-056 settings-surfaces, UI-057
surfaces, UI-058 css-contract), 26 rust, 17 typescript, 7 database,
3 contracts, 2 release (REL-002, REL-003), and 1 each of pipeline
(PIP-001), security (sbom), infrastructure (plan-actions), and pgsql
(parse-error). No shell backlog.

UI-054..UI-057 are 1:1 ports of the workflow audits at
`workflows/scripts/ui/audit-*.ts`; the workflow scripts stay there while the
new gates become the source of truth.

UI-054 was bug-fixed in two places during the live equality pass: default
imports of Svelte components now resolve to the local name (`import Icon
from '.../icon.svelte'` resolves to `Icon`, not the kebab-stem), and
`ui054PanelGroups` returns `[parent, ...children]` (matching audit's
`panelGroups`) so the parent's classes count toward panel-level drift.
The UI-058 expansion carries the full 28-rule vocabulary from the audit
(`audit-css-contract.ts` + `source-index.ts`) into the analyzer. postcss
+ postcss-selector-parser are imported; the animation/transition registries
are loaded via the consumer's `ui_animation_registry` /
`ui_runtime_producers_registry` inputs. The source walker lives at
`src/enact_compliance/providers/svelte_tailwind/source_index.ts` — a
1:1 audit-parity port (`TypeScriptRecipeIndex` + `collectSvelteRuntimeClasses`
+ `collectGeneratedDom` + `collectSvelteClasses` + `buildProductionSourceIndex`),
minus `ENACT_WEB_ALIAS` (the package walks `<uiTree>/src` directly) and
minus `InvalidAuditError` (a local `SourceIndexError` records per-file
parse failures and the walker skips the file). css-dead-selector now
attributes CSS classes to their actual .svelte/.ts source via
class="..." template attribute, class:foo={cond} directive,
element.className = / classList.add() in script blocks, and
import-followed const identifier resolution across .ts modules
(including template-literal class=... in generated DOM).

The source-index promotion (2026-10-15): `collectSvelteClasses` in
`source_index.ts` now runs on tree-sitter SgNodes instead of
`svelte/compiler` — one `findAll` per file, then pure-JS processing over
source slices; template and script expressions are re-parsed with the
TypeScript compiler and walked with the same semantics as the old ESTree
walker, so index outputs are byte-identical (verified across the
apps/web corpus and by `verify-promotion.sh`). The svelte portion of a
UI-058 run dropped from ~1100ms to ~380ms. The svelte/compiler version
stays as an env-gated fallback (`ENACT_SOURCE_INDEX=legacy`); the
`TypeScriptRecipeIndex` .ts path is unchanged. Grammar registration is
lazy in `source_index.ts` itself, so the fallback and non-darwin
platforms never load the `.so`.

The ast-grep promotions (2026-10-15): `@ast-grep/napi` is a package
dependency and the tree-sitter-svelte grammar is vendored at
`analyze/vendor/tree-sitter-svelte/` (one `.so` per platform; the
package ships darwin-arm64 — other platforms drop their build beside
it or the scanner throws at registration). `analyze/scanner.ts` parses
the run's Svelte files once (`parseFiles`, roots keyed by logical path)
and the promoted gates walk those: UI-054 (18 of its rules; the other
18 stay on the svelte/compiler `elementRelations` path), UI-057, UI-061,
UI-063. Gates whose rules are pure text regex (UI-055, UI-056, UI-059,
UI-060, UI-062) keep their text passes — the reference scanners were
regex-over-text too, so no parse is added. Every promoted gate falls
back to its previous text/AST pass for a file the grammar cannot parse,
so a parse failure never silences a gate. All ten gates verify
byte-identical against pre-promotion baselines via
`.scratchpad/ast-grep-svelte-gates/run/verify-promotion.sh`.

`INVARIANTS.md` records what is not true yet.
