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
155 changes: 155 additions & 0 deletions .github/workflows/slop-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
name: Slop CI

on:
pull_request:
paths-ignore:
- "**/*.md"
- "docs/**"
- "assets/**"
- "LICENSE"
workflow_dispatch:
inputs:
base_ref:
description: Base branch, tag, or local ref to compare against
required: false
default: main

concurrency:
group: slop-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
slop:
name: Slop analyzer delta
runs-on: ubuntu-latest
permissions:
contents: read
env:
SLOP_ANALYZER_VERSION: 0.1.2
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Resolve base commit
id: base
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
INPUT_BASE_REF: ${{ inputs.base_ref }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
base_sha="$PR_BASE_SHA"
else
base_ref="${INPUT_BASE_REF:-main}"
if git rev-parse --verify "$base_ref^{commit}" >/dev/null 2>&1; then
base_sha="$(git rev-parse "$base_ref")"
else
git fetch --no-tags origin "$base_ref"
base_sha="$(git rev-parse FETCH_HEAD)"
fi
fi

echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"

- name: Create archived base/head snapshots
id: snapshots
env:
BASE_SHA: ${{ steps.base.outputs.base_sha }}
run: |
base_dir="$(mktemp -d "${RUNNER_TEMP}/slop-base-XXXXXX")"
head_dir="$(mktemp -d "${RUNNER_TEMP}/slop-head-XXXXXX")"

git archive "$BASE_SHA" | tar -xf - -C "$base_dir"
git archive HEAD | tar -xf - -C "$head_dir"

echo "base_dir=$base_dir" >> "$GITHUB_OUTPUT"
echo "head_dir=$head_dir" >> "$GITHUB_OUTPUT"

- name: Scan archived snapshots with slop-analyzer
env:
BASE_DIR: ${{ steps.snapshots.outputs.base_dir }}
HEAD_DIR: ${{ steps.snapshots.outputs.head_dir }}
run: |
mkdir -p slop-artifacts

bunx "slop-analyzer@${SLOP_ANALYZER_VERSION}" scan "$BASE_DIR" --json > slop-artifacts/base-slop.json
bunx "slop-analyzer@${SLOP_ANALYZER_VERSION}" scan "$HEAD_DIR" --json > slop-artifacts/head-slop.json

- name: Build Hunk review artifacts
env:
BASE_SHA: ${{ steps.base.outputs.base_sha }}
run: |
bun run scripts/slop-review.ts \
--base slop-artifacts/base-slop.json \
--head slop-artifacts/head-slop.json \
> slop-artifacts/slop-summary.txt

bun run scripts/slop-review.ts \
--base slop-artifacts/base-slop.json \
--head slop-artifacts/head-slop.json \
--json \
> slop-artifacts/slop-delta.json

bun run scripts/slop-review.ts \
--base slop-artifacts/base-slop.json \
--head slop-artifacts/head-slop.json \
--agent-context \
> slop-artifacts/slop-agent-context.json

git diff --no-color "$BASE_SHA"...HEAD > slop-artifacts/pr.patch

{
echo "Review locally with Hunk:"
echo "hunk patch pr.patch --agent-context slop-agent-context.json"
} > slop-artifacts/README.txt

- name: Publish slop summary
env:
BASE_SHA: ${{ steps.base.outputs.base_sha }}
run: |
{
echo "## Slop analyzer"
echo
echo "- analyzer: slop-analyzer@${SLOP_ANALYZER_VERSION}"
echo "- base: $BASE_SHA"
echo
echo '```text'
cat slop-artifacts/slop-summary.txt
echo '```'
echo
echo "Review locally with:"
echo '```bash'
echo 'hunk patch pr.patch --agent-context slop-agent-context.json'
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload slop review artifacts
uses: actions/upload-artifact@v4
with:
name: slop-review
path: |
slop-artifacts/README.txt
slop-artifacts/base-slop.json
slop-artifacts/head-slop.json
slop-artifacts/slop-summary.txt
slop-artifacts/slop-delta.json
slop-artifacts/slop-agent-context.json
slop-artifacts/pr.patch
if-no-files-found: error

- name: Fail on newly introduced slop
run: |
bun run scripts/slop-review.ts \
--base slop-artifacts/base-slop.json \
--head slop-artifacts/head-slop.json \
--fail-on-new
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,37 @@ hunk diff --agent-context notes.json
hunk patch change.patch --agent-context notes.json
```

#### Review `slop-analyzer` findings in Hunk

Hunk can turn [`slop-analyzer`](https://github.com/benvinegar/slop-analyzer) JSON into inline review notes.

For one local report:

```bash
bunx slop-analyzer@0.1.2 scan . --json > head-slop.json
bun run scripts/slop-review.ts --head head-slop.json --agent-context > slop-agent-context.json
hunk diff --agent-context slop-agent-context.json
```

For CI, compare a base report to the current branch so only new findings fail the check and show up in review artifacts:

```bash
git worktree add ../base "$BASE_SHA"
bunx slop-analyzer@0.1.2 scan ../base --json > base-slop.json
bunx slop-analyzer@0.1.2 scan . --json > head-slop.json
bun run scripts/slop-review.ts --base base-slop.json --head head-slop.json --fail-on-new
bun run scripts/slop-review.ts --base base-slop.json --head head-slop.json --agent-context > slop-agent-context.json
git diff --no-color "$BASE_SHA"...HEAD > pr.patch
```

The repo's PR workflow at [`.github/workflows/slop-ci.yml`](.github/workflows/slop-ci.yml) automates that flow with archived base/head snapshots and uploads the review artifacts for local Hunk inspection.

Upload `pr.patch` plus `slop-agent-context.json` as CI artifacts, then open them locally with:

```bash
hunk patch pr.patch --agent-context slop-agent-context.json
```

## Examples

Ready-to-run demo diffs live in [`examples/`](examples/README.md).
Expand Down
136 changes: 136 additions & 0 deletions scripts/slop-review.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test";
import {
buildAgentContext,
buildDeltaOccurrences,
buildDeltaReport,
formatDeltaText,
type SlopReport,
} from "./slop-review";

const baseReport: SlopReport = {
summary: {
findingCount: 2,
},
findings: [
{
ruleId: "structure.duplicate-function-signatures",
family: "structure",
severity: "medium",
scope: "file",
message: "Found 2 duplicated function signatures",
evidence: ["normalizeUser at line 1", "normalizeTeam at line 1"],
score: 3,
path: "src/users/normalize.ts",
locations: [
{ path: "src/users/normalize.ts", line: 1 },
{ path: "src/teams/normalize.ts", line: 1 },
],
},
{
ruleId: "defensive.needless-try-catch",
family: "defensive",
severity: "strong",
scope: "file",
message: "Found 1 defensive try/catch block",
evidence: ["line 10: try=1, catch=1"],
score: 1,
path: "src/error.ts",
locations: [{ path: "src/error.ts", line: 10 }],
},
],
fileScores: [
{ path: "src/users/normalize.ts", score: 3, findingCount: 1 },
{ path: "src/teams/normalize.ts", score: 3, findingCount: 1 },
{ path: "src/error.ts", score: 1, findingCount: 1 },
],
};

const headReport: SlopReport = {
summary: {
findingCount: 3,
},
findings: [
...(baseReport.findings ?? []),
{
ruleId: "structure.duplicate-function-signatures",
family: "structure",
severity: "medium",
scope: "file",
message: "Found 3 duplicated function signatures",
evidence: [
"normalizeUser at line 1",
"normalizeTeam at line 1",
"normalizeAccount at line 1",
],
score: 4.5,
path: "src/users/normalize.ts",
locations: [
{ path: "src/users/normalize.ts", line: 1 },
{ path: "src/teams/normalize.ts", line: 1 },
{ path: "src/accounts/normalize.ts", line: 1 },
],
},
],
fileScores: [
{ path: "src/accounts/normalize.ts", score: 4.5, findingCount: 1 },
{ path: "src/users/normalize.ts", score: 4.5, findingCount: 1 },
{ path: "src/teams/normalize.ts", score: 4.5, findingCount: 1 },
{ path: "src/error.ts", score: 1, findingCount: 1 },
],
};

describe("slop review helpers", () => {
test("delta comparison keeps only new per-file occurrences from grouped findings", () => {
const delta = buildDeltaOccurrences(baseReport, headReport);

expect(delta).toHaveLength(3);
expect(delta.map((occurrence) => occurrence.path)).toEqual([
"src/accounts/normalize.ts",
"src/users/normalize.ts",
"src/teams/normalize.ts",
]);
});

test("agent context groups new findings by file and emits hunk-friendly annotations", () => {
const context = buildAgentContext(baseReport, headReport);

expect(context.summary).toContain("3 new findings across 3 files vs base");
expect(context.files.map((file) => file.path)).toEqual([
"src/accounts/normalize.ts",
"src/users/normalize.ts",
"src/teams/normalize.ts",
]);
expect(context.files[0]).toMatchObject({
path: "src/accounts/normalize.ts",
summary: "1 new slop finding · hotspot score 4.50.",
annotations: [
{
summary: "Found 3 duplicated function signatures",
newRange: [1, 1],
confidence: "medium",
source: "slop-analyzer",
author: "slop-analyzer",
},
],
});
expect(context.files[0]?.annotations[0]?.rationale).toContain(
"Rule: structure.duplicate-function-signatures",
);
expect(context.files[0]?.annotations[0]?.rationale).toContain("Also flagged in 2 other files.");
});

test("delta report and text summary describe new findings succinctly", () => {
const delta = buildDeltaReport(baseReport, headReport);
const text = formatDeltaText(baseReport, headReport);

expect(delta.summary).toEqual({
baseFindingCount: 2,
headFindingCount: 3,
newFindingCount: 3,
newFileCount: 3,
});
expect(text).toContain("New slop findings: 3 across 3 files");
expect(text).toContain("- src/accounts/normalize.ts");
expect(text).toContain("medium Found 3 duplicated function signatures");
});
});
Comment on lines +82 to +136
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.

P2 parseArgs error branches not covered

parseArgs is exported and has several distinct error paths (missing --head, flag-as-value guard, mutually exclusive --json/--agent-context, unknown argument) that are each exercised only by hand. A few targeted tests would prevent regressions when the CLI grows:

  • parseArgs([]) → throws "Pass --head"
  • parseArgs(["--head", "--base"]) → throws "Expected a file path"
  • parseArgs(["--head", "a.json", "--json", "--agent-context"]) → throws "Specify only one"
  • parseArgs(["--head", "a.json", "--unknown"]) → throws "Unknown argument"

Per the repo's testing guidelines, unit tests should be colocated next to the code they cover (scripts/slop-review.test.ts is already the right place).

Loading
Loading