mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-07 23:47:46 +00:00
fix(freehire-search): say what --region is, and warn when it matches a thin bucket (#651)
* fix(freehire-search): warn when --region gets a value outside the vocabulary freehire's contract - the openapi Regions enum and RegionValues in vocab.go - defines a closed set: nine buckets plus none. Anything else is a stray tag, so a region filter on us or any other country code matches a handful of postings and reads as an empty or thin market, which is how #616 started. search now checks the requested values (--region and the --facet regions= escape hatch, case-insensitively) against that set and, when one falls outside it, writes one JSON line on stderr after the results, with code REGION_VALUE_UNKNOWN. The result count is deliberately not consulted: a broad query returns real hits under a stray tag and the parameter is still wrong. stdout stays clean JSON, exit code 0. Tests are unit-level with a stubbed fetch: no extra request to the free API, and no change to the existing search tests. * changelog: place the #651 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+250
No files matched your search
@@ -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
|
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`.
|
**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
|
## Partial data
|
||||||
|
|
||||||
Facets are derived per-posting and can be **incomplete** — geography especially.
|
Facets are derived per-posting and can be **incomplete** — geography especially.
|
||||||
|
|||||||
@@ -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`.
|
`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`.
|
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
|
`search` hits the API's agent endpoint, so every JSON result already carries the
|
||||||
posting's **full** description (Markdown by default, `--description-format
|
posting's **full** description (Markdown by default, `--description-format
|
||||||
|
|||||||
@@ -110,6 +110,57 @@ function renderPlain(rows: JobResult[]): string {
|
|||||||
return rows.map(block).join("\n\n")
|
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<string>(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<number> {
|
export async function runSearch(opts: SearchOpts): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const path = opts.includeDescription === false ? LEAN_SEARCH_PATH : SEARCH_PATH
|
const path = opts.includeDescription === false ? LEAN_SEARCH_PATH : SEARCH_PATH
|
||||||
@@ -146,6 +197,9 @@ export async function runSearch(opts: SearchOpts): Promise<number> {
|
|||||||
) + "\n",
|
) + "\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
|
return 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED")
|
writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED")
|
||||||
|
|||||||
@@ -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> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -54,6 +54,15 @@ per-file diff commands.
|
|||||||
`north_america`, `latam`, `apac`, `mena`, `africa`, `cis`) plus `none`, note that `eu` excludes
|
`north_america`, `latam`, `apac`, `mena`, `africa`, `cis`) plus `none`, note that `eu` excludes
|
||||||
the UK, and point single-country searches at `--country`.
|
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
|
## [1.7.3] - 2026-10-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Reference in new issue
Block a user