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
2 changes: 2 additions & 0 deletions .agents/DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ Never import optional dependencies at the top level of a module. All imports mus
def run_tui_feature():
# 1. First, check availability (fast, lightweight)
from ruff_sync.dependencies import require_dependency

require_dependency("textual", extra_name="tui")

# 2. Then, perform local import (delayed expensive cycle)
from textual.app import App

...
```

Expand Down
22 changes: 14 additions & 8 deletions .agents/args_refactor_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ class ExecutionArgs(NamedTuple):
semantic: bool
diff: bool
init: bool
pre_commit: bool # plain bool — MISSING resolved to default
pre_commit: bool # plain bool — MISSING resolved to default
save: bool | None
output_format: OutputFormat
validate: bool # plain bool — MISSING resolved to default
strict: bool # plain bool — MISSING resolved to default
validate: bool # plain bool — MISSING resolved to default
strict: bool # plain bool — MISSING resolved to default
```

> [!NOTE]
Expand All @@ -120,9 +120,9 @@ class Arguments(NamedTuple):
def resolve(self) -> ExecutionArgs:
"""Resolve all MISSING sentinels to their effective defaults for execution."""
_, _, _, eff_validate, eff_strict, eff_pre_commit = resolve_defaults(
MISSING, # branch — already resolved, pass MISSING to skip
MISSING, # path — already resolved, pass MISSING to skip
MISSING, # exclude — already resolved, pass MISSING to skip
MISSING, # branch — already resolved, pass MISSING to skip
MISSING, # path — already resolved, pass MISSING to skip
MISSING, # exclude — already resolved, pass MISSING to skip
self.validate,
self.strict,
self.pre_commit,
Expand Down Expand Up @@ -205,11 +205,14 @@ Replace the `resolve_defaults()` call in `Arguments.resolve()` with `resolve_boo
```python
from ruff_sync.constants import resolve_bool_flags


class Arguments(NamedTuple):
# ...
def resolve(self) -> ExecutionArgs:
eff_validate, eff_strict, eff_pre_commit = resolve_bool_flags(
self.validate, self.strict, self.pre_commit,
self.validate,
self.strict,
self.pre_commit,
)
return ExecutionArgs(
command=self.command,
Expand Down Expand Up @@ -291,7 +294,7 @@ call is actually redundant. However, if the function is also used by code that p
async def _merge_multiple_upstreams(
target_doc: TOMLDocument,
is_target_ruff_toml: bool,
args: ExecutionArgs, # ← changed from Arguments
args: ExecutionArgs, # ← changed from Arguments
client: httpx.AsyncClient,
) -> TOMLDocument:
# No resolve_defaults() needed — args already has plain values
Expand Down Expand Up @@ -363,17 +366,20 @@ Add **new** tests for `resolve_bool_flags()`:
```python
from ruff_sync.constants import resolve_bool_flags


def test_resolve_bool_flags_all_missing():
validate, strict, pre_commit = resolve_bool_flags(MISSING, MISSING, MISSING)
assert validate is False
assert strict is False
assert pre_commit is True


def test_resolve_bool_flags_strict_implies_validate():
validate, strict, pre_commit = resolve_bool_flags(MISSING, True, MISSING)
assert validate is True
assert strict is True


def test_resolve_bool_flags_explicit_false():
validate, strict, pre_commit = resolve_bool_flags(False, False, False)
assert validate is False
Expand Down
4 changes: 1 addition & 3 deletions .agents/cli_animation_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,9 +441,7 @@ def recordings(ctx, tape=None):
tape_files = [tape_file]
else:
# Process all tape files except _common.tape
tape_files = sorted(
f for f in tapes_dir.glob("*.tape") if not f.name.startswith("_")
)
tape_files = sorted(f for f in tapes_dir.glob("*.tape") if not f.name.startswith("_"))

if not tape_files:
print("⚠️ No tape files found in tapes/")
Expand Down
13 changes: 7 additions & 6 deletions .agents/formatters-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ def error(
message: str,
file_path: pathlib.Path | None = None,
logger: logging.Logger | None = None,
check_name: str = "ruff-sync/config-drift", # machine-readable rule ID
drift_key: str | None = None, # e.g. "lint.select"
check_name: str = "ruff-sync/config-drift", # machine-readable rule ID
drift_key: str | None = None, # e.g. "lint.select"
) -> None: ...
```

Expand Down Expand Up @@ -107,11 +107,11 @@ this even when `UpstreamError` or another exception occurs.
1. **Add the format value** to `OutputFormat` in `src/ruff_sync/constants.py`:
```python
class OutputFormat(str, enum.Enum):
TEXT = "text"
JSON = "json"
TEXT = "text"
JSON = "json"
GITHUB = "github"
GITLAB = "gitlab"
SARIF = "sarif" # new
SARIF = "sarif" # new
```

2. **Implement the class** in `src/ruff_sync/formatters.py`. For a
Expand Down Expand Up @@ -151,7 +151,7 @@ fmt = get_formatter(args.output_format)
try:
...
finally:
fmt.finalize() # no-op for streaming; flushes JSON for accumulating
fmt.finalize() # no-op for streaming; flushes JSON for accumulating
```

`finalize()` is always called unconditionally — **do not** guard it with
Expand All @@ -168,6 +168,7 @@ is newly introduced or already resolved between branches.
```python
import hashlib


def _make_fingerprint(upstream_url: str, local_file: str, drift_key: str | None) -> str:
if drift_key:
raw = f"ruff-sync:drift:{upstream_url}:{local_file}:{drift_key}"
Expand Down
42 changes: 26 additions & 16 deletions .agents/gitlab-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ For a "config drift" issue per key (e.g., `lint.select`), a good stable fingerpr
```python
import hashlib


def make_fingerprint(upstream_url: str, local_file: str, drift_key: str) -> str:
"""Stable fingerprint for a config drift issue."""
raw = f"ruff-sync:drift:{upstream_url}:{local_file}:{drift_key}"
Expand Down Expand Up @@ -281,13 +282,15 @@ class GitlabFormatter:
fingerprint: str | None = None,
) -> None:
(logger or LOGGER).error(message)
self._issues.append(self._make_issue(
description=message,
check_name=check_name,
severity="major",
file_path=file_path,
fingerprint=fingerprint,
))
self._issues.append(
self._make_issue(
description=message,
check_name=check_name,
severity="major",
file_path=file_path,
fingerprint=fingerprint,
)
)

def warning(
self,
Expand All @@ -298,13 +301,15 @@ class GitlabFormatter:
fingerprint: str | None = None,
) -> None:
(logger or LOGGER).warning(message)
self._issues.append(self._make_issue(
description=message,
check_name=check_name,
severity="minor",
file_path=file_path,
fingerprint=fingerprint,
))
self._issues.append(
self._make_issue(
description=message,
check_name=check_name,
severity="minor",
file_path=file_path,
fingerprint=fingerprint,
)
)

def _make_issue(
self,
Expand All @@ -327,6 +332,7 @@ class GitlabFormatter:
@staticmethod
def _auto_fingerprint(description: str, path: str) -> str:
import hashlib

raw = f"ruff-sync:drift:{path}:{description}"
return hashlib.md5(raw.encode()).hexdigest()

Expand Down Expand Up @@ -370,7 +376,7 @@ class OutputFormat(str, enum.Enum):
TEXT = "text"
JSON = "json"
GITHUB = "github"
GITLAB = "gitlab" # NEW
GITLAB = "gitlab" # NEW
```

Update `get_formatter` in `formatters.py`:
Expand Down Expand Up @@ -530,7 +536,7 @@ fmt = get_formatter(args.output_format)
try:
...
finally:
fmt.finalize() # Writes the JSON array to stdout (piped to file by CI)
fmt.finalize() # Writes the JSON array to stdout (piped to file by CI)
```

**Do not** guard the call with `isinstance` or `hasattr` checks.
Expand Down Expand Up @@ -562,13 +568,15 @@ from unittest.mock import patch

from ruff_sync.formatters import GitlabFormatter


def test_gitlab_formatter_empty_on_no_issues(capsys):
fmt = GitlabFormatter()
fmt.finalize()
captured = capsys.readouterr()
issues = json.loads(captured.out)
assert issues == []


def test_gitlab_formatter_error_produces_major_issue(capsys):
fmt = GitlabFormatter()
fmt.error("drift found", file_path=pathlib.Path("pyproject.toml"))
Expand All @@ -581,6 +589,7 @@ def test_gitlab_formatter_error_produces_major_issue(capsys):
assert issues[0]["location"]["lines"]["begin"] == 1
assert "fingerprint" in issues[0]


def test_gitlab_formatter_fingerprint_is_stable(capsys):
fmt1 = GitlabFormatter()
fmt2 = GitlabFormatter()
Expand All @@ -594,6 +603,7 @@ def test_gitlab_formatter_fingerprint_is_stable(capsys):
issues2 = json.loads(out2)
assert issues1[0]["fingerprint"] == issues2[0]["fingerprint"]


def test_gitlab_formatter_no_bom(capsys):
fmt = GitlabFormatter()
fmt.finalize()
Expand Down
14 changes: 12 additions & 2 deletions .agents/issue-102-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,18 @@ class ResultFormatter(Protocol):
def note(self, message: str) -> None: ...
def info(self, message: str, logger: logging.Logger | None = None) -> None: ...
def success(self, message: str) -> None: ...
def error(self, message: str, file_path: pathlib.Path | None = None, logger: logging.Logger | None = None) -> None: ...
def warning(self, message: str, file_path: pathlib.Path | None = None, logger: logging.Logger | None = None) -> None: ...
def error(
self,
message: str,
file_path: pathlib.Path | None = None,
logger: logging.Logger | None = None,
) -> None: ...
def warning(
self,
message: str,
file_path: pathlib.Path | None = None,
logger: logging.Logger | None = None,
) -> None: ...
def debug(self, message: str, logger: logging.Logger | None = None) -> None: ...
def diff(self, diff_text: str) -> None: ...
```
Expand Down
39 changes: 19 additions & 20 deletions .agents/plans/issue-116-config-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def validate_toml_syntax(doc: TOMLDocument) -> bool:
"""
try:
import tomlkit # already a dep

tomlkit.parse(doc.as_string())
return True
except Exception: # noqa: BLE001
Expand Down Expand Up @@ -146,9 +147,7 @@ def validate_ruff_accepts_config(doc: TOMLDocument, is_ruff_toml: bool = False)
)
return False
except FileNotFoundError:
LOGGER.warning(
"⚠️ `ruff` not found on PATH — skipping Ruff config validation."
)
LOGGER.warning("⚠️ `ruff` not found on PATH — skipping Ruff config validation.")
return True # Soft fail: don't block if ruff isn't installed
except subprocess.TimeoutExpired:
LOGGER.warning("⚠️ Ruff config validation timed out — skipping.")
Expand Down Expand Up @@ -184,7 +183,7 @@ Validation is **opt-in**. Before touching `core.py`, wire up the CLI flag.
class Arguments(NamedTuple):
...
validate: bool = False # run --validate checks before writing
strict: bool = False # treat warnings as errors (implies validate)
strict: bool = False # treat warnings as errors (implies validate)
```

**In `common_parser`** (around line 236), add:
Expand Down Expand Up @@ -230,18 +229,17 @@ Open `core.py`. Find the `pull()` function (around line 1103). Look for this blo
**After** this block (and **before** `should_save = args.save ...`), insert:

```python
# Validation is opt-in — only run if --validate (or --strict) was passed
if args.validate:
is_ruff_toml = is_ruff_toml_file(_source_toml_path.name)
from ruff_sync.validation import validate_merged_config # noqa: PLC0415
if not validate_merged_config(
source_doc, is_ruff_toml=is_ruff_toml, strict=args.strict
):
fmt.error(
"❌ Merged config failed validation. Local file left unchanged.",
logger=LOGGER,
)
return 1
# Validation is opt-in — only run if --validate (or --strict) was passed
if args.validate:
is_ruff_toml = is_ruff_toml_file(_source_toml_path.name)
from ruff_sync.validation import validate_merged_config # noqa: PLC0415

if not validate_merged_config(source_doc, is_ruff_toml=is_ruff_toml, strict=args.strict):
fmt.error(
"❌ Merged config failed validation. Local file left unchanged.",
logger=LOGGER,
)
return 1
```

> **Note**: The inline import avoids a circular import issue if `validation.py` ever needs to
Expand Down Expand Up @@ -437,6 +435,7 @@ Add to `tests/test_config_validation.py`:
```python
def test_version_consistency_warn_on_mismatch(caplog: pytest.LogCaptureFixture) -> None:
import logging

doc = tomlkit.parse(
'[project]\nrequires-python = ">=3.10"\n\n[tool.ruff]\ntarget-version = "py39"\n'
)
Expand Down Expand Up @@ -492,9 +491,7 @@ def _get_deprecated_rule_codes() -> frozenset[str]:
if result.returncode != 0:
return frozenset()
rules = json.loads(result.stdout)
return frozenset(
r["code"] for r in rules if r.get("deprecated") is True
)
return frozenset(r["code"] for r in rules if r.get("deprecated") is True)
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError, KeyError):
return frozenset()
```
Expand Down Expand Up @@ -573,7 +570,9 @@ def check_deprecated_rules(
is_ruff_toml: bool = False,
_deprecated_codes: frozenset[str] | None = None,
) -> None:
deprecated_codes = _deprecated_codes if _deprecated_codes is not None else _get_deprecated_rule_codes()
deprecated_codes = (
_deprecated_codes if _deprecated_codes is not None else _get_deprecated_rule_codes()
)
...
```

Expand Down
11 changes: 7 additions & 4 deletions .agents/skills/dirty-equals/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ Instead of asserting on every field manually, compare against a "dirty" object t
```python
from dirty_equals import IsInt, IsPartialDict, IsStr


def test_config_logic():
result = {"status": "active", "version": 1, "extra": "data"}
# Declarative assertion
assert result == IsPartialDict({
"status": IsStr(regex="act.*"),
"version": IsInt(gt=0),
})
assert result == IsPartialDict(
{
"status": IsStr(regex="act.*"),
"version": IsInt(gt=0),
}
)
```

## Detailed reference
Expand Down
Loading
Loading