[
 {
  "id": "split-bill",
  "title": "Split a bill",
  "signature": "export function splitBill(totalCents: number, weights: number[]): number[]",
  "vague": "Write splitBill. It splits a restaurant bill between people according to their weights.",
  "worded": "Write splitBill. It allocates a bill between people in proportion to their weights, using the largest remainder method.\nInvariant: the shares are integers and always sum to exactly totalCents; no cent is lost or invented.\nEach person first gets the floor of their exact share. Hand out the leftover cents one at a time to the largest fractional remainders. Break ties deterministically, earliest index first. A person with weight 0 gets 0.\nGuard the inputs at the boundary: throw RangeError when weights is empty, when any weight is negative, when all weights are zero, or when totalCents is not a non-negative integer.",
  "scenarios": [
   {
    "name": "two equal people share evenly",
    "kind": "core",
    "source": "(m) => eq(m.splitBill(100, [1, 1]), [50, 50])"
   },
   {
    "name": "three people: no cent is lost",
    "kind": "core",
    "source": "(m) => eq(sum(m.splitBill(100, [1, 1, 1])), 100)"
   },
   {
    "name": "awkward weights: no cent is lost",
    "kind": "core",
    "source": "(m) => eq(sum(m.splitBill(1999, [3, 7, 11])), 1999)"
   },
   {
    "name": "shares are whole cents",
    "kind": "core",
    "source": "(m) => ok(m.splitBill(1000, [1, 2, 4]).every(Number.isInteger), \"not integers\")"
   },
   {
    "name": "one share per person",
    "kind": "core",
    "source": "(m) => eq(m.splitBill(500, [1, 2, 3, 4]).length, 4)"
   },
   {
    "name": "shares are within a cent of proportional",
    "kind": "core",
    "source": "(m) => {\n        const w = [5, 3, 2];\n        m.splitBill(997, w).forEach((s, i) => ok(Math.abs(s - 997 * w[i] / 10) <= 1, `share ${s}`));\n      }"
   },
   {
    "name": "a zero bill is all zeros",
    "kind": "core",
    "source": "(m) => eq(m.splitBill(0, [1, 2]), [0, 0])"
   },
   {
    "name": "a large bill still adds up",
    "kind": "core",
    "source": "(m) => eq(sum(m.splitBill(123456789, [17, 19, 23, 29, 31])), 123456789)"
   },
   {
    "name": "ties go to the earliest index",
    "kind": "trap",
    "source": "(m) => eq(m.splitBill(100, [1, 1, 1]), [34, 33, 33])"
   },
   {
    "name": "largest remainder wins the spare cent",
    "kind": "trap",
    "source": "(m) => eq(m.splitBill(100, [2, 1]), [67, 33])"
   },
   {
    "name": "weight zero gets nothing",
    "kind": "trap",
    "source": "(m) => eq(m.splitBill(10, [0, 1, 1]), [0, 5, 5])"
   },
   {
    "name": "empty weights: refused",
    "kind": "trap",
    "source": "(m) => throwsRange(() => m.splitBill(10, []))"
   },
   {
    "name": "negative weight: refused",
    "kind": "trap",
    "source": "(m) => throwsRange(() => m.splitBill(10, [1, -1]))"
   },
   {
    "name": "all weights zero: refused",
    "kind": "trap",
    "source": "(m) => throwsRange(() => m.splitBill(10, [0, 0]))"
   },
   {
    "name": "fractional total: refused",
    "kind": "trap",
    "source": "(m) => throwsRange(() => m.splitBill(10.5, [1, 1]))"
   },
   {
    "name": "negative total: refused",
    "kind": "trap",
    "source": "(m) => throwsRange(() => m.splitBill(-1, [1, 1]))"
   }
  ]
 },
 {
  "id": "safe-redirect",
  "title": "Redirect after login",
  "signature": "export function safeRedirect(target: string, allowedHosts: string[]): string",
  "vague": "Write safeRedirect. After login we send the user to target. Return target if it is safe to redirect to, otherwise return \"/\".",
  "worded": "Write safeRedirect. It prevents an open redirect after login. target is untrusted input from a query string.\nUse an allowlist, never a denylist. Return target unchanged when it is either a same-site relative path (a single leading slash followed by anything other than a slash or backslash), or an absolute http or https URL whose hostname exactly matches an entry in allowedHosts, case-insensitively. Otherwise return \"/\".\nParse with the URL class rather than string matching. Reject protocol-relative URLs (//host), backslash tricks (/\\host), control characters and surrounding whitespace, userinfo (https://good.com@evil.com), suffix lookalikes (good.com.evil.com), and every other scheme, including javascript: and data:.",
  "scenarios": [
   {
    "name": "a plain path is kept",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"/settings\", G), \"/settings\")"
   },
   {
    "name": "a path with a query is kept",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"/a?b=1\", G), \"/a?b=1\")"
   },
   {
    "name": "an allowed host is kept",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"https://good.com/x\", G), \"https://good.com/x\")"
   },
   {
    "name": "another host is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"https://evil.com/\", G), \"/\")"
   },
   {
    "name": "javascript: is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"javascript:alert(1)\", G), \"/\")"
   },
   {
    "name": "protocol-relative is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"//evil.com\", G), \"/\")"
   },
   {
    "name": "suffix lookalike is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"https://good.com.evil.com/\", G), \"/\")"
   },
   {
    "name": "userinfo trick is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"https://good.com@evil.com/\", G), \"/\")"
   },
   {
    "name": "allowed name in the path is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"https://evil.com/good.com\", G), \"/\")"
   },
   {
    "name": "backslash after the slash is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"/\\\\evil.com\", G), \"/\")"
   },
   {
    "name": "data: is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"data:text/html,hi\", G), \"/\")"
   },
   {
    "name": "empty string is refused",
    "kind": "core",
    "source": "(m) => eq(m.safeRedirect(\"\", G), \"/\")"
   },
   {
    "name": "two backslashes are refused",
    "kind": "trap",
    "source": "(m) => eq(m.safeRedirect(\"\\\\\\\\evil.com\", G), \"/\")"
   },
   {
    "name": "leading whitespace is refused",
    "kind": "trap",
    "source": "(m) => eq(m.safeRedirect(\"  https://evil.com\", G), \"/\")"
   },
   {
    "name": "a tab inside the slashes is refused",
    "kind": "trap",
    "source": "(m) => eq(m.safeRedirect(\"/\\t/evil.com\", G), \"/\")"
   },
   {
    "name": "hostname case is ignored",
    "kind": "trap",
    "source": "(m) => eq(m.safeRedirect(\"https://GOOD.com/x\", G), \"https://GOOD.com/x\")"
   },
   {
    "name": "scheme without slashes is refused",
    "kind": "trap",
    "source": "(m) => eq(m.safeRedirect(\"http:evil.com\", G), \"/\")"
   },
   {
    "name": "ftp is refused",
    "kind": "trap",
    "source": "(m) => eq(m.safeRedirect(\"ftp://good.com/x\", G), \"/\")"
   }
  ]
 },
 {
  "id": "resolve-upload",
  "title": "Save an uploaded file",
  "signature": "export function resolveUpload(baseDir: string, filename: string): string",
  "vague": "Write resolveUpload. It returns the full path to save a user's uploaded file inside baseDir. Use node:path.",
  "worded": "Write resolveUpload using node:path. filename comes from the user, so treat it as untrusted and prevent path traversal.\nResolve the full path, then check it is still inside baseDir. Compare with a trailing separator, because a plain prefix check lets /srv/uploads-evil pass for /srv/uploads. Throw an Error for absolute paths, null bytes, an empty name, and anything that escapes or resolves to baseDir itself.",
  "scenarios": [
   {
    "name": "a plain name lands in the folder",
    "kind": "core",
    "source": "(m) => eq(m.resolveUpload(\"/srv/uploads\", \"a.txt\"), \"/srv/uploads/a.txt\")"
   },
   {
    "name": "dots in a name are fine",
    "kind": "core",
    "source": "(m) => eq(m.resolveUpload(\"/srv/uploads\", \"report.final.pdf\"), \"/srv/uploads/report.final.pdf\")"
   },
   {
    "name": "a trailing slash on the folder is fine",
    "kind": "core",
    "source": "(m) => eq(m.resolveUpload(\"/srv/uploads/\", \"a.txt\"), \"/srv/uploads/a.txt\")"
   },
   {
    "name": "dot-dot cannot climb out",
    "kind": "trap",
    "source": "(m) => inside(m, \"../../etc/passwd\")"
   },
   {
    "name": "dot-dot in the middle cannot climb out",
    "kind": "trap",
    "source": "(m) => inside(m, \"a/../../secret\")"
   },
   {
    "name": "an absolute path cannot escape",
    "kind": "trap",
    "source": "(m) => inside(m, \"/etc/passwd\")"
   },
   {
    "name": "a sibling folder with the same prefix cannot pass",
    "kind": "trap",
    "source": "(m) => inside(m, \"../uploads-evil/x\")"
   },
   {
    "name": "a null byte cannot pass",
    "kind": "trap",
    "source": "(m) => inside(m, \"a.txt\\x00.png\")"
   },
   {
    "name": "an empty name does not return the folder itself",
    "kind": "trap",
    "source": "(m) => inside(m, \"\")"
   },
   {
    "name": "a single dot does not return the folder itself",
    "kind": "trap",
    "source": "(m) => inside(m, \".\")"
   }
  ]
 },
 {
  "id": "render-comment",
  "title": "Render a comment",
  "signature": "export function renderComment(text: string): string",
  "vague": "Write renderComment. It turns a user's comment into HTML: **text** becomes bold and [label](url) becomes a link.",
  "worded": "Write renderComment. It turns a user's comment into HTML: **text** becomes <strong> and [label](url) becomes a link. The comment is untrusted, so prevent stored XSS.\nOutput-encode the whole input first: & < > \" and ' become entities. Only then apply the formatting. Allowlist URL schemes: only http, https and mailto become a link. For any other scheme, including javascript: in any capitalisation, and data:, output the label as plain text.",
  "scenarios": [
   {
    "name": "bold works",
    "kind": "core",
    "source": "(m) => ok(/<(strong|b)>hi<\\/(strong|b)>/.test(html(m, \"**hi**\")), html(m, \"**hi**\"))"
   },
   {
    "name": "a link works",
    "kind": "core",
    "source": "(m) => ok(/<a [^>]*href=\"https:\\/\\/a\\.com\"[^>]*>x<\\/a>/.test(html(m, \"[x](https://a.com)\")), html(m, \"[x](https://a.com)\"))"
   },
   {
    "name": "plain words stay",
    "kind": "core",
    "source": "(m) => ok(html(m, \"hello there\").includes(\"hello there\"), \"lost the text\")"
   },
   {
    "name": "a script tag cannot run",
    "kind": "trap",
    "source": "(m) => ok(!/<script/i.test(html(m, \"<script>alert(1)</script>\")), \"script tag survived\")"
   },
   {
    "name": "an image handler cannot run",
    "kind": "trap",
    "source": "(m) => ok(!/<img/i.test(html(m, \"<img src=x onerror=alert(1)>\")), \"img tag survived\")"
   },
   {
    "name": "a javascript: link is refused",
    "kind": "trap",
    "source": "(m) => ok(!/href\\s*=\\s*[\"']?\\s*javascript/i.test(html(m, \"[x](javascript:alert(1))\")), html(m, \"[x](javascript:alert(1))\"))"
   },
   {
    "name": "capitals do not hide javascript:",
    "kind": "trap",
    "source": "(m) => ok(!/href\\s*=\\s*[\"']?\\s*javascript/i.test(html(m, \"[x](JaVaScRiPt:alert(1))\")), html(m, \"[x](JaVaScRiPt:alert(1))\"))"
   },
   {
    "name": "a data: link is refused",
    "kind": "trap",
    "source": "(m) => ok(!/href\\s*=\\s*[\"']?\\s*data:/i.test(html(m, \"[x](data:text/html,hi)\")), html(m, \"[x](data:text/html,hi)\"))"
   },
   {
    "name": "a quote cannot break out of the link",
    "kind": "trap",
    "source": "(m) => ok(!/<a [^>]*href\\s*=\\s*\"[^\"]*\"\\s+onmouseover/i.test(html(m, '[x](https://a.com\" onmouseover=\"alert(1))')), html(m, '[x](https://a.com\" onmouseover=\"alert(1))'))"
   },
   {
    "name": "an ampersand is encoded",
    "kind": "trap",
    "source": "(m) => ok(html(m, \"a & b\").includes(\"&amp;\"), html(m, \"a & b\"))"
   }
  ]
 },
 {
  "id": "memo-async",
  "title": "Cache an async call",
  "signature": "export function memoAsync<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>",
  "vague": "Write memoAsync. It caches the results of an async function by key.",
  "worded": "Write memoAsync. It caches an async function's results by key and prevents a cache stampede: concurrent callers for the same key must share one in-flight promise, so fn runs once.\nDo not cache failures. When the promise rejects, every waiter sees the rejection, the entry is evicted, and the next call runs fn again. Keys are arbitrary strings, including ones like \"constructor\", so use a Map rather than a plain object.",
  "scenarios": [
   {
    "name": "a later call uses the cache",
    "kind": "core",
    "source": "async (m) => {\n        const c = counted(), f = m.memoAsync(c.fn);\n        await f(\"a\");\n        eq(await f(\"a\"), \"v:a\");\n        eq(c.calls.length, 1);\n      }"
   },
   {
    "name": "keys are kept apart",
    "kind": "core",
    "source": "async (m) => {\n        const c = counted(), f = m.memoAsync(c.fn);\n        eq([await f(\"a\"), await f(\"b\")], [\"v:a\", \"v:b\"]);\n      }"
   },
   {
    "name": "concurrent callers share one call (stampede)",
    "kind": "trap",
    "source": "async (m) => {\n        const c = counted(), f = m.memoAsync(c.fn);\n        await Promise.all([f(\"a\"), f(\"a\"), f(\"a\")]);\n        eq(c.calls.length, 1);\n      }"
   },
   {
    "name": "a failure is not cached",
    "kind": "trap",
    "source": "async (m) => {\n        let n = 0;\n        const f = m.memoAsync(async () => {\n          n += 1;\n          if (n === 1)\n            throw Error(\"down\");\n          return \"up\";\n        });\n        await f(\"a\").catch(() => {});\n        eq(await f(\"a\"), \"up\");\n      }"
   },
   {
    "name": "every waiter sees the failure",
    "kind": "trap",
    "source": "async (m) => {\n        const f = m.memoAsync(async () => {\n          throw Error(\"down\");\n        }), r = await Promise.allSettled([f(\"a\"), f(\"a\")]);\n        eq(r.map((x) => x.status), [\"rejected\", \"rejected\"]);\n      }"
   },
   {
    "name": "the key \"constructor\" works",
    "kind": "trap",
    "source": "async (m) => {\n        const c = counted();\n        eq(await m.memoAsync(c.fn)(\"constructor\"), \"v:constructor\");\n      }"
   },
   {
    "name": "the key \"__proto__\" works",
    "kind": "trap",
    "source": "async (m) => {\n        const c = counted(), f = m.memoAsync(c.fn);\n        eq(await f(\"__proto__\"), \"v:__proto__\");\n        eq(await f(\"__proto__\"), \"v:__proto__\");\n        eq(c.calls.length, 1);\n      }"
   }
  ]
 },
 {
  "id": "payment-events",
  "title": "Handle payment webhooks",
  "signature": "export type PaymentEvent = { id: string; orderId: string; type: 'paid' | 'refunded' | 'failed'; at: number }\nexport function createPaymentProcessor(onPaid: (orderId: string) => void): { apply: (event: PaymentEvent) => void; status: (orderId: string) => string | undefined }",
  "vague": "Write createPaymentProcessor. apply(event) updates an order's status from a payment provider's webhook event. It calls onPaid when an order gets paid, so we can ship it.",
  "worded": "Write createPaymentProcessor. Webhooks are delivered at least once and out of order, so apply must be idempotent.\nDedupe by event id: a redelivered event changes nothing. An event older than the newest one applied to that order, by at, is stale and changes nothing. onPaid fires exactly once per order, however many distinct paid events arrive, and never for a stale event. status(orderId) returns the type of the newest applied event.",
  "scenarios": [
   {
    "name": "a paid event sets the status",
    "kind": "core",
    "source": "(m) => {\n        const { p } = processor(m);\n        p.apply(ev(\"1\", \"o\", \"paid\", 1));\n        eq(p.status(\"o\"), \"paid\");\n      }"
   },
   {
    "name": "a paid event calls onPaid",
    "kind": "core",
    "source": "(m) => {\n        const { p, paid } = processor(m);\n        p.apply(ev(\"1\", \"o\", \"paid\", 1));\n        eq(paid, [\"o\"]);\n      }"
   },
   {
    "name": "a failed event sets the status",
    "kind": "core",
    "source": "(m) => {\n        const { p, paid } = processor(m);\n        p.apply(ev(\"1\", \"o\", \"failed\", 1));\n        eq(p.status(\"o\"), \"failed\");\n        eq(paid, []);\n      }"
   },
   {
    "name": "an unknown order has no status",
    "kind": "core",
    "source": "(m) => eq(processor(m).p.status(\"x\"), void 0)"
   },
   {
    "name": "a redelivered event ships only once",
    "kind": "trap",
    "source": "(m) => {\n        const { p, paid } = processor(m);\n        p.apply(ev(\"1\", \"o\", \"paid\", 1));\n        p.apply(ev(\"1\", \"o\", \"paid\", 1));\n        eq(paid, [\"o\"]);\n      }"
   },
   {
    "name": "two paid events ship only once",
    "kind": "trap",
    "source": "(m) => {\n        const { p, paid } = processor(m);\n        p.apply(ev(\"1\", \"o\", \"paid\", 1));\n        p.apply(ev(\"2\", \"o\", \"paid\", 2));\n        eq(paid, [\"o\"]);\n      }"
   },
   {
    "name": "a late, older paid does not undo a refund",
    "kind": "trap",
    "source": "(m) => {\n        const { p } = processor(m);\n        p.apply(ev(\"2\", \"o\", \"refunded\", 2));\n        p.apply(ev(\"1\", \"o\", \"paid\", 1));\n        eq(p.status(\"o\"), \"refunded\");\n      }"
   },
   {
    "name": "a late, older paid does not ship",
    "kind": "trap",
    "source": "(m) => {\n        const { p, paid } = processor(m);\n        p.apply(ev(\"2\", \"o\", \"refunded\", 2));\n        p.apply(ev(\"1\", \"o\", \"paid\", 1));\n        eq(paid, []);\n      }"
   },
   {
    "name": "a late, older failure does not undo a payment",
    "kind": "trap",
    "source": "(m) => {\n        const { p } = processor(m);\n        p.apply(ev(\"2\", \"o\", \"paid\", 2));\n        p.apply(ev(\"1\", \"o\", \"failed\", 1));\n        eq(p.status(\"o\"), \"paid\");\n      }"
   }
  ]
 },
 {
  "id": "next-billing",
  "title": "Work out a billing date",
  "signature": "export function nextBillingDate(anchor: Date, periods: number): Date",
  "vague": "Write nextBillingDate. A subscription started on anchor and bills monthly. Return the date of bill number periods.",
  "worded": "Write nextBillingDate. A subscription started on anchor and bills monthly; return the date of bill number periods.\nHandle month-end clamping without anchor drift: always work from the anchor's day, not from the previous result. If the target month is too short, use its last day. So Jan 31 gives Feb 28 then Mar 31. Work in UTC only, so the host's timezone and daylight saving cannot shift the date, and keep the UTC time of day. Never mutate the input Date.",
  "scenarios": [
   {
    "name": "one month on, mid-month",
    "kind": "core",
    "source": "(m) => eq(iso(m, \"2025-01-15T00:00:00Z\", 1), \"2025-02-15\")"
   },
   {
    "name": "twelve months on",
    "kind": "core",
    "source": "(m) => eq(iso(m, \"2025-03-10T00:00:00Z\", 12), \"2026-03-10\")"
   },
   {
    "name": "period zero is the anchor",
    "kind": "core",
    "source": "(m) => eq(iso(m, \"2025-03-10T00:00:00Z\", 0), \"2025-03-10\")"
   },
   {
    "name": "across a year end",
    "kind": "core",
    "source": "(m) => eq(iso(m, \"2025-11-20T00:00:00Z\", 3), \"2026-02-20\")"
   },
   {
    "name": "Jan 31 plus one is the end of February",
    "kind": "trap",
    "source": "(m) => eq(iso(m, \"2025-01-31T00:00:00Z\", 1), \"2025-02-28\")"
   },
   {
    "name": "a leap year has a Feb 29",
    "kind": "trap",
    "source": "(m) => eq(iso(m, \"2024-01-31T00:00:00Z\", 1), \"2024-02-29\")"
   },
   {
    "name": "no drift: Jan 31 plus two is Mar 31",
    "kind": "trap",
    "source": "(m) => eq(iso(m, \"2025-01-31T00:00:00Z\", 2), \"2025-03-31\")"
   },
   {
    "name": "Aug 31 plus one is Sep 30",
    "kind": "trap",
    "source": "(m) => eq(iso(m, \"2025-08-31T00:00:00Z\", 1), \"2025-09-30\")"
   },
   {
    "name": "the time of day survives",
    "kind": "trap",
    "source": "(m) => eq(m.nextBillingDate(new Date(\"2025-01-15T13:45:00Z\"), 1).toISOString(), \"2025-02-15T13:45:00.000Z\")"
   },
   {
    "name": "the input date is not changed",
    "kind": "trap",
    "source": "(m) => {\n        const a = new Date(\"2025-01-15T00:00:00Z\");\n        m.nextBillingDate(a, 3);\n        eq(a.toISOString(), \"2025-01-15T00:00:00.000Z\");\n      }"
   }
  ]
 },
 {
  "id": "search-query",
  "title": "Build a search query",
  "signature": "export function buildSearch(filters: { term?: string; sortBy?: string; limit?: number }): { sql: string; params: unknown[] }",
  "vague": "Write buildSearch. It builds a SQLite query for our products table (columns name, price, created_at) that finds rows whose name contains term, sorted by the sortBy column, up to limit rows. Use ? placeholders.",
  "worded": "Write buildSearch. It builds a SQLite query for the products table (columns name, price, created_at) and must be safe from SQL injection. Use ? placeholders for values.\nPlaceholders cannot hold identifiers, so allowlist sortBy against the known columns, ignoring anything else. Escape LIKE wildcards in term, % and _ and the escape character itself, and add an ESCAPE clause, so a search for 50% finds a literal 50%. Coerce limit to an integer, clamp it between 1 and 100, and bind it as a parameter.",
  "scenarios": [
   {
    "name": "finds by term",
    "kind": "core",
    "source": "(m) => eq(query(m, { term: \"mug\" }).rows.length, 2)"
   },
   {
    "name": "no filters finds everything",
    "kind": "core",
    "source": "(m) => eq(query(m, {}).rows.length, 6)"
   },
   {
    "name": "sorts by a column",
    "kind": "core",
    "source": "(m) => eq(query(m, { sortBy: \"price\", term: \"mug\" }).rows.map((r) => r.name), [\"Red mug\", \"Blue mug\"])"
   },
   {
    "name": "respects a limit",
    "kind": "core",
    "source": "(m) => eq(query(m, { limit: 2 }).rows.length, 2)"
   },
   {
    "name": "a quote in the term is harmless",
    "kind": "core",
    "source": "(m) => eq(query(m, { term: \"O'Brien\" }).rows.length, 0)"
   },
   {
    "name": "injection through the term fails",
    "kind": "trap",
    "source": "(m) => {\n        const q = query(m, { term: \"'; DROP TABLE products; --\" });\n        ok(stillThere(q.db), \"table dropped\");\n        eq(q.rows.length, 0);\n      }"
   },
   {
    "name": "sortBy cannot read another table",
    "kind": "trap",
    "source": "(m) => {\n        try {\n          const { sql } = m.buildSearch({ sortBy: \"(SELECT password FROM users)\" });\n          ok(!/password|users/i.test(sql), \"attacker text reached the SQL\");\n        } catch {}\n      }"
   },
   {
    "name": "sortBy cannot end the statement",
    "kind": "trap",
    "source": "(m) => {\n        try {\n          const { sql } = m.buildSearch({ sortBy: \"price; DROP TABLE products\" });\n          ok(!/drop/i.test(sql), \"attacker text reached the SQL\");\n        } catch {}\n      }"
   },
   {
    "name": "a bad limit cannot reach the SQL",
    "kind": "trap",
    "source": "(m) => {\n        try {\n          const { sql } = m.buildSearch({ limit: \"1; DROP TABLE products\" });\n          ok(!/drop/i.test(sql), \"attacker text reached the SQL\");\n        } catch {}\n      }"
   },
   {
    "name": "a search for 50% means a literal 50%",
    "kind": "trap",
    "source": "(m) => eq(query(m, { term: \"50%\" }).rows.map((r) => r.name), [\"Save 50% coupon\"])"
   },
   {
    "name": "an underscore means an underscore",
    "kind": "trap",
    "source": "(m) => eq(query(m, { term: \"snake_case\" }).rows.map((r) => r.name), [\"snake_case poster\"])"
   }
  ]
 }
]
