Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Two plugins may declare the same symbol: an identical model is registered once and sinks merge their kinds and positions; a real conflict names both plugins instead of failing the run on the first duplicate (#92).
- A plugin's entrypoint may be a package, `<module>/__init__.py`, so a plugin can split its tables across files and import them relatively (#93).
- The cache key covers every file of a plugin directory, not only `.py` and `.toml`, so rules or advisories shipped as data invalidate cached results when they change (#94).

## 0.2.0 (2026-09-06)

Expand Down
5 changes: 3 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,9 @@ coretrace-python-analyzer --check src/ --sbom sbom.json --policy security/policy
## Large projects

`--cache DIR` keeps the results of a directory check on disk, one entry per module,
keyed by the module's source, the tool and plugin versions, the security models, the
advisories, the dependency graph and the modules it imports. On the next run an unchanged
keyed by the module's source, the tool version, every file of every plugin directory
(code, manifest and data alike), the security models, the advisories, the dependency
graph and the modules it imports. On the next run an unchanged
module is served from the cache, so editing one file re-analyses that file and its
importers only. Entries are plain data; an unreadable entry is recomputed.

Expand Down
17 changes: 10 additions & 7 deletions src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import hashlib
import json
import os
from collections.abc import Iterable, Mapping
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
Expand Down Expand Up @@ -64,15 +64,18 @@ def fingerprint(*parts: str) -> str:
return digest.hexdigest()


def directory_fingerprint(directory: Path, suffixes: Iterable[str] = (".py", ".toml")) -> str:
"""A digest of the source files under ``directory``, so edited plugin code misses."""
def directory_fingerprint(directory: Path) -> str:
"""A digest of every file under ``directory``, so an edited plugin misses: its code,
its manifest and the rule or advisory data it ships in any format. Bytecode and
hidden files are not part of what the plugin does."""

wanted = tuple(suffixes)
parts: list[str] = []
for path in sorted(directory.rglob("*")):
if path.is_file() and path.suffix in wanted:
parts.append(str(path.relative_to(directory)))
parts.append(path.read_text(encoding="utf-8", errors="replace"))
relative = path.relative_to(directory)
if not path.is_file() or any(p == "__pycache__" or p.startswith(".") for p in relative.parts):
continue
parts.append(str(relative))
parts.append(path.read_bytes().hex())
return fingerprint(*parts)


Expand Down
20 changes: 20 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import json
import shutil
from pathlib import Path

import pytest
Expand Down Expand Up @@ -119,6 +120,25 @@ def test_keys_depend_on_dependency_files(tmp_path: Path) -> None:
assert all(after[name] != before[name] for name in before)


def test_keys_depend_on_every_plugin_file(tmp_path: Path) -> None:
root = standard(tmp_path)
plugins = tmp_path / "plugins"
shutil.copytree(
PLUGINS / "syntax" / "dangerous_eval",
plugins / "dangerous_eval",
ignore=shutil.ignore_patterns("__pycache__"),
)
before = engine.analyze_project(root, [plugins]).keys
(plugins / "dangerous_eval" / "rules.json").write_text('{"v": 1}', encoding="utf-8")
after = engine.analyze_project(root, [plugins]).keys
(plugins / "dangerous_eval" / "__pycache__").mkdir(exist_ok=True)
(plugins / "dangerous_eval" / "__pycache__" / "x.pyc").write_bytes(b"\0")
with_bytecode = engine.analyze_project(root, [plugins]).keys

assert all(after[name] != before[name] for name in before)
assert with_bytecode == after


# --------------------------------------------------------------------------- reuse


Expand Down
Loading