Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
35 KiB
RuleBuilder Bundle Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship #438 (validation & polish), #435 (relative-date operators), and #434 (facet-value typeahead) for the visual rule builder on one feature branch.
Architecture: Pure-logic changes land in the small web/src/builder/rules/ modules (types/compile/parse + two new helper modules validation.ts, dateMacro.ts) under TDD. UI changes to RuleBuilder.tsx consume those helpers. #434 adds a disjoint C# read endpoint (Lucene term enumeration) built in a parallel worktree; its combobox is wired last, after the generated API types exist.
Tech Stack: React 18 + TypeScript + Vite (vitest for web tests); .NET 10 / C# / MediatR / Lucene.NET (ISearchIndex); NUnit + Shouldly + NSubstitute for C# tests.
Global Constraints
- Work in worktree
feat/rulebuilder-bundleofforigin/main. Never commit in/Users/timothy/ersatztv. →process.shared-tree-readonly - Backend #434 slice runs in its OWN worktree branched off
feat/rulebuilder-bundle, merged back by fast-forward/plumbing. All frontend work is one committing agent, sequential (sharedRuleBuilder.tsx). →process.one-worktree-one-committing-agent,process.foreign-worktree-plumbing-merge - The compile↔parse lossless round-trip contract (spa-conventions §12) must hold:
parse(compile(g)) ≡ normalizeGroup(g)for every validg. Extendroundtrip.test.tsfor every new operator. - New REST response DTOs live in
ErsatzTV.Core/Api/Search/*ResponseModel.cswith file-scoped#nullable enable. →api.response-dtos - A PR touching
ErsatzTV/Controllers/Api/**orErsatzTV.Core/Api/**MUST ship regeneratedv1.json/v1.d.ts/endpoint-index.mdin the same diff. →release.api-contract-ci-gate - Before any push touching
.cs: BOM-check the touched set (xxd -p | grep -c ^efbbbf) and run the format gate underbash -c. →process.bom-format-detection-recipe - Independent adversarial review over the whole diff before push (new API endpoint + >~150 lines). →
process.independent-review-rubric - Commit trailer on every commit:
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. Worktree hooks: commit with--no-verifyand run gates manually (worktree husky friction).
Test commands (run from the worktree):
- Web unit:
cd web && npx vitest run src/builder/rules/<file>.test.ts - Web all rules:
cd web && npx vitest run src/builder/rules - C#:
dotnet test ErsatzTV.Application.Tests --filter <Name>(or the relevant test project)
Task 1: #438/#435 — extend types, then rule/group validation module (validation.ts)
Files:
-
Modify:
web/src/builder/rules/types.ts(extend first, so validation typechecks) -
Create:
web/src/builder/rules/validation.ts -
Test:
web/src/builder/rules/validation.test.ts -
Step 0: Extend types.ts (the new operators/unit are consumed first by validation's tests):
export type Operator =
| 'is' | 'isNot' | 'contains' | 'startsWith' // text
| 'matches' | 'notMatches' // fulltext
| 'eq' | 'gt' | 'lt' | 'between' // number
| 'before' | 'after' // date (absolute)
| 'inLast' | 'notInLast'; // date (relative, #435)
export type DateUnit = 'day' | 'week' | 'month' | 'year';
export interface Rule {
field: string;
operator: Operator;
value: string;
value2?: string; // upper bound for `between`
unit?: DateUnit; // unit for `inLast`/`notInLast` (#435)
}
and extend the date row of OPERATORS_BY_TYPE:
date: ['before', 'after', 'between', 'inLast', 'notInLast']
Interfaces:
-
Produces:
ruleError(rule: Rule): string | null— a human string when the rule is incomplete, else null.groupHasErrors(group: Group): boolean— true if any descendant rule has an error.topGroupAllNegative(group: Group): boolean— true when every direct child of the top group is a negative rule (isNot/notMatches) (warning predicate, non-blocking).normalizeGroup(group: Group): Group— returns a copy withmatchcoerced to'all'for every group (recursively) that has fewer than 2 children (single-child connective is meaningless and cannot round-trip).
-
Step 1: Write the failing test
// web/src/builder/rules/validation.test.ts
import { describe, expect, it } from 'vitest';
import { groupHasErrors, normalizeGroup, ruleError, topGroupAllNegative } from './validation';
import type { Group, Rule } from './types';
const r = (o: Partial<Rule>): Rule => ({ field: 'title', operator: 'is', value: 'x', ...o });
describe('ruleError', () => {
it('flags between with a missing upper bound', () => {
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '', value2: '60' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '60' }))).toBeNull();
});
it('flags empty text values', () => {
for (const operator of ['is', 'isNot', 'contains', 'startsWith'] as const) {
expect(ruleError(r({ operator, value: '' }))).toBeTruthy();
expect(ruleError(r({ operator, value: 'x' }))).toBeNull();
}
});
it('flags relative-date rule with a non-positive or non-integer N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '0', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }))).toBeNull();
});
});
describe('groupHasErrors', () => {
it('walks nested groups', () => {
const g: Group = { match: 'all', children: [{ match: 'any', children: [r({ value: '' })] }] };
expect(groupHasErrors(g)).toBe(true);
expect(groupHasErrors({ match: 'all', children: [r({ value: 'ok' })] })).toBe(false);
});
});
describe('topGroupAllNegative', () => {
it('is true only when every top-level child is a negative rule', () => {
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'notMatches', field: 'plot' })] })).toBe(true);
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'is' })] })).toBe(false);
expect(topGroupAllNegative({ match: 'all', children: [] })).toBe(false);
});
});
describe('normalizeGroup', () => {
it("coerces a single-child group's match to 'all'", () => {
const g: Group = { match: 'any', children: [r({})] };
expect(normalizeGroup(g).match).toBe('all');
});
it('leaves a 2+ child group match unchanged and recurses', () => {
const g: Group = { match: 'any', children: [r({}), { match: 'any', children: [r({})] }] };
const n = normalizeGroup(g);
expect(n.match).toBe('any');
expect((n.children[1] as Group).match).toBe('all');
});
});
- Step 2: Run test to verify it fails
Run: cd web && npx vitest run src/builder/rules/validation.test.ts
Expected: FAIL — cannot find module ./validation.
- Step 3: Write minimal implementation
// web/src/builder/rules/validation.ts
import { isGroup, type Group, type Rule } from './types';
const NEGATIVE = new Set(['isNot', 'notMatches']);
const RELATIVE_DATE = new Set(['inLast', 'notInLast']);
export function ruleError(rule: Rule): string | null {
if (rule.operator === 'between') {
if (rule.value.trim() === '' || (rule.value2 ?? '').trim() === '') return 'Both bounds are required.';
return null;
}
if (RELATIVE_DATE.has(rule.operator)) {
const n = Number(rule.value);
if (!Number.isInteger(n) || n <= 0) return 'Enter a whole number greater than zero.';
return null;
}
if (rule.value.trim() === '') return 'A value is required.';
return null;
}
export function groupHasErrors(group: Group): boolean {
return group.children.some((c) => (isGroup(c) ? groupHasErrors(c) : ruleError(c) !== null));
}
export function topGroupAllNegative(group: Group): boolean {
const rules = group.children.filter((c): c is Rule => !isGroup(c));
if (rules.length === 0 || rules.length !== group.children.length) return false;
return rules.every((r) => NEGATIVE.has(r.operator));
}
export function normalizeGroup(group: Group): Group {
const children = group.children.map((c) => (isGroup(c) ? normalizeGroup(c) : c));
return { match: children.length < 2 ? 'all' : group.match, children };
}
- Step 4: Run test to verify it passes
Run: cd web && npx vitest run src/builder/rules/validation.test.ts
Expected: PASS (all cases).
- Step 5: Commit
git add web/src/builder/rules/types.ts web/src/builder/rules/validation.ts web/src/builder/rules/validation.test.ts
git commit --no-verify -m "feat(438,435): extend rule types; validation + single-child normalization helpers"
Task 2: #435 — dateMacro mapping module
types.tswas already extended in Task 1 Step 0 (operatorsinLast/notInLast,DateUnit,Rule.unit,OPERATORS_BY_TYPE.date). This task adds only the mapping module.
Files:
- Create:
web/src/builder/rules/dateMacro.ts - Test:
web/src/builder/rules/dateMacro.test.ts
Interfaces:
-
Produces:
dateMacro.ts:RELATIVE_FIELD_MAP: Record<'release_date' | 'added_date', 'released' | 'added'>compileRelative(rule: Rule): string | null— e.g.{release_date, inLast, '7', day}→released_inthelast:"7 day"; null if not a relative-date rule or invalid.parseRelative(field: string, quoted: string): Rule | null— recognizes synthetic field +"<n> <unit>", returns a Rule onrelease_date/added_date; null otherwise.RELATIVE_SYNTHETIC = /^(released|added)_(inthelast|notinthelast)$/(exported for parse.ts field detection).
-
Step 1: Write the failing test
// web/src/builder/rules/dateMacro.test.ts
import { describe, expect, it } from 'vitest';
import { compileRelative, parseRelative } from './dateMacro';
import type { Rule } from './types';
const r = (o: Partial<Rule>): Rule => ({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day', ...o });
describe('compileRelative', () => {
it('maps release_date/inLast → released_inthelast', () => {
expect(compileRelative(r({}))).toBe('released_inthelast:"7 day"');
});
it('maps added_date/notInLast → added_notinthelast', () => {
expect(compileRelative(r({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }))).toBe('added_notinthelast:"3 week"');
});
it('returns null for a non-relative rule', () => {
expect(compileRelative(r({ operator: 'before' }))).toBeNull();
});
it('returns null when N is invalid', () => {
expect(compileRelative(r({ value: '0' }))).toBeNull();
});
});
describe('parseRelative', () => {
it('round-trips released_inthelast', () => {
expect(parseRelative('released_inthelast', '7 day')).toEqual({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' });
});
it('round-trips added_notinthelast', () => {
expect(parseRelative('added_notinthelast', '3 week')).toEqual({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' });
});
it('returns null for an unrelated field', () => {
expect(parseRelative('title', '7 day')).toBeNull();
});
it('returns null for a malformed value', () => {
expect(parseRelative('released_inthelast', 'soon')).toBeNull();
});
});
- Step 2: Run test to verify it fails
Run: cd web && npx vitest run src/builder/rules/dateMacro.test.ts
Expected: FAIL — cannot find module ./dateMacro.
- Step 3: Write dateMacro.ts
// web/src/builder/rules/dateMacro.ts
import type { DateUnit, Rule } from './types';
export const RELATIVE_FIELD_MAP = { release_date: 'released', added_date: 'added' } as const;
type RelField = keyof typeof RELATIVE_FIELD_MAP;
const OP_TO_SUFFIX = { inLast: 'inthelast', notInLast: 'notinthelast' } as const;
const SUFFIX_TO_OP = { inthelast: 'inLast', notinthelast: 'notInLast' } as const;
const CATALOG_FROM_PREFIX = { released: 'release_date', added: 'added_date' } as const;
const UNITS: DateUnit[] = ['day', 'week', 'month', 'year'];
export const RELATIVE_SYNTHETIC = /^(released|added)_(inthelast|notinthelast)$/;
function isValidN(value: string): boolean {
const n = Number(value);
return Number.isInteger(n) && n > 0;
}
export function compileRelative(rule: Rule): string | null {
if (rule.operator !== 'inLast' && rule.operator !== 'notInLast') return null;
const prefix = RELATIVE_FIELD_MAP[rule.field as RelField];
if (!prefix) return null;
if (!isValidN(rule.value) || !rule.unit || !UNITS.includes(rule.unit)) return null;
return `${prefix}_${OP_TO_SUFFIX[rule.operator]}:"${rule.value} ${rule.unit}"`;
}
export function parseRelative(field: string, quoted: string): Rule | null {
const m = RELATIVE_SYNTHETIC.exec(field);
if (!m) return null;
const [, prefix, suffix] = m;
const vm = /^(\d+)\s+(day|week|month|year)$/.exec(quoted.trim());
if (!vm) return null;
const [, n, unit] = vm;
if (!isValidN(n)) return null;
return {
field: CATALOG_FROM_PREFIX[prefix as 'released' | 'added'],
operator: SUFFIX_TO_OP[suffix as 'inthelast' | 'notinthelast'],
value: n,
unit: unit as DateUnit
};
}
- Step 4: Run test to verify it passes
Run: cd web && npx vitest run src/builder/rules/dateMacro.test.ts
Expected: PASS.
- Step 5: Commit
git add web/src/builder/rules/dateMacro.ts web/src/builder/rules/dateMacro.test.ts
git commit --no-verify -m "feat(435): dateMacro compile/parse mapping for relative-date operators"
Task 3: #438 + #435 — compile.ts wiring
Files:
- Modify:
web/src/builder/rules/compile.ts - Modify:
web/src/builder/rules/compile.test.ts
Interfaces:
-
Consumes:
compileRelative(Task 2),ruleError(Task 1). -
Produces:
compileRuleemits the relative-date macro forinLast/notInLast, and returns''for a structurally-invalid rule (incompletebetween, empty text value) so malformed Lucene is never emitted (the empty string is already filtered bycompileGroup). -
Step 1: Write the failing test — append to
compile.test.ts:
import { compileRelative } from './dateMacro'; // ensure no import cycle; if compile imports dateMacro, this is fine
describe('compile — #438 guards + #435 relative dates', () => {
it('emits the relative-date macro', () => {
expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }] })).toBe('released_inthelast:"7 day"');
});
it('drops an incomplete between instead of emitting field:[v TO ]', () => {
const out = compile({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30' }] });
expect(out).not.toContain('TO ]');
expect(out).toBe(''); // sole invalid child filtered out
});
it('drops an empty text value instead of emitting field:* / field:**', () => {
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: '' }] })).toBe('');
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: '' }] })).toBe('');
});
});
- Step 2: Run test to verify it fails
Run: cd web && npx vitest run src/builder/rules/compile.test.ts
Expected: FAIL — relative macro not emitted; incomplete-between emits minutes:[30 TO ].
- Step 3: Edit compile.ts
Add the import and two guards at the top of compileRule:
import { isGroup, type Group, type Rule } from './types';
import { compileRelative } from './dateMacro';
import { ruleError } from './validation';
// ... quote/escapeWild/normalizeDate unchanged ...
function compileRule(rule: Rule): string {
// #435: relative-date operators map to synthetic query fields.
const relative = compileRelative(rule);
if (relative !== null) return relative;
// #438: never emit malformed Lucene for a structurally-invalid rule; compileGroup filters ''.
if (ruleError(rule) !== null) return '';
const f = rule.field;
const v = rule.value;
switch (rule.operator) {
// ... existing cases unchanged ...
default:
return '';
}
}
- Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/builder/rules/compile.test.ts
Expected: PASS (new + existing cases).
- Step 5: Commit
git add web/src/builder/rules/compile.ts web/src/builder/rules/compile.test.ts
git commit --no-verify -m "feat(438,435): compile relative-date macros; drop invalid rules instead of malformed Lucene"
Task 4: #435 — parse.ts relative-date recognition + round-trip
Files:
- Modify:
web/src/builder/rules/parse.ts - Modify:
web/src/builder/rules/parse.test.ts - Modify:
web/src/builder/rules/roundtrip.test.ts
Interfaces:
-
Consumes:
parseRelative,RELATIVE_SYNTHETIC(Task 2);normalizeGroup(Task 1). -
Produces:
parseAtomrecognizes(released|added)_(inthelast|notinthelast):"..."and returns the relative Rule; the round-trip test assertsparse(compile(g)) ≡ normalizeGroup(g). -
Step 1: Write the failing test — append to
parse.test.ts:
describe('parse — #435 relative dates', () => {
const ft = { release_date: 'date', added_date: 'date' } as const;
it('parses released_inthelast back to a relative rule', () => {
expect(parse('released_inthelast:"7 day"', ft)).toEqual({
match: 'all',
children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }]
});
});
it('parses added_notinthelast', () => {
expect(parse('added_notinthelast:"3 week"', ft)).toEqual({
match: 'all',
children: [{ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }]
});
});
});
Note: the synthetic field is NOT in fieldTypes, so parseAtom must handle it BEFORE the fieldTypes[field] lookup (which would reject it).
- Step 2: Run test to verify it fails
Run: cd web && npx vitest run src/builder/rules/parse.test.ts
Expected: FAIL — synthetic field returns null (unknown field type).
- Step 3: Edit parse.ts
At the top of parseAtom, before the type lookup, add relative-date recognition (only the quoted form is valid; NOT prefix does not apply):
import type { FieldType, Group, Operator, Rule } from './types';
import { parseRelative, RELATIVE_SYNTHETIC } from './dateMacro';
function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): Rule | null {
const trimmed = atom.trim();
// #435: relative-date synthetic fields are not in the catalog; match them first.
const relColon = trimmed.indexOf(':');
if (relColon > 0 && RELATIVE_SYNTHETIC.test(trimmed.slice(0, relColon))) {
const rawRel = trimmed.slice(relColon + 1);
if (rawRel.startsWith('"') && rawRel.endsWith('"') && rawRel.length >= 2) {
return parseRelative(trimmed.slice(0, relColon), rawRel.slice(1, -1));
}
return null;
}
const negate = trimmed.startsWith('NOT ');
// ... rest unchanged ...
}
- Step 4: Update the round-trip test — in
roundtrip.test.ts, (a) extend the generatormakeRuleto sometimes emitdaterules withinLast/notInLast+ a unit, and (b) compare againstnormalizeGroup(g)rather thangso single-child groups match. Concretely, importnormalizeGroupand change the assertion:
import { normalizeGroup } from './validation';
// ...
// inside the property loop, replace `expect(parse(compile(g), fieldTypes)).toEqual(g)` with:
expect(parse(compile(g), fieldTypes)).toEqual(normalizeGroup(g));
For the generator, add a relative-date branch (guard so value is a positive integer string and unit is set):
// where a date-field rule is generated:
if (rng() < 0.5) {
const op = rng() < 0.5 ? 'inLast' : 'notInLast';
const unit = (['day', 'week', 'month', 'year'] as const)[Math.floor(rng() * 4)];
return { field: dateField, operator: op, value: String(1 + Math.floor(rng() * 30)), unit };
}
// else fall through to the existing before/after/between generation
(Read the actual roundtrip.test.ts generator first — match its existing rng/field-selection shape; the above is the required behavior, not a verbatim drop-in.)
- Step 5: Run tests to verify they pass
Run: cd web && npx vitest run src/builder/rules
Expected: PASS (parse + roundtrip + all prior).
- Step 6: Commit
git add web/src/builder/rules/parse.ts web/src/builder/rules/parse.test.ts web/src/builder/rules/roundtrip.test.ts
git commit --no-verify -m "feat(435): parse relative-date macros; round-trip against normalizeGroup"
Task 5: #438 + #435 — RuleBuilder.tsx UI (validation surface, relative-date inputs, single-child toggle)
Files:
- Modify:
web/src/builder/rules/RuleBuilder.tsx - Modify:
web/src/screens/CollectionsScreen.tsx(consumer — disable Save on errors) - Modify:
web/src/builder/rules/RuleBuilder.test.tsx
Interfaces:
- Consumes:
ruleError,groupHasErrors,topGroupAllNegative,normalizeGroup(Task 1);DateUnit(Task 2). - Produces: RuleBuilder renders a per-rule error message + an aggregate warning; for
inLast/notInLastit renders a number input + unit<select>; single-child groups hide the any/all toggle. Validity is surfaced to the consumer via a new optional proponValidityChange?(hasErrors: boolean): void(or the existingonChangecontract — read the component first and follow whichever the file already uses).
Read
RuleBuilder.tsxin full before editing — it is the only file here whose exact JSX shape isn't reproduced in this plan. Match its existing operator-<select>, value-<input>, and group-toggle patterns. The required behaviors:
-
Step 1: Relative-date inputs. When
rule.operatorisinLast/notInLast, render (in place of the single date input) a numeric<input>bound torule.valueand a<select>bound torule.unitwith optionsday/week/month/year(defaultdaywhen the operator is first selected). When switching a date field's operator TOinLast/notInLast, setunit: 'day'if unset; when switching AWAY, clearunit. -
Step 2: Per-rule error. Call
ruleError(rule); when non-null, render the message inline under the rule row (use the existing error/helper-text styling in the SPA — grep the file/screen for an existing validation message class; do not invent new CSS). This is the #438 block for incompletebetween/ empty text / bad relative-N. -
Step 3: Single-child toggle. Hide the any/all match toggle for any group whose
children.length < 2(its connective is meaningless and is normalized to'all'). ApplynormalizeGroupto the tree in the builder'sonChangepath so stored state matches what round-trips. -
Step 4: All-negative warning. When
topGroupAllNegative(rootGroup)is true, render a non-blocking warning near the preview ("This query is entirely negative and will match nothing on its own"). Do NOT disable Save for this. -
Step 5: Consumer — disable Save on errors. In
CollectionsScreen.tsx, computegroupHasErrors(group)and disable the Save/Create button (and skip compile) while true. Read the screen's existing submit-disabled logic and AND this in. -
Step 6: Component tests. Add to
RuleBuilder.test.tsx: (a) selectinginLastshows number + unit inputs; (b) an incompletebetweenshows the error text; (c) a single-child group renders no any/all toggle; (d) an all-negative root shows the warning. Use the existing test-render harness in the file (React Testing Library). -
Step 7: Run web tests
Run: cd web && npx vitest run src/builder/rules && npx vitest run src/screens/CollectionsScreen
Expected: PASS.
- Step 8: Commit
git add web/src/builder/rules/RuleBuilder.tsx web/src/builder/rules/RuleBuilder.test.tsx web/src/screens/CollectionsScreen.tsx
git commit --no-verify -m "feat(438,435): RuleBuilder UI — validation surface, relative-date inputs, single-child toggle"
Task 6: #434 — backend distinct-values endpoint (PARALLEL WORKTREE)
Isolation: run this task in its OWN worktree branched off
feat/rulebuilder-bundle(git worktree add -b feat/rb-434-backend <path> feat/rulebuilder-bundle), merged back by fast-forward before Task 7. It touches only C# + generated OpenAPI — disjoint from Tasks 1–5. Use csharp-lsp for symbol lookup. Independent review required (new API endpoint).
Files:
- Modify:
ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs(add method) — confirm exact path via LSP - Modify:
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs(implement term enumeration) - Create:
ErsatzTV.Application/Search/Queries/GetSearchFieldValues.cs(+...Handler.cs) - Create:
ErsatzTV.Core/Api/Search/SearchFieldValuesResponseModel.cs - Modify:
ErsatzTV/Controllers/Api/SearchController.cs(add endpoint) - Test:
ErsatzTV.Application.Tests/Search/GetSearchFieldValuesHandlerTests.cs - Regenerate:
ErsatzTV/wwwroot/.../v1.json,web/src/api/v1.d.ts,docs/openapi/endpoint-index.mdvia./scripts/update-openapi.sh
Interfaces:
-
Produces (frontend Task 7 consumes the generated client for):
GET /api/v1/search/fields/{name}/values?q=&limit=→{ "values": string[] }. 404 whennameis absent fromSearchFieldCatalogor is nottype == "text".limitdefault 50, capped 50;qis a case-insensitive prefix filter (empty ⇒ first N terms). -
Step 1: Write the failing handler test
// GetSearchFieldValuesHandlerTests.cs (NUnit + Shouldly + NSubstitute)
// - substitute ISearchIndex returning e.g. ["Action","Adventure","Drama"] for field "genre"
// - assert: allow-listed text field "genre" with q="A" returns ["Action","Adventure"] (prefix, case-insensitive)
// - assert: unknown field "nope" → the not-found result (Option.None / Either.Left per the handler's result type)
// - assert: non-text field "minutes" (number) → not-found result
// - assert: limit is clamped to 50
Write these as real NUnit [Test] methods mirroring an existing ErsatzTV.Application.Tests/Search/* test (read one first for the harness/DI shape).
-
Step 2: Run to verify it fails —
dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues→ FAIL (types missing). -
Step 3: Implement
ISearchIndex:Task<List<string>> GetFieldValues(string field, string query, int limit);LuceneSearchIndex: open the index reader, enumerateTermsfor the field (MultiFields.GetTerms(reader, field)/TermsEnum), filter by case-insensitive prefix onquery, distinct, takelimit. (Confirm the Lucene.NET term-enumeration API against the version inDirectory.Packages.props.)- Handler: look the field up in
SearchFieldCatalog(the static list inErsatzTV.Application/Search/SearchFieldCatalog.cs); if missing ortype != "text"return the not-found result; else clamplimitto[1,50], callISearchIndex.GetFieldValues, wrap inSearchFieldValuesResponseModel. - DTO:
public record SearchFieldValuesResponseModel(List<string> Values);with file-scoped#nullable enable. - Controller:
[HttpGet("fields/{name}/values")]→mediator.Send(new GetSearchFieldValues(name, q, limit)), mapping the not-found result toNotFound()(follow the existingSearchControllerresult-mapping style).
-
Step 4: Run to verify pass —
dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues→ PASS. -
Step 5: Regenerate OpenAPI — build the app project FIRST, then
./scripts/update-openapi.sh, then confirm the endpoint appears inendpoint-index.mdandv1.d.tshasgetSearchFieldValues(or the generated name). -
Step 6: Format + BOM gate (touched
.cs):
bash -c 'git diff --name-only feat/rulebuilder-bundle...HEAD -- "*.cs" | while read f; do xxd -p "$f" | grep -q "^efbbbf" && echo "BOM: $f"; done'
bash -c 'dotnet format whitespace . --folder --include $(git diff --name-only feat/rulebuilder-bundle...HEAD -- "*.cs" | tr "\n" " ")'
-
Step 7: Commit —
git commit --no-verify -m "feat(434): distinct-values search endpoint (text fields, Lucene term enumeration)" -
Step 8: Merge back to feature branch by fast-forward (per
process.foreign-worktree-plumbing-merge, land on the branch ref without committing inside a foreign worktree):
git -C <feat/rulebuilder-bundle worktree> merge --ff-only feat/rb-434-backend
Task 7: #434 — facet-value combobox (frontend, after Task 6 merged)
Files:
- Modify:
web/src/api/search.ts(addgetSearchFieldValues) — or wherever the generated search client lives - Modify:
web/src/builder/rules/RuleBuilder.tsx(combobox for text value inputs) - Test:
web/src/builder/rules/RuleBuilder.test.tsx
Interfaces:
-
Consumes: the generated
GET /api/v1/search/fields/{name}/valuesclient from Task 6. -
Produces: text-field value inputs (
is/isNot/contains/startsWith) become an autocomplete combobox; free-text is preserved (any typed value still compiles). -
Step 1: API client. Add
getSearchFieldValues(name: string, q: string, limit = 50): Promise<string[]>inweb/src/api/search.ts, mirroring the existinggetSearchFields()there and reading.valuesfrom the response. -
Step 2: Combobox. In
RuleBuilder.tsx, for atext-typed field with a text operator, render an autocomplete input: on input change (debounced ~200ms), callgetSearchFieldValues(field, q)and show a datalist/suggestion dropdown. Selecting a suggestion setsrule.value. Typing a value not in the list is still accepted (free-text fallback → preview count remains the safety net). Reuse an existing combobox/datalist pattern in the SPA if one exists (grep for<datalistor an autocomplete component); otherwise a native<datalist>is the minimal, dependency-free choice. -
Step 3: Test. Add a
RuleBuilder.test.tsxcase: mockinggetSearchFieldValuesto return["Action","Adventure"], typing in a text value input surfaces the suggestions; selecting one updates the rule; a free-typed value not in the list is retained. -
Step 4: Run web tests —
cd web && npx vitest run src/builder/rules→ PASS. -
Step 5: Commit —
git commit --no-verify -m "feat(434): facet-value typeahead combobox for text fields"
Task 8: Docs — decisions records, spa-conventions §12, api-conventions
Files:
-
Modify:
docs/decisions.md(2 new records) + regeneratedocs/decisions/README.mdcatalog viascripts/build_decisions_catalog.py -
Modify:
docs/spa-conventions.md§12 -
Modify:
docs/api-conventions.md(endpoint checklist) -
Step 1: decisions.md — append two records (follow the existing 5-field metadata schema
key/status/since/supersedes/superseded-by;docs.decision-lifecycle):key: api.search-field-values— "Distinct field-value typeahead is a newGET /api/v1/search/fields/{name}/values?q=endpoint enumerating Lucene terms, allow-listed totextcatalog fields (404 otherwise), capped at 50; enum ships values inline so it needs no endpoint."key: rulebuilder.relative-date-macros— "Relative-date operators (inLast/notInLast) are a frontend-only compile/parse mapping onto the existingreleased_inthelast/added_inthelast(+notinthelast)CustomMultiFieldQueryParsermacros (value form\"<n> day|week|month|year\"); therelease_date↔released/added_date↔addedtable is the single seam. No backend change."
-
Step 2: Regenerate the catalog —
python3 scripts/build_decisions_catalog.py(or the documented invocation); verifydecisions_validate.pypasses:python3 scripts/decisions_validate.py. -
Step 3: spa-conventions.md §12 — add the relative-date operators (#435) and the typeahead-combobox behavior (#434) to the RuleBuilder section; note the single-child-group normalization (#438).
-
Step 4: api-conventions.md — add the
search/fields/{name}/valuesendpoint to the endpoint checklist/table. -
Step 5: Commit —
git commit --no-verify -m "docs(434,435,438): decisions records, spa-conventions §12, api-conventions"
Task 9: Whole-diff verification, review, live-E2E, push
- Step 1: Full local gate.
cd web && npx vitest run && npm run build(or the repo's web test/build scripts)dotnet build ErsatzTV.slndotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues- BOM + format gate over all touched
.cs(recipe in Task 6 Step 6).
- Step 2: Independent adversarial review — cold-context review-only agent (different model family if available) over the whole diff. Loop until a clean verdict; re-review the fix commit, not just the initial diff (
release.review-verdict-gate). Focus: the compile/parse round-trip invariant, the Lucene term-enumeration correctness + the 404 allow-list, and the combobox free-text fallback. - Step 3: Live-E2E —
scripts/e2e-local.shwith a fresh config dir; drive the SmartCollection RuleBuilder: build a relative-date rule, save, reload, confirm it round-trips; curl the newsearch/fields/genre/values?q=endpoint and confirm filtered values. (testing.live-e2e-prepush-timing.) - Step 4: Push once (batch all commits), open the PR
fixes #438 #435 #434, arm the CI monitor on the PR head sha at open (ci.monitor-armed-at-pr-open). Post theReview-verdict:comment referencing head (release.review-verdict-gate/ H10). - Step 5: Tick each issue's
## Done-whenboxes as evidence lands; the merge-consent gate derives consent from state.
Self-Review
Spec coverage: #438 (all four cases → Tasks 1,3,5), #435 (operators/mapping/compile/parse/UI → Tasks 2,3,4,5), #434 (endpoint + combobox + OpenAPI → Tasks 6,7), docs (Task 8), review/E2E/push (Task 9). ✔ All spec sections mapped.
Placeholder scan: logic-module code is complete and verbatim-runnable; RuleBuilder.tsx/C# steps state required behavior + exact signatures with an explicit "read the file first" instruction (their exact JSX/DI shape is not reproduced by design, since it wasn't read into the plan). No TBD/TODO.
Type consistency: Operator gains inLast/notInLast (Task 2) used identically in validation (Task 1 test), dateMacro (Task 2), compile (Task 3), parse (Task 4), UI (Task 5). Rule.unit?: DateUnit consistent throughout. normalizeGroup/groupHasErrors/topGroupAllNegative/ruleError names stable across Tasks 1→5. GetSearchFieldValues/SearchFieldValuesResponseModel/getSearchFieldValues consistent across Tasks 6→7. RELATIVE_FIELD_MAP/RELATIVE_SYNTHETIC consistent Task 2→4.
Ordering fix applied: the types.ts extension (operators inLast/notInLast, DateUnit, Rule.unit) is Task 1 Step 0, so Task 1's validation.test.ts (the first consumer of those types) typechecks and gates green on its own. Task 2 then adds only dateMacro.ts. Each task is independently green.