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
17 changes: 17 additions & 0 deletions .changeset/tune-issues-overview-noise-suggestions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@codacy/codacy-cloud-cli": patch
---

Stop `issues --overview` from suggesting noise reduction on repositories that aren't
actually noisy. The "Suggested actions to reduce noise" section now requires two absolute
floors before anything is suggested: the repository must have at least 200 issues in total,
and an individual pattern must produce at least 100 issues on its own. The per-pattern floor
matters because a repository with a long tail of tiny patterns pulls the median issues-per-
pattern very low, which previously made a pattern with only a handful of issues look
disproportionate — now a rule has to genuinely flood the repo before it's flagged. On top of
those floors, a pattern must still show a relative signal: the "dominant share" rule (≥10% of
all issues) only applies when there are at least 11 distinct patterns (an even split of N
patterns only drops below 10% once N is above 10, so 8-10 balanced patterns would otherwise
all be flagged), and the "disproportionate count" rule now compares each
pattern against the **median** issues-per-pattern instead of the mean, so a single huge
pattern can no longer inflate the baseline and hide smaller-but-still-disproportionate ones.
1 change: 1 addition & 0 deletions SPECS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,4 @@ _No pending tasks._ All commands implemented.
| 2026-06-24 | `findings` and `finding` now surface the vulnerable dependency's import chain from the new `dependencyChains` field: Direct (`Update <pkg> to <fixed>`) vs Transitive (`<chain> (Fixed in <fixed>)`), with the middle collapsed to `... N more ...` for 4+ packages. List shows the first chain + `... and X more`; detail shows all chains aligned under a single label. New helpers in `utils/formatting.ts` (`formatDependencyChain`, `formatDependencyChainsLine`, `formatDependencyChainsBlock`); `dependencyChains` added to both JSON projections (17 new tests, 390 total) |
| 2026-06-30 | npm-style "update available" notice via `update-notifier@5`: one-time stderr hint when a newer version is published, gated to `--output table` (suppressed for `json`, when piped, in CI, under `npx`). Non-blocking daily background check; never auto-updates. New `src/version.ts` (single source of name/version) + `src/utils/update-check.ts` (`maybeNotifyUpdate`); `preAction` hook + `--no-update-notifier` flag wired in `index.ts`. Opt-outs: `CODACY_DISABLE_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `--no-update-notifier`. `package.json` `overrides` pin transitive `got@^11.8.6`/`package-json@^7` (CVE-2022-33987) (7 new tests, 409 total) |
| 2026-07-07 | `ls` and `directories` commands: browse a repository's folders/files with quality metrics (Grade/Issues/Complexity/Duplication/Coverage). Auto-detect provider/org/repo and the cwd-relative path; `--path`/`--branch` options; `directories --plus-children` shows one extra level as a `└─` tree (header adds `, M subdirectories`). `--sort`/`--direction` (server-side; in `ls`, directories and files sorted independently), and `ls --search <term>` (files only; folds the path into the search as `<path>/%<term>` and shows full paths). Duplication uses `numberOfClones`; Complexity uses `complexity` (hotspots). Both fetch **all** pages (no pagination warning) via new `src/utils/repo-tree.ts` (path resolution + `resolveSort`/`resolveDirection` + `fetchAllDirectories`/`fetchAllFiles`). Row markers `▸` folder / dim `·` file (no emojis). Promoted `formatGrade` to `utils/formatting.ts` (now also colors E red) and added `formatCountCell`/`formatCoverageCell` (48 new tests, 457 total) |
| 2026-07-08 | `issues --overview` noise suggestions tuned to stop firing on low-volume repos: added a `NOISE_MIN_TOTAL` (200) floor on the repo's total issues that suppresses the whole "reduce noise" section below it, and a `NOISE_MIN_PATTERN` (100) absolute floor on each pattern's own count (AND-gated with the relative rules) so a long tail of tiny patterns can't drag the median down and make a ~9-issue pattern look noisy — the total floor is kept above the per-pattern floor so it isn't dead code; the ≥10% share rule now only applies with ≥11 distinct patterns (`NOISE_MIN_PATTERNS_FOR_SHARE` — an even split only drops below 10% once N > 10, so 8–10 balanced patterns would otherwise all be flagged); and the ≥3× multiple rule (`NOISE_MEDIAN_MULTIPLE`) now measures against the **median** (via new `medianOf()`) instead of the mean, so a single huge pattern can no longer inflate the baseline and mask smaller disproportionate patterns (5 new tests, 465 total) |
29 changes: 24 additions & 5 deletions SPECS/commands/issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,30 @@ False Positive" (the bucket is keyed on FP probability vs. the configured
threshold, so at/above threshold = a potential false positive).

After the tables, a **"Suggested actions to reduce noise"** section lists patterns
worth disabling. A pattern is "noisy" when it accounts for **≥10% of all issues**
shown, **or** has **≥3× the average** issues-per-pattern. The owning tool is
resolved by matching the pattern ID against each tool's `prefix` (longest match
wins); patterns whose tool can't be resolved (no/unknown prefix) are dropped
silently. The list is capped at 10 with a "… (N more)" note.
worth disabling. A pattern must clear two absolute floors **and** show a relative
signal:

- **Total floor** (`NOISE_MIN_TOTAL`, 200): the section is suppressed entirely
unless the repo has ≥200 issues in total — on low-volume repos, disabling a rule
to shave a handful of issues isn't real noise reduction. Kept above the
per-pattern floor so it does independent work (were they equal, any pattern that
clears the per-pattern floor would already push the repo past an equal total
floor, making it dead code).
- **Per-pattern floor** (`NOISE_MIN_PATTERN`, 100): the individual pattern must
have ≥100 issues on its own. Without it, a long tail of tiny patterns drags the
median down (e.g. to 3) so far that a pattern with only ~9 issues clears the
relative bar below — yet 9 issues is nothing worth disabling a rule over.
- **Relative signal** (either one): accounts for **≥10% of all issues** shown
(only applied when there are **≥11 distinct patterns** — an even split of N
patterns gives each `1/N`, which only drops below 10% once N > 10, so with 8–10
patterns a balanced repo would otherwise flag every one), **or** has **≥3× the
median** issues-per-pattern.
The median (not the mean) is used so a single huge pattern can't inflate the
baseline and mask smaller-but-still-disproportionate patterns.

The owning tool is resolved by matching the pattern ID against each tool's
`prefix` (longest match wins); patterns whose tool can't be resolved (no/unknown
prefix) are dropped silently. The list is capped at 10 with a "… (N more)" note.

The suggested step depends on **how the pattern is managed**, since not every
pattern can be disabled through the CLI:
Expand Down
2 changes: 1 addition & 1 deletion src/commands/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ Keeps the two command handlers thin: they only supply the API-specific callbacks
- **List mode** (default): card-style format sorted by severity (Error > High > Warning > Info)
- **Overview mode** (`-O, --overview`): seven count tables — Category, Severity, Language, Tag, Pattern, Author, False Positives
- The False Positives table relabels the API's raw bucket names via `FALSE_POSITIVE_LABELS`: `belowThreshold` → "Not a False Positive", `equalOrAboveThreshold` → "Potential False Positive" (the threshold is on FP probability, so at/above = potential FP — matching `printIssueCard`)
- **Noise suggestions**: after the tables, `detectNoisyPatterns()` flags patterns that account for ≥10% of all issues (`NOISE_SHARE`) **or** have ≥3× the average issues-per-pattern (`NOISE_AVG_MULTIPLE`), sorted by count desc. For each, `resolvePatternTool()` maps the pattern ID to its owning tool by matching `Tool.prefix` (e.g. `Bandit_B101` → prefix `Bandit_`; longest match wins) against the global tool list (`fetchAllTools()` / `ToolsService.listTools`). Resolved ones print a `> codacy pattern <tool> <patternId> --disable` line under "Suggested actions to reduce noise". Patterns whose tool can't be resolved (no/unmatched prefix) are **silently discarded**. The tools fetch only happens when noisy patterns exist; JSON output is unaffected (raw counts only)
- **Noise suggestions**: after the tables, `detectNoisyPatterns()` flags noisy patterns, sorted by count desc. A pattern must clear **two absolute floors** and show a **relative signal**: (1) the repo must have at least `NOISE_MIN_TOTAL` (200) issues total, else the whole section is suppressed — kept above `NOISE_MIN_PATTERN` so it does independent work (were they equal, any pattern clearing the per-pattern floor would already push the repo past an equal total floor, making it dead code); (2) the individual pattern must have at least `NOISE_MIN_PATTERN` (100) issues — this AND-gate is what stops a long tail of tiny patterns (which drags the median to ~3) from making a 9-issue pattern look "3× the median"; (3) *and* it must either account for ≥`NOISE_SHARE` (10%) of all issues **or** have ≥`NOISE_MEDIAN_MULTIPLE` (3×) the **median** issues-per-pattern. The share rule only applies when there are at least `NOISE_MIN_PATTERNS_FOR_SHARE` (11) distinct patterns — an even split of N patterns gives each `1/N`, which only drops below 10% once N > 10, so with 8–10 patterns a perfectly balanced repo would otherwise flag every pattern. The median (not the mean, via `medianOf()`) is deliberate: a single huge pattern would inflate a mean baseline and mask smaller-but-still-disproportionate patterns. For each noisy pattern, `resolvePatternTool()` maps the pattern ID to its owning tool by matching `Tool.prefix` (e.g. `Bandit_B101` → prefix `Bandit_`; longest match wins) against the global tool list (`fetchAllTools()` / `ToolsService.listTools`). Resolved ones print a `> codacy pattern <tool> <patternId> --disable` line under "Suggested actions to reduce noise". Patterns whose tool can't be resolved (no/unmatched prefix) are **silently discarded**. The tools fetch only happens when noisy patterns exist; JSON output is unaffected (raw counts only)
- **Filters**: `--branch`, `--patterns`, `--tools`, `--severities`, `--categories`, `--languages`, `--tags`, `--authors`, `--limit`
- **`--false-positives [value]`** (`-F`): tri-state filter — `true` (default when flag present) sends `onlyPotentialFalsePositives: true`, `false` sends `onlyPotentialFalsePositives: false`, omitted sends nothing
- **`--ignore` mode** (`-I`): fetches all issues matching current filters (all pages), then calls `AnalysisService.bulkIgnoreIssues` in batches of 100
Expand Down
209 changes: 205 additions & 4 deletions src/commands/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,10 @@ describe("issues command", () => {
});

describe("overview noise suggestions", () => {
// One dominant pattern plus nine small ones: total 2950, avg 295.
// Bandit_B101 (2500) is both >=10% of total and >=3x average → noisy.
// One dominant pattern plus nine small ones: total 2950, median 50.
// Bandit_B101 (2500) clears the per-pattern floor (100) and is >=3x the
// median (150) → noisy. (With only 10 patterns the share rule is off, so the
// median rule is what flags it here.)
function noisyOverview() {
const patterns = [
{ id: "Bandit_B101", title: "Use of assert detected", total: 2500 },
Expand Down Expand Up @@ -376,8 +378,9 @@ describe("issues command", () => {
});

it("shows no suggestions and skips the tools fetch when nothing is noisy", async () => {
// Twelve evenly-sized patterns: each is ~8.3% of total and equal to the
// average, so none crosses the >=10% or >=3x-average thresholds.
// Twelve evenly-sized patterns: each is ~8.3% of total (below the >=10%
// share floor of 120) and equal to the median (below the >=3x-median floor
// of 300), so none crosses either threshold.
const patterns = Array.from({ length: 12 }, (_, i) => ({
id: `Tool_${i}`,
title: `Pattern ${i}`,
Expand Down Expand Up @@ -406,6 +409,204 @@ describe("issues command", () => {
expect(output).not.toContain("Suggested actions to reduce noise");
expect(ToolsService.listTools).not.toHaveBeenCalled();
});

it("does not flag every pattern in a perfectly balanced repo", async () => {
// Ten evenly-distributed patterns of 100 (total 1000): each is exactly 10%
// of the total and clears the per-pattern floor, so a share threshold of 10
// would flag ALL of them. NOISE_MIN_PATTERNS_FOR_SHARE (11) disables the
// share rule here (10 < 11), and the median rule can't fire on a flat
// distribution (100 < 3x median 100), so nothing is flagged.
const patterns = Array.from({ length: 10 }, (_, i) => ({
id: `Tool_${i}`,
title: `Pattern ${i}`,
total: 100,
}));
vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({
data: {
counts: {
categories: [],
levels: [{ name: "Warning", total: 1000 }],
languages: [],
tags: [],
patterns,
authors: [],
potentialFalsePositives: [],
},
},
} as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo", "--overview",
]);

const output = getAllOutput();
expect(output).not.toContain("Suggested actions to reduce noise");
expect(ToolsService.listTools).not.toHaveBeenCalled();
});

it("makes no suggestions below the absolute total floor", async () => {
// A single pattern of 120 that clears the per-pattern floor (100) and is
// disproportionate (median 6, 3x = 18; 80% share), so everything except the
// total floor would flag it. But the repo has only 150 issues total, below
// NOISE_MIN_TOTAL (200) — so the whole section is suppressed regardless.
const patterns = [
{ id: "Bandit_B101", title: "Use of assert detected", total: 120 },
...Array.from({ length: 5 }, (_, i) => ({
id: `Other_${i}`,
title: `Pattern ${i}`,
total: 6,
})),
];
vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({
data: {
counts: {
categories: [],
levels: [{ name: "Warning", total: 150 }],
languages: [],
tags: [],
patterns,
authors: [],
potentialFalsePositives: [],
},
},
} as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo", "--overview",
]);

const output = getAllOutput();
expect(output).not.toContain("Suggested actions to reduce noise");
// Nothing is noisy, so the tools lookup is skipped entirely.
expect(ToolsService.listTools).not.toHaveBeenCalled();
});

it("does not apply the share rule when there are too few patterns", async () => {
// Five patterns of comparable size: {120, 80, 80, 80, 80}, total 440.
// The 120 is 27% of the total, but with only 5 patterns an even split is
// already 20% each, so the share rule is disabled. And 120 < 3x median(80)
// = 240, so the multiple rule doesn't fire either → nothing is noisy.
const patterns = [
{ id: "Bandit_B101", title: "Use of assert detected", total: 120 },
...Array.from({ length: 4 }, (_, i) => ({
id: `Other_${i}`,
title: `Pattern ${i}`,
total: 80,
})),
];
vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({
data: {
counts: {
categories: [],
levels: [{ name: "Warning", total: 440 }],
languages: [],
tags: [],
patterns,
authors: [],
potentialFalsePositives: [],
},
},
} as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo", "--overview",
]);

const output = getAllOutput();
expect(output).not.toContain("Suggested actions to reduce noise");
expect(ToolsService.listTools).not.toHaveBeenCalled();
});

it("flags a secondary pattern a mean baseline would have masked", async () => {
// A mega-outlier (5000) drags the *mean* to 512, so the old 3x-mean floor
// of 1536 would have hidden the genuinely-disproportionate 120-issue pattern
// (24x the typical 5). The median floor (3x median 5 = 15) catches both, and
// 120 clears the absolute per-pattern floor of 100.
const patterns = [
{ id: "Bandit_B608", title: "Possible SQL injection", total: 5000 },
{ id: "Bandit_B101", title: "Use of assert detected", total: 120 },
...Array.from({ length: 8 }, (_, i) => ({
id: `Other_${i}`,
title: `Pattern ${i}`,
total: 5,
})),
];
vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({
data: {
counts: {
categories: [],
levels: [{ name: "Warning", total: 5160 }],
languages: [],
tags: [],
patterns,
authors: [],
potentialFalsePositives: [],
},
},
} as any);
vi.mocked(ToolsService.listTools).mockResolvedValue(banditTool as any);
vi.mocked(AnalysisService.listRepositoryTools).mockResolvedValue(
banditRepoTool() as any,
);
vi.mocked(AnalysisService.listRepositoryToolPatterns).mockResolvedValue(
banditPattern() as any,
);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo", "--overview",
]);

const output = getAllOutput().replace(/\x1b\[[0-9;]*m/g, "");
expect(output).toContain("Suggested actions to reduce noise");
// The mega-outlier and the secondary pattern are both suggested.
expect(output).toContain("codacy pattern Bandit Bandit_B608 --disable");
expect(output).toContain("codacy pattern Bandit Bandit_B101 --disable");
// The typical 5-issue patterns are not.
expect(output).not.toContain('Disable "Pattern 0"');
});

it("does not flag a disproportionate pattern below the per-pattern floor", async () => {
// A repo of 210 issues (above NOISE_MIN_TOTAL) where one pattern of 90 is
// dominant — 43% share, and well above 3x the median of 8 (24). It clears
// every gate except the per-pattern floor: 90 < NOISE_MIN_PATTERN (100). A
// rule producing 90 issues isn't a flood worth disabling, so despite being
// disproportionate it must not be suggested.
const patterns = [
{ id: "Bandit_B101", title: "Use of assert detected", total: 90 },
...Array.from({ length: 15 }, (_, i) => ({
id: `Other_${i}`,
title: `Pattern ${i}`,
total: 8,
})),
];
vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({
data: {
counts: {
categories: [],
levels: [{ name: "Warning", total: 210 }],
languages: [],
tags: [],
patterns,
authors: [],
potentialFalsePositives: [],
},
},
} as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo", "--overview",
]);

const output = getAllOutput();
expect(output).not.toContain("Suggested actions to reduce noise");
// Nothing clears the absolute floor, so the tools lookup is skipped.
expect(ToolsService.listTools).not.toHaveBeenCalled();
});
});

it("should pass filter options to the API body", async () => {
Expand Down
Loading
Loading