From 053397ebad25ab6c079196ac29136bcabd8af1c8 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 26 May 2026 12:07:14 +0200 Subject: [PATCH] Fix steward search negation parsing --- frontend/src/lib/searchParser.js | 18 ++++++++- frontend/src/tests/searchParser.test.js | 49 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 frontend/src/tests/searchParser.test.js diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js index 81424fc9..2cd2f73d 100644 --- a/frontend/src/lib/searchParser.js +++ b/frontend/src/lib/searchParser.js @@ -23,6 +23,14 @@ const SINGLE_VALUE_TAGS = ['status', 'type', 'category', 'from', 'assigned', 'reviewed', 'sort', 'confidence', 'template', 'proposal', 'mission']; const MULTI_VALUE_TAGS = ['exclude', 'include', 'has', 'no', 'is', 'not']; const NUMERIC_TAGS = ['min-contributions']; +const NEGATED_MULTI_VALUE_TAGS = { + exclude: 'include', + include: 'exclude', + has: 'no', + no: 'has', + is: 'not', + not: 'is' +}; /** * Tokenize the search query, respecting quoted strings. @@ -130,10 +138,12 @@ export function parseSearch(query) { } const tokens = tokenize(query); + let negateNext = false; for (const token of tokens) { // Handle "NOT tag:value" as two tokens if (token.toUpperCase() === 'NOT') { + negateNext = true; continue; // Will be handled with next token } @@ -141,10 +151,13 @@ export function parseSearch(query) { if (!parsed) { // Untagged text — collect as free-text search terms filters.freeText.push(token); + negateNext = false; continue; } - const { tag, value, negated } = parsed; + const { tag, value } = parsed; + const negated = parsed.negated || negateNext; + negateNext = false; // Handle single-value tags if (SINGLE_VALUE_TAGS.includes(tag)) { @@ -152,7 +165,8 @@ export function parseSearch(query) { } // Handle multi-value tags else if (MULTI_VALUE_TAGS.includes(tag)) { - filters[tag].push(value); + const targetTag = negated ? NEGATED_MULTI_VALUE_TAGS[tag] : tag; + filters[targetTag].push(value); } // Handle numeric tags else if (NUMERIC_TAGS.includes(tag)) { diff --git a/frontend/src/tests/searchParser.test.js b/frontend/src/tests/searchParser.test.js new file mode 100644 index 00000000..40152ed9 --- /dev/null +++ b/frontend/src/tests/searchParser.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { parseSearch } from "../lib/searchParser.js"; +import { searchToParams } from "../lib/searchToParams.js"; + +function paramsFor(query) { + return searchToParams(parseSearch(query)); +} + +describe("steward search negation", () => { + it("normalizes negated presence filters to absence filters", () => { + const { filters } = parseSearch("-has:proposal -has:url -has:appeal"); + + expect(filters.has).toEqual([]); + expect(filters.no).toEqual(["proposal", "url", "appeal"]); + expect(paramsFor("-has:proposal")).toEqual({ has_proposal: false }); + expect(paramsFor("-has:url")).toEqual({ only_empty_evidence: true }); + expect(paramsFor("-has:appeal")).toEqual({ has_appeal: false }); + }); + + it("normalizes negated flag filters to not filters", () => { + const { filters } = parseSearch("-is:interesting -is:resubmitted"); + + expect(filters.is).toEqual([]); + expect(filters.not).toEqual(["interesting", "resubmitted"]); + expect(paramsFor("-is:interesting")).toEqual({ is_interesting: false }); + expect(paramsFor("-is:resubmitted")).toEqual({ resubmitted_more_info: false }); + }); + + it("supports NOT before multi-value filters", () => { + expect(paramsFor("NOT has:proposal")).toEqual({ has_proposal: false }); + expect(paramsFor("NOT is:interesting")).toEqual({ is_interesting: false }); + }); + + it("inverts explicit negative aliases when prefixed with a dash", () => { + expect(paramsFor("-no:proposal")).toEqual({ has_proposal: true }); + expect(paramsFor("-not:interesting")).toEqual({ is_interesting: true }); + }); + + it("normalizes negated include and exclude text filters", () => { + const { filters } = parseSearch("-include:spam -exclude:genlayer"); + + expect(filters.include).toEqual(["genlayer"]); + expect(filters.exclude).toEqual(["spam"]); + expect(paramsFor("-include:spam -exclude:genlayer")).toEqual({ + exclude_content: "spam", + include_content: "genlayer", + }); + }); +});