Skip to content
Closed
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 @@ -5,6 +5,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.18 (unreleased)

- Fix: the semantic cache no longer replays extractions from an older prompt after an upgrade (#1939, thanks @HunterMcGrew and @SinghAman21). Entries were keyed on `sha256(file content + path)` alone, with no component for the extraction prompt that produced them, so a release that changed the prompt left every unchanged file a cache hit: the run exited 0, `cost.json` looked cheap, and the graph silently carried two prompt generations side by side. Semantic entries are now namespaced by a fingerprint of the extraction prompt (`cache/semantic/p{fingerprint}/`, mirroring the AST cache's `v{version}/` layout), keeping both properties #1252 wanted — entries survive releases that don't touch the prompt, and invalidate only when it actually changed. The fingerprint normalizes line endings so a CRLF checkout doesn't look like a prompt change. Both extraction paths pass their prompt: the Python/CLI path (`llm.py`'s `_EXTRACTION_SYSTEM`, all backends) automatically, and the skill path via a new `prompt_file` argument in Step B0/B3 pointing at the `references/extraction-spec.md` the subagents were handed. Pre-existing entries predate fingerprinting and have unknowable vintage: they are still served rather than re-billing a whole corpus, but `check_semantic_cache` now warns with the count, so the "no signal at all" the report describes becomes a visible one; `--force` (or `GRAPHIFY_FORCE=1`) re-extracts them. Old-fingerprint entries are pruned by liveness only, never swept wholesale the way stale AST versions are — two hosts with different prompts can share one `graphify-out/`, and a wholesale sweep would have each run delete the other's entries. (The two monolith skills, aider and devin, inline their prompt instead of shipping a spec sidecar and stay on the unfingerprinted path for now.)
- Fix: the Stage 1 sensitive-directory check no longer silently drops legitimate source under `secrets/` or `credentials/` directories (#1943, thanks @HerenderKumar). A directory named `secrets/`, `.secrets/`, or `credentials/` is as often a real source package (Go `internal/secrets`, a `credentials/` service module) as a credential store, but `_is_sensitive` pruned everything beneath one wholesale, with no trace and no override. The dir list is now split: dedicated credential stores (`.ssh`, `.gnupg`, `.aws`, `.gcloud`) still drop everything unconditionally, while the ambiguous bare-name dirs spare genuine programming-language source — the same carve-out Stage 3 applies to keyword-named files (#1666), extracted into a shared `_is_graphable_source` predicate so the two stages can't drift. Rescued source still falls through the Stage 2/3 filename screens (`secrets/service_account.py` and `credentials/id_rsa` stay dropped), and data/config formats under those dirs (`secrets/db.json`, `.secrets/token.yaml`) remain flagged — those are exactly the formats credentials ship in.
- Fix: PostgreSQL foreign-key `references` edges are no longer dropped when a routine in the same schema is unparseable (#1854, thanks @sekmur). `pg_introspect` builds one synthetic DDL document and parsed it with the function stubs emitted before the FK `ALTER TABLE`s, so a C-language (or otherwise unparseable) routine's stub parsed as a tree-sitter ERROR node that swallowed the trailing FK statements into the error region, losing every FK edge after it. The FK DDL is now emitted before the function stubs, so table-to-table `references` edges are produced first and can't be eaten by a later unparseable routine.

## 0.9.17 (2026-07-16)
Expand Down
52 changes: 39 additions & 13 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,21 @@ def _zip_within_caps(path: Path) -> bool:
return False
return True

# Parent directories whose contents are always sensitive.
# Checked against path.parts[:-1] (parents only) so a root-level file named
# "credentials" or "secrets" is not falsely flagged by this stage.
_SENSITIVE_DIRS = frozenset({
".ssh", ".gnupg", ".aws", ".gcloud", "secrets", ".secrets", "credentials",
# Dedicated credential-store directories: everything beneath them is sensitive,
# with no carve-out — a .py inside ~/.ssh or ~/.aws is tooling for key material,
# not a source package, and keys there are routinely extensionless.
# Both sets are checked against path.parts[:-1] (parents only) so a root-level
# file named "credentials" or "secrets" is not falsely flagged by this stage.
_CREDENTIAL_STORE_DIRS = frozenset({
".ssh", ".gnupg", ".aws", ".gcloud",
})

# Bare-name directories that are as often legitimate source packages (Go
# internal/secrets, a credentials/ service module) as credential stores. Their
# contents are sensitive EXCEPT genuine programming-language source, mirroring
# the Stage 3 keyword carve-out (#1666) at the directory level (#1943).
_AMBIGUOUS_SENSITIVE_DIRS = frozenset({
"secrets", ".secrets", "credentials",
})

# Files that may contain secrets - skip silently. These patterns are specific
Expand Down Expand Up @@ -126,9 +136,11 @@ def _zip_within_caps(path: Path) -> bool:
]

# Data/serialization extensions that commonly ARE secret stores when their name
# hits a generic keyword (credentials.json, secrets.yaml, token.toml). These stay
# subject to the Stage 3 keyword drop even though some route through the CODE path
# for manifest parsing — only real programming-language source is exempt (#1666).
# hits a generic keyword (credentials.json, secrets.yaml, token.toml) or they sit
# in an ambiguous sensitive dir (secrets/db.json). These stay subject to the
# Stage 1 ambiguous-dir drop and the Stage 3 keyword drop even though some route
# through the CODE path for manifest parsing — only real programming-language
# source is exempt (#1666, #1943).
_SECRET_PRONE_DATA_EXTS = frozenset({
".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".config",
".xml", ".properties", ".env", ".txt",
Expand Down Expand Up @@ -182,12 +194,28 @@ def _generic_keyword_hit(name: str) -> bool:
_PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a paper


def _is_graphable_source(path: Path) -> bool:
"""True for genuine programming-language source — the only category exempt
from the ambiguous-dir (Stage 1, #1943) and generic-keyword (Stage 3, #1666)
drops. Data/serialization formats are NOT exempt even though some route
through the CODE path for manifest parsing: credentials.json / secrets.yaml
are exactly the stores those stages must keep catching.
"""
return classify_file(path) == FileType.CODE and path.suffix.lower() not in _SECRET_PRONE_DATA_EXTS


def _is_sensitive(path: Path) -> bool:
"""Return True if this file likely contains secrets and should be skipped."""
# Stage 1: any PARENT directory is a known secrets dir (parts[:-1] excludes
# the filename itself so a root-level file named "credentials" is not falsely
# skipped — the name patterns in Stage 2 handle the filename).
if any(part in _SENSITIVE_DIRS for part in path.parts[:-1]):
# skipped — the name patterns in Stage 2 handle the filename). Dedicated
# credential stores drop everything unconditionally; ambiguous bare-name dirs
# (secrets/, credentials/) spare genuine source (#1943), which still falls
# through so Stages 2-3 screen its filename like anywhere else.
parents = path.parts[:-1]
if any(part in _CREDENTIAL_STORE_DIRS for part in parents):
return True
if any(part in _AMBIGUOUS_SENSITIVE_DIRS for part in parents) and not _is_graphable_source(path):
return True
# Stage 2: filename pattern match
name = path.name
Expand All @@ -202,9 +230,7 @@ def _is_sensitive(path: Path) -> bool:
# secret stores this stage must catch. The specific Stage 2 patterns (.env, .pem,
# id_rsa, ...) still apply to everything regardless of extension.
if _generic_keyword_hit(name):
ext = path.suffix.lower()
is_source_code = classify_file(path) == FileType.CODE and ext not in _SECRET_PRONE_DATA_EXTS
return not is_source_code
return not _is_graphable_source(path)
return False


Expand Down
36 changes: 36 additions & 0 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,42 @@ def test_sensitive_token_config_yaml():
assert _is_sensitive(Path("token_config.yaml"))


# ── #1943: Stage 1 dir check gets the same source carve-out as Stage 3 ──
# secrets/ and credentials/ are as often real source packages (Go
# internal/secrets, a credentials/ service module) as credential stores.
# Genuine programming-language source beneath them must be graphed; data and
# config formats — the formats credentials actually ship in — stay dropped,
# and dedicated credential-store dirs (.ssh, .gnupg, .aws, .gcloud) keep
# dropping everything with no carve-out.

def test_sensitive_does_not_flag_source_under_secrets_dir():
# #1943 exact cases: real source under ambiguous dir names survives.
assert not _is_sensitive(Path("internal/secrets/vault.go"))
assert not _is_sensitive(Path("app/services/credentials/manager.py"))

def test_sensitive_still_flags_data_under_secrets_dir():
# #1943 guard: the carve-out is ONLY for real source — data/config files
# under ambiguous dirs remain flagged, whatever their nesting depth.
assert _is_sensitive(Path("secrets/db.json"))
assert _is_sensitive(Path(".secrets/token.yaml"))
assert _is_sensitive(Path("deploy/credentials/prod.env"))
assert _is_sensitive(Path("internal/secrets/README.md")) # docs are not source

def test_sensitive_flags_everything_under_credential_store_dirs():
# #1943: dedicated stores get no carve-out — even source-classified files
# inside .ssh/.gnupg/.aws/.gcloud stay dropped.
assert _is_sensitive(Path("/home/user/.ssh/config"))
assert _is_sensitive(Path(".aws/credentials"))
assert _is_sensitive(Path(".gnupg/helper.py"))
assert _is_sensitive(Path("backup/.gcloud/sync.sh"))

def test_sensitive_dir_carveout_does_not_bypass_name_screens():
# #1943: rescued source still falls through to Stages 2-3, so a file whose
# NAME is sensitive stays dropped even though its dir carve-out applied.
assert _is_sensitive(Path("secrets/service_account.py")) # Stage 2 pattern
assert _is_sensitive(Path("credentials/id_rsa")) # extensionless key


# ── Generic keywords must be load-bearing: topic slugs are not secret stores ──
# A keyword buried mid-phrase in a >=3-word descriptive name is a note ABOUT
# the topic, not a credential file. It must not be silently dropped.
Expand Down
Loading