From 529f6ad2acfc2b69646bf46c61ba645ddd611ffc Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Tue, 7 Jul 2026 15:26:42 +0100 Subject: [PATCH 1/3] feat: add ls and directories commands for browsing repo trees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two repository-level commands that browse a repo's tree with quality metrics (Grade / Issues / Complexity / Duplication / Coverage), backed by the listFiles and listDirectories endpoints (introduced by the API spec bump to 56.2.9): - `ls` lists directories and files at a path; `directories` (alias `dirs`) lists folders only, with `--plus-children` for one extra level as a tree. - Auto-detect provider/org/repo from the git remote and the path from the current directory; override with positional args, `--path`, `--branch`. - `--sort`/`--direction` (server-side; in `ls`, directories and files are sorted independently, never merged) and `ls --search ` (files at any depth under the path). - Both fetch every page (no pagination truncation). Rows use `▸`/`·` markers (no emojis); Duplication = cloned blocks, Complexity = max under the path. Shared helpers live in the new src/utils/repo-tree.ts; formatGrade was promoted to utils/formatting.ts (and now colors E red) alongside new cell formatters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/ls-and-directories-commands.md | 15 + README.md | 2 + SPECS/README.md | 3 + SPECS/commands/directories.md | 57 ++++ SPECS/commands/ls.md | 61 ++++ package.json | 2 +- src/commands/AGENTS.md | 59 ++++ src/commands/directories.test.ts | 246 +++++++++++++++ src/commands/directories.ts | 246 +++++++++++++++ src/commands/ls.test.ts | 350 ++++++++++++++++++++++ src/commands/ls.ts | 268 +++++++++++++++++ src/commands/repositories.ts | 15 +- src/index.ts | 4 + src/utils/formatting.test.ts | 39 +++ src/utils/formatting.ts | 36 +++ src/utils/repo-tree.test.ts | 215 +++++++++++++ src/utils/repo-tree.ts | 202 +++++++++++++ 17 files changed, 1805 insertions(+), 15 deletions(-) create mode 100644 .changeset/ls-and-directories-commands.md create mode 100644 SPECS/commands/directories.md create mode 100644 SPECS/commands/ls.md create mode 100644 src/commands/directories.test.ts create mode 100644 src/commands/directories.ts create mode 100644 src/commands/ls.test.ts create mode 100644 src/commands/ls.ts create mode 100644 src/utils/repo-tree.test.ts create mode 100644 src/utils/repo-tree.ts diff --git a/.changeset/ls-and-directories-commands.md b/.changeset/ls-and-directories-commands.md new file mode 100644 index 0000000..f0b156b --- /dev/null +++ b/.changeset/ls-and-directories-commands.md @@ -0,0 +1,15 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +Add `ls` and `directories` commands to browse a repository's tree with quality +metrics. `ls` lists the directories and files at a path — showing Grade, Issues, +Complexity, Duplication, and Coverage per row — and `directories` (alias `dirs`) +lists folders only, with `--plus-children` to also show one level of +sub-directories as a `└─` tree. Both auto-detect the provider/organization/repository +from the git remote and the path from your current directory (relative to the +repo root); override with positional args, `--path`, and `--branch`. Sort with +`--sort ` (`name`, `issues`, `grade`, `duplication`, `complexity`, +`coverage`) and `--direction asc|desc`. `codacy ls --search ` finds files +at any depth under the path. Folders and files are marked with `▸` and `·` (no +emojis). Both commands fetch every page of results, so nothing is truncated. diff --git a/README.md b/README.md index 13f90b8..c155403 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Supported providers: GitHub (`gh`), GitLab (`gl`), Bitbucket (`bb`). | `info` | Show authenticated user info and their organizations | | `repositories ` | List repositories for an organization | | `repository [provider] [org] [repo]` | Show metrics for a repository, or add/remove/follow/unfollow/reanalyze it (optionally waiting for results) | +| `ls [provider] [org] [repo]` | List (or search) directories and files at a path in a repository, with quality metrics and sorting | +| `directories [provider] [org] [repo]` | List directories at a path in a repository (optionally one level of sub-directories), with quality metrics | | `issues [provider] [org] [repo]` | Search issues in a repository with filters | | `issue [provider] [org] [repo] ` | Show details for a single issue, or ignore/unignore it | | `findings [provider] [org] [repo]` | Show security findings for a repository or organization | diff --git a/SPECS/README.md b/SPECS/README.md index fa84a98..923b881 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -15,6 +15,8 @@ _No pending tasks._ All commands implemented. | `info` | `inf` | ✅ Done | [info.md](commands/info.md) | | `repositories` | `repos` | ✅ Done | [repositories.md](commands/repositories.md) | | `repository` | `repo` | ✅ Done (actions added) | [repository.md](commands/repository.md) | +| `ls` | N/A | ✅ Done | [ls.md](commands/ls.md) | +| `directories` | `dirs` | ✅ Done | [directories.md](commands/directories.md) | | `pull-request` | `pr` | ✅ Done (--diff + Diff Coverage Summary added) | [pull-request.md](commands/pull-request.md) | | `issues` | `is` | ✅ Done | [issues.md](commands/issues.md) | | `issue` | `iss` | ✅ Done | [issue.md](commands/issue.md) | @@ -71,3 +73,4 @@ _No pending tasks._ All commands implemented. | 2026-06-18 | `repo --output json` now includes `repository.fileCount`, plucked from `coverage.numberTotalFiles` on the existing `getRepositoryWithAnalysis` response (present even without coverage data — no extra API call). Unlocks repo-size visibility for downstream consumers like the `configure-codacy-cloud` skill (1 new test, 373 total) | | 2026-06-24 | `findings` and `finding` now surface the vulnerable dependency's import chain from the new `dependencyChains` field: Direct (`Update to `) vs Transitive (` (Fixed in )`), 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 ` (files only; folds the path into the search as `/%` 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) | diff --git a/SPECS/commands/directories.md b/SPECS/commands/directories.md new file mode 100644 index 0000000..f67f608 --- /dev/null +++ b/SPECS/commands/directories.md @@ -0,0 +1,57 @@ +# `directories` Command Spec + +**Status:** ✅ Done (2026-07-07) + +## Purpose + +List the immediate directories at a path in a repository, each with quality metrics — like `ls` but folders only. With `--plus-children`, also lists each directory's immediate sub-directories (one extra level) so you can see a bigger picture of the folder structure and its metrics. + +## Usage + +``` +codacy directories # current directory's folders (auto-detected) +codacy directories --plus-children # also one more level of sub-folders +codacy directories --path src/website +codacy directories --sort issues --direction desc +codacy directories gh my-org my-repo --plus-children +codacy dirs --output json +``` + +## API Endpoints + +- [`listDirectories`](https://api.codacy.com/api/api-docs#listdirectories) — `RepositoryService.listDirectories(provider, org, repo, branch, path, sort, direction, cursor, limit)` + +Cursor-paginated; **all pages are fetched** (`fetchAllDirectories` in `utils/repo-tree.ts`) with no pagination warning. `--plus-children` issues one additional (fully paginated) `listDirectories` call per directory, concurrently, listing that directory's `path`. + +## Options + +| Option | Short | Description | +|---|---|---| +| `--path ` | `-p` | Repository-relative folder to list (defaults to the cwd relative to the repo root) | +| `--branch ` | `-b` | Branch name (defaults to the main branch) | +| `--plus-children` | `-c` | Also list each directory's immediate sub-directories | +| `--sort ` | `-S` | Sort by `name`, `issues`, `grade`, `duplication`, `complexity`, or `coverage` | +| `--direction ` | `-d` | `asc`/`ascending` or `desc`/`descending` | + +`--sort`/`--direction`, when given, are applied server-side to both the top-level listing and each `--plus-children` listing; otherwise directories are ordered by name. + +## Output + +Bold header `// — N directories` (with `--plus-children`, also `, M subdirectories` — the total children across all directories), then a columnar table (same columns as `ls`). Parent rows are prefixed `▸ `; with `--plus-children`, each child is rendered on its own indented row ` └─ ` under its parent. + +| Column | Source | Notes | +|---|---|---| +| Name | `▸ ` / ` └─ ` | `▸` parent folder, `└─` child connector | +| Grade | `gradeLetter` | A/B green, C yellow, D/E/F red | +| Issues | `totalIssues` | Abbreviated (`formatCount`) | +| Complexity | `complexity` | Highest file complexity under the path (hotspots) | +| Duplication | `numberOfClones` | Number of cloned code blocks (matches Codacy's UI) | +| Coverage | `coverageWithDecimals` | e.g. `76.3%` | + +Missing metrics render as a dim `-`. Only the Grade letter is colored. No pagination warning. Empty result prints "No directories found at …". + +JSON output: `{ path, directories: [ { …, children?: [...] } ] }`, each item projected via `pickDeep`; `children` present only with `--plus-children`. + +## Tests + +File: `src/commands/directories.test.ts` — 8 tests. Shared helpers covered in `src/utils/repo-tree.test.ts`. diff --git a/SPECS/commands/ls.md b/SPECS/commands/ls.md new file mode 100644 index 0000000..289ca64 --- /dev/null +++ b/SPECS/commands/ls.md @@ -0,0 +1,61 @@ +# `ls` Command Spec + +**Status:** ✅ Done (2026-07-07) + +## Purpose + +List the immediate directories **and** files at a path in a repository (like Unix `ls`), each with quality metrics. Repository is auto-detected from the git remote; the path is auto-detected from the current working directory relative to the repo root, or given via `--path`, or defaults to the repo root. + +## Usage + +``` +codacy ls # current directory (auto-detected) +codacy ls --path src/website # a specific folder +codacy ls gh my-org my-repo # root of an explicit repository +codacy ls gh my-org my-repo --path src/website +codacy ls --sort issues --direction desc # worst files/folders first +codacy ls --path app/client --search config # search files under a path +codacy ls --output json +``` + +## API Endpoints + +- [`listDirectories`](https://api.codacy.com/api/api-docs#listdirectories) — `RepositoryService.listDirectories(provider, org, repo, branch, path, sort, direction, cursor, limit)` +- [`listFiles`](https://api.codacy.com/api/api-docs#listfiles) — `RepositoryService.listFiles(provider, org, repo, branch, path, search, sort, direction, cursor, limit)` + +Both endpoints are cursor-paginated. This command **fetches every page** (via `fetchAllDirectories` / `fetchAllFiles` in `utils/repo-tree.ts`) — it does **not** show a pagination warning. The `path` argument is always passed as an explicit string (`""` = repo root, non-recursive); it is never `undefined`, which would make `listFiles` recursive. + +## Options + +| Option | Short | Description | +|---|---|---| +| `--path ` | `-p` | Repository-relative folder to list (defaults to the cwd relative to the repo root) | +| `--branch ` | `-b` | Branch name (defaults to the main branch) | +| `--search ` | `-s` | Search files (any depth) under the path instead of listing immediate children | +| `--sort ` | `-S` | Sort by `name`, `issues`, `grade`, `duplication`, `complexity`, or `coverage` | +| `--direction ` | `-d` | `asc`/`ascending` or `desc`/`descending` | + +**Sorting.** With no `--sort`/`--direction`, results are ordered by name client-side. When either is given, ordering is delegated to the API (`sort`/`direction` params) — directories and files are sorted **independently** (each fully paginated, then concatenated), never merged. The CLI `name` field maps to the API's `filename` for files. + +**Search.** `--search` lists **files only** (the directories endpoint has no search). The folder scope is folded into the search term (`/%`, or just `` at the root) and `path` is **not** sent, so matches are found at any depth under the path. Results show each file's full repository-relative path. + +## Output + +Bold header `// — N directories, M files`, then a columnar table with directories first (each sorted by name), then files. + +| Column | Source (directory / file) | Notes | +|---|---|---| +| Name | `▸ ` / `· ` | `▸` folder, dim `·` file (no emojis); in search mode the file's full path is shown | +| Grade | `gradeLetter` | A/B green, C yellow, D/E/F red | +| Issues | `totalIssues` | Abbreviated (`formatCount`, e.g. `1.2k`) | +| Complexity | `complexity` | Highest file complexity under the path (hotspots); files show the file value | +| Duplication | `numberOfClones` | Number of cloned code blocks (matches Codacy's UI, not duplicated lines) | +| Coverage | `coverageWithDecimals` | e.g. `76.3%` | + +Missing metrics render as a dim `-`. Only the Grade letter is colored (directory/file items carry no goal thresholds). No pagination warning (all pages fetched). Empty result prints "Nothing found at …". + +JSON output: `{ path, directories: [...], files: [...] }`, each item projected via `pickDeep`. + +## Tests + +File: `src/commands/ls.test.ts` — 14 tests. Shared helpers (path/sort/direction resolution, fetch-all) covered in `src/utils/repo-tree.test.ts`. diff --git a/package.json b/package.json index 739a8f7..71e86a3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepublishOnly": "npm run update-api && npm run build", "start": "npx ts-node src/index.ts", "start:dist": "node dist/index.js", - "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/56.1.1/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/56.2.9/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", "generate-api": "rm -rf ./src/api/client && openapi --input ./api-v3/api-swagger.yaml --output ./src/api/client --useUnionTypes --indent 2 --client fetch", "update-api": "npm run fetch-api && npm run generate-api", "check-types": "tsc --noEmit" diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 00ddc5e..7550e0a 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -109,6 +109,65 @@ Instead of a dedicated "Visibility" column (wastes horizontal space), public rep - **`--reanalyze` mode** (`-R`): fetches HEAD commit SHA, calls `RepositoryService.reanalyzeCommitById`; early return - **`--reanalyze-and-wait` mode** (`-w`): blocking variant — see "Reanalyze and wait" below. Baseline comes from `issuesOverview`; polling reads the repo's first commit via `listRepositoryCommits(limit=1)` analysis timestamps +## ls command (`ls.ts`) and directories command (`directories.ts`) + +Repository-level "file browser" commands built on `RepositoryService.listFiles` +and `listDirectories`. `ls` lists directories **and** files at a path; +`directories` (alias `dirs`) lists folders only, with `-c, --plus-children` to +also pull each folder's immediate sub-folders (one extra level). + +- **Repo + path resolution**: optional `[provider] [organization] [repository]` + positionals via `resolveRepoArgs(..., 0, ...)` (auto-detected from the git + remote when omitted). The listed path comes from `resolveListingPath(options.path, autoDetected)` + in `utils/repo-tree.ts`: an explicit `--path` wins; otherwise, when the repo + was auto-detected, the current working directory relative to the git root + (`getCwdRepoRelativePath` → `git rev-parse --show-toplevel` + `path.relative`); + otherwise the repo root. `autoDetected = !(provider && organization && repository)`. +- **Fetch-all, no pagination warning (deliberate deviation)**: unlike every other + paginated command, these two fetch *all* pages (`fetchAllDirectories` / + `fetchAllFiles` loop over `pagination.cursor`) and do **not** call + `printPaginationWarning` — the whole listing must be shown. Do not "fix" this + by adding a warning. +- **Explicit empty-string path**: the repo root is listed by passing `path = ""` + (not `undefined`). The generated client serializes `""` as `path=`, which the + API treats as "root, non-recursive"; `undefined` would omit the param and make + `listFiles` recursive across the whole repo. `fetchAll*` always take a `string`. +- **Row markers (no emojis)**: `▸` (U+25B8) for folders, dim `·` for files. + `directories --plus-children` renders each child on its own indented row with a + `└─` connector under its parent (`DirectoryNode.children`). +- **Metric columns**: Name, Grade, Issues, Complexity, Duplication, Coverage. + Grade via `formatGrade` (A/B green, C yellow, D/E/F red — Codacy folder/file + grades can be E). Issues/Complexity/Duplication via `formatCountCell` + (abbreviated, dim `-` when absent). Complexity is `complexity` (for a folder, + the *highest* file complexity under it — surfaces hotspots — not the + `complexitySum` total). Duplication is `numberOfClones` (number of cloned + code blocks, matching Codacy's UI — not `duplication`, which is duplicated + *lines*). Coverage via `formatCoverageCell` (`coverageWithDecimals`). Only the + Grade is colored — directory/file items carry no `goals` thresholds, so + metrics are not threshold-colored. +- **Ordering / `--sort` + `--direction`**: with no sort flags, results are + sorted client-side by name (directories) / basename (files), ascending. When + `--sort` or `--direction` is given, ordering is delegated to the API (`sort`/ + `direction` params, order preserved across pages) and the client sort is + skipped. `resolveSort`/`resolveDirection` (in `repo-tree.ts`) validate the + values and map them: CLI `name` → API `filename` for the files endpoint; + `ascending`/`descending` (and `asc`/`desc`) → `asc`/`desc`. In `ls`, + directories and files are always sorted **independently** and never merged + (dirs fully paginated + sorted, then files) so the type grouping holds. In + `directories`, the same sort is applied to the top-level and each + `--plus-children` listing. +- **`ls --search ` (files only)**: `listDirectories` has no `search`, so + search mode lists files only (directories skipped). The folder scope is folded + into the search string as `/%` (just `` at the repo root) and + `path` is **not** sent — otherwise the API would restrict to immediate + children. Results show each file's full repository-relative path (not just the + basename), since matches span folders. +- **`directories --plus-children` header**: appends `, M subdirectories` where M + is the summed count of children across all listed directories. +- **Aliases**: `directories` → `dirs`; `ls` has no alias (already the minimal + form). +- JSON: `ls` → `{ path, directories, files }`; `directories` → `{ path, directories: [{…, children?}] }`, each item projected via `pickDeep`. + ## Shared Formatting Utilities (`utils/formatting.ts`) Several helpers are shared between `repository.ts` and `pull-request.ts` via `utils/formatting.ts`: diff --git a/src/commands/directories.test.ts b/src/commands/directories.test.ts new file mode 100644 index 0000000..7d7ee44 --- /dev/null +++ b/src/commands/directories.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; +import { registerDirectoriesCommand } from "./directories"; +import { RepositoryService } from "../api/client/services/RepositoryService"; + +vi.mock("../api/client/services/RepositoryService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); +vi.mock("../utils/git-remote", () => ({ + detectRepoContext: vi.fn(() => ({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + })), +})); +vi.spyOn(console, "log").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +function createProgram(): Command { + const program = new Command(); + program.option("-o, --output ", "output format", "table"); + registerDirectoriesCommand(program); + return program; +} + +// Joined console output with ANSI color/style codes stripped, so assertions can +// match plain text (dim() otherwise splits the tree connector from its name). +function getOutput(): string { + return (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n") + .replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); +} + +function dir(name: string, path = name) { + return { + path, + name, + nrFiles: 5, + totalIssues: 10, + grade: 85, + gradeLetter: "B", + complexity: 42, + numberOfClones: 12, + coverageWithDecimals: 70, + }; +} + +describe("directories command", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CODACY_API_TOKEN = "test-token"; + }); + + it("lists directories with the ▸ marker and no files", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [dir("pages"), dir("components")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + ]); + + const out = getOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("▸ components"); + expect(out).toContain("2 directories"); + // files endpoint must not be called + expect(RepositoryService.listFiles).not.toHaveBeenCalled(); + }); + + it("--plus-children fetches each directory's children and renders a tree", async () => { + vi.mocked(RepositoryService.listDirectories) + // root call + .mockResolvedValueOnce({ data: [dir("pages", "pages")] } as any) + // children of "pages" + .mockResolvedValueOnce({ + data: [dir("account", "pages/account"), dir("admin", "pages/admin")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--plus-children", + ]); + + // one root call + one per directory + expect(RepositoryService.listDirectories).toHaveBeenCalledTimes(2); + // child call lists the parent's path + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[1][4], + ).toBe("pages"); + + const out = getOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("└─ account"); + expect(out).toContain("└─ admin"); + }); + + it("shows a subdirectory count in the header with --plus-children", async () => { + vi.mocked(RepositoryService.listDirectories) + // root: two directories + .mockResolvedValueOnce({ + data: [dir("pages", "pages"), dir("src", "src")], + } as any) + // children of "pages" (fetched first in map order) + .mockResolvedValueOnce({ + data: [dir("a", "pages/a"), dir("b", "pages/b")], + } as any) + // children of "src" + .mockResolvedValueOnce({ data: [dir("c", "src/c")] } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--plus-children", + ]); + + expect(getOutput()).toContain("2 directories, 3 subdirectories"); + }); + + it("forwards --sort/--direction to the root and children listings", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ data: [dir("pages", "pages")] } as any) + .mockResolvedValueOnce({ + data: [dir("account", "pages/account")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--plus-children", + "--sort", + "issues", + "--direction", + "desc", + ]); + + const root = vi.mocked(RepositoryService.listDirectories).mock.calls[0]; + expect(root[5]).toBe("issues"); // sort + expect(root[6]).toBe("desc"); // direction + const child = vi.mocked(RepositoryService.listDirectories).mock.calls[1]; + expect(child[5]).toBe("issues"); + expect(child[6]).toBe("desc"); + }); + + it("passes a normalized --path through to the endpoint", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--path", + "src/website/", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][4], + ).toBe("src/website"); + }); + + it("includes children in JSON output", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ data: [dir("pages", "pages")] } as any) + .mockResolvedValueOnce({ + data: [dir("account", "pages/account")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "--output", + "json", + "directories", + "gh", + "org", + "repo", + "--plus-children", + ]); + + const out = getOutput(); + expect(out).toContain('"children"'); + expect(out).toContain('"name": "account"'); + }); + + it("shows a message when no directories are found", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + ]); + + const out = getOutput(); + expect(out).toContain("No directories found"); + }); + + it("fails when CODACY_API_TOKEN is not set", async () => { + delete process.env.CODACY_API_TOKEN; + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const program = createProgram(); + await expect( + program.parseAsync(["node", "test", "directories", "gh", "org", "repo"]), + ).rejects.toThrow("process.exit called"); + + mockExit.mockRestore(); + }); +}); diff --git a/src/commands/directories.ts b/src/commands/directories.ts new file mode 100644 index 0000000..81390ff --- /dev/null +++ b/src/commands/directories.ts @@ -0,0 +1,246 @@ +import { Command } from "commander"; +import ora from "ora"; +import ansis from "ansis"; +import pluralize from "pluralize"; +import { checkApiToken } from "../utils/auth"; +import { handleError } from "../utils/error"; +import { + createTable, + getOutputFormat, + pickDeep, + printJson, +} from "../utils/output"; +import { + formatGrade, + formatCountCell, + formatCoverageCell, +} from "../utils/formatting"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; +import { + fetchAllDirectories, + resolveListingPath, + resolveSort, + resolveDirection, + SORT_FIELDS, +} from "../utils/repo-tree"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; + +// Leading marker for a directory row (see ls.ts for the rationale); children +// listed via --plus-children are indented under their parent with a tree +// connector instead of the glyph. +const FOLDER_GLYPH = "▸"; +const CHILD_CONNECTOR = " └─ "; + +const TABLE_HEAD = [ + "Name", + "Grade", + "Issues", + "Complexity", + "Duplication", + "Coverage", +]; + +// A directory optionally carrying its immediate sub-directories (--plus-children). +interface DirectoryNode extends DirectoryWithAnalysisInfo { + children?: DirectoryWithAnalysisInfo[]; +} + +/** Display path shown in the header, e.g. "/my-repo/src/website". */ +function displayPath(repository: string, targetPath: string): string { + return "/" + [repository, targetPath].filter(Boolean).join("/"); +} + +const DIR_JSON_FIELDS = [ + "path", + "name", + "gradeLetter", + "totalIssues", + "complexity", + "numberOfClones", + "coverageWithDecimals", + "nrFiles", +]; + +/** Metric cells shared by parent and child directory rows. */ +function directoryMetricCells(d: DirectoryWithAnalysisInfo): string[] { + return [ + formatGrade(d.gradeLetter), + formatCountCell(d.totalIssues), + // Complexity = highest file complexity under the folder (hotspots), + // Duplication = number of cloned blocks — matching Codacy's UI. + formatCountCell(d.complexity), + formatCountCell(d.numberOfClones), + formatCoverageCell(d.coverageWithDecimals), + ]; +} + +export function registerDirectoriesCommand(program: Command) { + program + .command("directories") + .alias("dirs") + .description( + "List directories at a path in a repository, with quality metrics", + ) + .argument( + "[provider]", + "git provider (gh, gl, or bb) — auto-detected from git remote if omitted", + ) + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") + .option( + "-p, --path ", + "repository-relative folder to list (defaults to the current directory relative to the repo root)", + ) + .option("-b, --branch ", "branch name (defaults to the main branch)") + .option( + "-c, --plus-children", + "also list each directory's immediate sub-directories", + ) + .option( + "-S, --sort ", + `sort by one of: ${SORT_FIELDS.join(", ")}`, + ) + .option( + "-d, --direction ", + "sort direction: asc (ascending) or desc (descending)", + ) + .addHelpText( + "after", + ` +Examples: + $ codacy directories list the current directory's folders (auto-detected) + $ codacy directories --plus-children also show one more level of sub-folders + $ codacy directories --path src/website + $ codacy directories --sort issues --direction desc + $ codacy directories gh my-org my-repo --plus-children + $ codacy dirs --output json`, + ) + .action(async function ( + this: Command, + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: { + path?: string; + branch?: string; + plusChildren?: boolean; + sort?: string; + direction?: string; + }, + ) { + try { + checkApiToken(); + const format = getOutputFormat(this); + + const autoDetected = !(providerArg && organizationArg && repositoryArg); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "directories", + [], + ); + const targetPath = resolveListingPath(options.path, autoDetected); + + // Sorting/direction, when requested, are applied server-side to both the + // top-level listing and each children listing; otherwise order by name. + const sort = resolveSort(options.sort, "directory"); + const direction = resolveDirection(options.direction); + const useServerSort = + options.sort !== undefined || options.direction !== undefined; + const sortByName = (arr: DirectoryWithAnalysisInfo[]) => { + if (!useServerSort) arr.sort((a, b) => a.name.localeCompare(b.name)); + }; + + const spinner = ora( + `Listing directories in ${displayPath(repository, targetPath)}...`, + ).start(); + + const dirs: DirectoryNode[] = await fetchAllDirectories( + provider, + organization, + repository, + options.branch, + targetPath, + sort, + direction, + ); + sortByName(dirs); + + // --plus-children: pull each folder's immediate sub-folders (one extra + // level), concurrently. Each of these is itself fully paginated. + if (options.plusChildren) { + spinner.text = "Fetching sub-directories..."; + await Promise.all( + dirs.map(async (d) => { + const children = await fetchAllDirectories( + provider, + organization, + repository, + options.branch, + d.path, + sort, + direction, + ); + sortByName(children); + d.children = children; + }), + ); + } + + spinner.stop(); + + if (format === "json") { + printJson({ + path: targetPath, + directories: dirs.map((d) => { + const projected = pickDeep(d, DIR_JSON_FIELDS); + if (d.children) { + projected.children = d.children.map((c) => + pickDeep(c, DIR_JSON_FIELDS), + ); + } + return projected; + }), + }); + return; + } + + if (dirs.length === 0) { + console.log( + ansis.dim( + `\nNo directories found at ${displayPath(repository, targetPath)}.`, + ), + ); + return; + } + + let header = + `${displayPath(repository, targetPath)} — ` + + `${dirs.length} ${pluralize("directory", dirs.length)}`; + if (options.plusChildren) { + const subdirs = dirs.reduce( + (n, d) => n + (d.children?.length ?? 0), + 0, + ); + header += `, ${subdirs} ${pluralize("subdirectory", subdirs)}`; + } + console.log(ansis.bold(`\n${header}\n`)); + + const table = createTable({ head: TABLE_HEAD }); + + for (const d of dirs) { + table.push([`${FOLDER_GLYPH} ${d.name}`, ...directoryMetricCells(d)]); + for (const child of d.children ?? []) { + table.push([ + `${ansis.dim(CHILD_CONNECTOR)}${child.name}`, + ...directoryMetricCells(child), + ]); + } + } + + console.log(table.toString()); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/ls.test.ts b/src/commands/ls.test.ts new file mode 100644 index 0000000..3d03b2a --- /dev/null +++ b/src/commands/ls.test.ts @@ -0,0 +1,350 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; +import { registerLsCommand } from "./ls"; +import { RepositoryService } from "../api/client/services/RepositoryService"; + +vi.mock("../api/client/services/RepositoryService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); +vi.mock("../utils/git-remote", () => ({ + detectRepoContext: vi.fn(() => ({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + })), +})); +vi.spyOn(console, "log").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +function createProgram(): Command { + const program = new Command(); + program.option("-o, --output ", "output format", "table"); + registerLsCommand(program); + return program; +} + +// Joined console output with ANSI color/style codes stripped, so assertions can +// match plain text (dim() otherwise splits a glyph from its name with a reset). +function getOutput(): string { + return (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n") + .replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); +} + +const mockDirs = [ + { + path: "pages", + name: "pages", + nrFiles: 12, + totalIssues: 1200, + grade: 90, + gradeLetter: "A", + complexity: 45, + numberOfClones: 234, + coverageWithDecimals: 76.3, + }, +]; + +const mockFiles = [ + { + fileId: 1, + branchId: 1, + path: "src/common.js", + totalIssues: 2, + grade: 70, + gradeLetter: "C", + coverageWithDecimals: 34.2, + numberOfMethods: 3, + // complexity + numberOfClones intentionally absent -> rendered as "-" + }, +]; + +function mockList(dirs: unknown[], files: unknown[]) { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: dirs, + } as any); + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ + data: files, + } as any); +} + +describe("ls command", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CODACY_API_TOKEN = "test-token"; + }); + + it("lists directories before files with the ▸ / · markers", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + const out = getOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("· common.js"); + // directory row appears before the file row + expect(out.indexOf("▸ pages")).toBeLessThan(out.indexOf("· common.js")); + // header with combined totals + expect(out).toContain("/repo — 1 directory, 1 file"); + // absent metrics render as a dim "-" + expect(out).toContain("-"); + }); + + it("passes an explicit empty-string path when listing the repo root", async () => { + mockList([], []); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][4], + ).toBe(""); + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][4]).toBe(""); + }); + + it("passes a normalized --path through to both endpoints", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--path", + "/src/website/", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][4], + ).toBe("src/website"); + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][4]).toBe( + "src/website", + ); + }); + + it("forwards the --branch option", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--branch", + "develop", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][3], + ).toBe("develop"); + }); + + it("auto-detects the repository from the git remote when no positionals are given", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls"]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0].slice(0, 3), + ).toEqual(["gh", "auto-org", "auto-repo"]); + }); + + it("aggregates across multiple pages", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ + data: [{ ...mockDirs[0], name: "pages" }], + pagination: { cursor: "c1" }, + } as any) + .mockResolvedValueOnce({ + data: [{ ...mockDirs[0], name: "components" }], + } as any); + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ data: [] } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + const out = getOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("▸ components"); + expect(out).toContain("2 directories"); + }); + + it("forwards --sort/--direction to both endpoints (server-side)", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--sort", + "issues", + "--direction", + "desc", + ]); + + const dcall = vi.mocked(RepositoryService.listDirectories).mock.calls[0]; + expect(dcall[5]).toBe("issues"); // sort + expect(dcall[6]).toBe("desc"); // direction + const fcall = vi.mocked(RepositoryService.listFiles).mock.calls[0]; + expect(fcall[6]).toBe("issues"); // sort + expect(fcall[7]).toBe("desc"); // direction + }); + + it("maps --sort name to 'filename' for files but 'name' for directories", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--sort", + "name", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][5], + ).toBe("name"); + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][6]).toBe( + "filename", + ); + }); + + it("search mode lists files only, folds the path into the search, and omits path", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--path", + "app/client", + "--search", + "config", + ]); + + // directories are not searched + expect(RepositoryService.listDirectories).not.toHaveBeenCalled(); + // files: path omitted (undefined), search = "/%" + const fcall = vi.mocked(RepositoryService.listFiles).mock.calls[0]; + expect(fcall[4]).toBeUndefined(); // path + expect(fcall[5]).toBe("app/client/%config"); // search + }); + + it("shows full file paths and a 'matching' header in search mode", async () => { + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ + data: [ + { + fileId: 1, + branchId: 1, + path: "src/commands/finding.ts", + totalIssues: 4, + grade: 20, + gradeLetter: "F", + numberOfMethods: 1, + }, + ], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--search", + "find", + ]); + + const out = getOutput(); + expect(out).toContain("· src/commands/finding.ts"); + expect(out).toContain('matching "find"'); + // at repo root the search term is sent as-is (no path prefix) + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][5]).toBe("find"); + }); + + it("shows a message when a search finds nothing", async () => { + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ data: [] } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--search", + "nope", + ]); + + expect(getOutput()).toContain('No files matching "nope"'); + }); + + it("outputs JSON with a { path, directories, files } shape", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "--output", + "json", + "ls", + "gh", + "org", + "repo", + ]); + + const out = getOutput(); + expect(out).toContain('"directories"'); + expect(out).toContain('"files"'); + expect(out).toContain('"name": "pages"'); + expect(out).toContain('"path": "src/common.js"'); + }); + + it("shows a message when nothing is found", async () => { + mockList([], []); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + const out = getOutput(); + expect(out).toContain("Nothing found"); + }); + + it("fails when CODACY_API_TOKEN is not set", async () => { + delete process.env.CODACY_API_TOKEN; + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const program = createProgram(); + await expect( + program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]), + ).rejects.toThrow("process.exit called"); + + mockExit.mockRestore(); + }); +}); diff --git a/src/commands/ls.ts b/src/commands/ls.ts new file mode 100644 index 0000000..f9a130c --- /dev/null +++ b/src/commands/ls.ts @@ -0,0 +1,268 @@ +import { Command } from "commander"; +import ora from "ora"; +import ansis from "ansis"; +import pluralize from "pluralize"; +import { checkApiToken } from "../utils/auth"; +import { handleError } from "../utils/error"; +import { + createTable, + getOutputFormat, + pickDeep, + printJson, +} from "../utils/output"; +import { + formatGrade, + formatCountCell, + formatCoverageCell, +} from "../utils/formatting"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; +import { + fetchAllDirectories, + fetchAllFiles, + resolveListingPath, + resolveSort, + resolveDirection, + SORT_FIELDS, +} from "../utils/repo-tree"; + +// Leading row markers (no emojis, for terminal safety — same Unicode family as +// the ⊙ used by the repositories command): ▸ folder, dim · file. +const FOLDER_GLYPH = "▸"; +const FILE_GLYPH = "·"; + +const TABLE_HEAD = [ + "Name", + "Grade", + "Issues", + "Complexity", + "Duplication", + "Coverage", +]; + +/** Last path segment of a repository-relative file path. */ +function basename(filePath: string): string { + const segments = filePath.split("/"); + return segments[segments.length - 1] || filePath; +} + +/** Display path shown in the header, e.g. "/my-repo/src/website". */ +function displayPath(repository: string, targetPath: string): string { + return "/" + [repository, targetPath].filter(Boolean).join("/"); +} + +export function registerLsCommand(program: Command) { + program + .command("ls") + .description( + "List directories and files at a path in a repository, with quality metrics", + ) + .argument( + "[provider]", + "git provider (gh, gl, or bb) — auto-detected from git remote if omitted", + ) + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") + .option( + "-p, --path ", + "repository-relative folder to list (defaults to the current directory relative to the repo root)", + ) + .option("-b, --branch ", "branch name (defaults to the main branch)") + .option( + "-s, --search ", + "search files (at any depth) under the path, instead of listing immediate children", + ) + .option( + "-S, --sort ", + `sort by one of: ${SORT_FIELDS.join(", ")}`, + ) + .option( + "-d, --direction ", + "sort direction: asc (ascending) or desc (descending)", + ) + .addHelpText( + "after", + ` +Examples: + $ codacy ls list the current directory (auto-detected) + $ codacy ls --path src/website list a specific folder + $ codacy ls gh my-org my-repo list the root of an explicit repository + $ codacy ls gh my-org my-repo --path src/website + $ codacy ls --sort issues --direction desc worst files/folders first + $ codacy ls --path app/client --search config find files matching "config" under app/client + $ codacy ls --output json`, + ) + .action(async function ( + this: Command, + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: { + path?: string; + branch?: string; + search?: string; + sort?: string; + direction?: string; + }, + ) { + try { + checkApiToken(); + const format = getOutputFormat(this); + + // When the repo is auto-detected (not all three positionals given), the + // path defaults to the current working directory; when it's explicit, + // no cwd inference is made. + const autoDetected = !(providerArg && organizationArg && repositoryArg); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "ls", + [], + ); + const targetPath = resolveListingPath(options.path, autoDetected); + + // Sorting/direction, when requested, are applied server-side (order is + // preserved across pages). Directories and files are sorted and listed + // independently — never merged — so the type grouping stays intact. + const direction = resolveDirection(options.direction); + const dirSort = resolveSort(options.sort, "directory"); + const fileSort = resolveSort(options.sort, "file"); + const useServerSort = + options.sort !== undefined || options.direction !== undefined; + + // Search mode (files only): the folder scope is folded into the search + // term (`/%`) so matches are found at any depth, and `path` + // is NOT sent (which would restrict to immediate children). + const searching = !!options.search; + const searchValue = searching + ? targetPath + ? `${targetPath}/%${options.search}` + : options.search + : undefined; + + const spinner = ora( + searching + ? `Searching "${options.search}" under ${displayPath(repository, targetPath)}...` + : `Listing ${displayPath(repository, targetPath)}...`, + ).start(); + + // Both endpoints are cursor-paginated; fetch every page so the listing + // is complete (no pagination warning, by design). Directories are + // skipped in search mode (the files endpoint alone supports search). + const [dirs, files] = await Promise.all([ + searching + ? Promise.resolve([]) + : fetchAllDirectories( + provider, + organization, + repository, + options.branch, + targetPath, + dirSort, + direction, + ), + fetchAllFiles( + provider, + organization, + repository, + options.branch, + searching ? undefined : targetPath, + searchValue, + fileSort, + direction, + ), + ]); + + spinner.stop(); + + // Search results span multiple folders, so show each file's full + // repository-relative path; a plain listing shows just the basename. + const fileLabel = (filePath: string): string => + searching ? filePath : basename(filePath); + + // With no explicit --sort/--direction, order deterministically by name; + // otherwise trust the server-side ordering. + if (!useServerSort) { + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => fileLabel(a.path).localeCompare(fileLabel(b.path))); + } + + if (format === "json") { + printJson({ + path: targetPath, + directories: dirs.map((d) => + pickDeep(d, [ + "path", + "name", + "gradeLetter", + "totalIssues", + "complexity", + "numberOfClones", + "coverageWithDecimals", + "nrFiles", + ]), + ), + files: files.map((f) => + pickDeep(f, [ + "path", + "gradeLetter", + "totalIssues", + "complexity", + "numberOfClones", + "coverageWithDecimals", + ]), + ), + }); + return; + } + + if (dirs.length === 0 && files.length === 0) { + console.log( + ansis.dim( + searching + ? `\nNo files matching "${options.search}" under ${displayPath(repository, targetPath)}.` + : `\nNothing found at ${displayPath(repository, targetPath)}.`, + ), + ); + return; + } + + const header = searching + ? `${displayPath(repository, targetPath)} — ` + + `${files.length} ${pluralize("file", files.length)} matching "${options.search}"` + : `${displayPath(repository, targetPath)} — ` + + `${dirs.length} ${pluralize("directory", dirs.length)}, ` + + `${files.length} ${pluralize("file", files.length)}`; + console.log(ansis.bold(`\n${header}\n`)); + + const table = createTable({ head: TABLE_HEAD }); + + for (const d of dirs) { + table.push([ + `${FOLDER_GLYPH} ${d.name}`, + formatGrade(d.gradeLetter), + formatCountCell(d.totalIssues), + // Complexity = highest file complexity under the folder (hotspots), + // Duplication = number of cloned blocks — matching Codacy's UI. + formatCountCell(d.complexity), + formatCountCell(d.numberOfClones), + formatCoverageCell(d.coverageWithDecimals), + ]); + } + + for (const f of files) { + table.push([ + `${ansis.dim(FILE_GLYPH)} ${fileLabel(f.path)}`, + formatGrade(f.gradeLetter), + formatCountCell(f.totalIssues), + formatCountCell(f.complexity), + formatCountCell(f.numberOfClones), + formatCoverageCell(f.coverageWithDecimals), + ]); + } + + console.log(table.toString()); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/repositories.ts b/src/commands/repositories.ts index feb5ac6..788e471 100644 --- a/src/commands/repositories.ts +++ b/src/commands/repositories.ts @@ -12,7 +12,7 @@ import { printPaginationWarning, } from "../utils/output"; import { AnalysisService } from "../api/client/services/AnalysisService"; -import { formatCount } from "../utils/formatting"; +import { formatCount, formatGrade } from "../utils/formatting"; import pluralize from "pluralize"; /** @@ -35,19 +35,6 @@ function formatMetric( return value < threshold ? ansis.red(display) : ansis.green(display); } -function formatGrade(gradeLetter: string | undefined): string { - if (!gradeLetter) return "N/A"; - const colors: Record string> = { - A: ansis.green, - B: ansis.green, - C: ansis.yellow, - D: ansis.red, - F: ansis.red, - }; - const colorFn = colors[gradeLetter] || ((s: string) => s); - return colorFn(gradeLetter); -} - export function registerRepositoriesCommand(program: Command) { program .command("repositories") diff --git a/src/index.ts b/src/index.ts index 0c67f7f..8a0576f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { maybeNotifyUpdate } from "./utils/update-check"; import { registerInfoCommand } from "./commands/info"; import { registerRepositoriesCommand } from "./commands/repositories"; import { registerRepositoryCommand } from "./commands/repository"; +import { registerLsCommand } from "./commands/ls"; +import { registerDirectoriesCommand } from "./commands/directories"; import { registerPullRequestCommand } from "./commands/pull-request"; import { registerIssuesCommand } from "./commands/issues"; import { registerIssueCommand } from "./commands/issue"; @@ -49,6 +51,8 @@ program.hook("preAction", (_thisCommand, actionCommand) => { registerInfoCommand(program); registerRepositoriesCommand(program); registerRepositoryCommand(program); +registerLsCommand(program); +registerDirectoriesCommand(program); registerPullRequestCommand(program); registerIssuesCommand(program); registerIssueCommand(program); diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index 1a1c854..a921cef 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -8,6 +8,9 @@ import { formatDependencyChain, formatDependencyChainsLine, formatDependencyChainsBlock, + formatGrade, + formatCountCell, + formatCoverageCell, } from "./formatting"; // Mock ansis to return raw text for easier testing @@ -193,6 +196,42 @@ describe("formatDuration", () => { }); }); +describe("formatGrade", () => { + // ansis is mocked to identity, so we assert on the letter and N/A fallback. + it("returns the grade letter for A–F including E", () => { + for (const g of ["A", "B", "C", "D", "E", "F"]) { + expect(formatGrade(g)).toBe(g); + } + }); + + it("returns N/A when the grade is missing", () => { + expect(formatGrade(undefined)).toBe("N/A"); + expect(formatGrade("")).toBe("N/A"); + }); +}); + +describe("formatCountCell", () => { + it("abbreviates a count", () => { + expect(formatCountCell(1200)).toBe("1.2k"); + expect(formatCountCell(0)).toBe("0"); + }); + + it("renders a dash when the value is absent", () => { + expect(formatCountCell(undefined)).toBe("-"); + }); +}); + +describe("formatCoverageCell", () => { + it("renders a one-decimal percentage", () => { + expect(formatCoverageCell(76.3)).toBe("76.3%"); + expect(formatCoverageCell(0)).toBe("0.0%"); + }); + + it("renders a dash when coverage is absent", () => { + expect(formatCoverageCell(undefined)).toBe("-"); + }); +}); + describe("isBeingAnalyzed", () => { it("is true when started but never finished", () => { expect(isBeingAnalyzed("2025-06-15T10:00:00Z", undefined)).toBe(true); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 5adc7da..8cde560 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -240,6 +240,42 @@ export function formatCount(n: number): string { return numeral(n).format("0.[0]a"); } +/** + * Color a quality grade letter: A/B green, C yellow, D/E/F red, anything else + * uncolored. Returns "N/A" when no grade is available. Codacy folder/file + * grades can be E (not just A–D/F), so it is colored red like D/F. + */ +export function formatGrade(gradeLetter: string | undefined): string { + if (!gradeLetter) return "N/A"; + const colors: Record string> = { + A: ansis.green, + B: ansis.green, + C: ansis.yellow, + D: ansis.red, + E: ansis.red, + F: ansis.red, + }; + const colorFn = colors[gradeLetter] || ((s: string) => s); + return colorFn(gradeLetter); +} + +/** + * Render a numeric metric as a table cell: abbreviated via `formatCount` + * ("1.2k"), or a dim "-" when the value is absent (e.g. complexity/duplication + * that Codacy didn't compute for a file or folder). + */ +export function formatCountCell(n: number | undefined): string { + return n === undefined ? ansis.dim("-") : formatCount(n); +} + +/** + * Render a coverage percentage as a table cell ("76.3%"), or a dim "-" when no + * coverage data is available. + */ +export function formatCoverageCell(pct: number | undefined): string { + return pct === undefined ? ansis.dim("-") : `${pct.toFixed(1)}%`; +} + /** * Print a bold section header, optionally with a total count. * e.g. printSection("Issues", 45000, "issue") → "Issues — Found 45k issues" diff --git a/src/utils/repo-tree.test.ts b/src/utils/repo-tree.test.ts new file mode 100644 index 0000000..03a51d4 --- /dev/null +++ b/src/utils/repo-tree.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execFileSync } from "child_process"; +import { RepositoryService } from "../api/client/services/RepositoryService"; +import { + normalizeRepoPath, + resolveListingPath, + getCwdRepoRelativePath, + fetchAllDirectories, + fetchAllFiles, + resolveSort, + resolveDirection, +} from "./repo-tree"; + +vi.mock("child_process", () => ({ execFileSync: vi.fn() })); +vi.mock("../api/client/services/RepositoryService"); + +const mockExec = vi.mocked(execFileSync); + +describe("normalizeRepoPath", () => { + it("strips a leading ./", () => { + expect(normalizeRepoPath("./src/website")).toBe("src/website"); + }); + + it("strips leading and trailing slashes", () => { + expect(normalizeRepoPath("/src/website/")).toBe("src/website"); + }); + + it("leaves a clean path unchanged", () => { + expect(normalizeRepoPath("src/website")).toBe("src/website"); + }); + + it("trims whitespace and treats an empty string as the root", () => { + expect(normalizeRepoPath(" ")).toBe(""); + expect(normalizeRepoPath("")).toBe(""); + }); +}); + +describe("getCwdRepoRelativePath", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the cwd relative to the git root", () => { + mockExec.mockReturnValue("/repo\n" as any); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo/src/website"); + expect(getCwdRepoRelativePath()).toBe("src/website"); + cwdSpy.mockRestore(); + }); + + it("returns '' when the cwd is the repo root", () => { + mockExec.mockReturnValue("/repo\n" as any); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); + expect(getCwdRepoRelativePath()).toBe(""); + cwdSpy.mockRestore(); + }); + + it("returns '' when not inside a git repository", () => { + mockExec.mockImplementation(() => { + throw new Error("not a git repository"); + }); + expect(getCwdRepoRelativePath()).toBe(""); + }); +}); + +describe("resolveListingPath", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses and normalizes an explicit path (wins over auto-detection)", () => { + expect(resolveListingPath("/src/website/", true)).toBe("src/website"); + expect(resolveListingPath("src", false)).toBe("src"); + }); + + it("defaults to the repo root when no path and repo is explicit", () => { + expect(resolveListingPath(undefined, false)).toBe(""); + }); + + it("uses the cwd-relative path when auto-detected and no explicit path", () => { + mockExec.mockReturnValue("/repo\n" as any); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo/src"); + expect(resolveListingPath(undefined, true)).toBe("src"); + cwdSpy.mockRestore(); + }); +}); + +describe("resolveSort", () => { + it("returns undefined when no sort is requested", () => { + expect(resolveSort(undefined, "file")).toBeUndefined(); + expect(resolveSort(undefined, "directory")).toBeUndefined(); + }); + + it("maps 'name' to 'filename' for files but keeps it for directories", () => { + expect(resolveSort("name", "file")).toBe("filename"); + expect(resolveSort("name", "directory")).toBe("name"); + }); + + it("passes other fields through and is case-insensitive", () => { + expect(resolveSort("issues", "file")).toBe("issues"); + expect(resolveSort("Coverage", "directory")).toBe("coverage"); + }); + + it("throws on an invalid field", () => { + expect(() => resolveSort("bogus", "file")).toThrow(/Invalid --sort/); + }); +}); + +describe("resolveDirection", () => { + it("returns undefined when no direction is requested", () => { + expect(resolveDirection(undefined)).toBeUndefined(); + }); + + it("maps ascending/descending and short forms", () => { + expect(resolveDirection("asc")).toBe("asc"); + expect(resolveDirection("ascending")).toBe("asc"); + expect(resolveDirection("desc")).toBe("desc"); + expect(resolveDirection("DESCENDING")).toBe("desc"); + }); + + it("throws on an invalid direction", () => { + expect(() => resolveDirection("sideways")).toThrow(/Invalid --direction/); + }); +}); + +describe("fetchAllDirectories", () => { + beforeEach(() => vi.clearAllMocks()); + + it("follows pagination cursors and aggregates every page", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ + data: [{ name: "a" }, { name: "b" }], + pagination: { cursor: "c1" }, + } as any) + .mockResolvedValueOnce({ data: [{ name: "c" }] } as any); + + const result = await fetchAllDirectories("gh", "org", "repo", "main", "src"); + + expect(result.map((d) => d.name)).toEqual(["a", "b", "c"]); + expect(RepositoryService.listDirectories).toHaveBeenCalledTimes(2); + // First call: no cursor; path passed through as the explicit string. + expect(RepositoryService.listDirectories).toHaveBeenNthCalledWith( + 1, + "gh", + "org", + "repo", + "main", + "src", + undefined, + undefined, + undefined, + 100, + ); + // Second call: passes the cursor from the first page. + expect(RepositoryService.listDirectories).toHaveBeenNthCalledWith( + 2, + "gh", + "org", + "repo", + "main", + "src", + undefined, + undefined, + "c1", + 100, + ); + }); + + it("passes an explicit empty-string path for the repo root", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [], + } as any); + + await fetchAllDirectories("gh", "org", "repo", undefined, ""); + + expect(RepositoryService.listDirectories).toHaveBeenCalledWith( + "gh", + "org", + "repo", + undefined, + "", + undefined, + undefined, + undefined, + 100, + ); + }); +}); + +describe("fetchAllFiles", () => { + beforeEach(() => vi.clearAllMocks()); + + it("follows pagination cursors and aggregates every page", async () => { + vi.mocked(RepositoryService.listFiles) + .mockResolvedValueOnce({ + data: [{ path: "a.ts" }], + pagination: { cursor: "c1" }, + } as any) + .mockResolvedValueOnce({ data: [{ path: "b.ts" }] } as any); + + const result = await fetchAllFiles("gh", "org", "repo", undefined, ""); + + expect(result.map((f) => f.path)).toEqual(["a.ts", "b.ts"]); + expect(RepositoryService.listFiles).toHaveBeenCalledTimes(2); + // Root listing must send an explicit "" (non-recursive), never undefined. + expect(RepositoryService.listFiles).toHaveBeenNthCalledWith( + 1, + "gh", + "org", + "repo", + undefined, + "", + undefined, + undefined, + undefined, + undefined, + 100, + ); + }); +}); diff --git a/src/utils/repo-tree.ts b/src/utils/repo-tree.ts new file mode 100644 index 0000000..840daa2 --- /dev/null +++ b/src/utils/repo-tree.ts @@ -0,0 +1,202 @@ +import { execFileSync } from "child_process"; +import * as path from "path"; +import { RepositoryService } from "../api/client/services/RepositoryService"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; +import { FileWithAnalysisInfo } from "../api/client/models/FileWithAnalysisInfo"; + +/** + * Helpers for the `ls` and `directories` commands: figuring out *where* in the + * repository to list (path resolution) and fetching *all* children of a folder + * (the list endpoints are cursor-paginated, and these commands intentionally + * fetch every page rather than showing a "first N results" warning). + */ + +/** + * Absolute path of the current git repository's root, or null when not inside a + * git repository. Mirrors `getGitRemoteUrl` in git-remote.ts (shells out, never + * throws). + */ +export function getGitRepoRoot(): string | null { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return null; + } +} + +/** + * Repository-relative path of the current working directory, using forward + * slashes. Returns "" (the repository root) when not inside a git repository or + * when the cwd is the repository root itself. + */ +export function getCwdRepoRelativePath(): string { + const root = getGitRepoRoot(); + if (!root) return ""; + const relative = path.relative(root, process.cwd()); + // path.relative returns "" when cwd === root; normalize Windows separators. + return relative.split(path.sep).join("/"); +} + +/** + * Normalize a user-supplied path into the form the API expects: no leading + * "./", no leading or trailing slashes. The repository root is represented as + * an empty string. + */ +export function normalizeRepoPath(input: string): string { + return input + .trim() + .replace(/^\.\/+/, "") // strip a leading "./" + .replace(/^\/+/, "") // strip leading slashes + .replace(/\/+$/, ""); // strip trailing slashes +} + +/** + * Decide which repository folder to list: + * - an explicit `--path` always wins (normalized), + * - otherwise, when the repo was auto-detected from the git remote, use the + * current working directory relative to the repo root, + * - otherwise (repo passed explicitly, no `--path`), the repository root (""). + */ +export function resolveListingPath( + explicitPath: string | undefined, + autoDetected: boolean, +): string { + if (explicitPath !== undefined) return normalizeRepoPath(explicitPath); + if (autoDetected) return getCwdRepoRelativePath(); + return ""; +} + +// Sort fields accepted at the CLI (--sort). The same vocabulary is used for +// both files and folders; the files endpoint calls the "name" field "filename", +// which resolveSort maps for us. +export const SORT_FIELDS = [ + "name", + "issues", + "grade", + "duplication", + "complexity", + "coverage", +] as const; + +/** + * Validate a --sort value and map it to the API field for the given endpoint. + * Returns undefined when no sort was requested; throws on an invalid value. + */ +export function resolveSort( + cliSort: string | undefined, + kind: "file" | "directory", +): string | undefined { + if (cliSort === undefined) return undefined; + const field = cliSort.toLowerCase(); + if (!(SORT_FIELDS as readonly string[]).includes(field)) { + throw new Error( + `Invalid --sort value '${cliSort}'. Valid values: ${SORT_FIELDS.join(", ")}.`, + ); + } + // The files endpoint names the file field "filename" rather than "name". + if (kind === "file" && field === "name") return "filename"; + return field; +} + +const DIRECTIONS: Record = { + asc: "asc", + ascending: "asc", + desc: "desc", + descending: "desc", +}; + +/** + * Validate a --direction value and map it to the API's "asc"/"desc". Returns + * undefined when no direction was requested; throws on an invalid value. + */ +export function resolveDirection( + cliDirection: string | undefined, +): string | undefined { + if (cliDirection === undefined) return undefined; + const dir = DIRECTIONS[cliDirection.toLowerCase()]; + if (!dir) { + throw new Error( + `Invalid --direction value '${cliDirection}'. Valid values: asc (ascending), desc (descending).`, + ); + } + return dir; +} + +/** + * Fetch every folder directly inside `path`, following pagination cursors until + * the API returns no more pages. `path` is always a string ("" = repo root); + * passing it explicitly (never undefined) keeps the listing non-recursive. + * Optional `sort`/`direction` are applied server-side (order is preserved + * across pages). + */ +export async function fetchAllDirectories( + provider: string, + organization: string, + repository: string, + branch: string | undefined, + path: string, + sort?: string, + direction?: string, +): Promise { + const all: DirectoryWithAnalysisInfo[] = []; + let cursor: string | undefined; + do { + const response = await RepositoryService.listDirectories( + provider, + organization, + repository, + branch, + path, + sort, + direction, + cursor, + 100, // limit + ); + all.push(...response.data); + cursor = response.pagination?.cursor; + } while (cursor); + return all; +} + +/** + * Fetch every file matching the request, following pagination cursors until the + * API returns no more pages. For a plain listing, `path` is a string ("" = repo + * root, non-recursive). For a search, pass `path = undefined` (recursive) and a + * `search` term — the caller folds the folder scope into the search string, so + * matches are found at any depth under it. Optional `sort`/`direction` are + * applied server-side. + */ +export async function fetchAllFiles( + provider: string, + organization: string, + repository: string, + branch: string | undefined, + path: string | undefined, + search?: string, + sort?: string, + direction?: string, +): Promise { + const all: FileWithAnalysisInfo[] = []; + let cursor: string | undefined; + do { + const response = await RepositoryService.listFiles( + provider, + organization, + repository, + branch, + path, + search, + sort, + direction, + cursor, + 100, // limit + ); + all.push(...response.data); + cursor = response.pagination?.cursor; + } while (cursor); + return all; +} From b3d0411761d77afda9893583cc2d3a1f0d28bef7 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Tue, 7 Jul 2026 15:53:10 +0100 Subject: [PATCH 2/3] refactor: address PR review on ls/directories commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the Codacy quality-gate findings and Gemini review feedback on PR #26: - Extract shared presentation into src/commands/tree-view.ts (metric cells, JSON projections, glyphs) and split each command's action handler into small helpers, bringing both under the Lizard complexity/length thresholds and removing the ls/directories duplication. - Replace the non-literal `new RegExp(String.fromCharCode(27)...)` in the test files with a shared `consoleOutput()` helper (src/test-support.ts, excluded from the build) that uses a plain regex literal — fixes the Opengrep non-literal-regexp finding and the duplicated helper. - Handle null (not just undefined) in formatCountCell/formatCoverageCell, since the API may return null for an uncomputed metric. - Normalize `.`/`./` paths to the repository root in normalizeRepoPath. - Bound the --plus-children children fetches with a concurrency cap (mapWithConcurrency) instead of an unbounded Promise.all. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/directories.test.ts | 20 +- src/commands/directories.ts | 308 ++++++++++++------------- src/commands/ls.test.ts | 22 +- src/commands/ls.ts | 377 ++++++++++++++++--------------- src/commands/tree-view.ts | 80 +++++++ src/test-support.ts | 18 ++ src/utils/formatting.test.ts | 6 +- src/utils/formatting.ts | 13 +- src/utils/repo-tree.test.ts | 32 +++ src/utils/repo-tree.ts | 27 ++- tsconfig.build.json | 1 + 11 files changed, 519 insertions(+), 385 deletions(-) create mode 100644 src/commands/tree-view.ts create mode 100644 src/test-support.ts diff --git a/src/commands/directories.test.ts b/src/commands/directories.test.ts index 7d7ee44..17177fc 100644 --- a/src/commands/directories.test.ts +++ b/src/commands/directories.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Command } from "commander"; import { registerDirectoriesCommand } from "./directories"; import { RepositoryService } from "../api/client/services/RepositoryService"; +import { consoleOutput } from "../test-support"; vi.mock("../api/client/services/RepositoryService"); vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); @@ -22,15 +23,6 @@ function createProgram(): Command { return program; } -// Joined console output with ANSI color/style codes stripped, so assertions can -// match plain text (dim() otherwise splits the tree connector from its name). -function getOutput(): string { - return (console.log as ReturnType).mock.calls - .map((c) => c[0]) - .join("\n") - .replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); -} - function dir(name: string, path = name) { return { path, @@ -66,7 +58,7 @@ describe("directories command", () => { "repo", ]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain("▸ pages"); expect(out).toContain("▸ components"); expect(out).toContain("2 directories"); @@ -101,7 +93,7 @@ describe("directories command", () => { vi.mocked(RepositoryService.listDirectories).mock.calls[1][4], ).toBe("pages"); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain("▸ pages"); expect(out).toContain("└─ account"); expect(out).toContain("└─ admin"); @@ -131,7 +123,7 @@ describe("directories command", () => { "--plus-children", ]); - expect(getOutput()).toContain("2 directories, 3 subdirectories"); + expect(consoleOutput()).toContain("2 directories, 3 subdirectories"); }); it("forwards --sort/--direction to the root and children listings", async () => { @@ -206,7 +198,7 @@ describe("directories command", () => { "--plus-children", ]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain('"children"'); expect(out).toContain('"name": "account"'); }); @@ -226,7 +218,7 @@ describe("directories command", () => { "repo", ]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain("No directories found"); }); diff --git a/src/commands/directories.ts b/src/commands/directories.ts index 81390ff..5f136de 100644 --- a/src/commands/directories.ts +++ b/src/commands/directories.ts @@ -4,74 +4,163 @@ import ansis from "ansis"; import pluralize from "pluralize"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; -import { - createTable, - getOutputFormat, - pickDeep, - printJson, -} from "../utils/output"; -import { - formatGrade, - formatCountCell, - formatCoverageCell, -} from "../utils/formatting"; +import { createTable, getOutputFormat, printJson } from "../utils/output"; import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { fetchAllDirectories, + mapWithConcurrency, resolveListingPath, resolveSort, resolveDirection, SORT_FIELDS, } from "../utils/repo-tree"; +import { + FOLDER_GLYPH, + CHILD_CONNECTOR, + TABLE_HEAD, + displayPath, + metricCells, + projectDir, +} from "./tree-view"; import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; -// Leading marker for a directory row (see ls.ts for the rationale); children -// listed via --plus-children are indented under their parent with a tree -// connector instead of the glyph. -const FOLDER_GLYPH = "▸"; -const CHILD_CONNECTOR = " └─ "; - -const TABLE_HEAD = [ - "Name", - "Grade", - "Issues", - "Complexity", - "Duplication", - "Coverage", -]; +// Cap on concurrent children fetches (--plus-children) so a directory with many +// sub-folders doesn't flood the API with unbounded parallel requests. +const CHILDREN_CONCURRENCY = 10; // A directory optionally carrying its immediate sub-directories (--plus-children). interface DirectoryNode extends DirectoryWithAnalysisInfo { children?: DirectoryWithAnalysisInfo[]; } -/** Display path shown in the header, e.g. "/my-repo/src/website". */ -function displayPath(repository: string, targetPath: string): string { - return "/" + [repository, targetPath].filter(Boolean).join("/"); +interface DirOptions { + path?: string; + branch?: string; + plusChildren?: boolean; + sort?: string; + direction?: string; +} + +interface DirContext { + provider: string; + organization: string; + repository: string; + targetPath: string; + branch?: string; + sort?: string; + direction?: string; + useServerSort: boolean; + plusChildren: boolean; +} + +function resolveDirContext( + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: DirOptions, +): DirContext { + const autoDetected = !(providerArg && organizationArg && repositoryArg); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "directories", + [], + ); + return { + provider, + organization, + repository, + targetPath: resolveListingPath(options.path, autoDetected), + branch: options.branch, + sort: resolveSort(options.sort, "directory"), + direction: resolveDirection(options.direction), + useServerSort: options.sort !== undefined || options.direction !== undefined, + plusChildren: !!options.plusChildren, + }; } -const DIR_JSON_FIELDS = [ - "path", - "name", - "gradeLetter", - "totalIssues", - "complexity", - "numberOfClones", - "coverageWithDecimals", - "nrFiles", -]; +/** + * Fetch the directories at the path (all pages), and — with --plus-children — + * each one's immediate sub-directories (bounded concurrency). Ordering is by + * name unless a server-side sort was requested. + */ +async function fetchDirTree(ctx: DirContext): Promise { + const sortByName = (arr: DirectoryWithAnalysisInfo[]) => { + if (!ctx.useServerSort) arr.sort((a, b) => a.name.localeCompare(b.name)); + }; + + const dirs: DirectoryNode[] = await fetchAllDirectories( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + ctx.targetPath, + ctx.sort, + ctx.direction, + ); + sortByName(dirs); + + if (ctx.plusChildren) { + await mapWithConcurrency(dirs, CHILDREN_CONCURRENCY, async (d) => { + const children = await fetchAllDirectories( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + d.path, + ctx.sort, + ctx.direction, + ); + sortByName(children); + d.children = children; + }); + } + return dirs; +} + +function dirJson(ctx: DirContext, dirs: DirectoryNode[]): Record { + return { + path: ctx.targetPath, + directories: dirs.map((d) => { + const projected = projectDir(d); + if (d.children) projected.children = d.children.map(projectDir); + return projected; + }), + }; +} -/** Metric cells shared by parent and child directory rows. */ -function directoryMetricCells(d: DirectoryWithAnalysisInfo): string[] { - return [ - formatGrade(d.gradeLetter), - formatCountCell(d.totalIssues), - // Complexity = highest file complexity under the folder (hotspots), - // Duplication = number of cloned blocks — matching Codacy's UI. - formatCountCell(d.complexity), - formatCountCell(d.numberOfClones), - formatCoverageCell(d.coverageWithDecimals), - ]; +function dirHeader(ctx: DirContext, dirs: DirectoryNode[]): string { + let header = + `${displayPath(ctx.repository, ctx.targetPath)} — ` + + `${dirs.length} ${pluralize("directory", dirs.length)}`; + if (ctx.plusChildren) { + const subdirs = dirs.reduce((n, d) => n + (d.children?.length ?? 0), 0); + header += `, ${subdirs} ${pluralize("subdirectory", subdirs)}`; + } + return header; +} + +function renderDir(ctx: DirContext, dirs: DirectoryNode[]): void { + if (dirs.length === 0) { + console.log( + ansis.dim( + `\nNo directories found at ${displayPath(ctx.repository, ctx.targetPath)}.`, + ), + ); + return; + } + console.log(ansis.bold(`\n${dirHeader(ctx, dirs)}\n`)); + const table = createTable({ head: TABLE_HEAD }); + for (const d of dirs) { + table.push([`${FOLDER_GLYPH} ${d.name}`, ...metricCells(d)]); + for (const child of d.children ?? []) { + table.push([ + `${ansis.dim(CHILD_CONNECTOR)}${child.name}`, + ...metricCells(child), + ]); + } + } + console.log(table.toString()); } export function registerDirectoriesCommand(program: Command) { @@ -96,10 +185,7 @@ export function registerDirectoriesCommand(program: Command) { "-c, --plus-children", "also list each directory's immediate sub-directories", ) - .option( - "-S, --sort ", - `sort by one of: ${SORT_FIELDS.join(", ")}`, - ) + .option("-S, --sort ", `sort by one of: ${SORT_FIELDS.join(", ")}`) .option( "-d, --direction ", "sort direction: asc (ascending) or desc (descending)", @@ -120,125 +206,29 @@ Examples: providerArg: string | undefined, organizationArg: string | undefined, repositoryArg: string | undefined, - options: { - path?: string; - branch?: string; - plusChildren?: boolean; - sort?: string; - direction?: string; - }, + options: DirOptions, ) { try { checkApiToken(); const format = getOutputFormat(this); - - const autoDetected = !(providerArg && organizationArg && repositoryArg); - const { provider, organization, repository } = resolveRepoArgs( - [providerArg, organizationArg, repositoryArg], - 0, - "directories", - [], + const ctx = resolveDirContext( + providerArg, + organizationArg, + repositoryArg, + options, ); - const targetPath = resolveListingPath(options.path, autoDetected); - - // Sorting/direction, when requested, are applied server-side to both the - // top-level listing and each children listing; otherwise order by name. - const sort = resolveSort(options.sort, "directory"); - const direction = resolveDirection(options.direction); - const useServerSort = - options.sort !== undefined || options.direction !== undefined; - const sortByName = (arr: DirectoryWithAnalysisInfo[]) => { - if (!useServerSort) arr.sort((a, b) => a.name.localeCompare(b.name)); - }; const spinner = ora( - `Listing directories in ${displayPath(repository, targetPath)}...`, + `Listing directories in ${displayPath(ctx.repository, ctx.targetPath)}...`, ).start(); - - const dirs: DirectoryNode[] = await fetchAllDirectories( - provider, - organization, - repository, - options.branch, - targetPath, - sort, - direction, - ); - sortByName(dirs); - - // --plus-children: pull each folder's immediate sub-folders (one extra - // level), concurrently. Each of these is itself fully paginated. - if (options.plusChildren) { - spinner.text = "Fetching sub-directories..."; - await Promise.all( - dirs.map(async (d) => { - const children = await fetchAllDirectories( - provider, - organization, - repository, - options.branch, - d.path, - sort, - direction, - ); - sortByName(children); - d.children = children; - }), - ); - } - + const dirs = await fetchDirTree(ctx); spinner.stop(); if (format === "json") { - printJson({ - path: targetPath, - directories: dirs.map((d) => { - const projected = pickDeep(d, DIR_JSON_FIELDS); - if (d.children) { - projected.children = d.children.map((c) => - pickDeep(c, DIR_JSON_FIELDS), - ); - } - return projected; - }), - }); - return; - } - - if (dirs.length === 0) { - console.log( - ansis.dim( - `\nNo directories found at ${displayPath(repository, targetPath)}.`, - ), - ); + printJson(dirJson(ctx, dirs)); return; } - - let header = - `${displayPath(repository, targetPath)} — ` + - `${dirs.length} ${pluralize("directory", dirs.length)}`; - if (options.plusChildren) { - const subdirs = dirs.reduce( - (n, d) => n + (d.children?.length ?? 0), - 0, - ); - header += `, ${subdirs} ${pluralize("subdirectory", subdirs)}`; - } - console.log(ansis.bold(`\n${header}\n`)); - - const table = createTable({ head: TABLE_HEAD }); - - for (const d of dirs) { - table.push([`${FOLDER_GLYPH} ${d.name}`, ...directoryMetricCells(d)]); - for (const child of d.children ?? []) { - table.push([ - `${ansis.dim(CHILD_CONNECTOR)}${child.name}`, - ...directoryMetricCells(child), - ]); - } - } - - console.log(table.toString()); + renderDir(ctx, dirs); } catch (err) { handleError(err); } diff --git a/src/commands/ls.test.ts b/src/commands/ls.test.ts index 3d03b2a..e2e298a 100644 --- a/src/commands/ls.test.ts +++ b/src/commands/ls.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Command } from "commander"; import { registerLsCommand } from "./ls"; import { RepositoryService } from "../api/client/services/RepositoryService"; +import { consoleOutput } from "../test-support"; vi.mock("../api/client/services/RepositoryService"); vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); @@ -22,15 +23,6 @@ function createProgram(): Command { return program; } -// Joined console output with ANSI color/style codes stripped, so assertions can -// match plain text (dim() otherwise splits a glyph from its name with a reset). -function getOutput(): string { - return (console.log as ReturnType).mock.calls - .map((c) => c[0]) - .join("\n") - .replace(new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g"), ""); -} - const mockDirs = [ { path: "pages", @@ -80,7 +72,7 @@ describe("ls command", () => { const program = createProgram(); await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain("▸ pages"); expect(out).toContain("· common.js"); // directory row appears before the file row @@ -171,7 +163,7 @@ describe("ls command", () => { const program = createProgram(); await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain("▸ pages"); expect(out).toContain("▸ components"); expect(out).toContain("2 directories"); @@ -277,7 +269,7 @@ describe("ls command", () => { "find", ]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain("· src/commands/finding.ts"); expect(out).toContain('matching "find"'); // at repo root the search term is sent as-is (no path prefix) @@ -299,7 +291,7 @@ describe("ls command", () => { "nope", ]); - expect(getOutput()).toContain('No files matching "nope"'); + expect(consoleOutput()).toContain('No files matching "nope"'); }); it("outputs JSON with a { path, directories, files } shape", async () => { @@ -317,7 +309,7 @@ describe("ls command", () => { "repo", ]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain('"directories"'); expect(out).toContain('"files"'); expect(out).toContain('"name": "pages"'); @@ -330,7 +322,7 @@ describe("ls command", () => { const program = createProgram(); await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); - const out = getOutput(); + const out = consoleOutput(); expect(out).toContain("Nothing found"); }); diff --git a/src/commands/ls.ts b/src/commands/ls.ts index f9a130c..f7ddcf1 100644 --- a/src/commands/ls.ts +++ b/src/commands/ls.ts @@ -4,18 +4,7 @@ import ansis from "ansis"; import pluralize from "pluralize"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; -import { - createTable, - getOutputFormat, - pickDeep, - printJson, -} from "../utils/output"; -import { - formatGrade, - formatCountCell, - formatCoverageCell, -} from "../utils/formatting"; -import { resolveRepoArgs } from "../utils/resolve-repo-args"; +import { createTable, getOutputFormat, printJson } from "../utils/output"; import { fetchAllDirectories, fetchAllFiles, @@ -24,30 +13,186 @@ import { resolveDirection, SORT_FIELDS, } from "../utils/repo-tree"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; +import { + FOLDER_GLYPH, + FILE_GLYPH, + TABLE_HEAD, + basename, + displayPath, + metricCells, + projectDir, + projectFile, +} from "./tree-view"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; +import { FileWithAnalysisInfo } from "../api/client/models/FileWithAnalysisInfo"; + +interface LsOptions { + path?: string; + branch?: string; + search?: string; + sort?: string; + direction?: string; +} + +interface LsContext { + provider: string; + organization: string; + repository: string; + targetPath: string; + branch?: string; + dirSort?: string; + fileSort?: string; + direction?: string; + useServerSort: boolean; + searching: boolean; + searchTerm?: string; + searchValue?: string; +} -// Leading row markers (no emojis, for terminal safety — same Unicode family as -// the ⊙ used by the repositories command): ▸ folder, dim · file. -const FOLDER_GLYPH = "▸"; -const FILE_GLYPH = "·"; +/** Resolve repo, path, sort/direction, and search from the args + options. */ +function resolveLsContext( + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: LsOptions, +): LsContext { + // When the repo is auto-detected (not all three positionals given), the path + // defaults to the current working directory; when explicit, no cwd inference. + const autoDetected = !(providerArg && organizationArg && repositoryArg); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "ls", + [], + ); + const targetPath = resolveListingPath(options.path, autoDetected); + const searching = !!options.search; + // Search folds the folder scope into the term (`/%`) so matches + // are found at any depth; `path` is not sent (see fetchLsEntries). + const searchValue = searching + ? targetPath + ? `${targetPath}/%${options.search}` + : options.search + : undefined; + return { + provider, + organization, + repository, + targetPath, + branch: options.branch, + dirSort: resolveSort(options.sort, "directory"), + fileSort: resolveSort(options.sort, "file"), + direction: resolveDirection(options.direction), + useServerSort: options.sort !== undefined || options.direction !== undefined, + searching, + searchTerm: options.search, + searchValue, + }; +} -const TABLE_HEAD = [ - "Name", - "Grade", - "Issues", - "Complexity", - "Duplication", - "Coverage", -]; +/** The file label: full path in search mode (results span folders), else basename. */ +function fileLabel(ctx: LsContext, filePath: string): string { + return ctx.searching ? filePath : basename(filePath); +} -/** Last path segment of a repository-relative file path. */ -function basename(filePath: string): string { - const segments = filePath.split("/"); - return segments[segments.length - 1] || filePath; +/** + * Fetch every page of directories and files (no pagination warning, by design). + * Directories are skipped in search mode (only the files endpoint supports + * search) and the two lists are ordered independently — never merged. + */ +async function fetchLsEntries(ctx: LsContext): Promise<{ + dirs: DirectoryWithAnalysisInfo[]; + files: FileWithAnalysisInfo[]; +}> { + const [dirs, files] = await Promise.all([ + ctx.searching + ? Promise.resolve([]) + : fetchAllDirectories( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + ctx.targetPath, + ctx.dirSort, + ctx.direction, + ), + fetchAllFiles( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + ctx.searching ? undefined : ctx.targetPath, + ctx.searchValue, + ctx.fileSort, + ctx.direction, + ), + ]); + // With no explicit --sort/--direction, order deterministically by name. + if (!ctx.useServerSort) { + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => + fileLabel(ctx, a.path).localeCompare(fileLabel(ctx, b.path)), + ); + } + return { dirs, files }; } -/** Display path shown in the header, e.g. "/my-repo/src/website". */ -function displayPath(repository: string, targetPath: string): string { - return "/" + [repository, targetPath].filter(Boolean).join("/"); +function lsJson( + ctx: LsContext, + dirs: DirectoryWithAnalysisInfo[], + files: FileWithAnalysisInfo[], +): Record { + return { + path: ctx.targetPath, + directories: dirs.map(projectDir), + files: files.map(projectFile), + }; +} + +function lsHeader( + ctx: LsContext, + dirCount: number, + fileCount: number, +): string { + const where = displayPath(ctx.repository, ctx.targetPath); + if (ctx.searching) { + return `${where} — ${fileCount} ${pluralize("file", fileCount)} matching "${ctx.searchTerm}"`; + } + return ( + `${where} — ${dirCount} ${pluralize("directory", dirCount)}, ` + + `${fileCount} ${pluralize("file", fileCount)}` + ); +} + +function renderLs( + ctx: LsContext, + dirs: DirectoryWithAnalysisInfo[], + files: FileWithAnalysisInfo[], +): void { + if (dirs.length === 0 && files.length === 0) { + const where = displayPath(ctx.repository, ctx.targetPath); + console.log( + ansis.dim( + ctx.searching + ? `\nNo files matching "${ctx.searchTerm}" under ${where}.` + : `\nNothing found at ${where}.`, + ), + ); + return; + } + console.log(ansis.bold(`\n${lsHeader(ctx, dirs.length, files.length)}\n`)); + const table = createTable({ head: TABLE_HEAD }); + for (const d of dirs) { + table.push([`${FOLDER_GLYPH} ${d.name}`, ...metricCells(d)]); + } + for (const f of files) { + table.push([ + `${ansis.dim(FILE_GLYPH)} ${fileLabel(ctx, f.path)}`, + ...metricCells(f), + ]); + } + console.log(table.toString()); } export function registerLsCommand(program: Command) { @@ -71,10 +216,7 @@ export function registerLsCommand(program: Command) { "-s, --search ", "search files (at any depth) under the path, instead of listing immediate children", ) - .option( - "-S, --sort ", - `sort by one of: ${SORT_FIELDS.join(", ")}`, - ) + .option("-S, --sort ", `sort by one of: ${SORT_FIELDS.join(", ")}`) .option( "-d, --direction ", "sort direction: asc (ascending) or desc (descending)", @@ -96,171 +238,32 @@ Examples: providerArg: string | undefined, organizationArg: string | undefined, repositoryArg: string | undefined, - options: { - path?: string; - branch?: string; - search?: string; - sort?: string; - direction?: string; - }, + options: LsOptions, ) { try { checkApiToken(); const format = getOutputFormat(this); - - // When the repo is auto-detected (not all three positionals given), the - // path defaults to the current working directory; when it's explicit, - // no cwd inference is made. - const autoDetected = !(providerArg && organizationArg && repositoryArg); - const { provider, organization, repository } = resolveRepoArgs( - [providerArg, organizationArg, repositoryArg], - 0, - "ls", - [], + const ctx = resolveLsContext( + providerArg, + organizationArg, + repositoryArg, + options, ); - const targetPath = resolveListingPath(options.path, autoDetected); - - // Sorting/direction, when requested, are applied server-side (order is - // preserved across pages). Directories and files are sorted and listed - // independently — never merged — so the type grouping stays intact. - const direction = resolveDirection(options.direction); - const dirSort = resolveSort(options.sort, "directory"); - const fileSort = resolveSort(options.sort, "file"); - const useServerSort = - options.sort !== undefined || options.direction !== undefined; - - // Search mode (files only): the folder scope is folded into the search - // term (`/%`) so matches are found at any depth, and `path` - // is NOT sent (which would restrict to immediate children). - const searching = !!options.search; - const searchValue = searching - ? targetPath - ? `${targetPath}/%${options.search}` - : options.search - : undefined; + const where = displayPath(ctx.repository, ctx.targetPath); const spinner = ora( - searching - ? `Searching "${options.search}" under ${displayPath(repository, targetPath)}...` - : `Listing ${displayPath(repository, targetPath)}...`, + ctx.searching + ? `Searching "${ctx.searchTerm}" under ${where}...` + : `Listing ${where}...`, ).start(); - - // Both endpoints are cursor-paginated; fetch every page so the listing - // is complete (no pagination warning, by design). Directories are - // skipped in search mode (the files endpoint alone supports search). - const [dirs, files] = await Promise.all([ - searching - ? Promise.resolve([]) - : fetchAllDirectories( - provider, - organization, - repository, - options.branch, - targetPath, - dirSort, - direction, - ), - fetchAllFiles( - provider, - organization, - repository, - options.branch, - searching ? undefined : targetPath, - searchValue, - fileSort, - direction, - ), - ]); - + const { dirs, files } = await fetchLsEntries(ctx); spinner.stop(); - // Search results span multiple folders, so show each file's full - // repository-relative path; a plain listing shows just the basename. - const fileLabel = (filePath: string): string => - searching ? filePath : basename(filePath); - - // With no explicit --sort/--direction, order deterministically by name; - // otherwise trust the server-side ordering. - if (!useServerSort) { - dirs.sort((a, b) => a.name.localeCompare(b.name)); - files.sort((a, b) => fileLabel(a.path).localeCompare(fileLabel(b.path))); - } - if (format === "json") { - printJson({ - path: targetPath, - directories: dirs.map((d) => - pickDeep(d, [ - "path", - "name", - "gradeLetter", - "totalIssues", - "complexity", - "numberOfClones", - "coverageWithDecimals", - "nrFiles", - ]), - ), - files: files.map((f) => - pickDeep(f, [ - "path", - "gradeLetter", - "totalIssues", - "complexity", - "numberOfClones", - "coverageWithDecimals", - ]), - ), - }); + printJson(lsJson(ctx, dirs, files)); return; } - - if (dirs.length === 0 && files.length === 0) { - console.log( - ansis.dim( - searching - ? `\nNo files matching "${options.search}" under ${displayPath(repository, targetPath)}.` - : `\nNothing found at ${displayPath(repository, targetPath)}.`, - ), - ); - return; - } - - const header = searching - ? `${displayPath(repository, targetPath)} — ` + - `${files.length} ${pluralize("file", files.length)} matching "${options.search}"` - : `${displayPath(repository, targetPath)} — ` + - `${dirs.length} ${pluralize("directory", dirs.length)}, ` + - `${files.length} ${pluralize("file", files.length)}`; - console.log(ansis.bold(`\n${header}\n`)); - - const table = createTable({ head: TABLE_HEAD }); - - for (const d of dirs) { - table.push([ - `${FOLDER_GLYPH} ${d.name}`, - formatGrade(d.gradeLetter), - formatCountCell(d.totalIssues), - // Complexity = highest file complexity under the folder (hotspots), - // Duplication = number of cloned blocks — matching Codacy's UI. - formatCountCell(d.complexity), - formatCountCell(d.numberOfClones), - formatCoverageCell(d.coverageWithDecimals), - ]); - } - - for (const f of files) { - table.push([ - `${ansis.dim(FILE_GLYPH)} ${fileLabel(f.path)}`, - formatGrade(f.gradeLetter), - formatCountCell(f.totalIssues), - formatCountCell(f.complexity), - formatCountCell(f.numberOfClones), - formatCoverageCell(f.coverageWithDecimals), - ]); - } - - console.log(table.toString()); + renderLs(ctx, dirs, files); } catch (err) { handleError(err); } diff --git a/src/commands/tree-view.ts b/src/commands/tree-view.ts new file mode 100644 index 0000000..a9ac0cf --- /dev/null +++ b/src/commands/tree-view.ts @@ -0,0 +1,80 @@ +import { pickDeep } from "../utils/output"; +import { + formatGrade, + formatCountCell, + formatCoverageCell, +} from "../utils/formatting"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; +import { FileWithAnalysisInfo } from "../api/client/models/FileWithAnalysisInfo"; + +/** + * Shared presentation helpers for the `ls` and `directories` commands: row + * markers, the metric columns, and JSON projections. Kept in one place so the + * two commands render identically and don't duplicate this logic. + */ + +// Row markers (no emojis, for terminal safety — same Unicode family as the ⊙ +// the CLI already ships): ▸ folder, dim · file; children use a └─ connector. +export const FOLDER_GLYPH = "▸"; +export const FILE_GLYPH = "·"; +export const CHILD_CONNECTOR = " └─ "; + +export const TABLE_HEAD = [ + "Name", + "Grade", + "Issues", + "Complexity", + "Duplication", + "Coverage", +]; + +// Metric fields common to directories and files; directories add name/nrFiles. +const METRIC_JSON_FIELDS = [ + "path", + "gradeLetter", + "totalIssues", + "complexity", + "numberOfClones", + "coverageWithDecimals", +]; +export const DIR_JSON_FIELDS = [...METRIC_JSON_FIELDS, "name", "nrFiles"]; +export const FILE_JSON_FIELDS = METRIC_JSON_FIELDS; + +/** Last path segment of a repository-relative path. */ +export function basename(filePath: string): string { + const segments = filePath.split("/"); + return segments[segments.length - 1] || filePath; +} + +/** Header display path, e.g. "/my-repo/src/website" ("/my-repo" at the root). */ +export function displayPath(repository: string, targetPath: string): string { + return "/" + [repository, targetPath].filter(Boolean).join("/"); +} + +/** + * The five metric cells shared by directory and file rows: Grade, Issues, + * Complexity, Duplication, Coverage. Complexity is the highest under the path + * (surfaces hotspots); Duplication is the number of cloned blocks — matching + * Codacy's UI. Missing metrics render as a dim "-". + */ +export function metricCells( + item: DirectoryWithAnalysisInfo | FileWithAnalysisInfo, +): string[] { + return [ + formatGrade(item.gradeLetter), + formatCountCell(item.totalIssues), + formatCountCell(item.complexity), + formatCountCell(item.numberOfClones), + formatCoverageCell(item.coverageWithDecimals), + ]; +} + +/** JSON projection of a directory item (fields shown in the table). */ +export function projectDir(d: DirectoryWithAnalysisInfo): Record { + return pickDeep(d, DIR_JSON_FIELDS); +} + +/** JSON projection of a file item (fields shown in the table). */ +export function projectFile(f: FileWithAnalysisInfo): Record { + return pickDeep(f, FILE_JSON_FIELDS); +} diff --git a/src/test-support.ts b/src/test-support.ts new file mode 100644 index 0000000..19fbd1d --- /dev/null +++ b/src/test-support.ts @@ -0,0 +1,18 @@ +/** + * Test-only helpers. Excluded from the published build via tsconfig.build.json. + */ + +/** + * Joined `console.log` output with ANSI color/style codes stripped, so + * assertions can match plain text — `dim()` otherwise splits a glyph from its + * name with a reset sequence. Assumes `console.log` has been replaced with a + * spy/mock (e.g. `vi.spyOn(console, "log").mockImplementation(() => {})`). + */ +export function consoleOutput(): string { + const calls = (console.log as unknown as { mock: { calls: unknown[][] } }) + .mock.calls; + return calls + .map((c) => c[0]) + .join("\n") + .replace(/\x1b\[[0-9;]*m/g, ""); +} diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index a921cef..85022c4 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -216,8 +216,9 @@ describe("formatCountCell", () => { expect(formatCountCell(0)).toBe("0"); }); - it("renders a dash when the value is absent", () => { + it("renders a dash when the value is absent (undefined or null)", () => { expect(formatCountCell(undefined)).toBe("-"); + expect(formatCountCell(null)).toBe("-"); }); }); @@ -227,8 +228,9 @@ describe("formatCoverageCell", () => { expect(formatCoverageCell(0)).toBe("0.0%"); }); - it("renders a dash when coverage is absent", () => { + it("renders a dash when coverage is absent (undefined or null)", () => { expect(formatCoverageCell(undefined)).toBe("-"); + expect(formatCoverageCell(null)).toBe("-"); }); }); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 8cde560..f2cc113 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -262,18 +262,19 @@ export function formatGrade(gradeLetter: string | undefined): string { /** * Render a numeric metric as a table cell: abbreviated via `formatCount` * ("1.2k"), or a dim "-" when the value is absent (e.g. complexity/duplication - * that Codacy didn't compute for a file or folder). + * that Codacy didn't compute for a file or folder). Handles both `undefined` + * and `null` — the API may return either for an uncomputed metric. */ -export function formatCountCell(n: number | undefined): string { - return n === undefined ? ansis.dim("-") : formatCount(n); +export function formatCountCell(n: number | undefined | null): string { + return n === undefined || n === null ? ansis.dim("-") : formatCount(n); } /** * Render a coverage percentage as a table cell ("76.3%"), or a dim "-" when no - * coverage data is available. + * coverage data is available (`undefined` or `null`). */ -export function formatCoverageCell(pct: number | undefined): string { - return pct === undefined ? ansis.dim("-") : `${pct.toFixed(1)}%`; +export function formatCoverageCell(pct: number | undefined | null): string { + return pct === undefined || pct === null ? ansis.dim("-") : `${pct.toFixed(1)}%`; } /** diff --git a/src/utils/repo-tree.test.ts b/src/utils/repo-tree.test.ts index 03a51d4..cf2d00c 100644 --- a/src/utils/repo-tree.test.ts +++ b/src/utils/repo-tree.test.ts @@ -9,6 +9,7 @@ import { fetchAllFiles, resolveSort, resolveDirection, + mapWithConcurrency, } from "./repo-tree"; vi.mock("child_process", () => ({ execFileSync: vi.fn() })); @@ -33,6 +34,11 @@ describe("normalizeRepoPath", () => { expect(normalizeRepoPath(" ")).toBe(""); expect(normalizeRepoPath("")).toBe(""); }); + + it("treats '.' and './' as the repository root", () => { + expect(normalizeRepoPath(".")).toBe(""); + expect(normalizeRepoPath("./")).toBe(""); + }); }); describe("getCwdRepoRelativePath", () => { @@ -118,6 +124,32 @@ describe("resolveDirection", () => { }); }); +describe("mapWithConcurrency", () => { + it("runs every item while never exceeding the concurrency limit", async () => { + const order: number[] = []; + let inFlight = 0; + let maxInFlight = 0; + const items = [0, 1, 2, 3, 4, 5, 6]; + + await mapWithConcurrency(items, 2, async (item) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await Promise.resolve(); + order.push(item); + inFlight--; + }); + + expect(order.sort((a, b) => a - b)).toEqual(items); + expect(maxInFlight).toBeLessThanOrEqual(2); + }); + + it("handles an empty list without invoking the worker", async () => { + const fn = vi.fn(async () => {}); + await mapWithConcurrency([], 4, fn); + expect(fn).not.toHaveBeenCalled(); + }); +}); + describe("fetchAllDirectories", () => { beforeEach(() => vi.clearAllMocks()); diff --git a/src/utils/repo-tree.ts b/src/utils/repo-tree.ts index 840daa2..d0011ca 100644 --- a/src/utils/repo-tree.ts +++ b/src/utils/repo-tree.ts @@ -47,13 +47,36 @@ export function getCwdRepoRelativePath(): string { * an empty string. */ export function normalizeRepoPath(input: string): string { - return input - .trim() + const trimmed = input.trim(); + // "." / "./" mean "here" — i.e. the current scope, which for the API is root. + if (trimmed === "." || trimmed === "./") return ""; + return trimmed .replace(/^\.\/+/, "") // strip a leading "./" .replace(/^\/+/, "") // strip leading slashes .replace(/\/+$/, ""); // strip trailing slashes } +/** + * Run `fn` over `items` with at most `limit` promises in flight at once — a + * bounded alternative to `Promise.all(items.map(...))` that avoids flooding the + * API with unbounded concurrent requests (each item may itself paginate). + */ +export async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const index = next++; + await fn(items[index]); + } + }; + const size = Math.min(limit, items.length); + await Promise.all(Array.from({ length: size }, () => worker())); +} + /** * Decide which repository folder to list: * - an explicit `--path` always wins (normalized), diff --git a/tsconfig.build.json b/tsconfig.build.json index 84010de..ba38d04 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,6 +4,7 @@ "node_modules", "dist", "**/*.test.ts", + "src/test-support.ts", "vitest.config.mts" ] } From 50c18506b675e991fe220f29ee2d4fbf0fa3158f Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Tue, 7 Jul 2026 15:55:01 +0100 Subject: [PATCH 3/3] docs(gemini): clarify beforeEach-only env convention in styleguide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior wording ("delete it in beforeEach/afterEach") led the reviewer to flag missing afterEach cleanup. Pin it to the actual convention — beforeEach re-assigns the token before every test, so no afterEach is needed — to stop the false positive recurring. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gemini/styleguide.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 1d67bc1..07fe7d2 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -5,11 +5,15 @@ Commander.js CLI). Please weigh these before flagging style or correctness concerns. ## Testing -- Tests mutate `process.env` directly: assign the variable in the test and - `delete` it in `beforeEach`/`afterEach` for isolation. This is the repo-wide - convention (see `src/utils/auth.test.ts` and ~10 other test files). Do **not** - suggest `vi.stubEnv` / `vi.unstubAllEnvs` — the codebase deliberately does not - use them, and consistency across the suite is preferred. +- Tests mutate `process.env` directly: set the variable in `beforeEach` (which + re-assigns it before every test, so leakage between tests is a non-issue) and, + when a specific test needs it unset, `delete` it inside that test. An + `afterEach` cleanup block is **not** required and is **not** the convention — + ~13 of the 14 test files (`repositories.test.ts`, `issue.test.ts`, + `findings.test.ts`, …) use `beforeEach` only. Do **not** suggest adding + `afterEach`, and do **not** suggest `vi.stubEnv` / `vi.unstubAllEnvs` — the + codebase deliberately does not use them, and consistency across the suite is + preferred. - API service calls are mocked with `vi.mock(...)`; tests are co-located as `.test.ts` next to the source.