Files
ersatztv/docs/superpowers/plans/2026-07-23-rulebuilder-bundle.md
T
2026-07-23 20:43:26 +02:00

35 KiB
Raw Blame History

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-bundle off origin/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 (shared RuleBuilder.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 valid g. Extend roundtrip.test.ts for every new operator.
  • New REST response DTOs live in ErsatzTV.Core/Api/Search/*ResponseModel.cs with file-scoped #nullable enable. → api.response-dtos
  • A PR touching ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/** MUST ship regenerated v1.json / v1.d.ts / endpoint-index.md in 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 under bash -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-verify and 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 with match coerced 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.ts was already extended in Task 1 Step 0 (operators inLast/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 on release_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: compileRule emits the relative-date macro for inLast/notInLast, and returns '' for a structurally-invalid rule (incomplete between, empty text value) so malformed Lucene is never emitted (the empty string is already filtered by compileGroup).

  • 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: parseAtom recognizes (released|added)_(inthelast|notinthelast):"..." and returns the relative Rule; the round-trip test asserts parse(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 generator makeRule to sometimes emit date rules with inLast/notInLast + a unit, and (b) compare against normalizeGroup(g) rather than g so single-child groups match. Concretely, import normalizeGroup and 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/notInLast it renders a number input + unit <select>; single-child groups hide the any/all toggle. Validity is surfaced to the consumer via a new optional prop onValidityChange?(hasErrors: boolean): void (or the existing onChange contract — read the component first and follow whichever the file already uses).

Read RuleBuilder.tsx in 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.operator is inLast/notInLast, render (in place of the single date input) a numeric <input> bound to rule.value and a <select> bound to rule.unit with options day/week/month/year (default day when the operator is first selected). When switching a date field's operator TO inLast/notInLast, set unit: 'day' if unset; when switching AWAY, clear unit.

  • 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 incomplete between / 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'). Apply normalizeGroup to the tree in the builder's onChange path 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, compute groupHasErrors(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) selecting inLast shows number + unit inputs; (b) an incomplete between shows 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 15. 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.md via ./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 when name is absent from SearchFieldCatalog or is not type == "text". limit default 50, capped 50; q is 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 failsdotnet 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, enumerate Terms for the field (MultiFields.GetTerms(reader, field) / TermsEnum), filter by case-insensitive prefix on query, distinct, take limit. (Confirm the Lucene.NET term-enumeration API against the version in Directory.Packages.props.)
    • Handler: look the field up in SearchFieldCatalog (the static list in ErsatzTV.Application/Search/SearchFieldCatalog.cs); if missing or type != "text" return the not-found result; else clamp limit to [1,50], call ISearchIndex.GetFieldValues, wrap in SearchFieldValuesResponseModel.
    • 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 to NotFound() (follow the existing SearchController result-mapping style).
  • Step 4: Run to verify passdotnet 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 in endpoint-index.md and v1.d.ts has getSearchFieldValues (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: Commitgit 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 (add getSearchFieldValues) — 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}/values client 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[]> in web/src/api/search.ts, mirroring the existing getSearchFields() there and reading .values from the response.

  • Step 2: Combobox. In RuleBuilder.tsx, for a text-typed field with a text operator, render an autocomplete input: on input change (debounced ~200ms), call getSearchFieldValues(field, q) and show a datalist/suggestion dropdown. Selecting a suggestion sets rule.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 <datalist or an autocomplete component); otherwise a native <datalist> is the minimal, dependency-free choice.

  • Step 3: Test. Add a RuleBuilder.test.tsx case: mocking getSearchFieldValues to 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 testscd web && npx vitest run src/builder/rules → PASS.

  • Step 5: Commitgit 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) + regenerate docs/decisions/README.md catalog via scripts/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 new GET /api/v1/search/fields/{name}/values?q= endpoint enumerating Lucene terms, allow-listed to text catalog 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 existing released_inthelast/added_inthelast (+notinthelast) CustomMultiFieldQueryParser macros (value form \"<n> day|week|month|year\"); the release_date↔released/added_date↔added table is the single seam. No backend change."
  • Step 2: Regenerate the catalogpython3 scripts/build_decisions_catalog.py (or the documented invocation); verify decisions_validate.py passes: 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}/values endpoint to the endpoint checklist/table.

  • Step 5: Commitgit 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.sln
    • dotnet 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-E2Escripts/e2e-local.sh with a fresh config dir; drive the SmartCollection RuleBuilder: build a relative-date rule, save, reload, confirm it round-trips; curl the new search/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 the Review-verdict: comment referencing head (release.review-verdict-gate / H10).
  • Step 5: Tick each issue's ## Done-when boxes 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.