diff --git a/documents/PROJECT_ARCHITECTURE.md b/documents/PROJECT_ARCHITECTURE.md index 262a4a21..2112dd48 100644 --- a/documents/PROJECT_ARCHITECTURE.md +++ b/documents/PROJECT_ARCHITECTURE.md @@ -111,7 +111,7 @@ No command or service instantiates its own dependencies. All wiring is centraliz - **Dependents**: `IConfigLoader`, `ConfigLoader`, `CommitBuilder`, `StalenessDetector`, `Validator`, `main.ts`, `path-query.ts`. #### `src/types/query.ts` -- **Contains**: `TargetType` type, `QueryTarget` interface, `PathQueryOptions` interface, `SearchOptions` interface, `QueryResult` interface, `QueryMeta` interface. +- **Contains**: `TargetType` type, `QueryTarget` interface, `DiscoveryOptions` interface, `QueryOptions` interface, `QueryResult` interface, `QueryMeta` interface. - **Single Responsibility**: Defines the shape of queries (inputs) and their results (outputs) for the query pipeline. - **Dependencies**: `domain.ts` (for `LoreAtom`, `ConfidenceLevel`, etc.). - **Dependents**: `PathResolver`, `AtomRepository`, `path-query.ts`, command files, formatters. @@ -202,7 +202,7 @@ No command or service instantiates its own dependencies. All wiring is centraliz - **Single Responsibility**: Central query engine. Retrieves `LoreAtom` objects from git history by target path, Lore-id, revision range, scope, or globally. Handles follow-link BFS traversal. - **Dependencies**: `IGitClient`, `TrailerParser`, `domain.ts`, `query.ts`, `constants.ts`. - **Dependents**: Commands (`context`, `constraints`, `rejected`, `directives`, `tested`, `search`, `log`, `stale`, `trace`, `squash`, `doctor`), `Validator`, `main.ts`. -- **Key methods**: `findByTarget()`, `findByLoreId()`, `findByRange()`, `findAll()`, `findByScope()`, `resolveFollowLinks()`. +- **Key methods**: `findByTarget()`, `findByLoreId()`, `findByRange()`, `findAll()`, `resolveFollowLinks()`. #### `src/services/supersession-resolver.ts` - **Contains**: `SupersessionResolver` class. @@ -535,7 +535,7 @@ Each command calls `executePathQuery()` with its specific `visibleTrailers` para **Gaps / Risks:** - `AtomRepository` has the most methods of any service (7 public: `findByTarget`, `findByLoreId`, `findByCommitHash`, `findByRange`, `findAll`, `findByScope`, `resolveFollowLinks`). While all relate to "retrieving atoms from git," the `resolveFollowLinks` BFS traversal is a distinct concern that could be extracted into a `FollowLinkResolver` service. - `Validator` has 10 validation rules implemented as private methods within a single class. If the rule set grows, a rule-based dispatch pattern (array of validation rule objects) would improve OCP compliance. -- Search filtering is extracted into the `SearchFilter` service class with `applyFilters()`, `atomHasTrailer()`, and `atomMatchesText()` methods. +- Search filtering is extracted into the `SearchFilter` service class which is injected into `AtomRepository`. ### O -- Open/Closed Principle @@ -737,48 +737,40 @@ JsonFormatter() // no dependencies ## 6. Data Flow -### Query Flow: `lore constraints src/auth.ts` +### Query Flow: `lore search --author "cole" --text "auth"` ```mermaid sequenceDiagram actor User participant CLI as Commander (main.ts) - participant CMD as constraints.ts - participant PQ as path-query.ts - participant PR as PathResolver + participant CMD as search.ts participant AR as AtomRepository participant GC as GitClient participant TP as TrailerParser participant SR as SupersessionResolver participant FMT as Formatter - User->>CLI: lore constraints src/auth.ts - CLI->>CMD: route to constraints action - CMD->>PQ: executePathQuery("src/auth.ts", opts, deps, "constraints", ["Constraint"]) + User->>CLI: lore search --author "cole" --text "auth" + CLI->>CMD: route to search action - PQ->>PR: parseTarget("src/auth.ts") - PR-->>PQ: QueryTarget { type: "file" } - PQ->>PR: toGitLogArgs(target) - PR-->>PQ: ["--", "src/auth.ts"] - - PQ->>AR: findByTarget(gitLogArgs, options) - AR->>GC: log(["--", "src/auth.ts"]) + CMD->>AR: findAll({ author: "cole", text: "auth" }) + Note right of AR: Discovery Mode (Pass 1: Git) + AR->>GC: log(["--grep= Lore-id: ...", "--author=cole", "--grep=auth", "--all-match", ...]) GC-->>AR: RawCommit[] + + Note right of AR: Discovery Mode (Pass 2: Application) loop Each RawCommit AR->>TP: containsLoreTrailers(trailers) - AR->>TP: parse(trailers, customKeys) - AR->>GC: getFilesChanged(hash) + AR->>TP: parse(trailers) + AR->>AR: applyFilters(atom) [Authoritative Pass] end - AR-->>PQ: LoreAtom[] + AR-->>CMD: LoreAtom[] - PQ->>SR: resolve(atoms) - SR-->>PQ: Map‹loreId, SupersessionStatus› - PQ->>SR: filterActive(atoms, map) - SR-->>PQ: active LoreAtom[] + CMD->>SR: resolve(atoms) + SR-->>CMD: Map‹loreId, SupersessionStatus› - PQ->>FMT: formatQueryResult({ visibleTrailers: ["Constraint"] }) - FMT-->>PQ: formatted string - PQ->>User: console.log(output) + CMD->>FMT: formatQueryResult() + FMT-->>User: console.log(output) ``` ### Commit Flow: Agent pipes JSON to `lore commit` @@ -1100,7 +1092,7 @@ Adding a new trailer type requires modifying all four locations. **Recommendatio ### `search.ts` Search Logic Extraction (Resolved) -Search filtering logic (`applyFilters`, `atomHasTrailer`, `atomMatchesText`) has been extracted into the `SearchFilter` service class (`src/services/search-filter.ts`). The command file now delegates to this service. +Search filtering logic (`applyFilters`, `atomHasTrailer`, `atomMatchesText`) has been re-extracted into the `SearchFilter` service and is now injected into `AtomRepository`. This maintains the performance of "Atom Discovery Mode" (Git pushdown) while adhering to SRP and reducing the complexity of the repository. `AtomRepository` coordinates the pipeline: pushing coarse filters to Git and delegating the authoritative second pass to `SearchFilter`. --- diff --git a/package-lock.json b/package-lock.json index f197df65..d5513384 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lore-protocol", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lore-protocol", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "chalk": "^5.4.1", diff --git a/src/commands/helpers/path-query.ts b/src/commands/helpers/path-query.ts index ad626741..a0610767 100644 --- a/src/commands/helpers/path-query.ts +++ b/src/commands/helpers/path-query.ts @@ -5,7 +5,7 @@ import type { PathResolver } from '../../services/path-resolver.js'; import type { IOutputFormatter } from '../../interfaces/output-formatter.js'; import type { LoreConfig } from '../../types/config.js'; import type { TrailerKey, LoreAtom, SupersessionStatus } from '../../types/domain.js'; -import type { PathQueryOptions, QueryResult, TargetType } from '../../types/query.js'; +import type { QueryOptions, QueryResult, TargetType } from '../../types/query.js'; import type { FormattableQueryResult } from '../../types/output.js'; import { buildQueryMeta } from './build-query-meta.js'; @@ -37,6 +37,7 @@ export interface PathQueryCommandOptions { readonly limit?: number; readonly maxCommits?: number; readonly since?: string; + readonly until?: string; } /** @@ -55,14 +56,15 @@ export async function executePathQuery( ): Promise { const { atomRepository, supersessionResolver, pathResolver, getFormatter, config } = deps; - const queryOptions: PathQueryOptions = { + const queryOptions: QueryOptions = { scope: options.scope ?? null, - follow: options.follow ?? false, - all: options.all ?? false, + followLinks: options.follow ?? false, + includeSuperseded: options.all ?? false, author: options.author ?? null, limit: options.limit ?? null, maxCommits: options.maxCommits ?? null, since: options.since ?? null, + until: options.until ?? null, }; // Step 1: Resolve target or use --scope @@ -71,7 +73,7 @@ export async function executePathQuery( let targetDisplay: string; if (queryOptions.scope) { - atoms = await atomRepository.findByScope(queryOptions.scope, queryOptions); + atoms = await atomRepository.findAll(queryOptions); targetType = 'global'; targetDisplay = `scope:${queryOptions.scope}`; } else { @@ -83,7 +85,7 @@ export async function executePathQuery( } // Step 2: Follow links if requested - if (queryOptions.follow && atoms.length > 0) { + if (queryOptions.followLinks && atoms.length > 0) { atoms = await atomRepository.resolveFollowLinks(atoms, config.follow.maxDepth); } @@ -94,14 +96,14 @@ export async function executePathQuery( // Step 4: Filter superseded atoms unless --all let displayAtoms: readonly LoreAtom[]; - if (queryOptions.all) { + if (queryOptions.includeSuperseded) { displayAtoms = atoms; } else { displayAtoms = supersessionResolver.filterActive(atoms, supersessionMap); } // Step 4b: Apply result limit (--limit) after supersession filtering - if (queryOptions.limit !== null && queryOptions.limit > 0) { + if (queryOptions.limit !== null && queryOptions.limit !== undefined && queryOptions.limit > 0) { displayAtoms = displayAtoms.slice(0, queryOptions.limit); } diff --git a/src/commands/log.ts b/src/commands/log.ts index 57c2e7c2..ab4eaf30 100644 --- a/src/commands/log.ts +++ b/src/commands/log.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import type { AtomRepository } from '../services/atom-repository.js'; import type { SupersessionResolver } from '../services/supersession-resolver.js'; import type { IOutputFormatter } from '../interfaces/output-formatter.js'; -import type { PathQueryOptions, QueryResult } from '../types/query.js'; +import type { QueryOptions, QueryResult } from '../types/query.js'; import type { LoreAtom } from '../types/domain.js'; import type { FormattableQueryResult } from '../types/output.js'; import { mergeOptions } from './helpers/merge-options.js'; @@ -13,6 +13,7 @@ interface LogCommandOptions { readonly limit?: number; readonly maxCommits?: number; readonly since?: string; + readonly until?: string; } /** @@ -42,24 +43,23 @@ export function registerLogCommand( if (paths.length > 0) { // Use git-level path filtering via findByTarget (#24) - const queryOptions: PathQueryOptions = { + const queryOptions: QueryOptions = { scope: null, - follow: false, - all: false, + followLinks: false, + includeSuperseded: false, author: null, limit: null, maxCommits: options.maxCommits ?? null, since: options.since ?? null, + until: options.until ?? null, }; atoms = await atomRepository.findByTarget(['--', ...paths], queryOptions); } else { - const findOptions: { since?: string; maxCommits?: number } = {}; - if (options.since) { - findOptions.since = options.since; - } - if (options.maxCommits !== undefined && options.maxCommits > 0) { - findOptions.maxCommits = options.maxCommits; - } + const findOptions: QueryOptions = { + ...(options.since ? { since: options.since } : {}), + ...(options.until ? { until: options.until } : {}), + ...(options.maxCommits !== undefined && options.maxCommits > 0 ? { maxCommits: options.maxCommits } : {}), + }; atoms = await atomRepository.findAll(findOptions); } diff --git a/src/commands/search.ts b/src/commands/search.ts index 4fa00476..ab7336f8 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,10 +1,9 @@ import type { Command } from 'commander'; import type { AtomRepository } from '../services/atom-repository.js'; import type { SupersessionResolver } from '../services/supersession-resolver.js'; -import type { SearchFilter } from '../services/search-filter.js'; import type { IOutputFormatter } from '../interfaces/output-formatter.js'; import type { TrailerKey, LoreAtom, SupersessionStatus } from '../types/domain.js'; -import type { SearchOptions, QueryResult } from '../types/query.js'; +import type { QueryOptions, QueryResult } from '../types/query.js'; import type { FormattableQueryResult } from '../types/output.js'; import { mergeOptions } from './helpers/merge-options.js'; import { buildQueryMeta } from './helpers/build-query-meta.js'; @@ -34,7 +33,6 @@ export function registerSearchCommand( deps: { atomRepository: AtomRepository; supersessionResolver: SupersessionResolver; - searchFilter: SearchFilter; getFormatter: () => IOutputFormatter; }, ): void { @@ -55,12 +53,12 @@ export function registerSearchCommand( .option('--max-commits ', 'Maximum git commits to scan (supersession may be incomplete)', parsePositiveInt) .action(async (_options: SearchCommandOptions, command: Command) => { const options = mergeOptions(command); - const { atomRepository, supersessionResolver, searchFilter, getFormatter } = deps; + const { atomRepository, supersessionResolver, getFormatter } = deps; - const searchOptions: SearchOptions = { - confidence: (options.confidence as SearchOptions['confidence']) ?? null, - scopeRisk: (options.scopeRisk as SearchOptions['scopeRisk']) ?? null, - reversibility: (options.reversibility as SearchOptions['reversibility']) ?? null, + const searchOptions: QueryOptions = { + confidence: (options.confidence as QueryOptions['confidence']) ?? null, + scopeRisk: (options.scopeRisk as QueryOptions['scopeRisk']) ?? null, + reversibility: (options.reversibility as QueryOptions['reversibility']) ?? null, has: (options.has as TrailerKey) ?? null, author: options.author ?? null, scope: options.scope ?? null, @@ -76,11 +74,15 @@ export function registerSearchCommand( since: searchOptions.since ?? undefined, until: searchOptions.until ?? undefined, maxCommits: searchOptions.maxCommits ?? undefined, + author: searchOptions.author, + scope: searchOptions.scope, + has: searchOptions.has, + confidence: searchOptions.confidence, + scopeRisk: searchOptions.scopeRisk, + reversibility: searchOptions.reversibility, + text: searchOptions.text, }); - // Apply filters via SearchFilter service - atoms = searchFilter.applyFilters(atoms, searchOptions); - // Compute supersession on full set so each atom's status is available to the formatter const supersessionMap: Map = supersessionResolver.resolve(atoms); @@ -95,7 +97,7 @@ export function registerSearchCommand( } // Apply result limit (--limit) after all filtering and supersession - if (searchOptions.limit !== null && searchOptions.limit > 0) { + if (searchOptions.limit !== null && searchOptions.limit !== undefined && searchOptions.limit > 0) { displayAtoms = displayAtoms.slice(0, searchOptions.limit); } @@ -118,7 +120,7 @@ export function registerSearchCommand( }); } -function buildSearchTargetDescription(options: SearchOptions): string { +function buildSearchTargetDescription(options: QueryOptions): string { const parts: string[] = []; if (options.confidence) parts.push(`confidence=${options.confidence}`); diff --git a/src/commands/stale.ts b/src/commands/stale.ts index 87b31b4a..eaa4febb 100644 --- a/src/commands/stale.ts +++ b/src/commands/stale.ts @@ -5,7 +5,7 @@ import type { StalenessDetector } from '../services/staleness-detector.js'; import type { PathResolver } from '../services/path-resolver.js'; import type { IOutputFormatter } from '../interfaces/output-formatter.js'; import type { LoreAtom, SupersessionStatus } from '../types/domain.js'; -import type { PathQueryOptions } from '../types/query.js'; +import type { QueryOptions } from '../types/query.js'; import type { FormattableStalenessResult, StaleAtomReport } from '../types/output.js'; import { STALE_SIGNAL } from '../util/constants.js'; import { mergeOptions } from './helpers/merge-options.js'; @@ -46,14 +46,15 @@ export function registerStaleCommand( if (target) { const parsedTarget = pathResolver.parseTarget(target); const gitLogArgs = pathResolver.toGitLogArgs(parsedTarget); - const queryOptions: PathQueryOptions = { + const queryOptions: QueryOptions = { scope: null, - follow: false, - all: false, + followLinks: false, + includeSuperseded: false, author: null, limit: null, maxCommits: null, since: null, + until: null, }; atoms = await atomRepository.findByTarget(gitLogArgs, queryOptions); } else { diff --git a/src/interfaces/git-client.ts b/src/interfaces/git-client.ts index 78a724b5..bb37f197 100644 --- a/src/interfaces/git-client.ts +++ b/src/interfaces/git-client.ts @@ -33,5 +33,6 @@ export interface IGitClient { getFilesChanged(commitHash: string): Promise; countCommitsSince(path: string, sinceCommitHash: string): Promise; resolveRef(ref: string): Promise; + resolveDate(dateStr: string): Promise; getHeadMessage(): Promise; } diff --git a/src/main.ts b/src/main.ts index c027262e..d7d35cc9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { TrailerParser } from './services/trailer-parser.js'; import { PathResolver } from './services/path-resolver.js'; import { LoreIdGenerator } from './services/lore-id-generator.js'; import { ConfigLoader } from './services/config-loader.js'; +import { SearchFilter } from './services/search-filter.js'; import { AtomRepository } from './services/atom-repository.js'; import { SupersessionResolver } from './services/supersession-resolver.js'; import { StalenessDetector } from './services/staleness-detector.js'; @@ -24,7 +25,6 @@ import { Validator } from './services/validator.js'; import { TerminalPrompt } from './services/terminal-prompt.js'; import { CommitInputResolver } from './services/commit-input-resolver.js'; import { HeadLoreIdReader } from './services/head-lore-id-reader.js'; -import { SearchFilter } from './services/search-filter.js'; import { TextFormatter } from './formatters/text-formatter.js'; import { JsonFormatter } from './formatters/json-formatter.js'; @@ -93,13 +93,13 @@ async function main(): Promise { } // 4. Create services that depend on others - const atomRepository = new AtomRepository(gitClient, trailerParser, config.trailers.custom); + const searchFilter = new SearchFilter(); + const atomRepository = new AtomRepository(gitClient, trailerParser, searchFilter, config.trailers.custom); const supersessionResolver = new SupersessionResolver(); const stalenessDetector = new StalenessDetector(gitClient, config); const commitBuilder = new CommitBuilder(trailerParser, loreIdGenerator, config); const squashMerger = new SquashMerger(loreIdGenerator); const validator = new Validator(trailerParser, atomRepository, config); - const searchFilter = new SearchFilter(); const prompt = new TerminalPrompt(); const commitInputResolver = new CommitInputResolver(prompt); const headLoreIdReader = new HeadLoreIdReader(gitClient, trailerParser); @@ -148,7 +148,6 @@ async function main(): Promise { registerSearchCommand(program, { atomRepository, supersessionResolver, - searchFilter, getFormatter, }); diff --git a/src/services/atom-repository.ts b/src/services/atom-repository.ts index a636ba9c..afb77d3f 100644 --- a/src/services/atom-repository.ts +++ b/src/services/atom-repository.ts @@ -1,8 +1,20 @@ import type { IGitClient, RawCommit } from '../interfaces/git-client.js'; -import type { PathQueryOptions } from '../types/query.js'; +import type { QueryOptions, DiscoveryOptions } from '../types/query.js'; import type { LoreAtom, LoreId, LoreTrailers } from '../types/domain.js'; import type { TrailerParser } from '../services/trailer-parser.js'; -import { LORE_ID_PATTERN, REFERENCE_TRAILER_KEYS, GIT_FILES_CHANGED_BATCH_SIZE } from '../util/constants.js'; +import type { SearchFilter, FilterOptions } from '../services/search-filter.js'; +import { + LORE_ID_PATTERN, + REFERENCE_TRAILER_KEYS, + GIT_FILES_CHANGED_BATCH_SIZE, +} from '../util/constants.js'; +import { escapeRegex } from '../util/regex.js'; + +/** + * Internal extension of QueryOptions that includes pre-resolved dates. + * This ensures the Authoritative Pass in SearchFilter is deterministic. + */ +interface ResolvedQueryOptions extends QueryOptions, FilterOptions {} /** * Retrieves LoreAtoms from git history. @@ -15,6 +27,7 @@ export class AtomRepository { constructor( private readonly gitClient: IGitClient, private readonly trailerParser: TrailerParser, + private readonly searchFilter: SearchFilter, private readonly customTrailerKeys: readonly string[] = [], ) {} @@ -23,12 +36,13 @@ export class AtomRepository { * Accepts pre-resolved git log args (from PathResolver) so that * path resolution is the caller's responsibility (DIP). */ - async findByTarget(gitLogArgs: readonly string[], options: PathQueryOptions): Promise { - const logArgs = this.buildLogArgs(options); + async findByTarget(gitLogArgs: readonly string[], options: QueryOptions): Promise { + const resolved = await this.resolveQueryDates(options); + const logArgs = this.buildLogArgs(resolved); const allArgs = [...logArgs, ...gitLogArgs]; const rawCommits = await this.gitClient.log(allArgs); const atoms = await this.parseRawCommits(rawCommits); - return this.applyFilters(atoms, options); + return this.searchFilter.applyFilters(atoms, resolved); } /** @@ -41,10 +55,19 @@ export class AtomRepository { return null; } - const logArgs = ['--all', `--grep=Lore-id: ${loreId}`]; + const logArgs = [ + '--all', + '--extended-regexp', + '--regexp-ignore-case', + '--all-match', + `--grep=^Lore-id: ${escapeRegex(loreId)}`, + ]; const rawCommits = await this.gitClient.log(logArgs); const atoms = await this.parseRawCommits(rawCommits); + // Authoritative Final Pass: Ensure the parsed ID actually matches the target. + // This protects against "cross-talk" where the target ID appeared in the + // commit body, but the actual trailer block contains a different valid ID. return atoms.find((atom) => atom.loreId === loreId) ?? null; } @@ -69,40 +92,32 @@ export class AtomRepository { } /** - * Find all Lore atoms, optionally filtered by date range and limit. + * Find Lore atoms across the entire repository. + * + * Uses "Atom Discovery Mode" to push filters (Lore-id, author, scope) down to + * the Git layer for optimized performance on large repositories. */ - async findAll(options: { since?: string; until?: string; maxCommits?: number } = {}): Promise { - const args = this.buildBaseLogArgs(); - - if (options.since) { - args.push(`--since=${options.since}`); - } - if (options.until) { - args.push(`--until=${options.until}`); - } - if (options.maxCommits !== undefined && options.maxCommits > 0) { - args.push(`--max-count=${options.maxCommits}`); - } - + async findAll(options: DiscoveryOptions = {}): Promise { + const queryOptions = this.makeDefaultOptions(options); + const resolved = await this.resolveQueryDates(queryOptions); + const args = this.buildLogArgs(resolved); const rawCommits = await this.gitClient.log(args); - return this.parseRawCommits(rawCommits); + const atoms = await this.parseRawCommits(rawCommits); + return this.searchFilter.applyFilters(atoms, resolved); } /** - * Find atoms matching a conventional commit scope. - * Parses the subject line to extract scope from `type(scope): description`. + * Resolve symbolic dates (relative, refs, ISO) into absolute JS Date objects. */ - async findByScope(scope: string, options: PathQueryOptions): Promise { - const logArgs = this.buildLogArgs(options); - const rawCommits = await this.gitClient.log(logArgs); - const atoms = await this.parseRawCommits(rawCommits); + private async resolveQueryDates(options: QueryOptions): Promise { + const sinceDate = options.since ? await this.gitClient.resolveDate(options.since) : null; + const untilDate = options.until ? await this.gitClient.resolveDate(options.until) : null; - const scopeFiltered = atoms.filter((atom) => { - const extractedScope = this.extractScope(atom.intent); - return extractedScope !== null && extractedScope.toLowerCase() === scope.toLowerCase(); - }); - - return this.applyFilters(scopeFiltered, options); + return { + ...options, + sinceDate, + untilDate, + }; } /** @@ -161,27 +176,76 @@ export class AtomRepository { } /** - * Build the base git log format arguments. - * Uses NUL-separated fields for reliable parsing. + * Build git log arguments including optional filters from DiscoveryOptions. + * Uses optimized coarse discovery by pushing filters to the Git layer. + * + * IMPORTANT: Git's `--grep` matches against the entire commit message. + * These patterns are designed for speed but may produce false positives if + * the text appears in the body or non-Lore trailers. The + * `searchFilter.applyFilters` method must always perform a second + * authoritative pass for precision. + * + * This method adds `--all-match`, `--extended-regexp`, and a mandatory + * Lore-id grep pattern. Any additional `--grep` patterns added by callers will + * be joined with AND semantics. */ - private buildBaseLogArgs(): string[] { - return []; - } + private buildLogArgs(options: ResolvedQueryOptions): string[] { + const args: string[] = []; - /** - * Build git log arguments including optional filters from PathQueryOptions. - */ - private buildLogArgs(options: PathQueryOptions): string[] { - const args = this.buildBaseLogArgs(); + if (options.sinceDate) { + args.push(`--since=${options.sinceDate.toISOString()}`); + } - if (options.since) { - args.push(`--since=${options.since}`); + if (options.untilDate) { + args.push(`--until=${options.untilDate.toISOString()}`); } - if (options.maxCommits !== null && options.maxCommits > 0) { + + if (options.maxCommits !== null && options.maxCommits !== undefined && options.maxCommits > 0) { args.push(`--max-count=${options.maxCommits}`); } + + // Atom Discovery Mode: + // We always include a check for a valid Lore-id so Git only returns valid atoms. + // This allows us to skip non-Lore commits (merges, chores, etc.) at the Git layer. + args.push('--grep=^Lore-id: [0-9a-f]{8}'); + args.push('--extended-regexp'); + args.push('--regexp-ignore-case'); + + // Use --all-match to ensure the Lore-id AND any other filters match (Lore semantics) + args.push('--all-match'); + if (options.author) { - args.push(`--author=${options.author}`); + args.push(`--author=${escapeRegex(options.author)}`); + } + + if (options.scope) { + // Improved precision: Match conventional commit type prefix and start of line. + // Note: Git matches anywhere; searchFilter.applyFilters ensures we only match the intent line. + args.push(`--grep=^[a-zA-Z]+\\(${escapeRegex(options.scope)}\\)`); + } + + if (options.has) { + // Push trailer existence check to Git layer. Matches any line starting with key. + args.push(`--grep=^${escapeRegex(options.has)}: `); + } + + if (options.confidence) { + args.push(`--grep=^Confidence: ${escapeRegex(options.confidence)}`); + } + + if (options.scopeRisk) { + args.push(`--grep=^Scope-risk: ${escapeRegex(options.scopeRisk)}`); + } + + if (options.reversibility) { + args.push(`--grep=^Reversibility: ${escapeRegex(options.reversibility)}`); + } + + if (options.text) { + // Push coarse full-text search to Git. + // Note: We use the raw text for a broad match; SearchFilter + // provides the authoritative precision pass. + args.push(`--grep=${escapeRegex(options.text)}`); } return args; @@ -189,16 +253,26 @@ export class AtomRepository { /** * Parse an array of RawCommit into LoreAtom[], filtering out non-Lore commits. + * + * This is the Authoritative Structural Pass. It verifies that commits found + * during the coarse Discovery Phase actually contain valid Lore trailers. + * + * It handles several "False Positive" scenarios from Git's --grep: + * 1. Body Matches: Git matches any line; we ensure trailers are in the trailer block. + * 2. Case Discrepancy: Git grep is case-insensitive; we enforce strict hex casing. + * 3. Structural Validity: We ensure the Lore-id follows the precise 8-char format. */ private async parseRawCommits(rawCommits: readonly RawCommit[]): Promise { // First pass: filter to Lore commits and parse trailers (synchronous work) const loreCommits: Array<{ raw: RawCommit; trailers: LoreTrailers }> = []; for (const raw of rawCommits) { + // 1. Structural Pass: Does the trailer block contain Lore keys? if (!this.trailerParser.containsLoreTrailers(raw.trailers)) { continue; } + // 2. Strict Pass: Parse and validate the Lore-id precisely. const trailers = this.trailerParser.parse(raw.trailers, this.customTrailerKeys); if (!LORE_ID_PATTERN.test(trailers['Lore-id'])) { continue; @@ -268,38 +342,25 @@ export class AtomRepository { } /** - * Apply post-query filters (author, since) that weren't handled at the git level. - * Note: author and since are also passed to git log, but this provides a second - * layer of filtering for edge cases. + * Create a complete QueryOptions object from partial overrides. */ - private applyFilters(atoms: LoreAtom[], options: PathQueryOptions): LoreAtom[] { - let result = atoms; - - if (options.author) { - const authorLower = options.author.toLowerCase(); - result = result.filter( - (atom) => atom.author.toLowerCase().includes(authorLower), - ); - } - - if (options.since) { - const sinceDate = new Date(options.since); - if (!isNaN(sinceDate.getTime())) { - result = result.filter((atom) => atom.date >= sinceDate); - } - } - - return result; - } - - /** - * Extract the scope from a conventional commit subject line. - * Pattern: `type(scope): description` - * Returns null if no scope is found. - */ - private extractScope(subject: string): string | null { - const match = subject.match(/^[a-zA-Z]+\(([^)]+)\)/); - return match ? match[1] : null; + private makeDefaultOptions(overrides: DiscoveryOptions = {}): QueryOptions { + return { + scope: null, + followLinks: false, + includeSuperseded: false, + author: null, + has: null, + confidence: null, + scopeRisk: null, + reversibility: null, + text: null, + limit: null, + maxCommits: null, + since: null, + until: null, + ...overrides, + }; } /** diff --git a/src/services/git-client.ts b/src/services/git-client.ts index 22fa871a..9f2b7341 100644 --- a/src/services/git-client.ts +++ b/src/services/git-client.ts @@ -149,6 +149,41 @@ export class GitClient implements IGitClient { return stdout.trim(); } + async resolveDate(dateStr: string): Promise { + // 1. INTUITIVE: Handle YYYY-MM-DD as LOCAL MIDNIGHT. + // This fixes Git's quirk where it resolves YYYY-MM-DD to "Now". + const isoMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (isoMatch) { + const year = parseInt(isoMatch[1], 10); + const month = parseInt(isoMatch[2], 10) - 1; // 0-indexed + const day = parseInt(isoMatch[3], 10); + return new Date(year, month, day); + } + + // 2. FAST: Handle full ISO-8601 natively in JS. + const jsDate = new Date(dateStr); + if (!isNaN(jsDate.getTime())) { + return jsDate; + } + + // 3. SMART: Resolve commit refs/hashes (Plumbing: show -s) + try { + const refTs = await this.exec(['show', '-s', '--format=%at', dateStr]); + const ts = parseInt(refTs.trim(), 10); + return !isNaN(ts) ? new Date(ts * 1000) : null; + } catch { + // 4. FALLBACK: Relative strings (Git rev-parse) + try { + const output = await this.exec(['rev-parse', `--since=${dateStr}`]); + const tsMatch = output.match(/=(\d+)/); + const ts = tsMatch ? parseInt(tsMatch[1], 10) : NaN; + return !isNaN(ts) ? new Date(ts * 1000) : null; + } catch { + return null; + } + } + } + /** * Parse the standard git log output with our custom format. * Records are separated by double null bytes; fields within a record diff --git a/src/services/search-filter.ts b/src/services/search-filter.ts index 87e5b94a..086bcf43 100644 --- a/src/services/search-filter.ts +++ b/src/services/search-filter.ts @@ -1,51 +1,80 @@ import type { LoreAtom, TrailerKey } from '../types/domain.js'; -import type { SearchOptions } from '../types/query.js'; +import type { DiscoveryOptions } from '../types/query.js'; import { ARRAY_TRAILER_KEYS, ENUM_TRAILER_KEYS } from '../util/constants.js'; /** - * Applies search filters to a collection of LoreAtoms. + * Internal options for filtering that include pre-resolved absolute dates. + */ +export interface FilterOptions extends DiscoveryOptions { + readonly sinceDate?: Date | null; + readonly untilDate?: Date | null; +} + +/** + * Applies authoritative application-level filtering to a collection of LoreAtoms. * * GRASP: Information Expert -- knows how to match atoms against search criteria. - * SRP: Only filtering logic, no git interaction or formatting. + * SRP: Only filtering logic, no git interaction or persistence. + * + * This service provides the "Authoritative Pass" in the Lore Discovery Mode + * pipeline, ensuring absolute precision after Git's coarse --grep pass. */ export class SearchFilter { /** - * Apply all active search filters to the atom list. + * Apply application-level filtering to the parsed atoms. + * + * Note: author, since, and until are also passed to git log (coarse filter) + * in the AtomRepository, but this method provides the authoritative second + * layer of filtering for absolute precision and Lore-specific semantics. */ - applyFilters(atoms: readonly LoreAtom[], options: SearchOptions): LoreAtom[] { + applyFilters(atoms: readonly LoreAtom[], options: FilterOptions): LoreAtom[] { let result = [...atoms]; - if (options.confidence !== null) { + if (options.confidence !== null && options.confidence !== undefined) { result = result.filter((a) => a.trailers.Confidence === options.confidence); } - if (options.scopeRisk !== null) { + if (options.scopeRisk !== null && options.scopeRisk !== undefined) { result = result.filter((a) => a.trailers['Scope-risk'] === options.scopeRisk); } - if (options.reversibility !== null) { + if (options.reversibility !== null && options.reversibility !== undefined) { result = result.filter((a) => a.trailers.Reversibility === options.reversibility); } - if (options.has !== null) { - const trailerKey = options.has; - result = result.filter((a) => this.atomHasTrailer(a, trailerKey)); + if (options.has !== null && options.has !== undefined) { + result = result.filter((a) => this.atomHasTrailer(a, options.has!)); } - if (options.author !== null) { + if (options.author !== null && options.author !== undefined) { + // Authoritative pass: Git --author matches full "Name "; Lore + // atoms only store the email (%ae). This pass ensures consistency + // with the Lore display. const authorLower = options.author.toLowerCase(); - result = result.filter((a) => a.author.toLowerCase().includes(authorLower)); + result = result.filter((atom) => atom.author.toLowerCase().includes(authorLower)); } - if (options.scope !== null) { - const scope = options.scope.toLowerCase(); + if (options.scope !== null && options.scope !== undefined) { + // Precise pass: Git --grep might match code snippets in the body. + // This pass ensures we only match the actual intent line's scope. + const scopeLower = options.scope.toLowerCase(); result = result.filter((a) => { - const match = a.intent.match(/^[a-zA-Z]+\(([^)]+)\)/); - return match !== null && match[1].toLowerCase() === scope; + const extracted = this.extractScope(a.intent); + return extracted !== null && extracted.toLowerCase() === scopeLower; }); } - if (options.text !== null) { + if (options.sinceDate) { + result = result.filter((atom) => atom.date >= options.sinceDate!); + } + + if (options.untilDate) { + result = result.filter((atom) => atom.date <= options.untilDate!); + } + + if (options.text !== null && options.text !== undefined) { + // Semantic pass: Git matches keywords anywhere. This pass precisely + // checks context (intent, body, specific Lore trailers). const textLower = options.text.toLowerCase(); result = result.filter((a) => this.atomMatchesText(a, textLower)); } @@ -89,7 +118,8 @@ export class SearchFilter { // Check array trailers for (const key of ARRAY_TRAILER_KEYS) { - for (const value of trailers[key]) { + const values = trailers[key] as readonly string[]; + for (const value of values) { if (value.toLowerCase().includes(textLower)) return true; } } @@ -102,4 +132,14 @@ export class SearchFilter { return false; } + + /** + * Extract the scope from a conventional commit subject line. + * Pattern: `type(scope): description` + * Returns null if no scope is found. + */ + private extractScope(subject: string): string | null { + const match = subject.match(/^[a-zA-Z]+\(([^)]+)\)/); + return match ? match[1] : null; + } } diff --git a/src/types/query.ts b/src/types/query.ts index c2b37276..84e8226c 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -10,29 +10,38 @@ export interface QueryTarget { readonly lineEnd: number | null; } -export interface PathQueryOptions { - readonly scope: string | null; - readonly follow: boolean; - readonly all: boolean; - readonly author: string | null; - /** Result-level cap applied by the command layer after querying. Not used by the repository. */ - readonly limit: number | null; - readonly maxCommits: number | null; - readonly since: string | null; +export interface DiscoveryOptions { + /** Filter by conventional commit scope. */ + readonly scope?: string | null; + /** Filter by author email (partial match supported). */ + readonly author?: string | null; + /** Filter by presence of a specific trailer key. */ + readonly has?: TrailerKey | null; + /** Filter by confidence level. */ + readonly confidence?: ConfidenceLevel | null; + /** Filter by scope-risk level. */ + readonly scopeRisk?: ScopeRiskLevel | null; + /** Filter by reversibility level. */ + readonly reversibility?: ReversibilityLevel | null; + /** Full-text search across intent, body, and trailer values. */ + readonly text?: string | null; + readonly since?: string | null; + readonly until?: string | null; + readonly maxCommits?: number | null; } -export interface SearchOptions { - readonly confidence: ConfidenceLevel | null; - readonly scopeRisk: ScopeRiskLevel | null; - readonly reversibility: ReversibilityLevel | null; - readonly has: TrailerKey | null; - readonly author: string | null; - readonly scope: string | null; - readonly text: string | null; - readonly since: string | null; - readonly until: string | null; - readonly limit: number | null; - readonly maxCommits: number | null; +/** + * Unified options for any Lore query (path-based or search-based). + */ +export interface QueryOptions extends DiscoveryOptions { + /** Result-level cap applied by the command layer (after querying). */ + readonly limit?: number | null; + + /** Include superseded entries in the result. */ + readonly includeSuperseded?: boolean; + + /** Transitively follow Related/Supersedes/Depends-on links (Path queries only). */ + readonly followLinks?: boolean; } export interface QueryResult { diff --git a/src/util/regex.ts b/src/util/regex.ts new file mode 100644 index 00000000..46385a33 --- /dev/null +++ b/src/util/regex.ts @@ -0,0 +1,7 @@ +/** + * Escape special characters in a string for use in a regular expression. + * Based on MDN implementation. + */ +export function escapeRegex(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} diff --git a/tests/integration/git-discovery.test.ts b/tests/integration/git-discovery.test.ts new file mode 100644 index 00000000..d8023338 --- /dev/null +++ b/tests/integration/git-discovery.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execSync } from 'node:child_process'; +import { rmSync, mkdirSync, writeFileSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { SearchFilter } from "../../src/services/search-filter.js"; +import { AtomRepository } from '../../src/services/atom-repository.js'; +import { GitClient } from '../../src/services/git-client.js'; +import { TrailerParser } from '../../src/services/trailer-parser.js'; + +describe('AtomRepository Git Integration', () => { + const testDir = realpathSync(tmpdir()) + '/lore-git-test-' + Math.random().toString(36).slice(2); + let repo: AtomRepository; + + beforeAll(() => { + // Setup a clean git repo + rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + + const run = (cmd: string) => execSync(cmd, { cwd: testDir }); + + run('git init -b main'); + run('git config user.name "Test User"'); + run('git config user.email "test@example.com"'); + + // 1. Valid Lore Atom + writeFileSync(join(testDir, 'file1.txt'), 'content1'); + run('git add .'); + run('git commit -m "feat(auth): login feature\n\nLore-id: 00000001\nConfidence: high"'); + + // Add delay to ensure distinct timestamps for --since tests + run('sleep 1.1'); + + // 2. Another Valid Lore Atom (different scope/author) + run('git config user.name "Other User"'); + writeFileSync(join(testDir, 'file2.txt'), 'content2'); + run('git add .'); + run('git commit -m "fix(ui): layout\n\nLore-id: 00000002\nConfidence: low"'); + + // 3. Non-Lore Commit (Should be filtered by discovery mode) + run('sleep 1.1'); + writeFileSync(join(testDir, 'file3.txt'), 'content3'); + run('git add .'); + run('git commit -m "chore: just a cleanup"'); + + // 4. False Positive (Has Lore-id in body, but not formatted as trailer) + // Actually, our Discovery Mode regex is '^Lore-id: ', so we'll test that. + writeFileSync(join(testDir, 'file4.txt'), 'content4'); + run('git add .'); + run('git commit -m "feat: fake\n\nNot really a Lore-id: 12345678"'); + + const gitClient = new GitClient(testDir); + const trailerParser = new TrailerParser(); + + const searchFilter = new SearchFilter(); + + repo = new AtomRepository( + gitClient, + trailerParser, searchFilter, + ); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('Discovery Mode: should only return commits with valid Lore-id trailers', async () => { + const result = await repo.findAll({}); + // Should find #1 and #2, but not #3 (chore) or #4 (fake trailer) + expect(result).toHaveLength(2); + const ids = result.map(a => a.loreId); + expect(ids).toContain('00000001'); + expect(ids).toContain('00000002'); + }); + + it('Coarse Filtering: should correctly filter by author at Git level', async () => { + const result = await repo.findAll({ author: 'test@example.com' }); + expect(result).toHaveLength(2); // Both lore atoms have same email + }); + + it('Coarse Filtering: should correctly filter by scope at Git level', async () => { + const result = await repo.findAll({ scope: 'auth' }); + expect(result).toHaveLength(1); + expect(result[0].loreId).toBe('00000001'); + }); + + it('Coarse Filtering: should handle AND logic (all-match) at Git level', async () => { + // Search for author "test@example.com" AND scope "ui" (should be 1) + const result2 = await repo.findAll({ author: 'test@example.com', scope: 'ui' }); + expect(result2).toHaveLength(1); + expect(result2[0].loreId).toBe('00000002'); + }); + + it('Coarse Filtering: should handle date-based filtering (since/until)', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const resultFuture = await repo.findAll({ since: tomorrow.toISOString() }); + expect(resultFuture).toHaveLength(0); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const resultPast = await repo.findAll({ since: yesterday.toISOString() }); + expect(resultPast).toHaveLength(2); + }); + + it('Coarse Filtering: should handle relative dates (e.g., "1 hour ago")', async () => { + const result = await repo.findAll({ since: '1 hour ago' }); + // Since we just created the commits in beforeAll, they should be found. + expect(result).toHaveLength(2); + }); + + it('Coarse Filtering: should handle commit references (e.g., "HEAD~2")', async () => { + // In our setup: + // HEAD = #4 (Fake) + // HEAD~1 = #3 (Chore) + // HEAD~2 = #2 (Atom 00000002) - Date of this commit + // HEAD~3 = #1 (Atom 00000001) - Date of this commit + + // Everything since the date of Atom 00000002 (inclusive) + const result = await repo.findAll({ since: 'HEAD~2' }); + expect(result).toHaveLength(1); + expect(result[0].loreId).toBe('00000002'); + }); + + it('Coarse Filtering: should handle until filtering with refs (e.g., "HEAD~1")', async () => { + // HEAD~1 is the chore commit (#3). + // Everything UNTIL chore commit should include both atoms #1 and #2. + const result = await repo.findAll({ until: 'HEAD~1' }); + expect(result).toHaveLength(2); + const ids = result.map(a => a.loreId); + expect(ids).toContain('00000001'); + expect(ids).toContain('00000002'); + }); + + it('Coarse Filtering: should handle commit hashes', async () => { + // Get the hash of Atom 00000002 + const atoms = await repo.findAll({}); + const atom2 = atoms.find(a => a.loreId === '00000002')!; + const hash = atom2.commitHash; + + // since that hash should include it + const resultSince = await repo.findAll({ since: hash }); + expect(resultSince.map(a => a.loreId)).toContain('00000002'); + + // short hash should also work + const resultShort = await repo.findAll({ since: hash.substring(0, 7) }); + expect(resultShort.map(a => a.loreId)).toContain('00000002'); + }); + + it('Coarse Filtering: should handle garbage date strings gracefully', async () => { + // Git resolves garbage to "now", so --since="garbage" should return nothing. + const result = await repo.findAll({ since: 'not-a-date-at-all' }); + expect(result).toHaveLength(0); + + // Git resolves garbage to "now", so --until="garbage" should return everything + // up to the current second (which is all commits in this test setup). + const resultUntil = await repo.findAll({ until: 'garbage-date' }); + expect(resultUntil).toHaveLength(2); + }); +}); diff --git a/tests/unit/repro-false-positive.integration.test.ts b/tests/unit/repro-false-positive.integration.test.ts new file mode 100644 index 00000000..ba8bdaf1 --- /dev/null +++ b/tests/unit/repro-false-positive.integration.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { execSync } from 'node:child_process'; +import { rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SearchFilter } from "../../src/services/search-filter.js"; +import { AtomRepository } from '../../src/services/atom-repository.js'; +import { GitClient } from '../../src/services/git-client.js'; +import { TrailerParser } from '../../src/services/trailer-parser.js'; + +describe('AtomRepository False Positive Repro', () => { + const testDir = join(process.cwd(), 'tests/tmp-repro-test'); + let repo: AtomRepository; + let gitClient: GitClient; + + beforeAll(() => { + rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + + const run = (cmd: string) => execSync(cmd, { cwd: testDir }); + + run('git init -b main'); + run('git config user.name "Test User"'); + run('git config user.email "test@example.com"'); + + // 1. Valid Lore Atom + writeFileSync(join(testDir, 'file1.txt'), 'content1'); + run('git add .'); + run('git commit -m "feat: valid\n\nLore-id: 00000001"'); + + // 2. False Positive (Lore-id in the middle of the body) + writeFileSync(join(testDir, 'file2.txt'), 'content2'); + run('git add .'); + run('git commit -m "feat: false positive\n\nMentioning Lore-id: 12345678 in the middle of a sentence."'); + + gitClient = new GitClient(testDir); + const trailerParser = new TrailerParser(); + + const searchFilter = new SearchFilter(); + repo = new AtomRepository( + gitClient, + trailerParser, searchFilter, + ); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('findByLoreId should use an anchored grep (repro failure)', async () => { + const logSpy = vi.spyOn(gitClient, 'log'); + + // Searching for the false positive ID + const result = await repo.findByLoreId('12345678'); + + // Correctness check: result should be null because it's not a real trailer + expect(result).toBeNull(); + + // Precision check: Git should NOT have returned any commits if we used anchors. + // But currently we don't, so Git WILL return the false positive commit, + // and parseRawCommits will have to filter it out. + // If we were using anchors, logSpy.mock.results[0].value (the RawCommit array) would be empty. + + const matchedCommits = await logSpy.mock.results[0].value; + // DESIRED: Git should NOT have returned any commits if we used anchors. + expect(matchedCommits).toHaveLength(0); + + logSpy.mockRestore(); + }); + + it('findAll should use an anchored grep (repro failure)', async () => { + const logSpy = vi.spyOn(gitClient, 'log'); + + await repo.findAll({}); + + const matchedCommits = await logSpy.mock.results[0].value; + // DESIRED: It should only match 1 commit (the valid one). + expect(matchedCommits).toHaveLength(1); + + logSpy.mockRestore(); + }); +}); diff --git a/tests/unit/services/atom-repository-refinement.test.ts b/tests/unit/services/atom-repository-refinement.test.ts new file mode 100644 index 00000000..60e32978 --- /dev/null +++ b/tests/unit/services/atom-repository-refinement.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SearchFilter } from "../../../src/services/search-filter.js"; +import { AtomRepository } from '../../../src/services/atom-repository.js'; +import { TrailerParser } from '../../../src/services/trailer-parser.js'; +import type { IGitClient, RawCommit } from '../../../src/interfaces/git-client.js'; +import type { QueryOptions } from '../../../src/types/query.js'; + +describe('AtomRepository Refinement', () => { + let gitClient: IGitClient; + let trailerParser: TrailerParser; + let repo: AtomRepository; + + beforeEach(() => { + gitClient = { + log: vi.fn(), + getFilesChanged: vi.fn().mockResolvedValue(['file.ts']), + } as any; + trailerParser = new TrailerParser(); + const searchFilter = new SearchFilter(); + repo = new AtomRepository(gitClient, trailerParser, searchFilter); + }); + + describe('stripTrailersFromBody (Internal Refinement)', () => { + it('should remove trailers even with varying whitespace', async () => { + const trailers = 'Lore-id: 12345678\nConfidence: high'; + const raw: RawCommit = { + hash: 'h1', + date: '2026-01-01T00:00:00Z', + author: 'a@b.com', + subject: 'feat: sub', + body: 'Main body text.\n\n Lore-id: 12345678 \n Confidence: high \n\n', + trailers: trailers, + }; + vi.mocked(gitClient.log).mockResolvedValue([raw]); + + const [atom] = await repo.findAll(); + expect(atom.body).toBe('Main body text.'); + }); + + it('should not strip text that looks like a trailer but is in the middle of the body', async () => { + const trailers = 'Lore-id: 12345678'; + const raw: RawCommit = { + hash: 'h1', + date: '2026-01-01T00:00:00Z', + author: 'a@b.com', + subject: 'feat: sub', + body: 'This line looks like a trailer:\nConstraint: must be fast\n\nBut the real one is here.\n\nLore-id: 12345678', + trailers: trailers, + }; + vi.mocked(gitClient.log).mockResolvedValue([raw]); + + const [atom] = await repo.findAll(); + expect(atom.body).toContain('Constraint: must be fast'); + expect(atom.body).not.toContain('Lore-id: 12345678'); + }); + + it('should handle empty bodies gracefully', async () => { + const trailers = 'Lore-id: 12345678'; + const raw: RawCommit = { + hash: 'h1', + date: '2026-01-01T00:00:00Z', + author: 'a@b.com', + subject: 'feat: sub', + body: trailers, + trailers: trailers, + }; + vi.mocked(gitClient.log).mockResolvedValue([raw]); + + const [atom] = await repo.findAll(); + expect(atom.body).toBe(''); + }); + }); + + describe('followLinks Integration (End-to-End)', () => { + it('should transitively resolve links when followLinks is enabled', async () => { + const trailersA = 'Lore-id: aaaaaaaa\nRelated: bbbbbbbb'; + const trailersB = 'Lore-id: bbbbbbbb'; + + const commitA: RawCommit = { + hash: 'hash-a', + date: '2026-01-01T00:00:00Z', + author: 'a@b.com', + subject: 'feat: a', + body: 'Main body a', + trailers: trailersA, + }; + const commitB: RawCommit = { + hash: 'hash-b', + date: '2026-01-02T00:00:00Z', + author: 'a@b.com', + subject: 'feat: b', + body: 'Main body b', + trailers: trailersB, + }; + + vi.mocked(gitClient.log) + .mockResolvedValueOnce([commitA]) + .mockResolvedValueOnce([commitB]); + + vi.mocked(gitClient.getFilesChanged) + .mockResolvedValue(['file.ts']); + + const options: QueryOptions = { + followLinks: true, + }; + + let atoms = await repo.findByTarget(['--', 'file.ts'], options); + if (options.followLinks) { + atoms = await repo.resolveFollowLinks(atoms, 1); + } + + expect(atoms).toHaveLength(2); + const ids = atoms.map(a => a.loreId); + expect(ids).toContain('aaaaaaaa'); + expect(ids).toContain('bbbbbbbb'); + + const secondCallArgs = vi.mocked(gitClient.log).mock.calls[1][0]; + expect(secondCallArgs).toContain('--grep=^Lore-id: bbbbbbbb'); + }); + }); + + describe('findByLoreId Robustness (The "Three Pass" System)', () => { + it('should correctly discard atoms where the target ID is in the body but trailers have a different ID', async () => { + const targetId = '11111111'; + const actualId = '22222222'; + + const commit: RawCommit = { + hash: 'h-cross-talk', + date: '2026-01-01T00:00:00Z', + author: 'a@b.com', + subject: 'feat: cross talk', + body: `Some text...\nLore-id: ${targetId}\n...more text.`, + trailers: `Lore-id: ${actualId}`, + }; + + vi.mocked(gitClient.log).mockResolvedValue([commit]); + vi.mocked(gitClient.getFilesChanged).mockResolvedValue(['file.ts']); + + const result = await repo.findByLoreId(targetId); + + expect(result).toBeNull(); + }); + + it('should find the atom when trailers match exactly', async () => { + const targetId = 'aaaaaaaa'; + const commit: RawCommit = { + hash: 'h-match', + date: '2026-01-01T00:00:00Z', + author: 'a@b.com', + subject: 'feat: match', + body: 'Main body', + trailers: `Lore-id: ${targetId}`, + }; + + vi.mocked(gitClient.log).mockResolvedValue([commit]); + vi.mocked(gitClient.getFilesChanged).mockResolvedValue(['file.ts']); + + const result = await repo.findByLoreId(targetId); + + expect(result).not.toBeNull(); + expect(result!.loreId).toBe(targetId); + }); + }); +}); diff --git a/tests/unit/services/atom-repository.test.ts b/tests/unit/services/atom-repository.test.ts index 45d502f7..cc27f0b8 100644 --- a/tests/unit/services/atom-repository.test.ts +++ b/tests/unit/services/atom-repository.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SearchFilter } from '../../../src/services/search-filter.js'; import { AtomRepository } from '../../../src/services/atom-repository.js'; import type { IGitClient, RawCommit } from '../../../src/interfaces/git-client.js'; import type { PathQueryOptions } from '../../../src/types/query.js'; @@ -89,6 +90,8 @@ function createMockGitClient(overrides: Partial = {}): IGitClient { getFilesChanged: vi.fn(async () => []), countCommitsSince: vi.fn(async () => 0), resolveRef: vi.fn(async () => 'abc123'), + resolveDate: vi.fn(async (date) => new Date(date)), + getHeadMessage: vi.fn(async () => 'feat(auth): initial commit'), ...overrides, }; } @@ -139,7 +142,8 @@ describe('AtomRepository', () => { beforeEach(() => { gitClient = createMockGitClient(); trailerParser = createMockTrailerParser(); - repo = new AtomRepository(gitClient, trailerParser as any); + const searchFilter = new SearchFilter(); + repo = new AtomRepository(gitClient, trailerParser as any, searchFilter); }); describe('findByTarget', () => { @@ -186,17 +190,19 @@ describe('AtomRepository', () => { await repo.findByTarget(makeGitLogArgs(), options); const logArgs = vi.mocked(gitClient.log).mock.calls[0][0]; - expect(logArgs).toContain('--author=alice@example.com'); + expect(logArgs).toContain('--author=alice@example\\.com'); }); it('should pass since filter to git log args', async () => { vi.mocked(gitClient.log).mockResolvedValue([]); + const testDate = new Date('2025-01-01'); + vi.mocked(gitClient.resolveDate).mockResolvedValue(testDate); const options = makeQueryOptions({ since: '2025-01-01' }); await repo.findByTarget(makeGitLogArgs(), options); const logArgs = vi.mocked(gitClient.log).mock.calls[0][0]; - expect(logArgs).toContain('--since=2025-01-01'); + expect(logArgs).toContain(`--since=${testDate.toISOString()}`); }); it('should pass maxCommits to git log args', async () => { @@ -317,20 +323,24 @@ describe('AtomRepository', () => { it('should pass since option to git log', async () => { vi.mocked(gitClient.log).mockResolvedValue([]); + const testDate = new Date('2025-01-01'); + vi.mocked(gitClient.resolveDate).mockResolvedValue(testDate); await repo.findAll({ since: '2025-01-01' }); const logArgs = vi.mocked(gitClient.log).mock.calls[0][0]; - expect(logArgs).toContain('--since=2025-01-01'); + expect(logArgs).toContain(`--since=${testDate.toISOString()}`); }); it('should pass until option to git log', async () => { vi.mocked(gitClient.log).mockResolvedValue([]); + const testDate = new Date('2025-06-01'); + vi.mocked(gitClient.resolveDate).mockResolvedValue(testDate); await repo.findAll({ until: '2025-06-01' }); const logArgs = vi.mocked(gitClient.log).mock.calls[0][0]; - expect(logArgs).toContain('--until=2025-06-01'); + expect(logArgs).toContain(`--until=${testDate.toISOString()}`); }); it('should pass maxCommits option to git log', async () => { @@ -359,14 +369,14 @@ describe('AtomRepository', () => { }); }); - describe('findByScope', () => { + describe('findAll with scope', () => { it('should find atoms matching the scope', async () => { const authCommit = makeLoreCommit({ subject: 'feat(auth): add login', loreId: 'aaaa1111' }); const dbCommit = makeLoreCommit({ subject: 'fix(database): fix query', loreId: 'bbbb2222' }); vi.mocked(gitClient.log).mockResolvedValue([authCommit, dbCommit]); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); - const result = await repo.findByScope('auth', makeQueryOptions()); + const result = await repo.findAll({ ...makeQueryOptions(), scope: 'auth' }); expect(result).toHaveLength(1); expect(result[0].loreId).toBe('aaaa1111'); @@ -377,7 +387,7 @@ describe('AtomRepository', () => { vi.mocked(gitClient.log).mockResolvedValue([commit]); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); - const result = await repo.findByScope('auth', makeQueryOptions()); + const result = await repo.findAll({ ...makeQueryOptions(), scope: 'auth' }); expect(result).toHaveLength(1); }); @@ -387,7 +397,7 @@ describe('AtomRepository', () => { vi.mocked(gitClient.log).mockResolvedValue([commit]); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); - const result = await repo.findByScope('payments', makeQueryOptions()); + const result = await repo.findAll({ ...makeQueryOptions(), scope: 'payments' }); expect(result).toEqual([]); }); @@ -397,7 +407,7 @@ describe('AtomRepository', () => { vi.mocked(gitClient.log).mockResolvedValue([commit]); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); - const result = await repo.findByScope('auth', makeQueryOptions()); + const result = await repo.findAll({ ...makeQueryOptions(), scope: 'auth' }); expect(result).toEqual([]); }); diff --git a/tests/unit/services/filtering-parity.test.ts b/tests/unit/services/filtering-parity.test.ts new file mode 100644 index 00000000..adac00f6 --- /dev/null +++ b/tests/unit/services/filtering-parity.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SearchFilter } from "../../../src/services/search-filter.js"; +import { AtomRepository } from '../../../src/services/atom-repository.js'; +import { TrailerParser } from '../../../src/services/trailer-parser.js'; +import type { IGitClient, RawCommit } from '../../../src/interfaces/git-client.js'; +import type { PathQueryOptions } from '../../../src/types/query.js'; + +describe('AtomRepository Filtering Parity', () => { + let gitClient: IGitClient; + let trailerParser: TrailerParser; + let repo: AtomRepository; + + const mockAtoms: RawCommit[] = [ + { + hash: 'hash1', + date: '2026-05-01T12:00:00Z', + author: 'cole@example.com', + subject: 'feat(auth): valid login', + body: 'Body text here', + trailers: 'Lore-id: abc12345\nConfidence: high', + }, + { + hash: 'hash2', + date: '2026-05-02T12:00:00Z', + author: 'ivan@example.com', + subject: 'fix(ui): layout bug', + body: 'Body text here', + trailers: 'Lore-id: def67890\nConfidence: low', + }, + { + hash: 'hash3', + date: '2026-05-03T12:00:00Z', + author: 'cole@example.com', + subject: 'feat(api): endpoint', + body: 'Search for "login" here but not in subject', + trailers: 'Lore-id: 01234567\nConfidence: medium', + }, + { + hash: 'hash4', + date: '2026-05-04T12:00:00Z', + author: 'other@example.com', + subject: 'chore: no lore here', + body: 'just text', + trailers: '', // Missing Lore-id + }, + { + hash: 'hash5', + date: '2026-05-05T12:00:00Z', + author: 'false-positive-login@example.com', + subject: 'feat(api): false positive', + body: 'No match here', + trailers: 'Lore-id: fedcba98\nConfidence: high\nConstraint: This commit matches in email only', + }, + ]; + + beforeEach(() => { + gitClient = { + log: vi.fn(), + resolveRef: vi.fn().mockResolvedValue('head-hash'), + getFilesChanged: vi.fn().mockResolvedValue(['src/file.ts']), + getCommitsByHashes: vi.fn(), + } as any; + + trailerParser = new TrailerParser(); + + const searchFilter = new SearchFilter(); + + repo = new AtomRepository( + gitClient, + trailerParser, searchFilter, + ); + }); + + describe('Discovery Phase (Git Coarse Filtering)', () => { + it('should always include Atom Discovery Mode flags (Lore-id sentinel)', async () => { + vi.mocked(gitClient.log).mockResolvedValue([]); + await repo.findAll(); + + const args = vi.mocked(gitClient.log).mock.calls[0][0]; + + expect(args).toContain('--grep=^Lore-id: [0-9a-f]{8}'); + expect(args).toContain('--extended-regexp'); + expect(args).toContain('--regexp-ignore-case'); + expect(args).toContain('--all-match'); + }); + + it('should generate correct Git flags for author and scope with Discovery Mode', async () => { + vi.mocked(gitClient.log).mockResolvedValue([]); + const options = { + author: 'cole', + scope: 'auth', + }; + + await repo.findAll(options); + const args = vi.mocked(gitClient.log).mock.calls[0][0]; + + expect(args).toContain('--author=cole'); + expect(args).toContain('--regexp-ignore-case'); + expect(args).toContain('--all-match'); + // Scope regex check + expect(args).toContain('--grep=^[a-zA-Z]+\\(auth\\)'); + expect(args).toContain('--extended-regexp'); + }); + + it('should generate correct Git flags for the "has" trailer filter', async () => { + vi.mocked(gitClient.log).mockResolvedValue([]); + await repo.findAll({ has: 'Constraint' }); + + const args = vi.mocked(gitClient.log).mock.calls[0][0]; + expect(args).toContain('--grep=^Constraint: '); + }); + + it('should generate correct Git flags for Enum filters (pushdown)', async () => { + vi.mocked(gitClient.log).mockResolvedValue([]); + await repo.findAll({ + confidence: 'high', + scopeRisk: 'narrow', + reversibility: 'clean', + }); + + const args = vi.mocked(gitClient.log).mock.calls[0][0]; + expect(args).toContain('--grep=^Confidence: high'); + expect(args).toContain('--grep=^Scope-risk: narrow'); + expect(args).toContain('--grep=^Reversibility: clean'); + }); + + it('should generate correct Git flags for full-text search (pushdown)', async () => { + vi.mocked(gitClient.log).mockResolvedValue([]); + await repo.findAll({ text: 'login logic' }); + + const args = vi.mocked(gitClient.log).mock.calls[0][0]; + expect(args).toContain('--grep=login logic'); + }); + + it('should escape regex special characters in scope and loreId (Security)', async () => { + vi.mocked(gitClient.log).mockResolvedValue([]); + + // Test scope escaping + await repo.findAll({ scope: 'auth)' }); + let args = vi.mocked(gitClient.log).mock.calls[0][0]; + // Expected: ^[a-zA-Z]+\(auth\)\) + // Since it's passed as a literal string to execFile, no extra JS backslashes are needed in the match + expect(args).toContain('--grep=^[a-zA-Z]+\\(auth\\)\\)'); + + // Test loreId in findByLoreId (which also uses escapeRegex) + await repo.findByLoreId('abc12345'); + args = vi.mocked(gitClient.log).mock.calls[1][0]; + expect(args).toContain('--grep=^Lore-id: abc12345'); + }); + }); + + describe('Refinement Phase (Lore Fine Filtering)', () => { + it('should correctly narrow results even if Git produces false positives', async () => { + // Simulate Git returning everything (no coarse filtering) + vi.mocked(gitClient.log).mockResolvedValue(mockAtoms); + + const result = await repo.findAll({ + author: 'cole', + scope: 'auth', + }); + + expect(result).toHaveLength(1); + expect(result[0].commitHash).toBe('hash1'); + }); + + it('should correctly refine results for Enums and Has', async () => { + vi.mocked(gitClient.log).mockResolvedValue(mockAtoms); + + // Only hash1 is high confidence + const resultConf = await repo.findAll({ confidence: 'high' }); + expect(resultConf).toHaveLength(2); // hash1 and hash5 + + // hash5 has a Constraint trailer + const resultHas = await repo.findAll({ has: 'Constraint' }); + expect(resultHas).toHaveLength(1); + expect(resultHas[0].commitHash).toBe('hash5'); + }); + + it('should correctly refine results for full-text search', async () => { + vi.mocked(gitClient.log).mockResolvedValue(mockAtoms); + + // Search for "login" which is in body of hash3 but not subject + const result = await repo.findAll({ text: 'login' }); + expect(result).toHaveLength(2); // hash1 (subject) and hash3 (body) + const hashes = result.map(a => a.commitHash); + expect(hashes).toContain('hash1'); + expect(hashes).toContain('hash3'); + }); + }); + + describe('Integration of Filters', () => { + it('behaves as an AND operation across different filter types', async () => { + vi.mocked(gitClient.log).mockResolvedValue(mockAtoms); + + const options: Partial = { + author: 'cole', + scope: 'api', + }; + + const result = await repo.findAll(options); + + // Only hash3 matches both + expect(result).toHaveLength(1); + expect(result[0].commitHash).toBe('hash3'); + }); + }); +}); diff --git a/tests/unit/services/git-client.test.ts b/tests/unit/services/git-client.test.ts new file mode 100644 index 00000000..30f25651 --- /dev/null +++ b/tests/unit/services/git-client.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GitClient } from '../../../src/services/git-client.js'; + +describe('GitClient.resolveDate', () => { + let client: GitClient; + + beforeEach(() => { + client = new GitClient(); + // We mock exec directly on the instance's private method if needed, + // but better to mock the class prototype for all tests. + }); + + it('should resolve ISO dates natively using JS', async () => { + const isoDate = '2026-05-20T10:00:00.000Z'; + const input = '2026-05-20T10:00:00Z'; + const result = await client.resolveDate(input); + + expect(result).not.toBeNull(); + expect(result?.toISOString()).toBe(isoDate); + }); + + it('should resolve YYYY-MM-DD as local midnight', async () => { + const input = '2026-05-20'; + const expected = new Date(2026, 4, 20); // May is 4 + const result = await client.resolveDate(input); + + expect(result?.getTime()).toBe(expected.getTime()); + }); + + it('should resolve commit refs using git show', async () => { + const ref = 'HEAD~5'; + const mockTimestamp = '1716200000'; + + // Mock successful git show call + const execSpy = vi.spyOn(client as any, 'exec').mockImplementation(async (args: string[]) => { + if (args.includes('show') && args.includes(ref)) { + return mockTimestamp; + } + throw new Error('Fallback to next strategy'); + }); + + const result = await client.resolveDate(ref); + + expect(result).not.toBeNull(); + expect(result?.getTime()).toBe(parseInt(mockTimestamp) * 1000); + expect(execSpy).toHaveBeenCalledWith(['show', '-s', '--format=%at', ref]); + }); + + it('should resolve short and full commit hashes', async () => { + const fullHash = 'abc1234567890abcdef1234567890abcdef1234'; + const shortHash = 'abc1234'; + const mockTimestamp = '1716200000'; + + const execSpy = vi.spyOn(client as any, 'exec').mockResolvedValue(mockTimestamp); + + const resultFull = await client.resolveDate(fullHash); + expect(resultFull?.getTime()).toBe(parseInt(mockTimestamp) * 1000); + expect(execSpy).toHaveBeenCalledWith(['show', '-s', '--format=%at', fullHash]); + + const resultShort = await client.resolveDate(shortHash); + expect(resultShort?.getTime()).toBe(parseInt(mockTimestamp) * 1000); + expect(execSpy).toHaveBeenCalledWith(['show', '-s', '--format=%at', shortHash]); + }); + + it('should resolve relative date strings using git rev-parse', async () => { + const relative = '3 days ago'; + const mockTimestamp = '1715940800'; + + const execSpy = vi.spyOn(client as any, 'exec').mockImplementation(async (args: string[]) => { + if (args.includes('rev-parse') && args.some(a => a.includes(relative))) { + return `--max-age=${mockTimestamp}`; + } + throw new Error('Failing fast passes'); + }); + + const result = await client.resolveDate(relative); + + expect(result).not.toBeNull(); + expect(result?.getTime()).toBe(parseInt(mockTimestamp) * 1000); + expect(execSpy).toHaveBeenCalledWith(['rev-parse', `--since=${relative}`]); + }); + + it('should return null if all resolution strategies fail', async () => { + vi.spyOn(client as any, 'exec').mockRejectedValue(new Error('Git failure')); + + const result = await client.resolveDate('completely-invalid-garbage'); + + expect(result).toBeNull(); + }); + + it('should mirror git behavior for garbage strings (resolving to "now")', async () => { + const garbage = 'not-a-date'; + const nowTs = Math.floor(Date.now() / 1000).toString(); + + vi.spyOn(client as any, 'exec').mockImplementation(async (args: string[]) => { + if (args.includes('rev-parse') && args.some(a => a.includes(garbage))) { + return `--max-age=${nowTs}`; + } + throw new Error('Not found'); + }); + + const result = await client.resolveDate(garbage); + expect(result).not.toBeNull(); + // Should be within a small window of "now" + expect(Math.abs(result!.getTime() - parseInt(nowTs) * 1000)).toBeLessThan(2000); + }); +}); diff --git a/tests/unit/services/search-filter.test.ts b/tests/unit/services/search-filter.test.ts new file mode 100644 index 00000000..6ee0feff --- /dev/null +++ b/tests/unit/services/search-filter.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { SearchFilter } from '../../../src/services/search-filter.js'; +import type { LoreAtom } from '../../../src/types/domain.js'; +import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; + +describe('SearchFilter', () => { + const filter = new SearchFilter(); + + const mockAtoms: LoreAtom[] = [ + { + loreId: 'abc12345', + commitHash: 'h1', + date: new Date('2026-05-01T12:00:00Z'), + author: 'cole@example.com', + intent: 'feat(auth): valid login', + body: 'Body text here', + trailers: { + 'Lore-id': 'abc12345', + Confidence: 'high', + 'Scope-risk': 'narrow', + Reversibility: 'clean', + Constraint: ['c1'], + Rejected: [], + Directive: [], + Tested: [], + 'Not-tested': [], + Supersedes: [], + 'Depends-on': [], + Related: [], + custom: CustomTrailerCollection.empty(), + }, + filesChanged: ['f1.ts'], + }, + { + loreId: 'def67890', + commitHash: 'h2', + date: new Date('2026-05-10T12:00:00Z'), + author: 'ivan@example.com', + intent: 'fix(ui): layout bug', + body: 'Body text here', + trailers: { + 'Lore-id': 'def67890', + Confidence: 'low', + 'Scope-risk': 'wide', + Reversibility: 'migration-needed', + Constraint: [], + Rejected: ['r1'], + Directive: [], + Tested: [], + 'Not-tested': [], + Supersedes: [], + 'Depends-on': [], + Related: [], + custom: CustomTrailerCollection.empty(), + }, + filesChanged: ['f2.ts'], + }, + ]; + + it('should filter by scope', () => { + const results = filter.applyFilters(mockAtoms, { scope: 'auth' }); + expect(results).toHaveLength(1); + expect(results[0].loreId).toBe('abc12345'); + }); + + it('should filter by author', () => { + const results = filter.applyFilters(mockAtoms, { author: 'ivan' }); + expect(results).toHaveLength(1); + expect(results[0].loreId).toBe('def67890'); + }); + + it('should filter by date range (since)', () => { + const results = filter.applyFilters(mockAtoms, { + since: '2026-05-05', + sinceDate: new Date('2026-05-05') + }); + expect(results).toHaveLength(1); + expect(results[0].loreId).toBe('def67890'); + }); + + it('should filter by trailer presence (has)', () => { + const results = filter.applyFilters(mockAtoms, { has: 'Constraint' }); + expect(results).toHaveLength(1); + expect(results[0].loreId).toBe('abc12345'); + }); + + it('should filter by confidence', () => { + const results = filter.applyFilters(mockAtoms, { confidence: 'high' }); + expect(results).toHaveLength(1); + expect(results[0].loreId).toBe('abc12345'); + }); + + it('should filter by full-text search', () => { + const results = filter.applyFilters(mockAtoms, { text: 'layout' }); + expect(results).toHaveLength(1); + expect(results[0].loreId).toBe('def67890'); + }); +});