diff --git a/.agents/skills/freehire-search/SKILL.md b/.agents/skills/freehire-search/SKILL.md index c748a6a9..c329216a 100644 --- a/.agents/skills/freehire-search/SKILL.md +++ b/.agents/skills/freehire-search/SKILL.md @@ -169,6 +169,11 @@ result carries at least `id` (the freehire slug), `title`, `company`, `location` omit the description — it would swamp a scannable list. All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`. +One diagnostic is not an error: a `--region` (or `--facet regions=`) value outside the +documented vocabulary - `us` is a country code, not a region - prints +`{ "warning": "...", "code": "REGION_VALUE_UNKNOWN" }` on **stderr** after the results, +and the process still exits `0` with stdout a valid JSON document. + ## Partial data Facets are derived per-posting and can be **incomplete** — geography especially. diff --git a/.agents/skills/freehire-search/cli/README.md b/.agents/skills/freehire-search/cli/README.md index 63b8f8ce..4901d6f5 100644 --- a/.agents/skills/freehire-search/cli/README.md +++ b/.agents/skills/freehire-search/cli/README.md @@ -43,6 +43,9 @@ Compose (`make up` → API on `:8080`, same `/api/v1/...` paths). `search` accepts `--format json|table|plain` (default `json`); `detail` accepts `--format json|plain`. All errors are written to **stderr** as `{ "error": "...", "code": "..." }` with exit code `1`. +One diagnostic is a warning instead: a `--region` value outside the documented vocabulary +prints `{ "warning": "...", "code": "REGION_VALUE_UNKNOWN" }` to stderr after the results, +and exits `0`, leaving stdout a valid JSON document. `search` hits the API's agent endpoint, so every JSON result already carries the posting's **full** description (Markdown by default, `--description-format diff --git a/.agents/skills/freehire-search/cli/src/commands/search.ts b/.agents/skills/freehire-search/cli/src/commands/search.ts index 49be3728..ac8a5e4a 100644 --- a/.agents/skills/freehire-search/cli/src/commands/search.ts +++ b/.agents/skills/freehire-search/cli/src/commands/search.ts @@ -110,6 +110,57 @@ function renderPlain(rows: JobResult[]): string { return rows.map(block).join("\n\n") } +// freehire's contract - the openapi `Regions` enum and `RegionValues` in vocab.go - +// defines a closed set: these nine buckets plus `none`. Anything else is a stray +// tag, `us` above all, which is a country code that matches only a handful of +// postings. A search filtered on one therefore looks empty or nearly empty, so the +// CLI says the value is wrong instead of letting it read as a thin market. +const REGION_VALUES = [ + "global", + "north_america", + "latam", + "eu", + "uk", + "mena", + "africa", + "apac", + "cis", + "none", +] as const + +/** Region values the caller filtered on: `--region` plus the `--facet regions=` escape hatch. */ +function requestedRegions(opts: SearchOpts): string[] { + return [...opts.regions, ...(opts.facets.regions ?? [])] +} + +/** + * One line on stderr when a region filter uses a value outside the closed + * vocabulary, written after stdout and without touching the exit code, so a bad + * parameter never reads as "no postings". The result count is deliberately not + * consulted: a broad query returns real hits under a stray tag and is still wrong, + * which is exactly the case a zero-result check would miss. + */ +function warnOnUnknownRegion(opts: SearchOpts): void { + const requested = requestedRegions(opts) + if (requested.length === 0) return + const known = new Set(REGION_VALUES) + const unknown = [...new Set(requested.map((value) => value.trim().toLowerCase()))].filter( + (value) => value !== "" && !known.has(value), + ) + if (unknown.length === 0) return + const country = unknown.includes("us") + ? " A country belongs in --country, e.g. --country US." + : " A country belongs in --country." + process.stderr.write( + JSON.stringify({ + warning: + `--region ${unknown.join(", ")} is not a region value: freehire's regions are ` + + `${REGION_VALUES.join(", ")}.${country}`, + code: "REGION_VALUE_UNKNOWN", + }) + "\n", + ) +} + export async function runSearch(opts: SearchOpts): Promise { try { const path = opts.includeDescription === false ? LEAN_SEARCH_PATH : SEARCH_PATH @@ -146,6 +197,9 @@ export async function runSearch(opts: SearchOpts): Promise { ) + "\n", ) } + // After stdout: a diagnostic must never interleave with the JSON, and the exit + // code stays 0 because a wrong filter value is not a failure of the request. + warnOnUnknownRegion(opts) return 0 } catch (e) { writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED") diff --git a/.agents/skills/freehire-search/cli/tests/region-value-warning.test.ts b/.agents/skills/freehire-search/cli/tests/region-value-warning.test.ts new file mode 100644 index 00000000..e32ea89c --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/region-value-warning.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { runSearch, type SearchOpts } from "../src/commands/search" + +// freehire's contract defines a closed region vocabulary (nine buckets plus `none`), +// so a value outside it is a wrong parameter, not a thin market: `us` is a country +// code that still matches a handful of stray-tagged postings. The warning therefore +// fires on the value alone - no network, and not only when the result is empty, +// which is the case a count-based check would miss. + +const originalFetch = globalThis.fetch +const originalStdoutWrite = process.stdout.write +const originalStderrWrite = process.stderr.write + +const DOCUMENTED = ["global", "north_america", "latam", "eu", "uk", "mena", "africa", "apac", "cis", "none"] + +function capture(stream: "stdout" | "stderr"): { read: () => string } { + let buf = "" + const write = ((chunk: string | Uint8Array) => { + buf += chunk.toString() + return true + }) as typeof process.stdout.write + if (stream === "stdout") process.stdout.write = write + else process.stderr.write = write + return { read: () => buf } +} + +function job() { + return { + public_slug: "senior-fullstack-engineer-acme-1a2b3c4d", + source: "greenhouse", + external_id: "1", + url: "https://example.com/jobs/1", + title: "Senior Fullstack Engineer", + company: "Acme", + company_slug: "acme", + location: "Remote", + description: "body", + skills: ["typescript"], + work_mode: "remote", + regions: ["us"], + countries: [], + cities: [], + posted_at: "2026-10-17T00:00:00Z", + created_at: "2026-10-17T00:00:00Z", + enrichment: {}, + } +} + +/** Serve one canned search answer; returns the URLs the CLI asked for. */ +function stubSearch(total: number): { urls: string[] } { + const urls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url) + const results = total > 0 ? [job()] : [] + return Response.json({ data: results, meta: { total } }) + }) as typeof fetch + return { urls } +} + +function opts(over: Partial = {}): SearchOpts { + return { + jobage: 14, + page: 1, + limit: 25, + format: "json", + descriptionFormat: "markdown", + regions: [], + countries: [], + cities: [], + seniority: [], + category: [], + skills: [], + facets: {}, + ...over, + } +} + +afterEach(() => { + globalThis.fetch = originalFetch + process.stdout.write = originalStdoutWrite + process.stderr.write = originalStderrWrite +}) + +describe("--region outside the closed vocabulary", () => { + test("warns on stderr, keeps stdout parseable, exits 0", async () => { + stubSearch(0) + const stdout = capture("stdout") + const stderr = capture("stderr") + const code = await runSearch(opts({ query: "typescript", regions: ["us"] })) + expect(code).toBe(0) + expect(JSON.parse(stdout.read()).meta.total).toBe(0) + const line = stderr.read() + expect(line).toContain("REGION_VALUE_UNKNOWN") + expect(line).toContain("--country US") + // One line, as asked: a stderr consumer reads a line, not a paragraph. + expect(line.trimEnd().split("\n")).toHaveLength(1) + }) + + test("warns even when the query returns postings", async () => { + // The case a zero-result check misses: a broad query returns real hits under a + // stray tag and the parameter is still wrong. + stubSearch(3) + capture("stdout") + const stderr = capture("stderr") + const code = await runSearch(opts({ query: "software engineer", regions: ["us"] })) + expect(code).toBe(0) + expect(stderr.read()).toContain("REGION_VALUE_UNKNOWN") + }) + + test("covers the --facet regions= escape hatch", async () => { + stubSearch(0) + capture("stdout") + const stderr = capture("stderr") + await runSearch(opts({ facets: { regions: ["us"] } })) + expect(stderr.read()).toContain("REGION_VALUE_UNKNOWN") + }) + + test("compares case-insensitively", async () => { + stubSearch(0) + capture("stdout") + const upperUnknown = capture("stderr") + await runSearch(opts({ regions: ["US"] })) + expect(upperUnknown.read()).toContain("REGION_VALUE_UNKNOWN") + + process.stderr.write = originalStderrWrite + const upperKnown = capture("stderr") + await runSearch(opts({ regions: ["EU"] })) + expect(upperKnown.read()).toBe("") + }) + + test("names only the values outside the vocabulary", async () => { + stubSearch(0) + capture("stdout") + const stderr = capture("stderr") + await runSearch(opts({ regions: ["eu", "us", "north_america"] })) + const warning = JSON.parse(stderr.read()).warning as string + expect(warning).toContain("--region us is not a region value") + expect(warning).not.toContain("--region eu") + }) + + test("stays silent for every documented value", async () => { + for (const value of DOCUMENTED) { + stubSearch(0) + capture("stdout") + const stderr = capture("stderr") + await runSearch(opts({ regions: [value] })) + expect(stderr.read()).toBe("") + process.stderr.write = originalStderrWrite + } + }) + + test("warns after a table, not only after JSON", async () => { + stubSearch(1) + const stdout = capture("stdout") + const stderr = capture("stderr") + await runSearch(opts({ format: "table", regions: ["us"] })) + expect(stdout.read()).toContain("Senior Fullstack Engineer") + expect(stderr.read()).toContain("REGION_VALUE_UNKNOWN") + }) + + test("does not blame none when another value is the invalid one", async () => { + stubSearch(0) + capture("stdout") + const stderr = capture("stderr") + await runSearch(opts({ regions: ["none", "us"] })) + const warning = JSON.parse(stderr.read()).warning as string + expect(warning).toContain("--region us is not a region value") + expect(warning).toContain("--country US") + }) + + test("no region filter means no warning and one request", async () => { + const stub = stubSearch(0) + capture("stdout") + const stderr = capture("stderr") + await runSearch(opts({ query: "typescript" })) + expect(stderr.read()).toBe("") + expect(stub.urls).toHaveLength(1) + }) +}) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7df633..c21161f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,15 @@ per-file diff commands. `north_america`, `latam`, `apac`, `mena`, `africa`, `cis`) plus `none`, note that `eu` excludes the UK, and point single-country searches at `--country`. +- **`freehire-search search` warns when `--region` gets a value outside freehire's vocabulary** + (#651, fixes #616, `.agents/skills/freehire-search/cli/src/commands/search.ts`, `SKILL.md`, + `cli/README.md`) - an unknown region such as `us` or `europe` matches nothing, so the search read + as "no postings" rather than "wrong parameter". `search` now checks `--region` and + `--facet regions=` against the nine buckets plus `none`, case-insensitively, and prints one + `REGION_VALUE_UNKNOWN` warning on stderr that lists the buckets and points a country at + `--country`. Results, stdout and the exit code are unchanged, so a bucket freehire adds later + still works. + ## [1.7.3] - 2026-10-19 ### Added