Metadata-Version: 2.4
Name: diffimpactscout
Version: 0.7.1
Summary: Incremental pre-push guard and AST blast-radius impact analyzer for git repositories.
Author: DiffImpactScout contributors
License: MIT
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: author
Dynamic: description
Dynamic: description-content-type
Dynamic: license
Dynamic: license-file
Dynamic: requires-python
Dynamic: summary

# DiffImpactScout

An incremental pre-push guard and AST blast-radius impact analyzer for git repositories.

- Runs a battery of hygiene, syntax, and linter checks on just the files you changed, before you push.
- Estimates the blast radius of a change: which Python symbols you touched and where they are referenced across Python, Django templates, and frontend code.
- Installs as a git pre-push hook that is non-blocking by default, so it informs without getting in the way.

## Features

- Incremental checks: every check is scoped to the change-set or changed lines, not the whole repository.
- Auto-fixing hygiene checks (line endings, trailing whitespace, end-of-file newline) that clean files in place.
- Python AST symbol map: detects changed classes, functions, methods, module fields, and class fields, including deletions and renames.
- Cross-layer impact linking: Python symbols to Django `{% url %}` template tags, frontend HTTP calls (`http`, `HttpClient`, `$http`, `axios`, `fetch`, React Query), and TS/JS import/export bindings of changed symbol names.
- Symbol cache (`.impact_analysis_cache.json`) so repeated runs skip unchanged files.
- Profiles for `generic`, `django`, `fastapi`, `python`, and `frontend` projects.
- Configurable pre-push hook installation that never silently clobbers an existing hook.

## Core idea

DiffImpactScout answers one question for a developer about to push: did I break something?

- **Only your changes are analyzed.** Every guard check and the impact report are computed against the diff between your branch and the resolved change base (upstream master or sprint branch when present, otherwise closest origin branch). Pre-existing violations that arrived from upstream or from sync merges are not re-reported, so a run answers "did I introduce this?" instead of "does this file have a problem?"
- **Non-blocking by default.** A push is only blocked when strictness is explicitly requested (strict mode or an always-block check). Default runs inform without getting in the way.
- **Fixes over complaints.** Hygiene problems that can be auto-fixed are fixed in place instead of failing the run.
- **Blast radius, not just lint.** The impact report shows which code outside your diff could be affected by your change, so the "not my change" threshold is applied to blame, not to risk.

The change-set also handles the full breadth of realistic push shapes: direct pushes, force pushes, new branches, sync merges (merge, rebase, squash), stacked branches, shallow clones, repos without a remote anchor, and fork upstream sync. All are covered by the automated pre-push scenario suite, so a missing upstream anchor never silently means "nothing was checked".

## Install

```sh
pip install diffimpactscout
```

The package is pure Python (standard library only at runtime) and requires Python 3.8 or newer and the `git` CLI on `PATH`.

Optional tools are only needed for the checks that use them: `ruff`, `eslint` (via `npx`), and `prettier` (via `npx`). If one is missing, the affected check emits a warning and the guard continues; it never blocks a push because a tool is absent.

## Quick start

```sh
# 1. Write a .diffimpactscout.json config (optional; defaults are used without one)
diffimpactscout init

# 2. Run the guard checks on your change-set
diffimpactscout guard

# 3. Analyze the blast radius of your change
diffimpactscout impact

# 4. Install the pre-push hook (guard + impact report by default)
diffimpactscout install-hooks
```

`init` accepts `--profile generic|django|fastapi|python|frontend` to seed profile-appropriate settings. `install-hooks` detects the stack automatically (see [Supported setups](#supported-setups)) and, unless the config's `mode` says otherwise, the hook runs both the guard and the impact report on every push.

## Supported setups

`install-hooks` detects the project stack on first run and writes a `.diffimpactscout.json` that fits it:

| Detection signal | Profile | Guard checks | Impact globs |
| --- | --- | --- | --- |
| `manage.py` + `settings.py`/`wsgi.py` | django | default + `ruff`, `ruff-format` | urls, templates, frontend |
| `fastapi` in deps or `main.py` | fastapi | default + `ruff`, `ruff-format` | `**/*.py` routes + frontend globs |
| `package.json` + `src`/`app` `*.ts`/`*.js` | frontend | default + `eslint`, `prettier` | templates + frontend globs |
| `*.py` present | python | default + `ruff`, `ruff-format` | Python module references |
| anything else (or no signal) | generic | default from `init` | none |

Other ecosystems (Go, Rust, Java, Ruby, ...) are detected and reported as "planned for future releases" in the preview; the generic profile still installs the universal guard checks. Add a language linter with an [external check](#configuration).

## Commands

The `init` command only needs a directory; every other command must be run inside a git repository.

### `diffimpactscout init`

Writes a `.diffimpactscout.json` into the current directory. If the file already exists it is left unchanged.

| Flag | Description |
| --- | --- |
| `--profile {generic,django,fastapi,python,frontend}` | Seed defaults from the named profile (default: `generic`). |

### `diffimpactscout guard`

Runs every check listed in `guard.checks` over the change-set and prints any issues. Exits non-zero only when the run is in blocking mode and a blocking check found issues (see [Pre-push hook](#pre-push-hook) and [Configuration](#configuration)).

| Flag | Description |
| --- | --- |
| `--staged` | Check only staged files (`git diff --cached`). |
| `--all` | Check every file tracked by git. |
| `files...` | Check only the given paths. |

With no flags, the change-set is the diff between your branch and the closest remote ref (upstream preferred, then origin), falling back to the union of committed, staged, and worktree changes when no remote base can be resolved.

### `diffimpactscout impact`

Analyzes the blast radius of the change-set. In an interactive terminal it prints the report and asks `Proceed with push? (Y/n)`; answering `n` or `no` exits non-zero.

| Flag | Description |
| --- | --- |
| `--staged` | Analyze the staged diff (base ref becomes `HEAD`). |
| `--fast` | Skip template and frontend scanning; limit Python scanning to the changed files plus import-linked files. |
| `--json` | Emit the report as JSON (`{changed_count, rows, endpoints, unresolved, unresolved_by_reason, hidden_count, shown_count, hidden_counts, cache_hit_rate}`) instead of a table; see [Report format](#report-format). |
| `--expand` | Open rolled-up rows (e.g. "35 templates directly extend it") to list their child files. |
| `--all` | Also show lower-confidence (heuristic/transitive) impacts. |
| `--top N` | Cap the table body at N ranked rows (default: 20); overflow collapses to one line per module. `--all` and `--expand` bypass the cap. |
| `--sarif PATH` | Emit SARIF 2.1.0 to `PATH` (High maps to `error`, Medium to `warning`, Low to `note`). A bare `--sarif` writes the SARIF JSON to stdout in place of the report. |
| `--fail-on {high,medium,low}` | Exit non-zero when findings reach the gate (a bare flag means `high`). With no flag the run warns and exits `0`. |
| `--advisory` | Print `advisory mode: N findings would block` to stderr but always exit `0`. |

### `diffimpactscout guard-and-impact-check`

Runs the `guard` checks first, then always falls through to the `impact` blast-radius report so the developer sees the guard verdict and the affected areas together. This is the default pre-push hook scope, so one push runs the whole workflow. The command exits non-zero when the guard blocks in strict mode (or when impact is set to block), so a failing guard still surfaces the impact report but the push is rejected.

| Flag | Description |
| --- | --- |
| `--staged` | Restrict both the guard and the impact analysis to staged files. |
| `--fast` | Passes through to `impact` (skip template/frontend scanning). |
| `--json` | Passes through to `impact` (emit the report as JSON). |
| `--markdown` | Passes through to `impact` (emit a Markdown table). |
| `--expand` | Passes through to `impact` (open rolled-up rows to list their child files). |
| `--all` | Passes through to `impact` (also show lower-confidence impacts). |
| `--top N` | Passes through to `impact` (cap the table body at N ranked rows). |
| `--sarif PATH` | Passes through to `impact` (emit SARIF 2.1.0; a bare flag writes it to stdout). |
| `--fail-on {high,medium,low}` | Passes through to `impact` (gate the exit code; a bare flag means `high`). |
| `--advisory` | Passes through to `impact` (report the gate to stderr, always exit `0`). |

### `diffimpactscout dependency-check`

Verifies that every external tool referenced by the configured guard checks is on `PATH` (`ruff`, `eslint`, `prettier`). Missing tools print an install hint and exit `1`; a clean run prints an OK banner and exits `0`. `install-hooks` also runs a non-blocking version of this check after installing a hook, warning about missing tools only when stdin is a terminal.

### `diffimpactscout check CHECK_ID [files...]`

Runs a single check by id (see [Checks reference](#checks-reference)). With no file arguments it runs against the change-set.

### `diffimpactscout install-hooks`

Installs a `pre-push` git hook that runs the configured hook scope on push: `diffimpactscout guard-and-impact-check` by default (guard checks then the impact blast-radius report), or `diffimpactscout guard` when `mode` is `pre-push`.

On first run the command detects the stack, prints a preview of the `.diffimpactscout.json` it would write, and (when stdin is a terminal) asks for confirmation. Answer `n` to override profile, blocking mode, extra checks, impact, and the hook scope (mode). Use `--yes` in CI to skip prompts, `--profile` and `--blocking` to pre-select settings, and `--reconfigure` to rewrite an existing config. Without `--reconfigure` an existing config is left untouched (and its mode is reused for the hook).

| Flag | Description |
| --- | --- |
| `--profile {generic,django,fastapi,python,frontend}` | Pre-select the setup profile; skips the stack question. |
| `--blocking {warn,strict}` | Blocking mode for guard checks (default: `warn`). |
| `--yes`, `-y` | Accept defaults and skip prompts (non-interactive). |
| `--reconfigure` | Rewrite an existing `.diffimpactscout.json`. |
| `--force` | Overwrite an existing `pre-push` hook that DiffImpactScout did not install. |
| `--uninstall` | Remove the DiffImpactScout `pre-push` hook. |

After a hook is installed a non-blocking dependency check runs: tools referenced by the guard checks (`ruff`, `eslint`, `prettier`) that are missing on `PATH` are reported with install hints, so e.g. a first-time `ruff` install is prompted instead of silently degrading the checks.

### `diffimpactscout --version`

Prints the installed version.

## Configuration

DiffImpactScout is configured by a `.diffimpactscout.json` file in the repository root. Without one, the defaults below are used.

```json
{
  "version": 1,
  "mode": "guard-and-impact-check",
  "ignore_paths": [
    "**/node_modules/**",
    "**/venv/**",
    "**/.venv/**",
    "**/migrations/**",
    "**/staticfiles/**",
    "**/dist/**",
    "**/build/**",
    "**/.git/**"
  ],
  "use_gitignore": false,
  "guard": {
    "checks": [
      {"id": "hygiene/mixed-line-ending"},
      {"id": "hygiene/trailing-whitespace"},
      {"id": "hygiene/end-of-file-fixer"},
      {"id": "syntax/json-syntax"},
      {"id": "syntax/ast-syntax"},
      {"id": "syntax/merge-conflict"},
      {"id": "repo/large-files", "args": ["--maxkb=250000"]},
      {"id": "repo/private-key"}
    ],
    "blocking": "warn"
  },
  "impact": {
    "profile": "generic",
    "urls_globs": [],
    "template_globs": [],
    "frontend_globs": [],
    "cache_file": ".impact_analysis_cache.json",
    "fast_mode": false,
    "threads": 4,
    "ignore_symbols": []
  }
}
```

| Section | Key | Meaning |
| --- | --- | --- |
| top-level | `mode` | Hook scope: `guard-and-impact-check` (default, runs the guard then the impact report on every push) or `pre-push` (guard only). |
| top-level | `ignore_paths` | Glob patterns (matching any path suffix) excluded from all checks and from impact scanning. |
| top-level | `use_gitignore` | When `true`, also excludes paths ignored by `git check-ignore`. |
| `guard` | `checks` | Ordered list of check entries; each `{"id": ...}` may add `args`, `blocking`, and `always_block` overrides. |
| `guard` | `blocking` | `"warn"` (default) or `"strict"`. In strict mode every check marked blocking can fail the run. |
| `impact` | `profile` | `generic`, `django`, or `fastapi`; selects route extraction plus default globs. |
| `impact` | `urls_globs` / `template_globs` / `frontend_globs` | Glob patterns for route files, Django templates, and frontend sources. |
| `impact` | `cache_file` | Path of the symbol cache (relative to the repo root). |
| `impact` | `fast_mode`, `threads` | Reserved defaults from `init`; fast mode is currently selected with the `impact --fast` flag. |
| `impact` | `reverse` | `true`/`false`; when on, template-only and frontend-only diffs are analyzed back to render sites, views, routes, and referencing frontend files. Default: off for `generic`/`python` profiles, on for `django`/`frontend`. |
| `impact` | `experimental` | `true`/`false`; enables the endpoint chain (changed frontend HTTP calls to routes, handler views, and the service/utility functions they call). Advisory only. Default: off in all profiles. |
| `impact` | `asset_url_prefixes` | Template URL prefixes stripped before asset resolution. User entries are **appended** to the built-in defaults (`{{ MEDIA_URL }}`, `{{ STATIC_URL }}`, `{% static '`, `url('`); they cannot replace or clear the defaults. |
| `impact` | `ignore_symbols` | Extra symbol names to treat as generic. Additive to the built-in stoplist (`client_id`, `id`, `name`, `status`, `update`, `uuid`); generic-named attribute references resolve only when the object is imported from the changed module. |

A config entry can also declare a custom external check:

```json
{"id": "my-lint", "type": "external", "command": ["make", "lint"]}
```

External checks run `command` (each `{file}` placeholder is replaced with the file path) and report a failed exit status as an issue. `scoped` can be `"files"` (command runs once per file) or `"repo"` (once for the whole repository).

### Profiles

- `generic`: no route or cross-layer globs; impact analysis reports only Python references.
- `python`: same impact globs as `generic`; guard checks append `ruff` and `ruff-format`.
- `django`: route files `**/urls.py`, templates `**/templates/**/*.html`, frontend `**/src/**/*.ts` and `**/app/**/*.js`; guard checks append `ruff` and `ruff-format`.
- `fastapi`: routes extracted from `**/*.py`, same frontend globs; guard checks append `ruff` and `ruff-format`.
- `frontend`: template globs `**/*.html` and frontend globs `**/src/**/*.ts` and `**/app/**/*.js`; guard checks append `eslint` and `prettier`.

### Blocking behavior

- `guard.blocking` is `"warn"` by default: issues are reported but the run exits `0`. Only checks marked `always_block` (by default just `repo/private-key`) fail the run in warn mode.
- Set `guard.blocking` to `"strict"` (or export `IMPACT_CHECK_STRICT=1`): every check marked `{"blocking": true}` that finds issues fails the run.
- Override per check, for example:

```json
{"id": "syntax/ast-syntax", "always_block": true}
```

## Pre-push hook

`diffimpactscout install-hooks` writes a `pre-push` hook that runs the configured hook scope on every push — `guard-and-impact-check` by default (guard checks plus the impact report), `guard` only when `mode` is `pre-push`.

- The hook directory is resolved from `git rev-parse --git-common-dir` and honors `core.hooksPath`.
- The hook script is marked with `# diffimpactscout pre-push hook`. On `--uninstall` (or a re-install with `--force`), only a hook carrying this marker is touched.
- If a `pre-push` hook already exists without the marker, installation refuses with a `use --force to overwrite` message.
- If the tool is not found, the hook prints a notice and exits `0`, so pushes are never blocked by a missing install.
- Because the guard is warn-by-default, an installed hook reports issues without blocking the push unless you enable strict mode.

### Environment variables

| Variable | Effect |
| --- | --- |
| `DIFFIMPACTSCOUT_SKIP` | When set (any value), `guard` and `impact` skip entirely and exit `0`. |
| `IMPACT_CHECK_SKIP` | Legacy alias with the same behavior as `DIFFIMPACTSCOUT_SKIP`. |
| `IMPACT_CHECK_STRICT` | Falsy values are `0`, `false`, `no`, `off`, and empty; anything else puts `guard` in strict mode and makes `impact` non-interactive runs fail on High/Medium hits. |
| `DIFFIMPACT_OK` | When truthy (e.g. `DIFFIMPACT_OK=1`), logs an override note and forces `impact` to exit `0` even when `--fail-on` would block. |
| `PRE_COMMIT_FROM_REF` / `PRE_COMMIT_TO_REF` | Override the diff range used to compute the change-set (e.g. set by a pre-commit-style wrapper). |

## Impact analysis

The `impact` command answers: if I push these changes, what else could break?

1. It parses the change-set (`git diff --find-renames`) and extracts the Python entities you added, removed, or renamed: classes, functions, methods, module fields, and class fields.
2. It builds an AST symbol map of every tracked Python file in the repo (skipping excluded paths), cached in `impact.cache_file`, and finds every reference to the changed symbols. Lookup kind follows the entity: class fields match attribute loads, methods match name and attribute loads, functions/classes also match imports. To keep the blast radius honest, entities are scoped to the diff hunks that actually changed (a one-line tweak inside a large function does not report the whole function), and same-name references in other files are only reported when the file imports the name from the changed module (`views.orders` counts, a bare `orders` in an unrelated file does not).
3. Depending on the profile it extracts routes:
   - `django`: `path()`/`re_path()`/`url()` entries in `urls_globs` that carry a `name=`.
   - `fastapi`: `get`/`post`/`put`/`delete`/`patch`/`options` decorators on `router`/`app`-style objects in `urls_globs`.
4. It links layers: Django templates using `{% url 'name' %}` resolve through the route to its handler; frontend HTTP calls resolve the quoted endpoint to a route by path. Recognized call styles are `http.*`, `HttpClient.*`, `$http.*`, `axios.*`, bare `fetch()`/`axios()`, and React Query `useQuery`/`useMutation` wrappers around `fetch`/`axios`. Changed Python symbol names that appear in TS/JS `import`/`require`/`export` bindings become `frontend-symbol` rows. Literal template refs that cannot be resolved to a route, non-literal (`{% url var %}`) refs, unknown URLs, assets with no template reference, and binary/unsupported inputs land in an "unresolved" bucket flagged for manual checking.
5. Every impacted reference gets a severity:

| Condition | Severity |
| --- | --- |
| Symbol was deleted or renamed | High |
| Symbol is referenced across 2+ layers (python / template / frontend) | High |
| Reference is inside a file that is itself in the change-set | Low |
| No reference path or no changed paths to compare | Low |
| Otherwise | Medium |

Actions follow severity: High rows say `review/verify`, Medium rows `verify`, Low rows `ok`, and deleted symbols `verify dangling references`.

### Example report excerpt

```
# Impact Analysis Report

### Breaking

| # | Impacted File Path | Module / Subsystem | Category | Detected Reference / Usage | Severity | Action Required | Badge |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | app/templates/dashboard.html | app/templates | template | {% url 'dashboard' %} at app/templates/dashboard.html:8 | High | review/verify | Impacted |

### Direct consumers

| # | Impacted File Path | Module / Subsystem | Category | Detected Reference / Usage | Severity | Action Required | Badge |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 2 | app/views/dashboard.py | app/views | python | render_dashboard (import at dashboard.py:12) | Medium | verify | Changed |
| 3 | frontend/src/api.ts | frontend/src | frontend | http.get("/dashboard/") at api.ts:41 | Medium | verify | Changed |

## Unresolved references (manual check required)
- {'file': 'frontend/src/api.ts', 'path': '/account/settings', 'dynamic': False}

Summary: 3 changed file(s); High: 1, Medium: 2, Low: 0
```

In a terminal, `impact` then asks `Proceed with push? (Y/n)`. On a non-interactive run (for example after a CI trigger), the report is shown as a warning and the push is not blocked unless `IMPACT_CHECK_STRICT` is set.

### Report format

The default report is a plain ASCII table: no emoji, no Findings section, and severity is a plain table cell (High/Medium/Low), never a bracketed tag. `--markdown` renders the same columns and sections as Markdown tables instead.

- **Sections.** Body rows are grouped under section headers, always in this order: `Breaking` (deleted/renamed symbols and other High-severity rows), then `Direct consumers`, then `Transitive (collapsed)`. Empty sections are omitted. Rows inside a section keep severity rank.
- **Badge column.** Every row carries a `Badge`: `Changed` when the impacted file is itself in the change-set, else `Impacted`.
- **Row keys.** Each body row ends with an unbroken `path:line` key (e.g. `app/views/dashboard.py:12`) that never wraps across lines, so scripts can rejoin wrapped rows by matching the trailing token.
- **Top-N cap.** `--top N` limits the body to the N highest-ranked rows (default: 20); hidden rows collapse to one overflow line per module (`... +3 more in app/views (1 High)`). The header severity counts and the top impacted paths are computed over all rows, so they do not change with `--top`. `--all` and `--expand` bypass the cap.
- **JSON surface.** `--json` emits `{changed_count, rows, endpoints, unresolved, unresolved_by_reason, hidden_count, shown_count, hidden_counts, cache_hit_rate}` for scripting. Each entry in `rows` carries the same `section`, `badge`, and `key` fields shown in the table; `shown_count` is the body row count after the Top-N cap, and `hidden_counts` maps each overflowed module to its hidden row count. The original five keys are unchanged, so existing scripts keep working. Each row additionally carries `confidence` (`high`/`medium`/`low`), `evidence`, `why_matters`/`suggested_action` (Medium and High rows), `owner`, and the ranking terms `fan_in`, `missing_test`, `churn`, plus the computed `risk`.
- **Empty JSON files.** The `syntax/json-syntax` guard check skips empty `.json` files with a warning instead of failing them.
- **JSONC files.** `syntax/json-syntax` parses JSONC (comments and trailing commas) for files that are JSON-with-comments by convention -- `tsconfig.json`, `tsconfig.*.json`, `jsconfig.json`, `.vscode/*.json`, `devcontainer.json`, `*.code-workspace` -- and keeps every other `.json` strict.

### Confidence and evidence

Every row carries a confidence tier and an evidence dict so a reader can see how the impact was established:

| Confidence | Meaning | Evidence |
| --- | --- | --- |
| `high` | The referencing file imports the symbol (or the object whose attribute is used) from a changed module. | `{"import_chain": [...]}` lists the defining modules. |
| `medium` | Same-module reference without a qualifying import. | `{"import_chain": ["<module>"]}`. |
| `low` | The import could not be resolved (e.g. a dynamic reference). | `{"unresolved": true}`. |

Low-confidence rows are hidden by default and shown under `Transitive (collapsed)` with `--all`; `--json` always includes every row. Generic-name hits pruned by `impact.ignore_symbols` are dropped entirely and never resurface, even with `--all`.

### Why and action per row

Medium and High rows carry `why_matters` (a one-line reason naming the symbol and the import chain, or an explicit `(unresolved import)` note) and `suggested_action` (for example `run tests/test_x.py`, `update call site`, or `remove import` for a deleted symbol). `suggested_action` is what the `Action Required` column shows, and both fields are appended to the rationale lines as `[why: ...; do: ...]`.

### Polyglot linking and unresolved reasons

Frontend HTTP calls are scanned with one canonical call-head pass, so every recognized style produces the same `(method, URL)` pair used for route matching:

| Style | Example |
| --- | --- |
| Dotted `http` / `HttpClient` / `$http` | `this.http.get('/orders/')` |
| Dotted `axios` | `axios.post('/orders/')` |
| Bare `fetch()` / `axios()` | `fetch('/orders/')` (method `get` unless an options object overrides it) |
| React Query | `useQuery(() => fetch('/orders/'))`, `useMutation(() => axios.post('/orders/'))` |

A non-literal URL never links. Changed Python symbol names bound by a TS/JS named import, `require`, or export produce `category: frontend-symbol` rows at the importing file.

Every report ends with an audit line counting unresolved entries by cause. The same counts are available as `unresolved_by_reason` in `--json`:

| Reason | Cause |
| --- | --- |
| `dynamic-ref` | A non-literal template ref such as `{% url view_name %}`. |
| `unknown-url` | A literal URL that matches no extracted route. |
| `no-import-chain` | A changed asset with no tracked template or importer. |
| `binary/unsupported` | A legacy/free-text unresolved reason classified as an unsupported input. |

Dynamic refs and unmatched URLs never produce linked rows.

One known gap: a `{% url %}` tag that carries a quoted route name plus context
arguments (for example `{% url 'order-detail' order.pk %}`) is currently
skipped entirely -- it produces neither a linked row nor an unresolved audit
entry, so the audit line does not count it.

### Reachability

When the repository has entry points (Django/FastAPI routes and Django management commands), each Python consumer is checked for reachability through a forward callee walk (depth cap 5, node cap 500). A consumer no entry point reaches is downgraded exactly one severity level (High to Medium, Medium to Low, Low unchanged), marked `reachability: "unreachable"`, and still listed with `--all`. Reachable consumers keep their severity.

### Performance budget and cache

The full-mode regression guard runs a 200-file synthetic repository with `MAX_SECONDS = 30` (measured around 0.1s cold and warm on development hardware). `--fast` remains an import-linked subset that agrees with full mode on High rows for the route-free scale fixture. `cache_hit_rate` in `--json` reports the symbol-cache hit ratio for the analyzed files (`0.0` cold, `1.0` fully warm).

### Risk ranking and owners

Rows are ranked by a per-row risk score, highest first, with the path as a tiebreak:

```
risk = 0.4 * severity + 0.3 * fan_in + 0.2 * missing_test + 0.1 * churn
```

All terms are 0-1. `severity` maps High to 1.0, Medium to 0.5, Low to 0.0. `fan_in` is the number of distinct referencing files for the symbol, normalized by the largest fan-in in the run. `missing_test` is 1 when no conventional `tests/test_<stem>.py` exists for the impacted Python file, else 0 (non-Python rows score 0). `churn` is reserved and currently 0. The same ranking orders the Top impacted paths in the summary header, where each entry is shown as `path (owner)`.

`owner` is resolved from a `CODEOWNERS` file at the repository root. The last matching pattern wins, the leading `@` is stripped, and rows with no match (or no `CODEOWNERS` file) show as `unowned`.

### Breaking-change rules

Changes that are hard to undo are surfaced under the `Breaking` section as High rows (a pure addition emits none):

- A deleted test file (or a test file with more than half its lines removed) reports `deleted test coverage` with the action `restore or justify removal`.
- A removed export or a changed signature reports `breaking API change`.
- A touched migration or infra file (a `migrations/` path, `.github/`/`.circleci/` CI config, or a lockfile) reports `migration/infra change - needs rollout note`.

### Reverse chains

When the change-set touches no Python (or in addition to changed Python symbols), `impact` also runs reverse chains (`impact.reverse` must be on; they are skipped entirely under `impact --fast`):

- **Template chain:** a changed Django template is matched (by exact template name) to the Python functions that render it, then through the existing route and frontend linking; templates that extend the changed template are reported too. Dynamic `{% include var %}` tags land in "unresolved"; dynamic `{% extends %}` targets are ignored.
- **Asset chain:** a changed tracked JS/TS file is matched to the templates that load it (`script src`, `{% static %}`, `link href`), then to the views rendering those templates. A changed tracked asset with no referencing template lands in "unresolved"; untracked targets (bundles, CDNs) are ignored, never guessed.
- **Import chain:** changed JS/TS exports are matched to other tracked frontend files that import them; deleted exports are flagged High. Minified and vendored files are excluded; unresolvable path aliases are skipped.
- **Endpoint chain (experimental):** changed frontend HTTP literals are matched to Django routes, the handler view, and the service/utility functions it calls. Enable with `"experimental": true` in the `impact` section; advisory only, and never a sole grounds for blocking.

Reverse reports are confidence-tiered: **frontend** impacts (the changed
template/asset/export and its direct frontend references) are shown first,
then **backend impacts that are certainly affected** (functions that render a
changed template through a known render API). Transitive descendants, render
sites of descendant templates, and route bindings are hidden by default and
summarized as a hidden count.

- `--expand` opens rolled-up rows (e.g. "35 templates directly extend it") to
  list their child files.
- `--all` additionally shows the lower-confidence (heuristic/transitive) tier.

## Checks reference

All checks are incremental: file-scoped checks run on files in the change-set, and line-scoped checks flag only changed lines.

| id | What it checks | Blocks by default? |
| --- | --- | --- |
| `hygiene/mixed-line-ending` | Converts CRLF/CR to LF (`--fix=lf` only); auto-fixes files in place. | No (fixes) |
| `hygiene/trailing-whitespace` | Strips trailing spaces/tabs and trailing blank lines; auto-fixes files in place. | No (fixes) |
| `hygiene/end-of-file-fixer` | Ensures files end with exactly one newline; auto-fixes files in place. | No (fixes) |
| `syntax/json-syntax` | Validates that `.json` files parse. | No |
| `syntax/ast-syntax` | Validates that `.py` files parse as Python. | No |
| `syntax/merge-conflict` | Flags `<<<<<<<` / `=======` / `>>>>>>>` conflict markers on changed lines. | No |
| `repo/large-files` | Flags files over `--maxkb` (default 250000). | No |
| `repo/private-key` | Flags files containing private-key material (RSA, EC, OpenSSH, DSA, PGP blocks). | **Always** |
| `ruff` | Runs `ruff check` on changed lines of `.py` files (needs `ruff`). | No |
| `ruff-format` | Runs `ruff format --check` on changed lines (needs `ruff`). | No |
| `eslint` | Runs `npx eslint` on changed files, honoring a baseline (needs `eslint`). | No |
| `prettier` | Runs `npx prettier --check` on changed files under `src/` or `app/`, honoring a baseline (needs `prettier`). | No |

"Always" means the check has `always_block: true`, so `repo/private-key` fails the guard even in warn mode: a private key should never be pushed. Every other blocking check only fails the run in strict mode (`guard.blocking: "strict"` or `IMPACT_CHECK_STRICT=1`).

## Requirements

- Python 3.8 or newer.
- Standard library only at runtime; no third-party dependencies.
- The `git` CLI on `PATH`.
- Optional: `ruff`, `eslint`, and `prettier` for their respective checks (missing tools degrade to warnings).

## FAQ

**Why is nothing failing?**
DiffImpactScout is warn-by-default. `guard` reports issues but exists `0` unless a `repo/private-key` hit, or strict mode is active. The same applies to `impact` in non-interactive contexts.

**How do I make it block?**
Set `IMPACT_CHECK_STRICT=1` (or any truthy value), or set `"blocking": "strict"` in the `guard` section of `.diffimpactscout.json`. You can also force specific checks to always block with `"always_block": true` on a check entry.

**I want to skip the guard, how?**
Export either `DIFFIMPACTSCOUT_SKIP` or `IMPACT_CHECK_SKIP` (any value); both `guard` and `impact` will exit `0` without doing anything.

**I already have a pre-push hook.**
`install-hooks` refuses to overwrite a hook it did not install. Review the existing hook and re-run with `--force` to replace it, or call `guard` from your own hook.

**My templates are in nested or unusual locations.**
Set `impact.template_globs` (and `frontend_globs`) in the config to cover your layout; use the `django` profile for a sensible starting point.

**The hygiene checks modified my files.**
That is by design: mixed line endings, trailing whitespace, and missing final newlines are fixed in place. Checks that can fix do so rather than failing; run `diffimpactscout check hygiene/end-of-file-fixer <path>` to apply one fixer to specific files.

## License

MIT. See [LICENSE](LICENSE).
