fix(portals)!: reject unknown flags in all six CLIs

Silently discarded flags produced silently wrong results: jobdanmark
with --query (its real flag is --text) returned all 13,862 jobs as if
they matched, exit 0, empty stderr - indistinguishable from a real
result set. The four bunli CLIs get an argv preflight built from each
command's own options object; linkedin and freehire validate parsed
flags against per-command known sets. help/version still pass, and
add-portal.md's existing bogus-flag-exits-1 contract now holds for the
reference implementations contributors copy. One linkedin pin updated:
"--jobage-minutes -5" now fails as UNKNOWN_FLAG (the stray -5 token)
rather than BAD_ARG - same loud-failure invariant, earlier gate. Review
finding F13 (2026-08-19), decision approved by Mads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mads Lorentzen
2026-08-19 20:44:26 +02:00
co-authored by Claude Opus 5
parent d4e0c64c3c
commit 3bfd525cc4
13 changed files with 324 additions and 17 deletions
@@ -118,6 +118,17 @@ function parseIntFlag(name: string, raw: string | boolean | string[]): number |
return val
}
// Long-form flag names each command accepts (parseFlags resolves the short
// aliases q/n to these before validation). "help"/"h" pass so `search --help`
// still prints usage.
const KNOWN_FLAGS: Record<string, Set<string>> = {
search: new Set([
"query", "category", "city", "company", "country", "facet", "format", "jobage", "limit",
"page", "region", "remote", "seniority", "skill", "description-format", "help", "h",
]),
detail: new Set(["format", "description-format", "help", "h"]),
}
async function main(): Promise<number> {
const argv = process.argv.slice(2)
const flags = parseFlags(argv)
@@ -128,6 +139,25 @@ async function main(): Promise<number> {
return cmd ? 0 : 1
}
// Reject unknown flags instead of silently discarding them: a discarded
// filter changes what the search returns with no error (a wrong flag name
// once returned an entire portal's database as if it matched the query).
// add-portal.md's contract requires a bogus flag to exit 1 with a JSON
// error on stderr.
const knownFlags = KNOWN_FLAGS[cmd]
if (knownFlags) {
for (const key of Object.keys(flags)) {
if (key === "_" || knownFlags.has(key)) continue
process.stderr.write(
JSON.stringify({
error: `unknown flag --${key} for '${cmd}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
code: "UNKNOWN_FLAG",
}) + "\n",
)
return 1
}
}
if (cmd === "search") {
const fmt = (flags.format as string) || "json"
@@ -77,3 +77,20 @@ describe("freehire CLI flag validation", () => {
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--query", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
});
+33 -2
View File
@@ -1,4 +1,5 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
@@ -8,7 +9,37 @@ const cli = await createCLI({
description: "CLI for Akademikernes Jobbank (jobbank.dk) — job search for highly educated candidates",
})
cli.command(search)
cli.command(detail)
const commands = [search, detail]
for (const command of commands) {
cli.command(command)
}
// Reject unknown --flags before dispatch. bunli silently discards them, and a
// silently discarded filter changes what the search returns without any error
// (a wrong flag name once returned an entire portal's database as if it
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
// with a JSON error on stderr; this enforces it for the reference CLIs too.
const argv = process.argv.slice(2)
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
if (invoked) {
const known = new Set([
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
"help",
"version",
])
for (const token of argv.slice(1)) {
if (token === "--") break
if (token.startsWith("--")) {
const flag = token.slice(2).split("=")[0]
if (!known.has(flag)) {
writeError(
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
"UNKNOWN_FLAG",
)
process.exit(1)
}
}
}
}
await cli.run()
@@ -57,3 +57,26 @@ describe("Jobbank CLI flag validation", () => {
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--key", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
const result = await runCLI(["search", "--query", "test"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
});
});
@@ -1,4 +1,5 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
import { categories } from "./commands/categories.js"
@@ -11,10 +12,37 @@ const cli = await createCLI({
description: "CLI for the Jobdanmark.dk public job search API",
})
cli.command(search)
cli.command(detail)
cli.command(categories)
cli.command(autocomplete)
cli.command(locations)
const commands = [search, detail, categories, autocomplete, locations]
for (const command of commands) {
cli.command(command)
}
// Reject unknown --flags before dispatch. bunli silently discards them, and a
// silently discarded filter changes what the search returns without any error
// (a wrong flag name once returned an entire portal's database as if it
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
// with a JSON error on stderr; this enforces it for the reference CLIs too.
const argv = process.argv.slice(2)
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
if (invoked) {
const known = new Set([
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
"help",
"version",
])
for (const token of argv.slice(1)) {
if (token === "--") break
if (token.startsWith("--")) {
const flag = token.slice(2).split("=")[0]
if (!known.has(flag)) {
writeError(
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
"UNKNOWN_FLAG",
)
process.exit(1)
}
}
}
}
await cli.run()
@@ -72,3 +72,26 @@ describe("Jobdanmark CLI flag validation", () => {
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--text", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
const result = await runCLI(["search", "--query", "test"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
});
});
+33 -2
View File
@@ -1,4 +1,5 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
@@ -8,7 +9,37 @@ const cli = await createCLI({
description: "CLI for searching jobs on Jobindex.dk",
})
cli.command(search)
cli.command(detail)
const commands = [search, detail]
for (const command of commands) {
cli.command(command)
}
// Reject unknown --flags before dispatch. bunli silently discards them, and a
// silently discarded filter changes what the search returns without any error
// (a wrong flag name once returned an entire portal's database as if it
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
// with a JSON error on stderr; this enforces it for the reference CLIs too.
const argv = process.argv.slice(2)
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
if (invoked) {
const known = new Set([
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
"help",
"version",
])
for (const token of argv.slice(1)) {
if (token === "--") break
if (token.startsWith("--")) {
const flag = token.slice(2).split("=")[0]
if (!known.has(flag)) {
writeError(
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
"UNKNOWN_FLAG",
)
process.exit(1)
}
}
}
}
await cli.run()
@@ -61,3 +61,20 @@ describe("Jobindex CLI flag validation", () => {
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--query", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
});
+33 -4
View File
@@ -1,4 +1,5 @@
import { createCLI } from "@bunli/core"
import { writeError } from "./helpers.js"
import { search } from "./commands/search.js"
import { detail } from "./commands/detail.js"
import { occupations } from "./commands/occupations.js"
@@ -10,9 +11,37 @@ const cli = await createCLI({
description: "CLI for the Jobnet.dk Danish government job portal API",
})
cli.command(search)
cli.command(detail)
cli.command(occupations)
cli.command(suggestions)
const commands = [search, detail, occupations, suggestions]
for (const command of commands) {
cli.command(command)
}
// Reject unknown --flags before dispatch. bunli silently discards them, and a
// silently discarded filter changes what the search returns without any error
// (a wrong flag name once returned an entire portal's database as if it
// matched the query). add-portal.md's contract requires a bogus flag to exit 1
// with a JSON error on stderr; this enforces it for the reference CLIs too.
const argv = process.argv.slice(2)
const invoked = commands.find((c) => (c as { name?: string }).name === argv[0])
if (invoked) {
const known = new Set([
...Object.keys((invoked as { options?: Record<string, unknown> }).options ?? {}),
"help",
"version",
])
for (const token of argv.slice(1)) {
if (token === "--") break
if (token.startsWith("--")) {
const flag = token.slice(2).split("=")[0]
if (!known.has(flag)) {
writeError(
`unknown flag --${flag} for '${argv[0]}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
"UNKNOWN_FLAG",
)
process.exit(1)
}
}
}
}
await cli.run()
@@ -72,3 +72,26 @@ describe("Jobnet CLI flag validation", () => {
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "--search-string", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
const result = await runCLI(["search", "--query", "test"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
});
});
@@ -63,6 +63,16 @@ EXAMPLES
Personal use only — uses LinkedIn's public pages; keep volume low (LinkedIn ToS).
`
// Long-form flag names each command accepts (parseFlags resolves the short
// aliases q/l/n to these before validation). "help"/"h" pass so `search --help`
// still prints usage.
const KNOWN_FLAGS: Record<string, Set<string>> = {
search: new Set([
"location", "query", "jobage", "jobage-minutes", "remote", "page", "limit", "format", "help", "h",
]),
detail: new Set(["format", "help", "h"]),
}
async function main(): Promise<number> {
const argv = process.argv.slice(2)
const flags = parseFlags(argv)
@@ -73,6 +83,25 @@ async function main(): Promise<number> {
return cmd ? 0 : 1
}
// Reject unknown flags instead of silently discarding them: a discarded
// filter changes what the search returns with no error (a wrong flag name
// once returned an entire portal's database as if it matched the query).
// add-portal.md's contract requires a bogus flag to exit 1 with a JSON
// error on stderr.
const knownFlags = KNOWN_FLAGS[cmd]
if (knownFlags) {
for (const key of Object.keys(flags)) {
if (key === "_" || knownFlags.has(key)) continue
process.stderr.write(
JSON.stringify({
error: `unknown flag --${key} for '${cmd}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
code: "UNKNOWN_FLAG",
}) + "\n",
)
return 1
}
}
if (cmd === "search") {
const location = typeof flags.location === "string" ? flags.location : undefined
if (!location) {
@@ -68,13 +68,14 @@ describe("LinkedIn CLI flag validation", () => {
// parseFlags in cli.ts treats a next-token starting with "-" as absent
// (`next.startsWith("-")` → flag becomes boolean `true`), and there is no
// `--flag=value` syntax. So "-5" never reaches --jobage-minutes as a value;
// parseInt("true") is NaN, and BAD_ARG comes from the NaN branch, not the
// `v <= 0` guard. Negatives are unreachable through the CLI as currently parsed.
// it parses as a stray flag named "5", which the unknown-flag guard now
// rejects before the NaN branch can. Either way the invariant holds: a
// negative value fails loudly with exit 1 and a JSON error, never a
// silent unfiltered search.
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "-5"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/jobage-minutes/);
expect(err.code).toBe("UNKNOWN_FLAG");
});
});
@@ -126,3 +127,20 @@ describe("LinkedIn CLI flag validation", () => {
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "-l", "Denmark", "-q", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
});