Files
ai-job-search/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts
T
Ayobami Adegoke c844359ed9 fix(jobdanmark-search): skip autocomplete items without text instead of crashing (#421) (#422)
The filter derefed item.text.toLowerCase() from a cast API response on
the same line that already guards g.items ?? [], so one item with a null
or missing text threw TypeError and the whole command exited 1 as
API_ERROR. The filter is extracted into an exported
filterAutocompleteGroups (the jobnet testability pattern), text is typed
nullable so the compiler enforces the guard, and an item without usable
text is skipped: it can never match the required non-empty query, so
downstream output never sees one. The null-text case was verified to
fail against the verbatim unguarded extraction with the production
TypeError. Closes out the #416/#418 audit.
2026-09-03 19:37:21 +02:00

123 lines
3.8 KiB
TypeScript

import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface AutocompleteItem {
id: string
// Nullable because apiFetch casts the JSON body with no runtime validation:
// an item missing its text arrives typed as if it had one, and the filter
// below is the only place the command derefs it (#421). A null text can
// never match the required non-empty query, so such an item is filtered
// out here and downstream output never sees it.
text: string | null
value: number
category: string
slug: string
}
interface AutocompleteGroup {
title: string
items: AutocompleteItem[]
}
/**
* Filter the API's autocomplete groups to items whose text matches the query
* (the API always returns all categories, so a nonsense query must yield []).
* Exported for tests.
*/
export function filterAutocompleteGroups(raw: AutocompleteGroup[], query: string): AutocompleteGroup[] {
const queryLower = query.toLowerCase()
return raw
.map((g) => ({
title: g.title,
items: (g.items ?? []).filter(
(item) => typeof item.text === "string" && item.text.toLowerCase().includes(queryLower),
),
}))
.filter((g) => g.items.length > 0)
}
export const autocomplete = defineCommand({
name: "autocomplete",
description: "Suggest job titles and categories for a query",
options: {
query: option(z.string().optional(), {
description: "Search text to autocomplete (required)",
}),
limit: option(z.coerce.number().int().min(1).optional(), {
description: "Cap total suggestions returned",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
if (!flags.query) {
writeError("--query is required", "MISSING_REQUIRED")
process.exit(1)
}
try {
const raw = await apiFetch<AutocompleteGroup[]>("/api/search/autocomplete", {
q: flags.query,
})
if (signal.aborted) return
const filtered = filterAutocompleteGroups(raw, flags.query)
let result = filtered
if (flags.limit !== undefined) {
// Apply limit across all groups, distributing across groups
let remaining = flags.limit
result = []
for (const group of filtered) {
if (remaining <= 0) break
const items = group.items.slice(0, remaining)
remaining -= items.length
if (items.length > 0) {
result.push({ title: group.title, items })
}
}
}
if (flags.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (flags.format === "table") {
outputTable(result)
} else {
outputPlain(result)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(data: AutocompleteGroup[]): void {
console.log("category id text value slug")
for (const group of data) {
for (const item of group.items) {
const cat = item.category.padEnd(10)
const id = item.id.substring(0, 20).padEnd(20)
const text = (item.text ?? "").substring(0, 32).padEnd(32)
const value = String(item.value).padEnd(6)
const slug = item.slug
console.log(`${cat} ${id} ${text} ${value} ${slug}`)
}
}
}
function outputPlain(data: AutocompleteGroup[]): void {
for (const group of data) {
console.log(`=== ${group.title} ===`)
for (const item of group.items) {
console.log(` ${item.text ?? ""} (${item.category}, id=${item.value}, slug=${item.slug})`)
}
}
}