diff --git a/.ai-team/agents/linus/history.md b/.ai-team/agents/linus/history.md index 27837ff..d37df3a 100644 --- a/.ai-team/agents/linus/history.md +++ b/.ai-team/agents/linus/history.md @@ -135,3 +135,18 @@ - v1.1 enables observability (skills usage, burndown metrics) - Key implementation: DecisionService.search() and HealthCheck diagnostic command - Roadmap session logged to .ai-team/log/2026-02-23-feature-roadmap.md + +### DecisionSearchService (#69) (2026-02-24) +- Created DecisionSearchService in src/services/DecisionSearchService.ts - pure service, no file I/O, no VS Code deps +- Methods: search(), filterByDate(), filterByAuthor(), filter() (combined criteria) +- Search ranking: title match (weight 10) > author match (weight 5) > content match (weight 3) +- Multi-word queries split on whitespace; each term scored independently and summed +- filterByDate() uses string comparison on YYYY-MM-DD keys (avoids timezone issues) +- filterByAuthor() is case-insensitive substring match +- filter() chains: search first (preserves ranking order) then date range then author +- Open-ended date ranges: omit startDate or endDate in DecisionSearchCriteria +- Decisions without date excluded from date filters; decisions without author excluded from author filters +- Exported DecisionSearchCriteria and ScoredDecision interfaces for consumer use +- 37 tests in decisionSearchService.test.ts covering all methods and edge cases +- Service is decoupled from DecisionService - operates on DecisionEntry[], not files +- UI integration (tree view search box, filter controls) is Rusty's follow-up diff --git a/.ai-team/decisions/inbox/linus-decision-search-api.md b/.ai-team/decisions/inbox/linus-decision-search-api.md new file mode 100644 index 0000000..dac0af5 --- /dev/null +++ b/.ai-team/decisions/inbox/linus-decision-search-api.md @@ -0,0 +1,33 @@ +# Decision: DecisionSearchService API Design + +**Date:** 2026-02-24 +**Author:** Linus +**Issue:** #69 + +## Context + +Issue #69 requires search and filtering for decisions. The existing `DecisionService` handles parsing; we need a separate service for query operations. + +## Decision + +Created `DecisionSearchService` as a pure, stateless service operating on `DecisionEntry[]`: + +- **`search(decisions, query)`** — full-text search with relevance ranking (title 10× > author 5× > content 3×) +- **`filterByDate(decisions, startDate, endDate)`** — inclusive date range using YYYY-MM-DD string comparison +- **`filterByAuthor(decisions, author)`** — case-insensitive substring match +- **`filter(decisions, criteria)`** — chains all three: search first (preserves ranking), then date, then author + +Exported types: `DecisionSearchCriteria`, `ScoredDecision`. + +## Rationale + +- Separation from `DecisionService` keeps parsing and querying decoupled — each can evolve independently +- Pure functions on arrays = trivially testable, no file I/O or VS Code deps +- Search-first chaining preserves relevance ordering through subsequent filters +- String comparison on YYYY-MM-DD avoids timezone issues that plague Date object comparisons + +## Impact + +- Rusty can consume `DecisionSearchService` from tree view code to wire up the search UI +- The `filter()` method accepts a `DecisionSearchCriteria` object — Rusty should bind UI inputs to this interface +- No changes to `DecisionEntry` model or `DecisionService` were needed diff --git a/src/services/DecisionSearchService.ts b/src/services/DecisionSearchService.ts new file mode 100644 index 0000000..8fe0fb0 --- /dev/null +++ b/src/services/DecisionSearchService.ts @@ -0,0 +1,165 @@ +/** + * Service for searching and filtering parsed decisions. + * + * Pure service layer — operates on DecisionEntry[] arrays, no file I/O. + * Designed to be consumed by tree providers and webview controllers. + * + * Ranking strategy: title match > body match > metadata match. + */ + +import { DecisionEntry } from '../models'; + +/** + * Criteria bundle for combined search + filter operations. + * All fields are optional — omitted fields are not applied. + */ +export interface DecisionSearchCriteria { + /** Free-text query matched against title, content, and author */ + query?: string; + /** Inclusive start date (YYYY-MM-DD) */ + startDate?: string; + /** Inclusive end date (YYYY-MM-DD) */ + endDate?: string; + /** Author name (case-insensitive substring match) */ + author?: string; +} + +/** + * A decision paired with its relevance score from a search operation. + */ +export interface ScoredDecision { + decision: DecisionEntry; + score: number; +} + +// Relevance weights — title matches are worth more than body/metadata +const TITLE_WEIGHT = 10; +const CONTENT_WEIGHT = 3; +const AUTHOR_WEIGHT = 5; + +export class DecisionSearchService { + + /** + * Full-text search across decisions with relevance ranking. + * Matches against title, content, and author fields. + * Results are sorted by score descending (most relevant first). + * + * Returns all decisions (unfiltered) when query is empty/whitespace. + */ + search(decisions: DecisionEntry[], query: string): DecisionEntry[] { + const trimmed = query.trim(); + if (!trimmed) { + return decisions; + } + + const scored = this.scoreDecisions(decisions, trimmed); + return scored + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + .map(s => s.decision); + } + + /** + * Filters decisions to those within an inclusive date range. + * Compares against the decision's `date` field (YYYY-MM-DD strings). + * Decisions without a date are excluded. + */ + filterByDate(decisions: DecisionEntry[], startDate: Date, endDate: Date): DecisionEntry[] { + const startKey = toDateKey(startDate); + const endKey = toDateKey(endDate); + + return decisions.filter(d => { + if (!d.date) { return false; } + return d.date >= startKey && d.date <= endKey; + }); + } + + /** + * Filters decisions by author (case-insensitive substring match). + * Returns all decisions when author is empty/whitespace. + * Decisions without an author field are excluded. + */ + filterByAuthor(decisions: DecisionEntry[], author: string): DecisionEntry[] { + const trimmed = author.trim().toLowerCase(); + if (!trimmed) { + return decisions; + } + + return decisions.filter(d => { + if (!d.author) { return false; } + return d.author.toLowerCase().includes(trimmed); + }); + } + + /** + * Combined filter that chains query search, date range, and author filter. + * Applies search first (to get ranking), then narrows with date/author. + */ + filter(decisions: DecisionEntry[], criteria: DecisionSearchCriteria): DecisionEntry[] { + let results = decisions; + + // Apply text search first (preserves relevance ordering) + if (criteria.query && criteria.query.trim()) { + results = this.search(results, criteria.query); + } + + // Apply date range filter + if (criteria.startDate || criteria.endDate) { + const start = criteria.startDate || '0000-00-00'; + const end = criteria.endDate || '9999-99-99'; + results = results.filter(d => { + if (!d.date) { return false; } + return d.date >= start && d.date <= end; + }); + } + + // Apply author filter + if (criteria.author && criteria.author.trim()) { + results = this.filterByAuthor(results, criteria.author); + } + + return results; + } + + // ─── Private Helpers ──────────────────────────────────────────────── + + /** + * Scores each decision against the query. + * Splits the query into individual terms for multi-word matching. + */ + private scoreDecisions(decisions: DecisionEntry[], query: string): ScoredDecision[] { + const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0); + + return decisions.map(decision => { + let score = 0; + const titleLower = (decision.title || '').toLowerCase(); + const contentLower = (decision.content || '').toLowerCase(); + const authorLower = (decision.author || '').toLowerCase(); + + for (const term of terms) { + if (titleLower.includes(term)) { + score += TITLE_WEIGHT; + } + if (contentLower.includes(term)) { + score += CONTENT_WEIGHT; + } + if (authorLower.includes(term)) { + score += AUTHOR_WEIGHT; + } + } + + return { decision, score }; + }); + } +} + +/** + * Converts a Date to a YYYY-MM-DD string using local time. + * Avoids timezone-shifting issues that toISOString() causes. + */ +function toDateKey(d: Date): string { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/src/services/index.ts b/src/services/index.ts index 390d59d..584da6b 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -22,6 +22,11 @@ export { } from './GitHubIssuesService'; export { SkillCatalogService } from './SkillCatalogService'; export { DecisionService } from './DecisionService'; +export { + DecisionSearchService, + type DecisionSearchCriteria, + type ScoredDecision +} from './DecisionSearchService'; export { SquadVersionService, type UpgradeCheckResult } from './SquadVersionService'; export { StandupReportService, diff --git a/src/test/suite/decisionSearchService.test.ts b/src/test/suite/decisionSearchService.test.ts new file mode 100644 index 0000000..137f64e --- /dev/null +++ b/src/test/suite/decisionSearchService.test.ts @@ -0,0 +1,336 @@ +/** + * Tests for DecisionSearchService — search, filter, and ranking of decisions. + * + * Coverage: + * - search(): full-text search with relevance ranking + * - filterByDate(): inclusive date range filtering + * - filterByAuthor(): case-insensitive author matching + * - filter(): combined criteria chaining + * - Edge cases: empty inputs, missing fields, malformed data + */ + +import * as assert from 'assert'; +import { DecisionSearchService, DecisionSearchCriteria } from '../../services/DecisionSearchService'; +import { DecisionEntry } from '../../models'; + +/** + * Helper to build a DecisionEntry with sensible defaults. + */ +function makeDecision(overrides: Partial & { title: string }): DecisionEntry { + return { + filePath: '/fake/decisions.md', + lineNumber: 0, + ...overrides, + }; +} + +suite('DecisionSearchService', () => { + let service: DecisionSearchService; + + const decisions: DecisionEntry[] = [ + makeDecision({ + title: 'Use TypeScript for Extension', + date: '2026-02-14', + author: 'Linus', + content: '## Use TypeScript for Extension\n**By:** Linus\nWe chose TypeScript for type safety.', + }), + makeDecision({ + title: 'CI Pipeline Configuration', + date: '2026-02-15', + author: 'Livingston', + content: '## CI Pipeline Configuration\n**By:** Livingston\nGitHub Actions for CI/CD pipeline.', + }), + makeDecision({ + title: 'Lazy Activation Strategy', + date: '2026-02-16', + author: 'Danny', + content: '## Lazy Activation Strategy\n**By:** Danny\nActivate on onView:squadMembers for performance.', + }), + makeDecision({ + title: 'TypeScript Strict Mode', + date: '2026-02-17', + author: 'Linus', + content: '## TypeScript Strict Mode\n**By:** Linus\nEnable strict mode in tsconfig for safety.', + }), + makeDecision({ + title: 'Dashboard Tab Layout', + date: '2026-02-18', + author: 'Rusty', + content: '## Dashboard Tab Layout\n**By:** Rusty\nUse tabbed layout for the dashboard webview.', + }), + ]; + + setup(() => { + service = new DecisionSearchService(); + }); + + // ─── search() ──────────────────────────────────────────────────────── + + suite('search()', () => { + test('returns all decisions for empty query', () => { + const results = service.search(decisions, ''); + assert.strictEqual(results.length, decisions.length); + }); + + test('returns all decisions for whitespace-only query', () => { + const results = service.search(decisions, ' '); + assert.strictEqual(results.length, decisions.length); + }); + + test('matches title text', () => { + const results = service.search(decisions, 'Pipeline'); + assert.ok(results.length > 0, 'Should find at least one result'); + assert.strictEqual(results[0].title, 'CI Pipeline Configuration'); + }); + + test('matches content text', () => { + const results = service.search(decisions, 'GitHub Actions'); + assert.ok(results.length > 0, 'Should find CI Pipeline decision'); + assert.ok(results.some(d => d.title === 'CI Pipeline Configuration')); + }); + + test('matches author name', () => { + const results = service.search(decisions, 'Rusty'); + assert.ok(results.length > 0, 'Should find Rusty\'s decisions'); + assert.strictEqual(results[0].title, 'Dashboard Tab Layout'); + }); + + test('is case-insensitive', () => { + const results = service.search(decisions, 'typescript'); + assert.ok(results.length >= 2, 'Should find both TypeScript decisions'); + }); + + test('title matches rank higher than content-only matches', () => { + // 'TypeScript' appears in title of two decisions; content-only in none extra + const results = service.search(decisions, 'TypeScript'); + // Both TypeScript decisions should be at the top + const topTitles = results.slice(0, 2).map(d => d.title); + assert.ok(topTitles.includes('Use TypeScript for Extension')); + assert.ok(topTitles.includes('TypeScript Strict Mode')); + }); + + test('multi-word query matches across fields', () => { + const results = service.search(decisions, 'Linus strict'); + assert.ok(results.length > 0); + // "TypeScript Strict Mode" by Linus should rank highest (matches in title + author) + assert.strictEqual(results[0].title, 'TypeScript Strict Mode'); + }); + + test('returns empty array for no matches', () => { + const results = service.search(decisions, 'xyzzyNonExistent'); + assert.strictEqual(results.length, 0); + }); + + test('handles empty decisions array', () => { + const results = service.search([], 'anything'); + assert.strictEqual(results.length, 0); + }); + + test('handles decisions with missing fields gracefully', () => { + const sparse: DecisionEntry[] = [ + makeDecision({ title: 'Minimal Decision' }), + makeDecision({ title: '', content: undefined, author: undefined }), + ]; + // Should not throw + const results = service.search(sparse, 'Minimal'); + assert.ok(results.length >= 1); + assert.strictEqual(results[0].title, 'Minimal Decision'); + }); + }); + + // ─── filterByDate() ────────────────────────────────────────────────── + + suite('filterByDate()', () => { + test('filters to decisions within date range (inclusive)', () => { + const start = new Date(2026, 1, 15); // Feb 15 + const end = new Date(2026, 1, 17); // Feb 17 + const results = service.filterByDate(decisions, start, end); + assert.strictEqual(results.length, 3); + assert.ok(results.every(d => d.date! >= '2026-02-15' && d.date! <= '2026-02-17')); + }); + + test('single-day range returns only that day', () => { + const day = new Date(2026, 1, 16); // Feb 16 + const results = service.filterByDate(decisions, day, day); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Lazy Activation Strategy'); + }); + + test('excludes decisions without a date', () => { + const withNoDate: DecisionEntry[] = [ + ...decisions, + makeDecision({ title: 'No Date Decision' }), + ]; + const start = new Date(2026, 0, 1); + const end = new Date(2026, 11, 31); + const results = service.filterByDate(withNoDate, start, end); + assert.ok(!results.some(d => d.title === 'No Date Decision')); + }); + + test('returns empty when no decisions fall in range', () => { + const start = new Date(2025, 0, 1); + const end = new Date(2025, 0, 31); + const results = service.filterByDate(decisions, start, end); + assert.strictEqual(results.length, 0); + }); + + test('handles empty decisions array', () => { + const start = new Date(2026, 0, 1); + const end = new Date(2026, 11, 31); + const results = service.filterByDate([], start, end); + assert.strictEqual(results.length, 0); + }); + }); + + // ─── filterByAuthor() ──────────────────────────────────────────────── + + suite('filterByAuthor()', () => { + test('filters by exact author name (case-insensitive)', () => { + const results = service.filterByAuthor(decisions, 'linus'); + assert.strictEqual(results.length, 2); + assert.ok(results.every(d => d.author?.toLowerCase() === 'linus')); + }); + + test('filters by partial author name', () => { + const results = service.filterByAuthor(decisions, 'Lin'); + assert.strictEqual(results.length, 2); + }); + + test('returns all decisions for empty author', () => { + const results = service.filterByAuthor(decisions, ''); + assert.strictEqual(results.length, decisions.length); + }); + + test('returns all decisions for whitespace-only author', () => { + const results = service.filterByAuthor(decisions, ' '); + assert.strictEqual(results.length, decisions.length); + }); + + test('excludes decisions without an author', () => { + const withNoAuthor: DecisionEntry[] = [ + ...decisions, + makeDecision({ title: 'Anonymous', date: '2026-02-20' }), + ]; + const results = service.filterByAuthor(withNoAuthor, 'Linus'); + assert.ok(!results.some(d => d.title === 'Anonymous')); + }); + + test('returns empty when no author matches', () => { + const results = service.filterByAuthor(decisions, 'NonExistentAuthor'); + assert.strictEqual(results.length, 0); + }); + + test('handles empty decisions array', () => { + const results = service.filterByAuthor([], 'Linus'); + assert.strictEqual(results.length, 0); + }); + }); + + // ─── filter() (combined) ───────────────────────────────────────────── + + suite('filter() — combined criteria', () => { + test('applies all criteria together', () => { + const criteria: DecisionSearchCriteria = { + query: 'TypeScript', + startDate: '2026-02-14', + endDate: '2026-02-16', + author: 'Linus', + }; + const results = service.filter(decisions, criteria); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Use TypeScript for Extension'); + }); + + test('returns all decisions with empty criteria', () => { + const results = service.filter(decisions, {}); + assert.strictEqual(results.length, decisions.length); + }); + + test('applies only query when other fields are absent', () => { + const results = service.filter(decisions, { query: 'Dashboard' }); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Dashboard Tab Layout'); + }); + + test('applies only date range when query/author are absent', () => { + const results = service.filter(decisions, { + startDate: '2026-02-17', + endDate: '2026-02-18', + }); + assert.strictEqual(results.length, 2); + }); + + test('applies only author when query/date are absent', () => { + const results = service.filter(decisions, { author: 'Danny' }); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Lazy Activation Strategy'); + }); + + test('open-ended start date (endDate only)', () => { + const results = service.filter(decisions, { endDate: '2026-02-15' }); + assert.strictEqual(results.length, 2); + }); + + test('open-ended end date (startDate only)', () => { + const results = service.filter(decisions, { startDate: '2026-02-17' }); + assert.strictEqual(results.length, 2); + }); + + test('combined criteria with no matches returns empty', () => { + const results = service.filter(decisions, { + query: 'TypeScript', + author: 'Rusty', // Rusty didn't write TypeScript decisions + }); + assert.strictEqual(results.length, 0); + }); + + test('preserves search ranking order after date/author filters', () => { + const criteria: DecisionSearchCriteria = { + query: 'TypeScript', + author: 'Linus', + }; + const results = service.filter(decisions, criteria); + assert.strictEqual(results.length, 2); + // Both should be Linus's TypeScript decisions, ranked by relevance + assert.ok(results[0].title.includes('TypeScript')); + assert.ok(results[1].title.includes('TypeScript')); + }); + }); + + // ─── Edge Cases ────────────────────────────────────────────────────── + + suite('edge cases', () => { + test('decisions with identical scores maintain stable order', () => { + const identical: DecisionEntry[] = [ + makeDecision({ title: 'Alpha Feature', content: 'same content', author: 'A', date: '2026-01-01' }), + makeDecision({ title: 'Beta Feature', content: 'same content', author: 'A', date: '2026-01-02' }), + ]; + const results = service.search(identical, 'same content'); + assert.strictEqual(results.length, 2); + }); + + test('special regex characters in query do not throw', () => { + // Shouldn't throw — query is used via includes(), not regex + const results = service.search(decisions, '.*+?^${}()|[]\\'); + assert.strictEqual(results.length, 0); + }); + + test('very long query string does not throw', () => { + const longQuery = 'word '.repeat(1000); + const results = service.search(decisions, longQuery); + assert.ok(Array.isArray(results)); + }); + + test('filterByDate with reversed range returns empty', () => { + const start = new Date(2026, 1, 18); + const end = new Date(2026, 1, 14); + const results = service.filterByDate(decisions, start, end); + assert.strictEqual(results.length, 0); + }); + + test('filter with only whitespace query behaves like no query', () => { + const results = service.filter(decisions, { query: ' ' }); + assert.strictEqual(results.length, decisions.length); + }); + }); +});