Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Case-exact file resolution and deduplication on case-insensitive filesystems
(macOS APFS, Windows NTFS): `file_exists_handler`, `regex_handler`,
`llm_eval_handler`, `llm_extract_handler`, and config file discovery verify
path components against case-exact directory entries, preventing spurious
matches on lowercase aliases, deduplicating file contents read into
consultation evidence, and citing exact repository paths (fixes #645).

- Control `when` and step `when` now use one evaluator
(framework-design 5.1.1). A list-valued control `when` such as
`{ ci_provider = ["github", "gitlab"] }` made the control N/A even when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4211,7 +4211,7 @@ help_md = """Create architecture/design documentation.

[controls."OSPS-SA-01.01".locator]
project_path = "documentation.design"
discover = ["ARCHITECTURE.md", "docs/architecture.md", "docs/design.md", "DESIGN.md"]
discover = ["ARCHITECTURE.md", "docs/ARCHITECTURE.md", "docs/architecture.md", "docs/design.md", "DESIGN.md"]
kind = "file"

[controls."OSPS-SA-01.01".locator.llm_hints]
Expand All @@ -4223,7 +4223,7 @@ look_for_urls = true
handler = "pattern"
# rst variants added; ARCHITECTURE/DESIGN commonly live as .rst
# in Sphinx-based Python projects.
files = ["ARCHITECTURE.md", "ARCHITECTURE.rst", "docs/architecture.md", "docs/architecture.rst", "docs/design.md", "docs/design.rst", "DESIGN.md", "DESIGN.rst", "README.md", "README.rst"]
files = ["ARCHITECTURE.md", "ARCHITECTURE.rst", "docs/ARCHITECTURE.md", "docs/ARCHITECTURE.rst", "docs/architecture.md", "docs/architecture.rst", "docs/design.md", "docs/design.rst", "DESIGN.md", "DESIGN.rst", "README.md", "README.rst"]
pass_if_any = true

[controls."OSPS-SA-01.01".passes.pattern.patterns]
Expand Down Expand Up @@ -4286,7 +4286,7 @@ help_md = """Document external APIs and interfaces.

[controls."OSPS-SA-02.01".locator]
project_path = "documentation.api"
discover = ["API.md", "docs/api.md", "docs/api/", "openapi.yaml", "openapi.json", "swagger.yaml", "swagger.json"]
discover = ["API.md", "docs/API.md", "docs/api.md", "docs/api/", "openapi.yaml", "openapi.json", "swagger.yaml", "swagger.json"]
kind = "file"

[controls."OSPS-SA-02.01".locator.llm_hints]
Expand All @@ -4299,7 +4299,7 @@ look_for_urls = true
handler = "pattern"
# README.rst added for Sphinx-based projects; API.rst / USAGE.rst
# variants common in the same ecosystem.
files = ["API.md", "API.rst", "docs/api.md", "docs/api.rst", "README.md", "README.rst", "docs/README.md", "USAGE.md", "USAGE.rst", "docs/getting-started.md", "openapi.yaml", "openapi.json", "swagger.yaml", "swagger.json"]
files = ["API.md", "API.rst", "docs/API.md", "docs/API.rst", "docs/api.md", "docs/api.rst", "README.md", "README.rst", "docs/README.md", "USAGE.md", "USAGE.rst", "docs/getting-started.md", "openapi.yaml", "openapi.json", "swagger.yaml", "swagger.json"]
pass_if_any = true

[controls."OSPS-SA-02.01".passes.pattern.patterns]
Expand Down
24 changes: 17 additions & 7 deletions packages/darnit/src/darnit/config/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,27 @@ def discover_files(
if file_locations is None:
return {}

from darnit.core.utils import resolve_case_exact_path

discovered = {}

for ref_path, patterns in file_locations.items():
for pattern in patterns:
full_pattern = os.path.join(local_path, pattern)
matches = glob_module.glob(full_pattern)
if matches:
# Use first match, relative path
rel_path = os.path.relpath(matches[0], local_path)
discovered[ref_path] = rel_path
break
if "*" in pattern or "?" in pattern:
full_pattern = os.path.join(local_path, pattern)
matches = glob_module.glob(full_pattern)
exact_matches = [m for m in matches if resolve_case_exact_path(m, local_path)]
if exact_matches:
# Use first match, relative path
rel_path = os.path.relpath(exact_matches[0], local_path)
discovered[ref_path] = rel_path
break
else:
exact = resolve_case_exact_path(pattern, local_path)
if exact and os.path.exists(exact):
rel_path = os.path.relpath(exact, local_path)
discovered[ref_path] = rel_path
break

return discovered

Expand Down
101 changes: 101 additions & 0 deletions packages/darnit/src/darnit/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Shared utility functions for the baseline MCP server."""

import functools
import glob as glob_module
import json
import os
Expand Down Expand Up @@ -702,3 +703,103 @@ def get_git_ref(local_path: str) -> str | None:
except (FileNotFoundError, OSError, subprocess.SubprocessError) as e:
logger.debug(f"git ref command failed: {type(e).__name__}")
return None


@functools.lru_cache(maxsize=2048)
def _dir_entries_for_mtime(dir_path: str, mtime_ns: int) -> frozenset[str]:
try:
return frozenset(os.listdir(dir_path))
except OSError:
return frozenset()


def _dir_entries(dir_path: str) -> frozenset[str]:
"""Return directory entries as a frozenset, or empty frozenset on OSError.

Keys by directory path and st_mtime_ns so modifications (e.g. remediation file creation)
automatically invalidate the cache without requiring manual clearing.
"""
try:
mtime_ns = os.stat(dir_path).st_mtime_ns
except OSError:
return frozenset()
return _dir_entries_for_mtime(dir_path, mtime_ns)


def clear_dir_entries_cache() -> None:
"""Clear cached directory listings (primarily for test environments)."""
_dir_entries_for_mtime.cache_clear()


def resolve_case_exact_path(path: str, root: str | None = None) -> str | None:
"""Verify that a path exists with case-exact directory entries.

On case-insensitive filesystems (such as macOS APFS or Windows NTFS),
`os.path.exists` or `open()` can succeed when probing a lowercase
path (e.g. `readme.md`) even if the repository only contains `README.md`.
This function traverses each path component and verifies against the
actual directory entries (`os.listdir`), ensuring exact-case matching
identical to case-sensitive filesystems (Linux).

Args:
path: File path (relative or absolute).
root: Optional repository root to anchor relative paths or bounds.

Returns:
The normalized absolute path if all components exist with exact case,
or None if any component is missing or mismatched in case.
"""
if not path:
return None

if root is not None and not os.path.isabs(path):
full_path = os.path.normpath(os.path.join(os.path.abspath(root), path))
base = os.path.abspath(root)
else:
full_path = os.path.normpath(os.path.abspath(path))
if root is not None:
base_cand = os.path.abspath(root)
try:
if os.path.commonpath([base_cand, full_path]) == base_cand:
base = base_cand
else:
base = os.path.abspath(os.sep)
except ValueError:
base = os.path.abspath(os.sep)
else:
base = os.path.abspath(os.sep)

try:
rel = os.path.relpath(full_path, base)
except ValueError:
return None

if rel == ".":
return base
if rel.startswith(".."):
return None

curr = base
for part in rel.split(os.sep):
if not part or part == ".":
continue
if part not in _dir_entries(curr):
return None
curr = os.path.join(curr, part)

return curr


def get_file_identity(full_path: str) -> tuple[int, int] | str:
"""Return a unique file identity (st_dev, st_ino) or realpath fallback.

Used to deduplicate candidate files (e.g. across case-aliases, symlinks,
or multiple probes pointing to the same underlying file) before reading
or reporting evidence.
"""
try:
st = os.stat(full_path)
return (st.st_dev, st.st_ino)
except OSError:
return os.path.realpath(full_path)

Loading
Loading