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
170 changes: 170 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<!-- desloppify-begin -->
<!-- desloppify-skill-version: 2 -->
---
name: desloppify
description: >
Codebase health scanner and technical debt tracker. Use when the user asks
about code quality, technical debt, dead code, large files, god classes,
duplicate functions, code smells, naming issues, import cycles, or coupling
problems. Also use when asked for a health score, what to fix next, or to
create a cleanup plan. Supports 28 languages.
allowed-tools: Bash(desloppify *)
---

# Desloppify

## 1. Your Job

Improve code quality by maximising the **strict score** honestly.

**The main thing you do is run `desloppify next`** — it tells you exactly what to fix and how. Fix it, resolve it, run `next` again. Keep going.

Follow the scan output's **INSTRUCTIONS FOR AGENTS** — don't substitute your own analysis.

## 2. The Workflow

Two loops. The **outer loop** rescans periodically to measure progress.
The **inner loop** is where you spend most of your time: fixing issues one by one.

### Outer loop — scan and check

```bash
desloppify scan --path . # analyse the codebase
desloppify status # check scores — are we at target?
```
If not at target, work the inner loop. Rescan periodically — especially after clearing a cluster or batch of related fixes. Issues cascade-resolve and new ones may surface.

### Inner loop — fix issues

Repeat until the queue is clear:

```
1. desloppify next ← tells you exactly what to fix next
2. Fix the issue in code
3. Resolve it (next shows you the exact command including required attestation)
```

Score may temporarily drop after fixes — cascade effects are normal, keep going.
If `next` suggests an auto-fixer, run `desloppify fix <fixer> --dry-run` to preview, then apply.

**To be strategic**, use `plan` to shape what `next` gives you:
```bash
desloppify plan # see the full ordered queue
desloppify plan move <pat> top # reorder — what unblocks the most?
desloppify plan cluster create <name> # group related issues to batch-fix
desloppify plan focus <cluster> # scope next to one cluster
desloppify plan defer <pat> # push low-value items aside
desloppify plan skip <pat> # hide from next
desloppify plan done <pat> # mark complete
desloppify plan reopen <pat> # reopen
```

### Subjective reviews

The scan will prompt you when a subjective review is needed — just follow its instructions.
If you need to trigger one manually:
```bash
desloppify review --run-batches --runner codex --parallel --scan-after-import
```

### Other useful commands

```bash
desloppify next --count 5 # top 5 priorities
desloppify next --cluster <name> # drill into a cluster
desloppify show <pattern> # filter by file/detector/ID
desloppify show --status open # all open findings
desloppify plan skip --permanent "<id>" --note "reason" # accept debt (lowers strict score)
desloppify scan --path . --reset-subjective # reset subjective baseline to 0
```

## 3. Reference

### How scoring works

Overall score = **40% mechanical** + **60% subjective**.

- **Mechanical (40%)**: auto-detected issues — duplication, dead code, smells, unused imports, security. Fixed by changing code and rescanning.
- **Subjective (60%)**: design quality review — naming, error handling, abstractions, clarity. Starts at **0%** until reviewed. The scan will prompt you when a review is needed.
- **Strict score** is the north star: wontfix items count as open. The gap between overall and strict is your wontfix debt.
- **Score types**: overall (lenient), strict (wontfix counts), objective (mechanical only), verified (confirmed fixes only).

### Subjective reviews in detail

- **Preferred**: `desloppify review --run-batches --runner codex --parallel --scan-after-import` — does everything in one command.
- **Manual path**: `desloppify review --prepare` → review per dimension → `desloppify review --import file.json`.
- Import first, fix after — import creates tracked state entries for correlation.
- Integrity: reviewers score from evidence only. Scores hitting exact targets trigger auto-reset.
- Even moderate scores (60-80) dramatically improve overall health.
- Stale dimensions auto-surface in `next` — just follow the queue.

### Key concepts

- **Tiers**: T1 auto-fix → T2 quick manual → T3 judgment call → T4 major refactor.
- **Auto-clusters**: related findings are auto-grouped in `next`. Drill in with `next --cluster <name>`.
- **Zones**: production/script (scored), test/config/generated/vendor (not scored). Fix with `zone set`.
- **Wontfix cost**: widens the lenient↔strict gap. Challenge past decisions when the gap grows.
- Score can temporarily drop after fixes (cascade effects are normal).

## 4. Escalate Tool Issues Upstream

When desloppify itself appears wrong or inconsistent:

1. Capture a minimal repro (`command`, `path`, `expected`, `actual`).
2. Open a GitHub issue in `peteromallet/desloppify`.
3. If you can fix it safely, open a PR linked to that issue.
4. If unsure whether it is tool bug vs user workflow, issue first, PR second.

## Prerequisite

`command -v desloppify >/dev/null 2>&1 && echo "desloppify: installed" || echo "NOT INSTALLED — run: pip install --upgrade git+https://github.com/peteromallet/desloppify.git"`

<!-- desloppify-end -->

## VS Code Copilot Overlay

VS Code Copilot supports native subagents via `.github/agents/` definitions.
Use them for context-isolated subjective reviews.

### Subjective review

1. **Preferred**: `desloppify review --run-batches --runner codex --parallel --scan-after-import`.
2. **Copilot/cloud path**: `desloppify review --external-start --external-runner claude` → use generated prompt/template → run printed `--external-submit` command.
3. **Manual path**: define a reviewer agent, split dimensions, merge, import.

For the manual path, define a reviewer in `.github/agents/desloppify-reviewer.md`:

```yaml
---
name: desloppify-reviewer
tools: ['read', 'search']
---
You are a code quality reviewer. You will be given a codebase path, a set of
dimensions to score, and what each dimension means. Read the code, score each
dimension 0-100 from evidence only, and return JSON in the required format.
Do not anchor to target thresholds. When evidence is mixed, score lower and
explain uncertainty.
```

And an orchestrator in `.github/agents/desloppify-review-orchestrator.md`:

```yaml
---
name: desloppify-review-orchestrator
tools: ['agent', 'read', 'search']
agents: ['desloppify-reviewer']
---
```

Split dimensions across `desloppify-reviewer` calls (Copilot runs them concurrently), merge assessments (average overlaps) and findings, then import.

### Review integrity

1. Do not use prior chat context, score history, or target-threshold anchoring while scoring.
2. Score from evidence only; when mixed, score lower and explain uncertainty.
3. Return JSON matching the format in the base skill doc. For `--external-submit`, include `session` from the generated template.
4. `findings` MUST match `query.system_prompt` exactly. Use `"findings": []` when no defects found.
5. Import is fail-closed: invalid findings abort unless `--allow-partial` is passed.

<!-- desloppify-overlay: copilot -->
<!-- desloppify-end -->
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ bin/
### Mac OS ###
.DS_Store
/.idea/
/.desloppify
11 changes: 10 additions & 1 deletion common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ dependencies {
// Lombok
compileOnly("org.projectlombok:lombok:1.18.32")
annotationProcessor("org.projectlombok:lombok:1.18.32")

// Testing
testImplementation(platform("org.junit:junit-bom:5.12.2"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

java {
Expand All @@ -53,6 +58,10 @@ tasks.shadowJar {
archiveClassifier.set("")
}

tasks.test {
useJUnitPlatform()
}

publishing {
publications {
create<MavenPublication>("mavenJava") {
Expand Down Expand Up @@ -85,4 +94,4 @@ publishing {
}
}
}
}
}
19 changes: 10 additions & 9 deletions common/src/main/java/games/negative/engine/util/NumberUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -391,18 +391,19 @@ public static String condense(BigDecimal number) {
*/
public static String condense(BigDecimal number, final char[] set) {
BigDecimal thousand = BigDecimal.valueOf(1000);
String condensed;
if (number.compareTo(thousand) < 0) {
return number.stripTrailingZeros().toPlainString(); // Return the number itself if less than 1000.
}

int exp = (int) (Math.floor(Math.log10(number.doubleValue()) / 3));

String suffixes = (set == null) ? SUFFIXES : new String(set);
char suffix = suffixes.charAt(Math.min(exp - 1, suffixes.length() - 1));
condensed = number.stripTrailingZeros().toPlainString(); // Return the number itself if less than 1000.
} else {
int exp = (int) (Math.floor(Math.log10(number.doubleValue()) / 3));

BigDecimal result = number.divide(thousand.pow(exp), 1, RoundingMode.HALF_UP);
String suffixes = (set == null) ? SUFFIXES : new String(set);
char suffix = suffixes.charAt(Math.min(exp - 1, suffixes.length() - 1));

return String.format("%.1f%c", result, suffix);
BigDecimal result = number.divide(thousand.pow(exp), 1, RoundingMode.HALF_UP);
condensed = String.format("%.1f%c", result, suffix);
}
return condensed;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ private static Duration parseUnitFormat(String input) {
if (Character.isDigit(c)) {
numberBuffer.append(c);
} else if (Character.isWhitespace(c)) {
continue; // Allow whitespace
// Allow whitespace.
} else {
if (numberBuffer.isEmpty()) {
throw new IllegalArgumentException(
Expand Down
18 changes: 18 additions & 0 deletions common/src/test/java/games/negative/engine/util/IntListTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package games.negative.engine.util;

import games.negative.engine.util.IntList;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

class IntListTest {

@Test
void parseSupportsSinglesAndRanges() {
List<Integer> values = IntList.parse(List.of("1", "3-5", " 7 "));

assertEquals(List.of(1, 3, 4, 5, 7), values);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package games.negative.engine.util;

import games.negative.engine.util.NumberUtil;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;

class NumberUtilTest {

@Test
void fancyAddsExpectedSuffixes() {
assertEquals("1st", NumberUtil.fancy(1));
assertEquals("12th", NumberUtil.fancy(12));
assertEquals("23rd", NumberUtil.fancy(23));
}

@Test
void condenseFormatsValues() {
assertEquals("1.5k", NumberUtil.condense(1500));
assertEquals("999", NumberUtil.condense(new BigDecimal("999")));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package games.negative.engine.util;

import games.negative.engine.util.OptionalBool;
import org.junit.jupiter.api.Test;

import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

class OptionalBoolTest {

@Test
void ofReturnsSingletonInstances() {
assertSame(OptionalBool.of(true), OptionalBool.of(true));
assertSame(OptionalBool.of(false), OptionalBool.of(false));
}

@Test
void mapIfTrueAndFalseBehaveAsExpected() {
Optional<String> whenTrue = OptionalBool.of(true).mapIfTrue(() -> "value");
Optional<String> whenFalse = OptionalBool.of(false).mapIfTrue(() -> "value");

assertEquals(Optional.of("value"), whenTrue);
assertTrue(whenFalse.isEmpty());
}

@Test
void ifTrueOrElseRunsCorrectBranch() {
AtomicInteger marker = new AtomicInteger(0);
OptionalBool.of(false).ifTrueOrElse(
() -> marker.set(1),
() -> marker.set(2)
);

assertFalse(OptionalBool.of(false).isTrue());
assertEquals(2, marker.get());
}
}
26 changes: 26 additions & 0 deletions common/src/test/java/games/negative/engine/util/TimeUtilTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package games.negative.engine.util;

import games.negative.engine.util.TimeUtil;
import org.junit.jupiter.api.Test;

import java.time.Duration;

import static org.junit.jupiter.api.Assertions.assertEquals;

class TimeUtilTest {

@Test
void formatAndParseRoundTripUnitFormat() {
Duration duration = TimeUtil.parse("1h30m");
assertEquals(Duration.ofMinutes(90), duration);
assertEquals("1h 30m", TimeUtil.format(duration, true));
}

@Test
void formatAndParseRoundTripColonFormat() {
Duration duration = Duration.ofHours(1).plusMinutes(5).plusSeconds(2);
String formatted = TimeUtil.formatColonSeparated(duration);
assertEquals("1:05:02", formatted);
assertEquals(duration, TimeUtil.parseColonSeparated(formatted));
}
}
Loading
Loading