Examples
Worked, runnable examples. Each one states the goal, the exact command, the output you should expect, and what to do with it.
Every example assumes the scanner and the model bundle are already in place (see install.md):
brew install llama.cpp
pip install precommiteu
hf download AlexandruGirlea/precommiteu-models base.gguf gdpr/detector-adapter.gguf --local-dir ~/.precommiteu/models
export PRECOMMITEU_MODELS_DIR=~/.precommiteu/models
Model output is generated, not templated. Article ids, line ranges and quoted evidence are stable; the wording of a description varies between runs. The outputs below are representative, not byte-exact.
1. First scan on a real project
Goal. Find out what precommitEU says about your code, against one regulation, without gating anything yet.
Start with the file selection. --dry-run loads no model and finishes instantly:
precommiteu scan src/ --dry-run
src/api/handlers.py
src/billing/invoices.py
src/store/user_store.py
src/store/models.py
That is exactly the set a real scan will process. Test files, docs, generated files, binaries, dependency directories and anything matched by .eu-ignore are already gone. If the list is empty, jump to example 2: the filter is the usual reason.
Now the scan:
precommiteu scan src/ --show-advisories --json-out result.json
Progress goes to stderr, one line per event:
[scan_start] files_total=4
[detector_adapter] regulation=gdpr adapter=gdpr/detector-adapter.gguf
[file_start] src/api/handlers.py regulation=gdpr
[orchestrator_done] src/api/handlers.py regulation=gdpr kept_raw=0 exit_reason=direct
[file_done] src/api/handlers.py regulation=gdpr kept=0
[file_start] src/store/user_store.py regulation=gdpr
[orchestrator_done] src/store/user_store.py regulation=gdpr kept_raw=1 exit_reason=emit
[finding] src/store/user_store.py
[file_done] src/store/user_store.py regulation=gdpr kept=1
[scan_done] findings_total=1
The summary goes to stdout:
Findings: 1
[gdpr] src/store/user_store.py:50 gdpr_art32
Full identity record is written to the application log.
Advisories (unconfirmed, non-blocking): 2
[gdpr] src/billing/invoices.py: Customer records exported without a retention limit
[gdpr] src/api/handlers.py: Request payload logged before field filtering
What to do with it. Read the findings first: each one is backed by a verbatim quote from your file, so it takes seconds to agree or disagree. Advisories are a reading list, not a task list. Nothing failed here: without --fail-on-findings the exit code is 0 even with findings present.
Budget roughly a minute per file on CPU. Scan a directory, not the whole repo, on the first run.
2. A cross-file finding, end to end
Goal. See the scanner catch a violation that only exists when two files are read together.
The repository ships the pair as a test fixture: tests/fixtures/risky_code/models.py and tests/fixtures/risky_code/user_store.py.
models.py defines the record and a formatting helper:
def audit_line(self) -> str:
return (
f"{self.full_name} <{self.email}> national_id={self.national_id} "
f"dob={self.date_of_birth.isoformat()} address={self.home_address}"
)
user_store.py line 50 calls it:
def save_user(self, profile: UserProfile) -> None:
logger.info("saving profile: %s", profile.audit_line())
Neither line is alarming alone. Together they write a full identity record (name, email, national id, date of birth, home address) into the application log.
Read this before you try it: the fixture directory is not scannable
File discovery deliberately skips test and fixture paths: tests/, test/, spec/, fixtures/, testdata/, e2e/, plus *_test / *.spec basenames. Scanning the fixture in place returns nothing at all:
precommiteu scan tests/fixtures/risky_code --dry-run
Empty output, exit code 0, files_total=0 on a real run. This is not a bug and there is no flag to switch it off. Copy the two files somewhere that looks like application code first:
mkdir -p /tmp/pceu-demo/app && cp tests/fixtures/risky_code/models.py tests/fixtures/risky_code/user_store.py /tmp/pceu-demo/app/
cd /tmp/pceu-demo && precommiteu scan app --dry-run
app/models.py
app/user_store.py
Two files selected. Now it is scannable.
The scan
cd /tmp/pceu-demo && precommiteu scan app --json-out result.json --out summary.md
[scan_start] files_total=2
[detector_adapter] regulation=gdpr adapter=gdpr/detector-adapter.gguf
[file_start] app/models.py regulation=gdpr
[orchestrator_done] app/models.py regulation=gdpr kept_raw=0 exit_reason=direct
[file_done] app/models.py regulation=gdpr kept=0
[file_start] app/user_store.py regulation=gdpr
[orchestrator_done] app/user_store.py regulation=gdpr kept_raw=1 exit_reason=emit
[finding] app/user_store.py
[file_done] app/user_store.py regulation=gdpr kept=1
[scan_done] findings_total=1
Findings: 1
[gdpr] app/user_store.py:50 gdpr_art32
Personal data (full name, email, national id, date of birth, home
address) is written to the application log via audit_line(), with no
masking or access control on the log sink.
Two things in that trace are worth understanding.
models.py took the direct route (exit_reason=direct): it mentions no sibling file, so the fast two-call path is enough. user_store.py imports models, so the sibling stem models appears as a whole word in the file text, and auto routing escalated it to the orchestrator, which read models.py before deciding. That is the whole cross-file mechanism: structural escalation, sandboxed to the scanned file’s own directory.
The finding is filed against user_store.py, not models.py. The rule is that a confirmed finding must quote code that is actually visible in the file being judged. audit_line() on its own formats a string; the call on line 50 is what sends it to the log.
The evidence
jq '.findings[0]' result.json
{
"regulation": "gdpr",
"source": "precommiteu",
"file": "app/user_store.py",
"start_line": 50,
"end_line": 58,
"probable_article_id": "gdpr_art32",
"code_evidence": "logger.info(\"saving profile: %s\", profile.audit_line())",
"description": "Personal data (full name, email, national id, date of birth, home address) is written to the application log via audit_line(), with no masking or access control on the log sink.",
"eu_ignore_reason": null,
"eu_ignore_source": null
}
code_evidence is the verbatim line 50. If the validator had returned a quote that could not be located in the analyzed text, the finding would have been dropped with the reason evidence_not_visible rather than reported. That is the trade the scanner makes: fewer findings, each one checkable in one glance.
summary.md holds the same result as a PR comment: a ## precommitEU heading, a count line, and one table row per finding.
**1 finding** across GDPR.
| Location | Article | Evidence | Description |
| --- | --- | --- | --- |
| app/user_store.py:50-58 | [GDPR Art. 32](https://eur-lex.europa.eu/LexUriServ/LexUriServ.do?uri=CELEX:32016R0679:EN:HTML) | `logger.info("saving profile: %s", profile.audit_line())` | Personal data ... is written to the application log ... |
What to do with it. Fix it (log the user id, not the record), or, if the sink is genuinely access-controlled and reviewed, suppress it with an audited marker. Example 7 shows how.
3. Several regulations in one run
Goal. Cover more than GDPR without drowning in output.
precommiteu scan src/ --regulations gdpr,eu_ai_act
The pack names are exactly: gdpr, eu_ai_act, eu_data_act, dora, dsa, cra_dma_nis2. gdpr is the default. A typo is a configuration error, not a silent no-op:
precommiteu scan src/ --regulations gdrp
error: regulation 'gdrp' not packaged in precommiteu.regulations
Exit code 2.
Why “all six” is worse, not better
precommiteu scan src/ --regulations gdpr,eu_ai_act,eu_data_act,dora,dsa,cra_dma_nis2
That command is valid and it is usually a mistake.
- Cost is linear in packs. Every selected file is scanned once per regulation, with that regulation’s detector adapter. Six packs is six passes over the same files, so roughly six times the wall-clock.
- Noise is worse than linear. Each pack has its own detector, and a detector that finds nothing still produces candidates. Candidates that fail validation become advisories. Running a pack that does not apply to your product adds advisories about obligations you do not have, and those are the ones people learn to ignore, which is how they stop reading the real ones.
- Findings stay honest, attention does not. Only confirmed, evidence-backed findings gate a build, so extra packs will not fail your PR spuriously. They will still cost you review time.
Pick the packs that match what the code actually does. A CRUD product with personal data wants gdpr. Add eu_ai_act when you ship model inference, dora in financial services, dsa for a platform with user-generated content, eu_data_act for connected-product or cloud-switching obligations, cra_dma_nis2 for product security, gatekeeper or critical-infrastructure duties.
One constraint to know: --detector-adapter points at a single file, so it cannot be combined with multiple regulations. Omit it and let each pack resolve its own adapter from the models directory.
error: a single --detector-adapter can't be combined with multiple regulations; omit it to auto-resolve per regulation
4. Gating a pull request
Goal. Fail a PR that introduces a confirmed violation, and show the finding in the code-scanning UI.
The scanner reads exactly one CI variable, GIT_MERGE_TARGET_BRANCH. Wire your platform’s variable into it:
GIT_MERGE_TARGET_BRANCH="${GITHUB_BASE_REF:-main}" precommiteu scan --ci --fail-on-findings --sarif precommiteu.sarif --out pr-summary.md
--ci scans only files added or modified in git diff --name-only --diff-filter=AM <target>...HEAD, so a PR touching three files costs three files of inference, not a whole repo. It also turns on --fail-on-error implicitly, so a scan that could not read a file never reports a clean pass.
As a GitHub Actions workflow:
name: compliance-scan
on: pull_request
permissions:
contents: read
security-events: write
jobs:
precommiteu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: AlexandruGirlea/precommiteu@v0.1.0
with:
regulations: gdpr
fail-on-findings: "true"
- uses: github/codeql-action/upload-sarif@v3
if: $
with:
sarif_file: precommiteu.sarif
category: precommiteu
Two details that decide whether this works.
fetch-depth: 0 gives the runner a merge base. A shallow clone cannot resolve the target branch, and the scan exits 2.
if: $ on the upload step matters because the scan exits 1 when it finds something. Without it, the failing run never uploads the SARIF and reviewers see a red check with no detail.
What the job does on a PR that adds the logger.info line from example 2:
Findings: 1
[gdpr] app/user_store.py:50 gdpr_art32
Personal data is written to the application log ...
Exit code 1, the check fails, and the finding appears in the Security tab against GDPR Art. 32 with a link to the article text.
| Exit | Meaning | What the PR author does |
|---|---|---|
| 0 | No confirmed findings | Nothing |
| 1 | Confirmed findings remain | Fix the code, or add an audited marker (example 7) |
| 2 | Usage or configuration error | Fix the workflow: shallow clone, wrong pack name, missing model |
| 3 | A file could not be scanned | Re-run; results were incomplete, so a pass would have been a lie |
| 130 | Interrupted | Partial reports are still on disk |
Advisories never appear in this table. They cannot fail a build.
5. Machine-readable output
Goal. Feed the result to something other than a human.
All four report flags can be combined in one run. Each writes a different artifact:
precommiteu scan src/ --json-out result.json --sarif precommiteu.sarif --out pr-summary.md --report scan-events.jsonl --log-file scan.log
| Flag | Format | Consumed by |
|---|---|---|
--json-out | JSON | Your own tooling, dashboards, policy checks. The complete result: findings (including suppressed ones), advisories, per-regulation statuses |
--sarif | SARIF 2.1.0 | GitHub code scanning, GitLab SAST, any SARIF viewer. Findings only, and only those whose article resolves in the pack registry |
--out | Markdown | A PR comment, posted verbatim. Findings table only |
--report | JSONL | Audit trails and debugging. Append-only ledger of every event in the order it happened |
--log-file | Text | Operators. Same events plus warnings and errors, timestamped. Written to precommiteu_scan.log by default |
--json-out, --sarif and --out are snapshots, rewritten atomically after every file and every finding, so a cancelled or crashed run still leaves valid, partial files. --report is an append-only stream and never overwrites a previous run.
Useful queries:
jq -r '.findings[] | "\(.file):\(.start_line) \(.probable_article_id)"' result.json
app/user_store.py:50 gdpr_art32
Count findings that would actually gate a build (suppressed ones do not):
jq '[.findings[] | select(.eu_ignore_reason == null)] | length' result.json
Check the scan was complete before trusting a clean result:
jq -r '.statuses[] | "\(.regulation) \(.status) chunks=\(.chunks_scanned) candidates=\(.detector_candidates) rejected=\(.validator_rejected)"' result.json
gdpr scanned chunks=2 candidates=3 rejected=2
Find out which route each file took, from the ledger:
jq -r 'select(.event == "orchestrator_done") | "\(.payload.file) \(.payload.route) tools=\(.payload.tool_calls)"' scan-events.jsonl
app/models.py direct tools=0
app/user_store.py orchestrator tools=3
Post the summary as a PR comment:
gh pr comment "$PR_NUMBER" --body-file pr-summary.md
6. Using the Python library
Goal. Run the same engine from your own script and do something custom with the results.
Each call starts the local inference servers it needs and shuts them down before returning. There is no server lifecycle to manage.
import os
from precommiteu import scan_paths
os.environ["PRECOMMITEU_MODELS_DIR"] = "/opt/precommiteu/models"
result = scan_paths(["app/"], regulations=("gdpr",))
for f in result.findings:
if f.eu_ignore_reason:
print(f"suppressed {f.file}:{f.start_line} ({f.eu_ignore_reason})")
continue
print(f"{f.file}:{f.start_line}-{f.end_line} [{f.probable_article_id}]")
print(f" {f.description}")
if f.code_evidence:
print(f" evidence: {f.code_evidence}")
for a in result.advisories:
print(f"advisory {a.file}: {a.description}")
blocking = [f for f in result.findings if f.eu_ignore_reason is None]
raise SystemExit(1 if blocking else 0)
app/user_store.py:50-58 [gdpr_art32]
Personal data is written to the application log via audit_line().
evidence: logger.info("saving profile: %s", profile.audit_line())
code_evidence is None on findings with source == "retrieval": those are advisories promoted by a high-confidence match against known violation cases, so there is no quoted line. Guard for it, as above.
Scanning a branch diff instead of a path:
from precommiteu import GitDiffError, scan_diff
try:
result = scan_diff(merge_target="main", agent_mode="direct")
except GitDiffError as exc:
print(f"diff scan unavailable: {exc}")
raise SystemExit(2)
if not result.findings:
print("clean")
GitDiffError covers the three real failure modes: the working directory is not a git repository, the merge target resolves as neither main nor origin/main, or git diff itself failed. When nothing changed, scan_diff returns immediately with empty findings and a status whose detail is "no files changed vs merge target", without loading a model.
Streaming, when you want output before the scan ends:
from precommiteu import scan_paths
def on_progress(event):
if event["event"] == "file_done":
print(f"{event['file']}: {event['kept']} finding(s)")
def on_finding(f):
print(f"FINDING {f.file}:{f.start_line} {f.probable_article_id}")
result = scan_paths(["app/"], on_progress=on_progress, on_finding=on_finding)
Exceptions raised inside a callback are logged and swallowed. They never abort the scan.
7. Suppressing a reviewed false positive
Goal. Stop a finding from failing the build, while keeping a record that a human decided it was acceptable.
Take the finding from example 2. Suppose the log sink is access-controlled, the DPO signed off, and there is a ticket. Add an audited marker on the line:
def save_user(self, profile: UserProfile) -> None:
logger.info("saving profile: %s", profile.audit_line()) # precommiteu-ignore: gdpr_art32 reason="Restricted audit sink, DPO sign-off SEC-1142"
Syntax: precommiteu-ignore: <article-rule> reason="<non-empty text>". The rule may be an exact article id (gdpr_art32) or an fnmatch glob over article ids (gdpr_art3*). The reason is mandatory: a marker without one is ignored. A bare wildcard is rejected with a warning, because blanket inline suppression defeats the point:
::warning::precommitEU: inline wildcard marker `precommiteu-ignore: *` rejected at line 50 (wildcard inline ignores are not supported).
The marker suppresses a confirmed finding citing a matching article whose line range falls within 2 lines of the marker. Keep it on or beside the offending line, not at the top of the file.
Re-run with gating on:
cd /tmp/pceu-demo && precommiteu scan app --fail-on-findings --json-out result.json ; echo "exit=$?"
No findings.
exit=0
The build passes. The record does not disappear:
jq '.findings[] | {file, probable_article_id, eu_ignore_reason, eu_ignore_source}' result.json
{
"file": "app/user_store.py",
"probable_article_id": "gdpr_art32",
"eu_ignore_reason": "Restricted audit sink, DPO sign-off SEC-1142",
"eu_ignore_source": "inline"
}
The same is true in SARIF, where the result carries properties.eu_ignored: true and properties.eu_ignore_reason.
So: removed from the console, removed from the --fail-on-findings exit check, still in the reports with the reason attached. An auditor can list every suppression in the repo from one file:
jq -r '.findings[] | select(.eu_ignore_reason != null) | "\(.file):\(.start_line) \(.probable_article_id) :: \(.eu_ignore_reason)"' result.json
When you want no trace at all
The inline redaction directives are a different mechanism. They blank the source before the model ever sees it, preserving line numbers:
API_KEY = load_key() # eu-ignore
# eu-ignore-next-line
send_email(user.address)
# eu-ignore-next-lines: 3
copy_a()
copy_b()
copy_c()
# eu-ignore-start
legacy_export_block()
# eu-ignore-end
# eu-ignore-file anywhere in a file skips the whole file. eu-ignore-next-lines accepts : or = before the count.
These leave nothing in any report: no finding, no reason, no audit trail. Use them for code the scanner has no business reading. Use audited markers for decisions you may need to defend.
8. Excluding vendored or generated code
Goal. Keep third-party and machine-written code out of the scan entirely.
Create .eu-ignore in the directory you run the scan from:
# .eu-ignore
third_party/
vendor/
/scripts/legacy_import.py
*.pb.go
migrations/**/seed_*.py
Rules, in the order they usually bite:
| Pattern | Effect |
|---|---|
third_party/ | Trailing slash restricts the match to directories. Any directory named third_party, at any depth |
/scripts/legacy_import.py | Leading slash anchors the pattern to the scan root |
*.pb.go | No slash in the pattern, so it matches any path component |
migrations/**/seed_*.py | ** matches any number of directories in slash-containing patterns |
# comment | Comments and blank lines are skipped |
Negation lines (!pattern) are not supported. They are parsed and silently skipped, so a file re-included by !keep_this.py stays excluded. There is no warning. Do not build an ignore file that depends on negation.
Verify before you trust it. --dry-run applies .eu-ignore and prints the survivors:
precommiteu scan . --dry-run | head -20
Note that a lot of exclusion is already automatic and does not belong in .eu-ignore: node_modules, vendor, dist, build, target, .venv, __pycache__, coverage, .terraform, generated, lockfiles, minified and .pb.* bundles, binaries, prose files, docs/, and every test and fixture path. Add patterns for what your repo does that the defaults cannot guess.
9. Speeding up a large repository
Goal. Get a useful answer out of a big codebase without waiting hours.
The single biggest lever is scanning fewer files. The second is skipping the orchestrator.
precommiteu scan src/billing src/identity --agent-mode direct --max-wall-seconds-per-file 45 --max-orchestrator-iterations 6 --regulations gdpr
| Lever | Flag | Effect |
|---|---|---|
| Scope | positional paths | Scan the directories that touch personal data, not the repo |
| Diff only | --ci | Only files changed vs the merge target |
| Route | --agent-mode direct | Fixed detector plus validator per file, no tool use, no cross-file reads. Fastest path |
| Time budget | --max-wall-seconds-per-file 45 | Hard wall-clock cap per file (default 90 s) |
| Steps budget | --max-orchestrator-iterations 6 | Cap on agent steps per file (default 12) |
| Size cutoff | --max-file-bytes 200000 | Skip large files (default 1000000; 0 removes the limit) |
| Packs | --regulations gdpr | One pass per pack, so one pack is one pass |
| GPU | --gpu-layers 99 | Default. Offloads everything if your llama-server build has CUDA or Metal |
What direct costs you: the cross-file case from example 2. user_store.py would be judged on its own text, without reading models.py. Use direct for a broad first pass, then re-run the interesting directories on auto.
Budgets are per file and hard: 12 orchestrator iterations, 90 seconds of wall-clock, and an 8000-token cap on every message sent to the model. A file that exhausts its budget is reported, not silently dropped, and a file that fails to scan raises the exit code to 3 under --fail-on-error.
Confirm the workload before committing to it:
precommiteu scan src/ --dry-run | wc -l
Multiply by roughly a minute per file on CPU. If that number is unpleasant, scope harder or move the whole-repo scan to a nightly job on a GPU machine and keep --ci on pull requests.
10. The local pre-commit loop
Goal. Catch a violation before it becomes a PR, without waiting a minute per commit.
Scan only what is staged, on the direct route:
git diff --cached --name-only --diff-filter=AM | xargs -r precommiteu scan --agent-mode direct --fail-on-findings
xargs -r matters: precommiteu scan with no paths and no --ci is a usage error (exit 2), so an empty staged set must not invoke it at all.
As a git hook, .git/hooks/pre-commit:
#!/usr/bin/env bash
set -eu
files=$(git diff --cached --name-only --diff-filter=AM)
[ -n "$files" ] || exit 0
echo "$files" | xargs precommiteu scan --agent-mode direct --fail-on-findings --progress none --json-out .git/precommiteu-last.json
chmod +x .git/hooks/pre-commit
A commit that stages the logger.info line from example 2:
Findings: 1
[gdpr] app/user_store.py:50 gdpr_art32
Personal data is written to the application log via audit_line().
The commit is refused. Fix the line, or add the audited marker from example 7, and commit again.
With the pre-commit framework, as a local hook in .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: precommiteu
name: precommitEU compliance scan
entry: precommiteu scan --agent-mode direct --fail-on-findings --progress none
language: system
pass_filenames: true
types_or: [python, java, javascript, ts, tsx, go]
pass_filenames: true appends the staged files as positional paths, which is exactly what scan expects. pre-commit does not run a hook when its file set is empty, so no empty-invocation guard is needed here.
Three habits that keep this loop tolerable:
--agent-mode directon commit,autoin CI. Local speed, thorough gate.- One regulation locally. Add packs in CI where the wait is someone else’s.
--progress noneso the hook prints findings and nothing else.
If the loop is still too slow for your commit rhythm, drop the hook and rely on --ci in the pull request. A gate that people disable is worse than a gate that runs later.