Skip to content

Commit b3c4a16

Browse files
authored
Merge pull request #80 from codervisor/copilot/implement-spec-124
Implement advanced search query syntax (spec 124 Phase 2)
2 parents 7fcc4f1 + 38f1622 commit b3c4a16

File tree

9 files changed

+1502
-43
lines changed

9 files changed

+1502
-43
lines changed

packages/cli/src/commands/search.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,49 @@ import type { SpecStatus, SpecPriority, SpecFilterOptions } from '../frontmatter
77
import { withSpinner } from '../utils/ui.js';
88
import { autoCheckIfEnabled } from './check.js';
99
import { sanitizeUserInput } from '../utils/ui.js';
10-
import { searchSpecs, type SearchableSpec } from '@leanspec/core';
10+
import { advancedSearchSpecs, getSearchSyntaxHelp, type SearchableSpec } from '@leanspec/core';
1111
import { parseCustomFieldOptions } from '../utils/cli-helpers.js';
1212

1313
/**
14-
* Search command - full-text search with metadata filters
14+
* Search command - full-text search with metadata filters and advanced query syntax
1515
*/
1616
export function searchCommand(): Command {
1717
return new Command('search')
18-
.description('Full-text search with metadata filters')
19-
.argument('<query>', 'Search query')
18+
.description('Full-text search with advanced query syntax')
19+
.argument('[query]', 'Search query (supports AND, OR, NOT, field:value, fuzzy~)')
2020
.option('--status <status>', 'Filter by status')
2121
.option('--tag <tag>', 'Filter by tag')
2222
.option('--priority <priority>', 'Filter by priority')
2323
.option('--assignee <name>', 'Filter by assignee')
2424
.option('--field <name=value...>', 'Filter by custom field (can specify multiple)')
2525
.option('--json', 'Output as JSON')
26-
.action(async (query: string, options: {
26+
.option('--help-syntax', 'Show advanced query syntax help')
27+
.action(async (query: string | undefined, options: {
2728
status?: SpecStatus;
2829
tag?: string;
2930
priority?: SpecPriority;
3031
assignee?: string;
3132
field?: string[];
3233
json?: boolean;
34+
helpSyntax?: boolean;
3335
}) => {
36+
// Show syntax help
37+
if (options.helpSyntax) {
38+
console.log('');
39+
console.log(chalk.cyan('Advanced Search Syntax'));
40+
console.log(chalk.gray('─'.repeat(60)));
41+
console.log('');
42+
console.log(getSearchSyntaxHelp());
43+
console.log('');
44+
return;
45+
}
46+
47+
if (!query) {
48+
console.log(chalk.yellow('Usage: lean-spec search <query>'));
49+
console.log(chalk.gray('Use --help-syntax for advanced query syntax'));
50+
return;
51+
}
52+
3453
const customFields = parseCustomFieldOptions(options.field);
3554
await performSearch(query, {
3655
status: options.status,
@@ -87,10 +106,13 @@ export async function performSearch(query: string, options: {
87106
title: typeof spec.frontmatter.title === 'string' ? spec.frontmatter.title : undefined,
88107
description: typeof spec.frontmatter.description === 'string' ? spec.frontmatter.description : undefined,
89108
content: spec.content,
109+
created: spec.frontmatter.created,
110+
updated: spec.frontmatter.updated_at,
111+
assignee: spec.frontmatter.assignee,
90112
}));
91113

92-
// Use intelligent search engine
93-
const searchResult = searchSpecs(query, searchableSpecs, {
114+
// Use advanced search engine (supports boolean operators, field filters, fuzzy matching)
115+
const searchResult = advancedSearchSpecs(query, searchableSpecs, {
94116
maxMatchesPerSpec: 5,
95117
contextLength: 80,
96118
});

packages/cli/src/mcp/tools/search.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { z } from 'zod';
66
import { loadAllSpecs } from '../../spec-loader.js';
77
import type { SpecStatus, SpecPriority, SpecFilterOptions } from '../../frontmatter.js';
8-
import { searchSpecs, type SearchableSpec } from '@leanspec/core';
8+
import { advancedSearchSpecs, type SearchableSpec } from '@leanspec/core';
99
import { formatErrorMessage, loadSubSpecMetadata } from '../helpers.js';
1010
import type { ToolDefinition, SpecData } from '../types.js';
1111

@@ -61,10 +61,13 @@ export async function searchSpecsData(query: string, options: {
6161
title: spec.frontmatter.title as string | undefined,
6262
description: spec.frontmatter.description as string | undefined,
6363
content: spec.content,
64+
created: spec.frontmatter.created,
65+
updated: spec.frontmatter.updated_at,
66+
assignee: spec.frontmatter.assignee,
6467
}));
6568

66-
// Use intelligent search engine
67-
const searchResult = searchSpecs(query, searchableSpecs, {
69+
// Use advanced search engine (supports boolean operators, field filters, fuzzy matching)
70+
const searchResult = advancedSearchSpecs(query, searchableSpecs, {
6871
maxMatchesPerSpec: 5,
6972
contextLength: 80,
7073
});
@@ -124,25 +127,34 @@ export function searchTool(): ToolDefinition {
124127
'search',
125128
{
126129
title: 'Search Specs',
127-
description: `Intelligent relevance-ranked search across all specification content. Uses field-weighted scoring (title > tags > description > content) to return the most relevant specs.
130+
description: `Advanced search across all specification content with support for boolean operators, field filters, date ranges, and fuzzy matching.
128131
129-
**Query Formulation Tips:**
130-
- Use 2-4 specific terms for best results (e.g., "search ranking" not "AI agent integration coding agent orchestration")
131-
- All terms must appear in the SAME field/line to match - keep queries focused
132-
- Prefer nouns and technical terms over common words
133-
- Use filters (status, tags, priority) to narrow scope instead of adding more search terms
132+
**Query Syntax:**
133+
- Simple terms: \`api authentication\` (implicit AND)
134+
- Boolean: \`api AND auth\`, \`frontend OR backend\`, \`api NOT deprecated\`
135+
- Field filters: \`status:in-progress\`, \`tag:api\`, \`priority:high\`, \`title:dashboard\`
136+
- Date filters: \`created:>2025-11-01\`, \`created:2025-01..2025-11-15\`
137+
- Fuzzy matching: \`authetication~\` (matches "authentication" despite typo)
138+
- Exact phrases: \`"token refresh"\`
139+
- Grouping: \`(frontend OR backend) AND api\`
140+
141+
**Query Tips:**
142+
- Use 2-4 specific terms for best results
143+
- Cross-field matching: terms can span title, tags, description, content
144+
- Combine field filters with search terms: \`tag:api status:planned oauth\`
134145
135146
**Examples:**
136-
- Good: "search ranking" or "token validation"
137-
- Good: "api" with tags filter ["integration"]
138-
- Poor: "AI agent integration coding agent orchestration" (too many terms, unlikely all in one line)
147+
- \`api authentication\` - Find specs with both terms
148+
- \`status:in-progress tag:api\` - API specs being worked on
149+
- \`created:>2025-11-01\` - Recently created specs
150+
- \`"user session" OR "token refresh"\` - Either phrase
139151
140152
Returns matching specs with relevance scores, highlighted excerpts, and metadata.`,
141153
inputSchema: {
142-
query: z.string().describe('Search term or phrase. Use 2-4 specific terms. All terms must appear in the same field/line to match. For broad concepts, use fewer terms + filters instead of long queries.'),
143-
status: z.enum(['planned', 'in-progress', 'complete', 'archived']).optional().describe('Limit search to specs with this status.'),
144-
tags: z.array(z.string()).optional().describe('Limit search to specs with these tags. Use this to narrow scope instead of adding more search terms.'),
145-
priority: z.enum(['low', 'medium', 'high', 'critical']).optional().describe('Limit search to specs with this priority.'),
154+
query: z.string().describe('Search query with optional advanced syntax. Supports: boolean operators (AND/OR/NOT), field filters (status:, tag:, priority:, title:, created:), fuzzy matching (term~), exact phrases ("phrase"), and parentheses for grouping.'),
155+
status: z.enum(['planned', 'in-progress', 'complete', 'archived']).optional().describe('Pre-filter by status (applied before query parsing).'),
156+
tags: z.array(z.string()).optional().describe('Pre-filter by tags (applied before query parsing).'),
157+
priority: z.enum(['low', 'medium', 'high', 'critical']).optional().describe('Pre-filter by priority (applied before query parsing).'),
146158
},
147159
outputSchema: {
148160
results: z.array(z.object({

packages/core/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,19 @@ export {
6161
// Search
6262
export {
6363
searchSpecs,
64+
advancedSearchSpecs,
65+
searchSpecsAdvanced,
6466
FIELD_WEIGHTS,
67+
parseQuery,
68+
getSearchSyntaxHelp,
6569
type SearchOptions,
6670
type SearchMatch,
6771
type SearchResult,
6872
type SearchResultSpec,
6973
type SearchResponse,
7074
type SearchMetadata,
7175
type SearchableSpec,
76+
type ParsedQuery,
77+
type FieldFilter,
78+
type DateFilter,
7279
} from './search/index.js';

packages/core/src/search/engine.test.ts

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { describe, it, expect } from 'vitest';
6-
import { searchSpecs } from './engine.js';
6+
import { searchSpecs, advancedSearchSpecs } from './engine.js';
77
import type { SearchableSpec } from './engine.js';
88

99
describe('Search Engine', () => {
@@ -405,3 +405,214 @@ Basic endpoint documentation.
405405
});
406406
});
407407
});
408+
409+
describe('Advanced Search (spec 124 Phase 2)', () => {
410+
const sampleSpecs: SearchableSpec[] = [
411+
{
412+
path: '042-oauth2-implementation',
413+
name: '042-oauth2-implementation',
414+
status: 'in-progress',
415+
priority: 'high',
416+
tags: ['api', 'security', 'auth'],
417+
title: 'OAuth2 Authentication Flow',
418+
description: 'Implement OAuth2 authentication with token refresh',
419+
content: 'OAuth2 flow supports authorization code grant with PKCE.',
420+
created: '2025-11-01',
421+
updated: '2025-11-15',
422+
assignee: 'marvin',
423+
},
424+
{
425+
path: '038-jwt-token-service',
426+
name: '038-jwt-token-service',
427+
status: 'complete',
428+
priority: 'medium',
429+
tags: ['api', 'auth'],
430+
title: 'JWT Token Service',
431+
description: 'JWT-based authentication service',
432+
content: 'JWT authentication flow with RS256 signing.',
433+
created: '2025-10-15',
434+
updated: '2025-11-10',
435+
assignee: 'alice',
436+
},
437+
{
438+
path: '051-user-session-management',
439+
name: '051-user-session-management',
440+
status: 'planned',
441+
priority: 'medium',
442+
tags: ['api', 'users'],
443+
title: 'User Session Management',
444+
description: 'Handle user sessions and authentication state',
445+
content: 'Manage user sessions across multiple devices.',
446+
created: '2025-11-20',
447+
assignee: 'bob',
448+
},
449+
{
450+
path: '025-api-rate-limiting',
451+
name: '025-api-rate-limiting',
452+
status: 'complete',
453+
priority: 'high',
454+
tags: ['api', 'security'],
455+
title: 'API Rate Limiting',
456+
description: 'Rate limiting for API endpoints (deprecated)',
457+
content: 'Implement rate limiting to prevent abuse.',
458+
created: '2025-09-01',
459+
updated: '2025-09-15',
460+
},
461+
];
462+
463+
describe('Boolean operators', () => {
464+
it('should support AND operator', () => {
465+
const result = advancedSearchSpecs('api AND authentication', sampleSpecs);
466+
467+
// Should find specs with both "api" and "authentication"
468+
expect(result.results.length).toBeGreaterThan(0);
469+
// OAuth2 spec should match (has both in various fields)
470+
const names = result.results.map(r => r.spec.name);
471+
expect(names).toContain('042-oauth2-implementation');
472+
});
473+
474+
it('should support OR operator', () => {
475+
const result = advancedSearchSpecs('session OR token', sampleSpecs);
476+
477+
// Should find specs with either "session" or "token"
478+
expect(result.results.length).toBeGreaterThan(0);
479+
const names = result.results.map(r => r.spec.name);
480+
expect(names).toContain('051-user-session-management');
481+
expect(names).toContain('038-jwt-token-service');
482+
});
483+
484+
it('should support NOT operator', () => {
485+
const result = advancedSearchSpecs('api NOT deprecated', sampleSpecs);
486+
487+
// Should find specs with "api" but not "deprecated"
488+
const names = result.results.map(r => r.spec.name);
489+
expect(names).not.toContain('025-api-rate-limiting');
490+
expect(names).toContain('042-oauth2-implementation');
491+
});
492+
493+
it('should support parentheses for grouping', () => {
494+
const result = advancedSearchSpecs('(session OR token) AND authentication', sampleSpecs);
495+
496+
// Should find specs matching (session OR token) AND authentication
497+
expect(result.results.length).toBeGreaterThan(0);
498+
});
499+
});
500+
501+
describe('Field-specific search', () => {
502+
it('should filter by status', () => {
503+
const result = advancedSearchSpecs('status:in-progress', sampleSpecs);
504+
505+
expect(result.results.length).toBe(1);
506+
expect(result.results[0].spec.status).toBe('in-progress');
507+
});
508+
509+
it('should filter by tag', () => {
510+
const result = advancedSearchSpecs('tag:security', sampleSpecs);
511+
512+
expect(result.results.length).toBe(2);
513+
for (const r of result.results) {
514+
expect(r.spec.tags).toContain('security');
515+
}
516+
});
517+
518+
it('should filter by priority', () => {
519+
const result = advancedSearchSpecs('priority:high', sampleSpecs);
520+
521+
expect(result.results.length).toBe(2);
522+
for (const r of result.results) {
523+
expect(r.spec.priority).toBe('high');
524+
}
525+
});
526+
527+
it('should combine field filters with search terms', () => {
528+
const result = advancedSearchSpecs('tag:api status:planned', sampleSpecs);
529+
530+
expect(result.results.length).toBe(1);
531+
expect(result.results[0].spec.name).toBe('051-user-session-management');
532+
});
533+
534+
it('should filter by title', () => {
535+
const result = advancedSearchSpecs('title:OAuth2', sampleSpecs);
536+
537+
expect(result.results.length).toBe(1);
538+
expect(result.results[0].spec.title).toContain('OAuth2');
539+
});
540+
});
541+
542+
describe('Date range filters', () => {
543+
it('should filter by created date (greater than)', () => {
544+
const result = advancedSearchSpecs('created:>2025-11-01', sampleSpecs);
545+
546+
// Should find specs created after Nov 1, 2025
547+
expect(result.results.length).toBeGreaterThan(0);
548+
for (const r of result.results) {
549+
const spec = sampleSpecs.find(s => s.name === r.spec.name);
550+
expect(spec?.created).toBeDefined();
551+
expect(spec!.created! > '2025-11-01').toBe(true);
552+
}
553+
});
554+
555+
it('should filter by created date (less than)', () => {
556+
const result = advancedSearchSpecs('created:<2025-10-01', sampleSpecs);
557+
558+
// Should find specs created before Oct 1, 2025
559+
expect(result.results.length).toBe(1);
560+
expect(result.results[0].spec.name).toBe('025-api-rate-limiting');
561+
});
562+
563+
it('should filter by date range', () => {
564+
const result = advancedSearchSpecs('created:2025-10-01..2025-11-01', sampleSpecs);
565+
566+
// Should find specs created between Oct 1 and Nov 1, 2025
567+
expect(result.results.length).toBeGreaterThan(0);
568+
for (const r of result.results) {
569+
const spec = sampleSpecs.find(s => s.name === r.spec.name);
570+
expect(spec?.created).toBeDefined();
571+
expect(spec!.created! >= '2025-10-01').toBe(true);
572+
expect(spec!.created! <= '2025-11-01').toBe(true);
573+
}
574+
});
575+
});
576+
577+
describe('Fuzzy matching', () => {
578+
it('should find specs with typo-tolerant search', () => {
579+
const result = advancedSearchSpecs('authetication~', sampleSpecs);
580+
581+
// Should find specs with "authentication" despite the typo
582+
expect(result.results.length).toBeGreaterThan(0);
583+
});
584+
585+
it('should not fuzzy match completely different words', () => {
586+
const result = advancedSearchSpecs('completely_different_word~', sampleSpecs);
587+
588+
// Should not find any specs
589+
expect(result.results.length).toBe(0);
590+
});
591+
});
592+
593+
describe('Complex queries', () => {
594+
it('should combine field filters, boolean operators, and search terms', () => {
595+
const result = advancedSearchSpecs('tag:api status:in-progress oauth', sampleSpecs);
596+
597+
expect(result.results.length).toBe(1);
598+
expect(result.results[0].spec.name).toBe('042-oauth2-implementation');
599+
});
600+
601+
it('should handle quoted phrases', () => {
602+
const result = advancedSearchSpecs('"token refresh"', sampleSpecs);
603+
604+
// Should find specs with exact phrase "token refresh"
605+
expect(result.results.length).toBeGreaterThan(0);
606+
});
607+
});
608+
609+
describe('Backward compatibility', () => {
610+
it('should work with simple queries (no advanced syntax)', () => {
611+
const result = advancedSearchSpecs('authentication', sampleSpecs);
612+
613+
// Should behave like regular search
614+
expect(result.results.length).toBeGreaterThan(0);
615+
expect(result.metadata.query).toBe('authentication');
616+
});
617+
});
618+
});

0 commit comments

Comments
 (0)