From c87893bdb52034c1a498493182ec77037c1575cb Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Wed, 9 Sep 2026 16:05:55 +0100 Subject: [PATCH] feat: add --matches-stack filter to the patterns command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the API's new `matchesStack` query param on `codacy patterns`, so a tool's code patterns can be narrowed to those that do (or don't) match the repository's detected stack. Tri-state, matching the existing `issues --false-positives`: the bare flag or `true` sends `matchesStack=true`, `false` sends `matchesStack=false`, omitting it sends nothing. Applies in bulk mode too, so `--enable-all`/`--disable-all` can be scoped to the stack. The post-update `toolPatternsOverview` call deliberately stays unfiltered — its counts describe the whole tool, not the updated subset. Required bumping the pinned API spec 57.3.9 -> 57.4.17 (`matchesStack` first ships in 57.4.14). The spec delta is purely additive, but the param is inserted mid-signature on `listRepositoryToolPatterns` (arg 12, before `sort`), so the full-positional-arg assertions in patterns.test.ts each gained a trailing `undefined`. `pattern.ts`/`issues.ts` stop at `search` (arg 9) and were unaffected. Also re-verified the repository-token whitelist as AGENTS.md requires after `update-api`: 57.4.x now declares the `ProjectTokenAuth` scheme in the spec (absent in 57.3.9), making the list machine-checkable, and it is 14 operations rather than 13 — the addition, `searchAiInventoryCategories`, is unused here. `patterns` stays fully whitelisted, so no new token guard. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/patterns-matches-stack.md | 22 +++++ SPECS/README.md | 3 +- SPECS/commands/tools-and-patterns.md | 11 ++- SPECS/repository-tokens.md | 23 ++++- package.json | 2 +- src/commands/issues.ts | 5 +- src/commands/patterns.test.ts | 128 ++++++++++++++++++++++++++- src/commands/patterns.ts | 20 +++++ src/utils/options.test.ts | 26 ++++++ src/utils/options.ts | 19 ++++ 10 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 .changeset/patterns-matches-stack.md create mode 100644 src/utils/options.test.ts create mode 100644 src/utils/options.ts diff --git a/.changeset/patterns-matches-stack.md b/.changeset/patterns-matches-stack.md new file mode 100644 index 0000000..1581a49 --- /dev/null +++ b/.changeset/patterns-matches-stack.md @@ -0,0 +1,22 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +New `-k, --matches-stack [value]` filter on `codacy patterns`, which narrows a tool's code patterns to those that do (or don't) match the repository's detected stack. + +It's a tri-state flag, the same shape as `issues --false-positives`: + +```bash +codacy patterns eslint9 --matches-stack # only patterns matching the repo stack +codacy patterns eslint9 --matches-stack true # same +codacy patterns eslint9 --matches-stack false # only patterns that don't match +codacy patterns eslint9 # unfiltered +``` + +The filter applies in bulk mode too, so `--enable-all` / `--disable-all` can be scoped to the stack: + +```bash +codacy patterns eslint9 --disable-all --matches-stack false +``` + +The summary printed after a bulk update still reports counts for the whole tool, not just the updated subset. diff --git a/SPECS/README.md b/SPECS/README.md index 30f6de2..c9c0ad0 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -25,7 +25,7 @@ _No pending tasks._ All commands implemented. | `finding` | `fin` | ✅ Done (CVE enrichment included) | [finding.md](commands/finding.md) | | `tools` | `tls` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | | `tool` | `tl` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | -| `patterns` | `pats` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | +| `patterns` | `pats` | ✅ Done (--matches-stack added) | [tools-and-patterns.md](commands/tools-and-patterns.md) | | `pattern` | `pat` | ✅ Done (info mode + guards added) | [tools-and-patterns.md](commands/tools-and-patterns.md) | | `analysis` | N/A | ✅ Done | [analysis.md](commands/analysis.md) | | `json-output` | N/A | ✅ Done | [json-output.md](commands/json-output.md) | @@ -87,3 +87,4 @@ _No pending tasks._ All commands implemented. | 2026-07-30 | (OD-378, review follow-up) `pull-requests` table polish + a real data bug. **Bug:** Complexity rendered as "no data" on every PR because the API omits the flat top-level `deltaComplexity` and only returns `quality.deltaComplexity` (while still sending a top-level `deltaClonesCount`) — new shared `prQualityMetric(pr, key)` in `utils/formatting.ts` reads the nested `quality` value first and falls back to the flat field; also applied to `repository`'s Open PR table and `pull-request`'s Analysis section, which had the same bug. **Layout:** `✓` moved to the first column; metric order now matches `repositories` (issues → complexity → duplication → coverage); the Coverage column is dropped entirely when no listed PR has a coverage value (new `hasAnyPrCoverage()` — repos without coverage return `diffCoverage.cause` and no numbers on any PR); missing metric values now render as a dim `-` instead of `N/A` in `formatDelta`/`formatPrCoverage`/`formatPrIssues`, matching `formatStandards`/`formatCountCell`/`formatCoverageCell`; and a zero issue count renders as a bare `0` rather than `+0`/`-0` (`-0` read as a negative), matching what `pull-request`'s Files table and `formatDelta` already did. **JSON:** added `quality.resultReasons`/`coverage.resultReasons` (Codacy review suggestion — they drive the per-metric gate coloring, so consumers need them to see which gates passed/failed) plus the `quality.*` metric mirrors the table actually renders (23 new tests, 544 total) | | 2026-08-11 | (OD-489) Repository (project) token support. New `--repository-token ` on every command (plus `CODACY_PROJECT_TOKEN`), sent as the `project-token` header; account tokens keep `api-token`. `src/utils/auth.ts` rewritten around a `RemoteAuth` discriminated union carrying both kind and source, replacing `checkApiToken()` with `resolveAuth(this)` / `resolveAccountAuth(this, why)` / `requireAccountToken(...)` / `fetchIfAccountToken(...)`. Precedence matches `codacy-analysis` exactly — flag > `CODACY_PROJECT_TOKEN` > `CODACY_API_TOKEN` > stored login — so `vitest.config.mts` now blanks `CODACY_PROJECT_TOKEN` (it outranks the account token and is exported job-wide by the coverage reporter, so tests would otherwise depend on the developer's shell). Codacy whitelists only 13 operations for repository tokens, so `tool`/`patterns`/`pattern` work unchanged, `issues` (incl. `--overview`) and `tools --import` work, and the 9 account-only commands plus `repository`'s 6 management flags, `issues --ignore`/`--ignored`, and `tools --import --force` (only when standards exist) **fail fast before any request** with a message naming the operation, the reason, and where the token came from. `repository`'s dashboard skips the two non-whitelisted calls: the table keeps the "Open Pull Requests" header with an explanatory line, and JSON keeps `pullRequests: []` (so `jq '.pullRequests[]'` still works) plus an additive `unavailable: ["pullRequests"]` — under an account token the payload is byte-identical. Also added the long-missing `.catch()` on the PR call so an account token lacking PR access degrades instead of losing the whole dashboard, and fixed `login`'s 401 message, which told repository-token users their token was "invalid" when it is rejected by `/user` by design. New `SPECS/repository-tokens.md` (whitelist + matrix, re-verify on every `npm run update-api`) and `SPECS/missing-endpoints.md` (ranked gaps for follow-up Linear tasks) (40 new tests, 606 total) | | 2026-09-07 | HTTP/HTTPS proxy + TLS support (issue #40). Node's global `fetch` — used by the generated client and the MITRE CVE lookup in `commands/finding.ts` — ignores `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`, so the CLI was unusable behind a corporate proxy. Rather than reimplement it, this delegates to `configureProxy()` from `@codacy/tooling` (pinned `0.1.0` → `0.23.0`, the same function `analysis-cli` calls), which installs a global `undici` dispatcher doing per-request protocol + `NO_PROXY` routing, bare `host:port` normalization, and `SSL_CERT_FILE`/`NODE_EXTRA_CA_CERTS` CA loading. New `src/utils/proxy.ts` is a ~4-line seam — `configureProxyFromEnv()` calls it and routes its deliberate fail-loud throw (unreadable/non-PEM CA bundle) into `handleError()`, giving red `Error: ` and exit 1 like every other failure here; `analysis-cli` exits 2 because it has a documented exit-code scheme, which this CLI does not. Called at the top of `src/index.ts`, above `OpenAPI.BASE` (ordering is only constrained to precede `program.parse`, since the dispatcher is resolved per request). Kept top-level rather than in the `preAction` hook so a typo'd `SSL_CERT_FILE` fails even on `--version`. Deliberately zero-argument: env is the sole input, which is what keeps parity exact. Superseded external PR #39, which hand-rolled the same feature with `undici@8.10.1` — that requires Node ≥ 22.19.0 against this package's `engines: ">=20"`, so `require("undici")` threw at module load and the CLI would not start at all on any Node 20.x; tooling's `undici@^6.21.0` supports Node ≥ 18.17. That regression passed CI, so `ci.yml` gained a smoke step running the built entry point (plain, with `HTTPS_PROXY`, and with a bad `SSL_CERT_FILE` expected to fail) — previously nothing executed `src/index.ts`, since every command test builds a bare `new Command()`. Upstream owns the proxy semantics and their 24 tests, so only the seam is tested here. Pinned exactly rather than with a caret: for a pre-1.0 package `^0.22.0` spans patches only (`>=0.22.0 <0.23.0-0`), so a caret would have bought silent patch drift against a dependency this repo has no proxy coverage for, without ever picking up a minor. Two findings from this work were fixed upstream and taken here via 0.23.0 — `undici` now loads lazily behind `configureProxy`'s early-out (an unproxied `--version` went from +27 ms to +0 ms against a `main` build), and a malformed proxy URL now fails with `Invalid HTTPS_PROXY value "...": `, naming the setting and redacting any credentials instead of surfacing a bare `Invalid URL` (4 new tests, 614 total) | +| 2026-09-09 | New `-k, --matches-stack [value]` filter on `patterns`, surfacing the API's `matchesStack` query param (filter a tool's code patterns by whether they match the repository's detected stack). Tri-state, matching the existing `issues --false-positives`: the bare flag or `true` sends `matchesStack=true`, `false` sends `matchesStack=false`, omitting it sends nothing — read explicitly rather than by truthiness so an explicit `false` stays distinct from "not requested". Applies in **both** list mode (`listRepositoryToolPatterns`) and bulk mode (`updateRepositoryToolPatterns`), like every other filter; the post-update `toolPatternsOverview` call deliberately stays unfiltered, since its counts describe the whole tool rather than the updated subset. The shared tri-state coercion `parseBooleanOption` moved out of `issues.ts` into a new `utils/options.ts` (+ tests) and is now imported by both commands. **Required an API bump: pinned `57.3.9` → `57.4.17`** (`matchesStack` first ships in `57.4.14`; `57.4.17` is the latest published build). The spec delta is purely additive — 2 unused new operations, 4 new schemas, `stackTagsFilterParam` on `listOrganizationRepositories` (unused; the CLI calls `...WithAnalysis`) — but `matchesStack` is inserted *mid-signature* on `listRepositoryToolPatterns` (arg 12, before `sort`), so every full-positional-arg assertion in `patterns.test.ts` gained a trailing `undefined`; `pattern.ts`/`issues.ts` stop at `search` (arg 9) and were unaffected. `SPECS/repository-tokens.md` re-verified: `57.4.x` now declares the `ProjectTokenAuth` scheme in the spec (it was absent in `57.3.9`), making the whitelist machine-checkable, and it is **14** operations, not 13 — the addition is `searchAiInventoryCategories`, unused here. `patterns` stays fully whitelisted, so no new token guard (11 new tests, 625 total) | diff --git a/SPECS/commands/tools-and-patterns.md b/SPECS/commands/tools-and-patterns.md index bfb8161..9cf0145 100644 --- a/SPECS/commands/tools-and-patterns.md +++ b/SPECS/commands/tools-and-patterns.md @@ -110,6 +110,8 @@ overwritten and the API can't list or change them: codacy patterns codacy patterns gh my-org my-repo eslint --severities Critical,High --enabled codacy patterns gh my-org my-repo eslint --output json +codacy patterns gh my-org my-repo eslint --matches-stack +codacy patterns gh my-org my-repo eslint --matches-stack false codacy patterns gh my-org my-repo eslint --enable-all --categories Security codacy patterns gh my-org my-repo eslint --disable-all --severities Minor ``` @@ -126,6 +128,7 @@ codacy patterns gh my-org my-repo eslint --disable-all --severities Minor | `--enabled` | `-e` | Show only enabled patterns (list mode only) | | `--disabled` | `-D` | Show only disabled patterns (list mode only) | | `--recommended` | `-r` | Show only recommended patterns | +| `--matches-stack [value]` | `-k` | Filter by whether patterns match the repository stack. Tri-state: the bare flag or `true` sends `matchesStack=true`, `false` sends `matchesStack=false`, omitting it sends nothing | | `--enable-all` | `-E` | Bulk enable matching patterns | | `--disable-all` | `-X` | Bulk disable matching patterns | @@ -160,16 +163,18 @@ Shows pagination warning if more than 100 results exist. ### Bulk update mode (`--enable-all` / `--disable-all`) -Enables or disables all patterns matching the applied filters (languages, categories, severities, tags, search, recommended). The `--enabled`/`--disabled` filter is not used in bulk update mode since it would be redundant. `--enable-all` and `--disable-all` are mutually exclusive. +Enables or disables all patterns matching the applied filters (languages, categories, severities, tags, search, recommended, matches-stack). The `--enabled`/`--disabled` filter is not used in bulk update mode since it would be redundant. `--enable-all` and `--disable-all` are mutually exclusive. -After the update, fetches the tool patterns overview and shows a summary: +After the update, fetches the tool patterns overview and shows a summary. The +overview call deliberately carries **no** filters — including `--matches-stack` +— because the counts describe the whole tool, not the updated subset: ``` ✔ Enabled matching ESLint patterns. 120/200 patterns now enabled. ``` ## Tests -File: `src/commands/patterns.test.ts` — 27 tests. +File: `src/commands/patterns.test.ts` — 35 tests. --- diff --git a/SPECS/repository-tokens.md b/SPECS/repository-tokens.md index 1527f33..4d5d676 100644 --- a/SPECS/repository-tokens.md +++ b/SPECS/repository-tokens.md @@ -45,8 +45,26 @@ depends on it). Both flag and env values are trimmed. > hardcode it; if the backend adds an operation, a guard here will still refuse > it. Source: Linear project *"Project token works in selected API v3 > (+expiration)"*. - -Codacy accepts a repository token on **exactly** these 13 operations. Everywhere +> +> **Last verified against API `57.4.17`** (2026-09-09). Since `57.4.x` the spec +> declares the `ProjectTokenAuth` security scheme (`project-token` header) on +> each supported operation, so the whitelist is now machine-checkable: +> +> ```bash +> python3 -c " +> import re +> cur=None; out=[] +> for l in open('api-v3/api-swagger.yaml'): +> m=re.match(r'\s*operationId:\s*(\S+)', l) +> if m: cur=m.group(1) +> if 'ProjectTokenAuth' in l and cur and cur not in out: out.append(cur) +> print(len(out)); print('\n'.join(sorted(out)))" +> ``` +> +> On `57.3.9` (the previously pinned build) the scheme was not declared +> anywhere, which is why this table was maintained by hand. + +Codacy accepts a repository token on **exactly** these 14 operations. Everywhere else it is rejected as if no token had been sent. | operationId | Method | Used by this CLI | @@ -64,6 +82,7 @@ else it is rejected as if no token had been sent. | `getRepositoryLanguages` | GET | — | | `getRepository` | GET | — | | `listIgnoredFiles` | GET | — | +| `searchAiInventoryCategories` | POST | — | `ToolsService.listTools` / `listPatterns` / `getPattern` are declared `security: []` in the spec — unauthenticated, so they work with any token or diff --git a/package.json b/package.json index 1414b53..ad02688 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/57.3.9/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/57.4.17/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/issues.ts b/src/commands/issues.ts index 1491f65..865f636 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -24,6 +24,7 @@ import { formatCount, } from "../utils/formatting"; import { sanitizeText } from "../utils/sanitize"; +import { parseBooleanOption } from "../utils/options"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { ToolsService } from "../api/client/services/ToolsService"; import { Tool } from "../api/client/models/Tool"; @@ -140,10 +141,6 @@ function normalizeCategory(input: string): string { return CATEGORY_NORMALIZE[key] ?? input; } -function parseBooleanOption(value: string): boolean { - return value.toLowerCase() !== "false"; -} - function printIssuesList(issues: CommitIssue[], total: number): void { printSection("Issues", total, "issue"); if (issues.length === 0) { diff --git a/src/commands/patterns.test.ts b/src/commands/patterns.test.ts index 7bd3a9f..5480fe1 100644 --- a/src/commands/patterns.test.ts +++ b/src/commands/patterns.test.ts @@ -186,6 +186,7 @@ describe("patterns command", () => { undefined, undefined, undefined, + undefined, ); const output = getAllOutput(); @@ -295,6 +296,7 @@ describe("patterns command", () => { "sql injection", true, undefined, + undefined, ); }); @@ -324,6 +326,7 @@ describe("patterns command", () => { undefined, undefined, undefined, + undefined, ); }); @@ -352,9 +355,69 @@ describe("patterns command", () => { undefined, undefined, true, + undefined, ); }); + // `--matches-stack [value]` is a tri-state: the bare flag and an explicit + // `true` both opt in, `false` opts out, and omitting it sends nothing. + describe("--matches-stack", () => { + const expectMatchesStack = (value: boolean | undefined) => + expect(AnalysisService.listRepositoryToolPatterns).toHaveBeenCalledWith( + "gh", + "test-org", + "test-repo", + "uuid-eslint", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + value, + ); + + const run = async (...extraArgs: string[]) => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "patterns", + "gh", + "test-org", + "test-repo", + "eslint", + ...extraArgs, + ]); + }; + + it("sends matchesStack=true for the bare flag", async () => { + await run("--matches-stack"); + expectMatchesStack(true); + }); + + it("sends matchesStack=true for --matches-stack true", async () => { + await run("--matches-stack", "true"); + expectMatchesStack(true); + }); + + it("sends matchesStack=false for --matches-stack false", async () => { + await run("--matches-stack", "false"); + expectMatchesStack(false); + }); + + it("sends nothing when the flag is omitted", async () => { + await run(); + expectMatchesStack(undefined); + }); + + it("accepts the -k short flag", async () => { + await run("-k", "false"); + expectMatchesStack(false); + }); + }); + it("should show ☑️ icon for patterns enforced by a coding standard", async () => { const program = createProgram(); await program.parseAsync([ @@ -593,6 +656,7 @@ describe("patterns command", () => { undefined, undefined, undefined, + undefined, ); expect(AnalysisService.toolPatternsOverview).toHaveBeenCalledWith( "gh", @@ -630,6 +694,7 @@ describe("patterns command", () => { undefined, undefined, undefined, + undefined, ); }); @@ -671,6 +736,66 @@ describe("patterns command", () => { "security", "injection", true, + undefined, + ); + }); + + // `matchesStack` is the 12th positional argument of + // updateRepositoryToolPatterns, so the bulk path filters on the stack too. + it("should pass --matches-stack to updateRepositoryToolPatterns", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "patterns", + "gh", + "test-org", + "test-repo", + "eslint", + "--enable-all", + "--matches-stack", + "false", + ]); + + expect( + AnalysisService.updateRepositoryToolPatterns, + ).toHaveBeenCalledWith( + "gh", + "test-org", + "test-repo", + "uuid-eslint", + { enabled: true }, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + false, + ); + }); + + it("should not scope the post-update overview to --matches-stack", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "patterns", + "gh", + "test-org", + "test-repo", + "eslint", + "--disable-all", + "--matches-stack", + ]); + + // The summary counts ("N/M patterns now enabled") describe the whole + // tool, so the overview call deliberately carries no filters. + expect(AnalysisService.toolPatternsOverview).toHaveBeenCalledWith( + "gh", + "test-org", + "test-repo", + "uuid-eslint", ); }); @@ -740,7 +865,7 @@ describe("patterns command", () => { expect(call[4]).toEqual({ enabled: true }); // The enabled filter should not be passed to bulk update // updateRepositoryToolPatterns has no enabled query param - expect(call).toHaveLength(11); + expect(call).toHaveLength(12); }); }); @@ -841,6 +966,7 @@ describe("patterns command", () => { undefined, undefined, undefined, + undefined, ); }); }); diff --git a/src/commands/patterns.ts b/src/commands/patterns.ts index a12cdd2..a5117ba 100644 --- a/src/commands/patterns.ts +++ b/src/commands/patterns.ts @@ -17,6 +17,7 @@ import { CONFIG_FILE_LOCKED_MESSAGE, PATTERN_JSON_FIELDS, } from "../utils/formatting"; +import { parseBooleanOption } from "../utils/options"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { ConfiguredPattern } from "../api/client/models/ConfiguredPattern"; import { SeverityLevel } from "../api/client/models/SeverityLevel"; @@ -73,6 +74,7 @@ interface BulkUpdateArgs { tags?: string; search?: string; recommended?: boolean; + matchesStack?: boolean; spinner: ReturnType; } @@ -92,6 +94,7 @@ async function handleBulkUpdate(args: BulkUpdateArgs): Promise { args.tags, args.search, args.recommended, + args.matchesStack, ); const overview = await AnalysisService.toolPatternsOverview( @@ -177,6 +180,11 @@ export function registerPatternsCommand(program: Command) { .option("-e, --enabled", "show only enabled patterns") .option("-D, --disabled", "show only disabled patterns") .option("-r, --recommended", "show only recommended patterns") + .option( + "-k, --matches-stack [value]", + "filter by whether patterns match the repository stack (true, false, or omit)", + parseBooleanOption, + ) .option("-E, --enable-all", "bulk enable matching patterns") .option("-X, --disable-all", "bulk disable matching patterns") .addOption(repositoryTokenOption()) @@ -189,6 +197,8 @@ Examples: $ codacy-cloud-cli patterns gh my-org my-repo eslint --severities Critical,High $ codacy-cloud-cli patterns gh my-org my-repo eslint --enabled --categories Security $ codacy-cloud-cli patterns gh my-org my-repo eslint --search "sql injection" --recommended + $ codacy-cloud-cli patterns gh my-org my-repo eslint --matches-stack # only patterns matching the repo stack + $ codacy-cloud-cli patterns gh my-org my-repo eslint --matches-stack false # only patterns that don't $ codacy-cloud-cli patterns gh my-org my-repo eslint --enable-all --categories Security $ codacy-cloud-cli patterns gh my-org my-repo eslint --disable-all --severities Minor`, ) @@ -252,6 +262,14 @@ Examples: const { severities, categories } = parseFilters(opts); + // Tri-state: `--matches-stack`/`--matches-stack true` sends true, + // `--matches-stack false` sends false, and omitting it sends nothing. + // Read explicitly rather than by truthiness so an explicit `false` + // stays distinct from "not requested". + let matchesStackFilter: boolean | undefined; + if (opts.matchesStack === true) matchesStackFilter = true; + else if (opts.matchesStack === false) matchesStackFilter = false; + if (opts.enableAll || opts.disableAll) { await handleBulkUpdate({ provider, @@ -266,6 +284,7 @@ Examples: tags: opts.tags, search: opts.search, recommended: opts.recommended ? true : undefined, + matchesStack: matchesStackFilter, spinner, }); return; @@ -289,6 +308,7 @@ Examples: opts.search, enabledFilter, opts.recommended ? true : undefined, + matchesStackFilter, ); spinner.stop(); diff --git a/src/utils/options.test.ts b/src/utils/options.test.ts new file mode 100644 index 0000000..9662dbe --- /dev/null +++ b/src/utils/options.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { parseBooleanOption } from "./options"; + +describe("parseBooleanOption", () => { + it('coerces "true" to true', () => { + expect(parseBooleanOption("true")).toBe(true); + }); + + it('coerces "false" to false', () => { + expect(parseBooleanOption("false")).toBe(false); + }); + + it("is case-insensitive", () => { + expect(parseBooleanOption("FALSE")).toBe(false); + expect(parseBooleanOption("False")).toBe(false); + expect(parseBooleanOption("TRUE")).toBe(true); + }); + + it('treats any value other than "false" as true', () => { + // Commander only calls the parser when a value is supplied, so the bare + // flag never reaches here; everything that does and isn't "false" opts in. + expect(parseBooleanOption("yes")).toBe(true); + expect(parseBooleanOption("1")).toBe(true); + expect(parseBooleanOption("")).toBe(true); + }); +}); diff --git a/src/utils/options.ts b/src/utils/options.ts new file mode 100644 index 0000000..514fe4f --- /dev/null +++ b/src/utils/options.ts @@ -0,0 +1,19 @@ +/** + * Shared coercion helpers for Commander option values. + */ + +/** + * Coerce the value of a tri-state boolean option declared as `--flag [value]`. + * + * Commander only invokes this parser when a value is actually supplied, so the + * bare flag (`--flag`) yields boolean `true` without passing through here. + * Anything other than a case-insensitive `"false"` is treated as `true`, which + * keeps `--flag`, `--flag true` and `--flag TRUE` equivalent. + * + * Read the resulting option as a tri-state — `true` / `false` / `undefined` + * (omitted) — rather than with a truthiness check, so "omitted" stays distinct + * from an explicit `false`. + */ +export function parseBooleanOption(value: string): boolean { + return value.toLowerCase() !== "false"; +}