Skip to content

Add git-aware diff linting: skillsaw lint --since REF - #401

Open
not-stbenjam wants to merge 8 commits into
mainfrom
lint-since
Open

Add git-aware diff linting: skillsaw lint --since REF#401
not-stbenjam wants to merge 8 commits into
mainfrom
lint-since

Conversation

@not-stbenjam

@not-stbenjam not-stbenjam commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What

skillsaw lint --since origin/main reports only the violations your changes introduced. Zero setup: no committed .skillsaw-baseline.json, nothing to regenerate, nothing to go stale.

$ skillsaw lint --since HEAD~1
skillsaw 0.16.0
Linting: /work/my-skills

Comparing against merge-base 5bbcd74480e2 (--since HEAD~1)

Errors:
  ✗ ERROR (content-embedded-secrets) [CLAUDE.md:24]: Potential secret detected: GitHub personal access token

Summary:
  Errors:   1
  Warnings: 0
  Baseline: 3 suppressed
$ echo $?
1

A repo with hundreds of legacy violations, you add one embedded secret, --since reports exactly that one and exits 1.

Why

Legacy noise is the #1 adoption blocker: pointing skillsaw at an existing repo produces a wall of pre-existing violations, and CI either fails on all of them or requires committing and continually refreshing a baseline file. --since kills both problems — CI fails only on your own regressions, and there is no baseline file to drift out of date.

How it works

Not diff line ranges — an ephemeral git baseline:

  1. Resolve the merge-base of HEAD and REF (commits that landed on REF after you branched are not counted against you).
  2. Check it out into a temporary detached git worktree (always removed, even on failure).
  3. Lint that snapshot with the same config and rule-selection flags (config is deliberately pinned from the current working tree — --since measures how the repo changed, not how the rule config changed).
  4. Build an in-memory BaselineFile from the result (INFO dropped, ratchet baseline_modes captured — exact skillsaw baseline semantics) and feed it through the existing baseline subtraction.

The key trick: entries are fingerprinted against the snapshot toplevel, then baseline.root_path is pointed at the real repo toplevel — current violations fingerprint to identical relative paths, so everything downstream (suppressed counts, stale entries, formatters, grade, exit codes) works unchanged.

Because baseline fingerprints hash rule ID + relative path + stripped line content (never line numbers):

  • Drift-immune — pre-existing violations stay suppressed even when your change shifts every line around them.
  • Ratchets compose — growing a SKILL.md past its merge-base token count re-fires context-budget with the delta; shrinking it stays quiet.
  • Fixed violations report as Baseline: N violation(s) fixed since REF.

--since takes precedence over a committed .skillsaw-baseline.json and is rejected when combined with --no-baseline.

GitHub Action

New since input (default '' — behavior is byte-compatible when unset):

- uses: actions/checkout@v5
  with:
    fetch-depth: 0  # --since needs history to find the merge-base
    persist-credentials: false
- uses: stbenjam/skillsaw@v0
  with:
    strict: true
    since: origin/${{ github.base_ref }}

Composes with the existing skillsaw/review workflow_run action: the uploaded report only contains the PR's own regressions, so inline comments shrink accordingly.

Limitations (documented in docs/baseline.md)

  • Renames resurface old violations — fingerprints include the relative path.
  • Roughly doubles lint time — the merge-base snapshot is linted too.
  • Requires git history — shallow clones get a precise error suggesting fetch-depth: 0 / git fetch --unshallow.

Test coverage

16 new end-to-end tests (tests/test_lint_since.py) against a new static fixture (tests/fixtures/lint-since/, git-initialized per-test since fixtures can't contain .git):

  • all legacy violations suppressed on a clean change (exit 0, suppressed count)
  • one new error-severity violation → exactly that one reported, exit 1
  • drift immunity (inserted section shifts every legacy violation's line)
  • ratchet composition: growth past the merge-base value re-fires; improvement stays suppressed
  • --since + --no-baseline → error; non-git dir, unknown ref, shallow clone → precise errors, no tracebacks
  • --since wins over a committed .skillsaw-baseline.json
  • worktree cleanup after success and failure (git worktree list shows only the main worktree)
  • dirty working tree: uncommitted new violation still caught
  • lint path missing at the merge-base contributes no baseline entries (no crash)

Full suite: 2484 passed. Verified against a real repo (openshift-eng/ai-helpers): default lint exits 0; --since HEAD~1 on the shallow clone produces the precise shallow-history error, and after git fetch --deepen 2 resolves the merge-base and exits 0 with the worktree cleaned up.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added skillsaw lint --since REF to report only violations introduced since the merge-base of HEAD and the chosen ref.
    • GitHub Action now supports an optional since input to enable the same behavior in CI.
    • Lint reporting now clearly compares against the merge-base and highlights violations fixed since that ref.
  • Bug Fixes / Behavior Changes
    • --since can’t be combined with --no-baseline; lint exits with an error.
  • Documentation
    • Updated README and CLI/CI/baseline docs with usage, precedence, and limitations.
  • Tests
    • Added end-to-end and unit coverage for --since behavior, formatting, precedence, and cleanup.

`--since REF` reports only the violations introduced since the
merge-base of HEAD and REF — no committed baseline file, no setup.

Mechanism: an ephemeral git baseline. The merge-base is checked out
into a temporary detached worktree, linted with the same config, and
the result becomes an in-memory BaselineFile fed through the existing
baseline subtraction. Entries are fingerprinted against the snapshot
toplevel, then baseline.root_path is pointed at the real repo toplevel
so current violations fingerprint to identical relative paths.

Because fingerprints hash rule_id + relative path + stripped line
content (never line numbers), pre-existing violations stay suppressed
even when the change shifts every line around them, and ratchet rules
compose: growing a file past its merge-base token count re-fires.

Mirrors `skillsaw baseline` semantics (INFO dropped, baseline_modes
captured). Config is deliberately pinned from the current working
tree. --since takes precedence over .skillsaw-baseline.json and
cannot be combined with --no-baseline. Shallow clones get a precise
error suggesting fetch-depth: 0 / git fetch --unshallow.

The GitHub Action gains a `since` input (default '', byte-compatible
behavior when unset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@not-stbenjam
not-stbenjam requested a review from stbenjam as a code owner July 6, 2026 23:29
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@stbenjam, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 928c309e-162f-47e6-8eab-4f3f8dd25479

📥 Commits

Reviewing files that changed from the base of the PR and between b07f5a5 and 5e54ef6.

📒 Files selected for processing (1)
  • README.md
📝 Walkthrough

Walkthrough

This PR adds skillsaw lint --since REF to lint only violations introduced since the merge-base of HEAD and a ref. It wires the option through the CLI and GitHub Action, builds an ephemeral git baseline, updates docs, and adds unit and end-to-end tests.

Changes

Lint --since feature

Layer / File(s) Summary
Ephemeral git baseline builder
src/skillsaw/git_baseline.py
New module resolves merge-base, creates a temporary detached worktree, lints the snapshot with current config/rule overrides, excludes INFO severities, and builds a BaselineFile retargeted to the real repo root, returning (BaselineFile, merge_base_sha).
CLI parser and lint command integration
src/skillsaw/cli/_parser.py, src/skillsaw/cli/_lint.py
Adds --since REF, validates it conflicts with --no-baseline, reorders rule-filter conflict checks, invokes build_git_baseline, prints a merge-base comparison message, and differentiates fixed-since reporting from standard stale-baseline messaging.
Action input and documentation
action.yml, README.md, docs/baseline.md, docs/ci.md, docs/cli.md
Adds a since action input wired into SKILLSAW_SINCE/--since; documents the feature, its mechanism, limitations, and CI usage for filtering PR-only violations.
Unit and end-to-end tests for --since
tests/test_git_baseline.py, tests/test_lint_since.py
Adds unit tests for merge-base resolution, worktree snapshotting, and baseline building, plus subprocess-based tests covering suppression, drift immunity, ratchet re-firing, flag validation, worktree cleanup, and precedence over committed baselines.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • stbenjam/skillsaw#308: Introduces the cli/ lint module split that this PR's --since changes to src/skillsaw/cli/_lint.py and _parser.py directly build upon.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding git-aware linting via skillsaw lint --since REF.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lint-since

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the --since flag to skillsaw lint, enabling users to lint only changes introduced since a specific git reference by building an ephemeral baseline from the merge-base in a temporary worktree. The feedback highlights several critical improvements: resolving potential crashes when repo_toplevel is called with a file path, ensuring repository type overrides are correctly propagated to the merge-base snapshot linting process, securing git commands against argument injection using --, and fixing a Windows portability issue in the test suite.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +73 to +81
def repo_toplevel(path: Path) -> Path:
"""Resolve the git working-tree toplevel containing *path*."""
result = _git(path, "rev-parse", "--show-toplevel")
if result.returncode != 0:
raise GitBaselineError(
f"--since requires a git repository, but {path} is not inside one "
f"({result.stderr.strip() or 'git rev-parse failed'})"
)
return Path(result.stdout.strip()).resolve()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If path is a file (e.g., when linting a specific file like skillsaw lint my-skill/SKILL.md), _git will be called with a file path as the cwd. This causes subprocess.run to raise a NotADirectoryError (or OSError on Windows) and crash with a traceback.

To prevent this, resolve the path and use its parent directory if it is not a directory.

Suggested change
def repo_toplevel(path: Path) -> Path:
"""Resolve the git working-tree toplevel containing *path*."""
result = _git(path, "rev-parse", "--show-toplevel")
if result.returncode != 0:
raise GitBaselineError(
f"--since requires a git repository, but {path} is not inside one "
f"({result.stderr.strip() or 'git rev-parse failed'})"
)
return Path(result.stdout.strip()).resolve()
def repo_toplevel(path: Path) -> Path:
"""Resolve the git working-tree toplevel containing *path*."""
resolved_path = path.resolve()
cwd = resolved_path if resolved_path.is_dir() else resolved_path.parent
result = _git(cwd, "rev-parse", "--show-toplevel")
if result.returncode != 0:
raise GitBaselineError(
f"--since requires a git repository, but {path} is not inside one "
f"({result.stderr.strip() or 'git rev-parse failed'})"
)
return Path(result.stdout.strip()).resolve()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b869de0repo_toplevel now uses path if path.is_dir() else path.parent as git's cwd, with a direct unit test. Note this wasn't reachable via the CLI (_resolve_lint_paths converts file arguments to their parent directories before this code runs, verified with a live skillsaw lint CLAUDE.md --since HEAD~1 run), but the helper is now robust for direct API callers.

Comment thread src/skillsaw/git_baseline.py Outdated

from .baseline import BaselineFile, build_baseline
from .config import LinterConfig
from .context import RepositoryContext

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Import RepositoryType so it can be used to type-annotate the repo_types parameter in build_git_baseline.

Suggested change
from .context import RepositoryContext
from .context import RepositoryContext, RepositoryType

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b869de0RepositoryType is imported and used to annotate the new repo_types parameter.

Comment on lines +135 to +146
def build_git_baseline(
repo_root: Path,
ref: str,
config: LinterConfig,
lint_paths: Sequence[Path],
version_string: str,
*,
rule_ids: Optional[Set[str]] = None,
skip_rule_ids: Optional[Set[str]] = None,
no_custom_rules: bool = False,
no_plugins: bool = False,
) -> Tuple[BaselineFile, str]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add repo_types to the build_git_baseline signature so that repository type overrides (e.g., specified via --type) are correctly propagated when linting the merge-base snapshot.

def build_git_baseline(
    repo_root: Path,
    ref: str,
    config: LinterConfig,
    lint_paths: Sequence[Path],
    version_string: str,
    *,
    rule_ids: Optional[Set[str]] = None,
    skip_rule_ids: Optional[Set[str]] = None,
    no_custom_rules: bool = False,
    no_plugins: bool = False,
    repo_types: Optional[Set[RepositoryType]] = None,
) -> Tuple[BaselineFile, str]:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b869de0build_git_baseline now takes repo_types: Optional[Set[RepositoryType]] = None and documents that base and current lints must use the same rule set.

Comment on lines +186 to +190
context = RepositoryContext(
mapped,
exclude_patterns=config.exclude_patterns,
content_paths=config.content_paths,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass repo_types to RepositoryContext to ensure the merge-base snapshot is analyzed with the same repository type overrides as the current working tree.

            context = RepositoryContext(
                mapped,
                repo_types=repo_types,
                exclude_patterns=config.exclude_patterns,
                content_paths=config.content_paths,
            )

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b869de0 — the snapshot RepositoryContext now receives repo_types. Added regression tests (subprocess and in-process) that fail without the fix: linting an unchanged repo with --type single-plugin --since HEAD~1 previously reported spurious new plugin-json-required/plugin-readme violations because the snapshot auto-detected dot-claude.

Comment thread src/skillsaw/cli/_lint.py
Comment on lines +135 to +145
baseline, merge_base = build_git_baseline(
repo_root,
args.since,
config,
paths,
cli_version,
rule_ids=rule_ids,
skip_rule_ids=skip_rule_ids,
no_custom_rules=args.no_custom_rules,
no_plugins=args.no_plugins,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass override_types (which holds the --type overrides) to build_git_baseline so that the merge-base snapshot is linted with the correct repository types.

Suggested change
baseline, merge_base = build_git_baseline(
repo_root,
args.since,
config,
paths,
cli_version,
rule_ids=rule_ids,
skip_rule_ids=skip_rule_ids,
no_custom_rules=args.no_custom_rules,
no_plugins=args.no_plugins,
)
baseline, merge_base = build_git_baseline(
repo_root,
args.since,
config,
paths,
cli_version,
rule_ids=rule_ids,
skip_rule_ids=skip_rule_ids,
no_custom_rules=args.no_custom_rules,
no_plugins=args.no_plugins,
repo_types=override_types,
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b869de0 — the call site now passes repo_types=override_types so --type applies to the merge-base snapshot lint too. Covered by test_type_override_applies_to_merge_base_lint, which fails without the fix.


def resolve_merge_base(repo_root: Path, ref: str) -> str:
"""Resolve the merge-base of HEAD and *ref*, with a precise error on failure."""
result = _git(repo_root, "merge-base", "HEAD", ref)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Use -- to separate git options from positional arguments (the ref). This prevents potential argument injection if a user-controlled ref starts with a hyphen (e.g., --help or other flags).

Suggested change
result = _git(repo_root, "merge-base", "HEAD", ref)
result = _git(repo_root, "merge-base", "HEAD", "--", ref)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b869de0resolve_merge_base now rejects empty refs and refs starting with - before they reach git, with a clear invalid ref error. Tests cover --since=-foo end-to-end (clean exit 1, no traceback) and the helper directly.

Comment thread tests/test_lint_since.py Outdated
commit_all(origin, "Mention docs build")

clone = tmp_path / "shallow"
git(tmp_path, "clone", "-q", "--depth", "1", f"file://{origin}", str(clone))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using file://{origin} will fail on Windows because the path contains backslashes (e.g., C:\path\to\origin), resulting in an invalid URL. Passing str(origin) directly is fully portable across Windows, macOS, and Linux.

Suggested change
git(tmp_path, "clone", "-q", "--depth", "1", f"file://{origin}", str(clone))
git(tmp_path, "clone", "-q", "--depth", "1", str(origin), str(clone))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b869de0 using Path.as_uri(), which produces a portable file:/// URL on all platforms. Note we can't pass the plain local path here: git silently ignores --depth for local-path clones ("--depth is ignored in local clones; use file:// instead"), which would produce a full clone and defeat the shallow-error test.

@codecov-commenter

codecov-commenter commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.41%. Comparing base (94b3160) to head (5e54ef6).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #401      +/-   ##
==========================================
+ Coverage   93.28%   93.41%   +0.13%     
==========================================
  Files         144      145       +1     
  Lines       11014    11114     +100     
==========================================
+ Hits        10274    10382     +108     
+ Misses        740      732       -8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@stbenjam stbenjam added the fable-5 PR's/Issues filed by Fable label Jul 6, 2026
The 16 integration tests in test_lint_since.py drive the CLI via
subprocess, which coverage.py does not trace — Codecov reported the
patch as uncovered even though it is exercised end-to-end.

Add tests/test_git_baseline.py: 25 in-process tests that import and
call the code directly (repo_toplevel, resolve_merge_base including
the shallow-clone hint, _snapshot_worktree cleanup on success/failure/
bad sha/remove-failure fallback, build_git_baseline root repointing,
INFO exclusion, ratchet modes, Linter suppression, and the lint CLI's
--since, committed-baseline, and stale-hint branches via _run_lint).

git_baseline.py is now at 100% line coverage in-process, and every
line the PR adds to cli/_lint.py is covered. The subprocess tests are
kept — they verify real exit codes and process-level behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stbenjam stbenjam added the panel-review Trigger a multi-specialist review panel label Jul 6, 2026
@github-actions github-actions Bot removed the panel-review Trigger a multi-specialist review panel label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Panel Verdict

Disposition: APPROVE


Specialist Findings

Architecture Reviewer

  • Clean module scoping in git_baseline.py: repo_toplevel, resolve_merge_base, _snapshot_worktree (context manager owning worktree lifecycle), and build_git_baseline each have a single responsibility. The "repoint root_path from snapshot toplevel to the real repo" trick (git_baseline.py:534) is the crux and is well-documented; it lets the existing baseline subtraction work unchanged.
  • Error propagation is clean — every git failure becomes a GitBaselineError with an actionable message, caught once at _lint.py:239 and rendered without a traceback.
  • SUGGESTIONsrc/skillsaw/git_baseline.py:503: --repo-types (override_types) is threaded into the working-tree RepositoryContext (_lint.py:176) but not into the snapshot lint inside build_git_baseline. The ephemeral baseline is computed with auto-detected repo types while the working tree uses the forced types. When --since is combined with --repo-types and the merge-base snapshot detects a different type set, enabled: auto rules diverge between baseline and current lint — a pre-existing violation can surface as "new," or a fixed one be miscounted. Thread override_types through into the snapshot RepositoryContext.
  • NOTE_lint.py:227: repo_toplevel(paths[0]) derives the repo from the first lint path only; other paths are validated against it (git_baseline.py:485) and error clearly. Acceptable.

Python Expert

  • Idiomatic throughout: contextlib.contextmanager, pathlib, subprocess.run with list args, f-strings, accurate type hints. from __future__ import annotations + typing generics keep it 3.9-compatible; no match statements.
  • The lazy from ..git_baseline import ... inside _run_lint matches the file's existing lazy-import convention.
  • NOTEgit_baseline.py:497: implicit adjacent-string concatenation in the log call splits the message ("... merge-base %s; " "no baseline entries for it") — harmless but easy to break later; join into one literal.
  • No performance concern beyond the documented ~2× lint cost, inherent to linting a second snapshot.

Security & Supply Chain Reviewer

  • _git uses subprocess.run(["git", *argv], ...) — list form, no shell=True; the user-supplied ref reaches only merge-base HEAD <ref> as a discrete argv element, so no command injection. Lint paths are validated to live inside the repo (git_baseline.py:485).
  • tempfile.mkdtemp (mode 0700); worktree removed on every exit path including exceptions, with a worktree prune fallback. No lingering state.
  • No new dependencies. Action steps remain pinned (setup-python@v6.3.0, upload-artifact@v7.0.1).
  • NOTEaction.yml:98: since is appended to ARGS and used unquoted (skillsaw $ARGS ...). This mirrors the pre-existing --strict/--fail-on handling, the value arrives via an env var (not direct ${{ }} interpolation into the script), and git ref names cannot contain shell metacharacters/spaces — no practical injection or splitting hazard. No change required.

QA Engineer

  • Coverage is excellent: 775 lines of new tests across in-process unit tests (test_git_baseline.py) and subprocess end-to-end tests (test_lint_since.py) with a realistic static fixture (tests/fixtures/lint-since/). Ran both files locally: 41 passed. They cover merge-base resolution, shallow-clone error, worktree hygiene (success/failure/remove-failure/invalid-sha), drift immunity, ratchet regression and improvement, --no-baseline conflict, precedence over a committed baseline, dirty working tree, and a path absent at the merge-base.
  • SUGGESTION: No test exercises --since + --repo-types (the Architecture finding) or --since with multiple lint paths. Add a case forcing --repo-types that asserts the baseline uses the same forced types, and one passing two sibling directories.

Technical Writer

  • Docs are thorough and consistent: README.md (new "Lint only your changes" section), docs/baseline.md (mechanism, ratchet composition, and a Limitations block covering renames, ~2× time, shallow clones), docs/ci.md (workflow example + input table), docs/cli.md, and the action.yml input description all align with the implementation.
  • Ran make generate-docs: no drift — the auto-generated docs/cli.md entry matches the parser help, so make update is satisfied.
  • Docstrings clearly explain the non-obvious root_path repoint. No findings.

Ecosystem Reviewer

  • This adds git-diff-aware baselining (--since) to skillsaw's own CLI — it does not add or expand support for any external tool, agent, repo_type, or config format. Core linter-workflow feature (comparable to --baseline). Out of scope; no plugin-redirect concern.

Panel Synthesis

A well-engineered, well-tested, well-documented feature that reuses the existing baseline fingerprint/subtraction machinery instead of inventing a parallel path — squarely aligned with the panel's "existing patterns over novel ones" bias. Worktree lifecycle and error handling are careful, error messages are actionable and traceback-free, and the 41 new tests pass while covering the failure surface thoroughly.

No BLOCKING findings. The one substantive item — --repo-types not threaded into the snapshot lint — is a genuine correctness inconsistency but confined to an uncommon flag combination (--since + --repo-types where the merge-base auto-detects a different type set). It does not affect the common CI path (since: origin/main), so it is a SUGGESTION rather than a merge blocker.


Required Actions Before Merge

None. Approved.


Optional Follow-ups

  1. Thread override_types (--repo-types) through build_git_baseline into the snapshot RepositoryContext so the ephemeral baseline and the working-tree lint enable the same auto rules (git_baseline.py:503). Small change; ideally in this PR.
  2. Add tests for --since + --repo-types and for --since with multiple lint paths (tests/test_git_baseline.py / tests/test_lint_since.py).
  3. Join the split log-message literal at git_baseline.py:497.

Generated by skillsaw-review-panel

not-stbenjam and others added 2 commits July 6, 2026 19:53
- Propagate --type repo-type overrides to the merge-base snapshot lint
  (new repo_types parameter on build_git_baseline). Without it the base
  and current lints could run different rule sets, reporting spurious
  "new" violations for rules enabled only by the override. Regression
  tests verified to fail without the fix.
- Reject --since refs that are empty or start with "-" before they
  reach git, where they would parse as command-line flags.
- Make repo_toplevel robust to file paths (use the parent directory as
  git's cwd). Not reachable via the CLI (_resolve_lint_paths converts
  files to parent dirs first) but defensive for direct API callers.
- Use Path.as_uri() instead of f"file://{path}" in the shallow-clone
  tests for Windows-safe URLs (a plain local path would make git
  silently ignore --depth, defeating the test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the review panel's suggested end-to-end test for --since with two
sibling lint paths (one shared ephemeral baseline, suppression counted
across both), and joins the accidentally split log-message literal in
git_baseline.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_lint_since.py (1)

54-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

copy_fixture signature diverges from documented convention.

Guidelines describe a copy_fixture(name, tmp_path) helper, but this defines copy_fixture(tmp_path) hardcoded to the lint-since fixture. Since only one fixture is used in this suite it works, but it diverges from the documented shared helper contract and won't generalize if another fixture is added later.

As per path instructions: "Use copy_fixture(name, tmp_path) to copy fixture repos into a temporary directory before running tests."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_lint_since.py` around lines 54 - 57, The helper `copy_fixture`
currently hardcodes the `lint-since` fixture and only accepts `tmp_path`, which
diverges from the shared test convention. Update `copy_fixture` to accept both
`name` and `tmp_path`, and use `name` to select the fixture directory when
calling `shutil.copytree`, then adjust the `test_lint_since` callers to pass the
fixture name so the helper matches the documented `copy_fixture(name, tmp_path)`
contract.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/ci.md`:
- Around line 136-137: The fallback fetch example uses BASE_REF without defining
it first, so update the docs snippet to either inline the GitHub base-branch
expression directly in the git fetch command or introduce BASE_REF before the
fetch call. Keep the guidance centered on the ci.md example so readers can copy
it safely without relying on an unset variable.

In `@tests/test_git_baseline.py`:
- Around line 419-431: The test may be reusing cached file contents after
rewriting CLAUDE.md in the same process, which can make the re-lint use stale
data. Update the test around test_committed_baseline_stale_hint_suggests_refresh
and _write_committed_baseline so the file-read cache is invalidated after writes
or avoid reusing the same path by using a fresh file/path per scenario before
calling run_lint_in_process. Ensure the second lint is forced to read the
updated CLAUDE.md contents, not the pre-edit version.

---

Nitpick comments:
In `@tests/test_lint_since.py`:
- Around line 54-57: The helper `copy_fixture` currently hardcodes the
`lint-since` fixture and only accepts `tmp_path`, which diverges from the shared
test convention. Update `copy_fixture` to accept both `name` and `tmp_path`, and
use `name` to select the fixture directory when calling `shutil.copytree`, then
adjust the `test_lint_since` callers to pass the fixture name so the helper
matches the documented `copy_fixture(name, tmp_path)` contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 85ced3e0-06c1-48ae-8352-9c61e1729075

📥 Commits

Reviewing files that changed from the base of the PR and between bb5f720 and bfc4326.

⛔ Files ignored due to path filters (3)
  • tests/fixtures/lint-since/.claude/rules/architecture.md is excluded by !tests/fixtures/**
  • tests/fixtures/lint-since/.skillsaw.yaml is excluded by !tests/fixtures/**
  • tests/fixtures/lint-since/CLAUDE.md is excluded by !tests/fixtures/**
📒 Files selected for processing (10)
  • README.md
  • action.yml
  • docs/baseline.md
  • docs/ci.md
  • docs/cli.md
  • src/skillsaw/cli/_lint.py
  • src/skillsaw/cli/_parser.py
  • src/skillsaw/git_baseline.py
  • tests/test_git_baseline.py
  • tests/test_lint_since.py

Comment thread docs/ci.md
Comment on lines +136 to +137
(Instead of `fetch-depth: 0`, fetching just the base branch also works:
`git fetch origin "$BASE_REF"`.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Define BASE_REF before using it.

BASE_REF is never set in this snippet, so the fallback fetch command fails if someone copies it verbatim. Inline the GitHub expression or define the variable first.

💡 Proposed fix
-(Instead of `fetch-depth: 0`, fetching just the base branch also works:
-`git fetch origin "$BASE_REF"`.)
+(Instead of `fetch-depth: 0`, fetching just the base branch also works:
+`git fetch origin "${{ github.base_ref }}"`.)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(Instead of `fetch-depth: 0`, fetching just the base branch also works:
`git fetch origin "$BASE_REF"`.)
(Instead of `fetch-depth: 0`, fetching just the base branch also works:
`git fetch origin "${{ github.base_ref }}"`.)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/ci.md` around lines 136 - 137, The fallback fetch example uses BASE_REF
without defining it first, so update the docs snippet to either inline the
GitHub base-branch expression directly in the git fetch command or introduce
BASE_REF before the fetch call. Keep the guidance centered on the ci.md example
so readers can copy it safely without relying on an unset variable.

Comment on lines +419 to +431
def test_committed_baseline_stale_hint_suggests_refresh(self, tmp_path, capsys):
repo = git_repo(tmp_path)
self._write_committed_baseline(repo, capsys)
content = (repo / "CLAUDE.md").read_text()
content = content.replace("- Try to keep functions under 50 lines.\n", "")
(repo / "CLAUDE.md").write_text(content)

rc, out, _ = run_lint_in_process(capsys, str(repo))

assert rc == 0
assert "Baseline: 1 stale entry (violations resolved since baseline was set)" in out
assert "Run `skillsaw baseline` to update." in out

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Potential stale file-read cache between baseline write and re-lint.

_write_committed_baseline runs _run_baseline in-process (reading CLAUDE.md), then the test overwrites CLAUDE.md and calls run_lint_in_process in the same process. If the utils file-read cache is keyed by path and not invalidated on write, the second lint could see the pre-edit content, silently passing or failing the "stale entry" assertion for the wrong reason.

Based on learnings: "the cache can return stale content and the re-lint may incorrectly use the old contents" when a test overwrites the same file path multiple times within a single test. Consider verifying the cache is invalidated on write, or writing to a distinct path/tmp_path per scenario to be safe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_git_baseline.py` around lines 419 - 431, The test may be reusing
cached file contents after rewriting CLAUDE.md in the same process, which can
make the re-lint use stale data. Update the test around
test_committed_baseline_stale_hint_suggests_refresh and
_write_committed_baseline so the file-read cache is invalidated after writes or
avoid reusing the same path by using a fresh file/path per scenario before
calling run_lint_in_process. Ensure the second lint is forced to read the
updated CLAUDE.md contents, not the pre-edit version.

Source: Learnings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fable-5 PR's/Issues filed by Fable

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants