diff --git a/.changeset/build-kit-coverage-and-hint.md b/.changeset/build-kit-coverage-and-hint.md new file mode 100644 index 0000000000000..23aa3181523a2 --- /dev/null +++ b/.changeset/build-kit-coverage-and-hint.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[fix] build: a page that matched one word of the query is no longer offered as a direct match (#5320). A page template's keywords include every component its source renders, so `build "actionable warning banner"` returned `login`, `contact-form` and `documentation-design` at 95 apiece — an exact keyword hit on "banner" alone, plus the coverage garnish, landing exactly on the direct-match threshold. Three pages that are not warnings, presented as the page to start from. Coverage now gates the pages group rather than garnishing its score, and a kit that comes back thin says so, naming the browse commands, so a caller does not read it as "the package has nothing". + +@josephfarina diff --git a/.changeset/search-integrations-and-guidance.md b/.changeset/search-integrations-and-guidance.md new file mode 100644 index 0000000000000..0381620cb03c3 --- /dev/null +++ b/.changeset/search-integrations-and-guidance.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[feat] search: components from configured integrations are searchable, and usage guidance is indexed (#5320). `search` gathered components straight off the resolved core directory, so a package listed in `astryx.config.mjs` was reachable by `component` and `template` and invisible to the one command whose job is finding things — the search command even loaded a `Project` already, purely to print integration warnings beside results that could not contain an integration's components. It now gathers through `Project`, so integration components rank alongside Core's and carry their own `package` and `import`. Component candidates also index `features` and best-practice text, scored below the description tier: a reader asking about "maintenance notices" now reaches `Banner`, whose own description only says "a persistent message", while a component that merely mentions a word in passing advice no longer ties with one that names it outright. Every result now reports `matchedTerms`/`queryTerms`. + +@josephfarina diff --git a/packages/cli/api/build/build.test.mjs b/packages/cli/api/build/build.test.mjs index e47d0027ad1c3..48c08006cd89d 100644 --- a/packages/cli/api/build/build.test.mjs +++ b/packages/cli/api/build/build.test.mjs @@ -90,3 +90,64 @@ describe('build API', () => { expect(r.data.domain).toHaveLength(0); }); }); + +describe('build kit — coverage gates the pages group', () => { + it('never offers a page that answered less than half the query', async () => { + const r = await build('actionable warning banner', {cwd: REPO}); + expect(r.type).toBe('build.kit'); + if (r.type !== 'build.kit') return; + for (const p of r.data.pages) { + expect(p.matchedTerms / p.queryTerms).toBeGreaterThanOrEqual(0.5); + } + }); + + it('does not call a one-word coincidence a direct match', async () => { + // A page's keywords include every component its source renders, so any + // page that happens to render a Banner keyword-matched "banner" at 90 — + // which, plus the coverage garnish, landed exactly on PAGE_DIRECT. Three + // pages that are not warnings were presented as a confident direct match. + const r = await build('actionable warning banner', {cwd: REPO}); + expect(r.type).toBe('build.kit'); + if (r.type !== 'build.kit') return; + expect(r.data.directMatch).toBe(false); + for (const p of r.data.pages) { + expect(['login', 'contact-form', 'documentation-design']).not.toContain(p.name); + } + }); + + it('still reports a direct match when the page really does answer the query', async () => { + const r = await build('contact form', {cwd: REPO}); + expect(r.type).toBe('build.kit'); + if (r.type !== 'build.kit') return; + expect(r.data.directMatch).toBe(true); + expect(r.data.pages[0].matchedTerms).toBe(r.data.pages[0].queryTerms); + }); + + it('leaves single-concept queries alone (nothing to cover)', async () => { + const r = await build('dashboard', {cwd: REPO}); + expect(r.type).toBe('build.kit'); + if (r.type !== 'build.kit') return; + for (const p of r.data.pages) expect(p.queryTerms).toBe(1); + }); +}); + +describe('build kit — a thin kit says what to try next', () => { + it('hints when the kit comes back nearly empty', async () => { + // An agent reading an empty kit concludes the package has nothing and + // falls back on its own memory of it, which is the failure build exists + // to prevent. + const r = await build('quantum flux capacitor telemetry', {cwd: REPO}); + expect(r.type).toBe('build.kit'); + if (r.type !== 'build.kit') return; + expect(r.data.pages.length + r.data.blocks.length + r.data.domain.length).toBeLessThan(3); + expect(r.data.hint).toMatch(/keyword search/i); + expect(r.data.hint).toMatch(/--list/); + }); + + it('carries no hint when the kit is healthy', async () => { + const r = await build('dashboard', {cwd: REPO}); + expect(r.type).toBe('build.kit'); + if (r.type !== 'build.kit') return; + expect(r.data.hint).toBeUndefined(); + }); +}); diff --git a/packages/cli/api/build/build.type.mjs b/packages/cli/api/build/build.type.mjs index 8b718119c7474..0526509bf8412 100644 --- a/packages/cli/api/build/build.type.mjs +++ b/packages/cli/api/build/build.type.mjs @@ -33,6 +33,7 @@ * @property {import('../search/search.type.mjs').SearchResultEntry[]} data.domain Idea-specific components/hooks (≤6), excluding frame/foundation. * @property {string[]} data.frame Always-on page-shell component names. * @property {string[]} data.foundation Always-on layout/typography/action component names. + * @property {string} [data.hint] Present only when the kit is thin — what to try instead, so a caller does not read an empty kit as "the package has nothing". */ /** diff --git a/packages/cli/api/build/kit/kit.mjs b/packages/cli/api/build/kit/kit.mjs index 73044f3b0b1f1..1468201a6228a 100644 --- a/packages/cli/api/build/kit/kit.mjs +++ b/packages/cli/api/build/kit/kit.mjs @@ -21,6 +21,22 @@ const PAGE_DIRECT = 95; const PAGE_FLOOR = 50; /** Below this a block/domain-component match is incidental noise. */ const DOMAIN_FLOOR = 55; +/** + * How much of a multi-word query a result must cover to be offered as a PAGE. + * + * Score alone cannot carry this. A page's keywords include every component its + * source renders, so `build "actionable warning banner"` scored `login`, + * `contact-form` and `documentation-design` at 95 apiece — an exact keyword hit + * (90) on "banner" alone, plus the coverage garnish, lands exactly on + * PAGE_DIRECT. Three pages that are not warnings, presented as a direct match, + * because each happens to render a Banner somewhere. + * + * Coverage has to gate rather than garnish: matching one of three concepts is + * not the same claim as matching three. + */ +const PAGE_COVERAGE = 0.5; +/** Fewer results than this and the kit says how to look further. */ +const THIN_KIT = 3; /** * Always-surfaced primitives. Every page needs a shell + layout/typography/ @@ -52,8 +68,25 @@ export async function buildKit(query, options = {}) { ); const results = result.data.results; + /** + * Did this result answer enough of the query to stand as a page? + * Single-concept queries have nothing to cover, so they always pass. + * @param {{matchedTerms?: number, queryTerms?: number}} r + */ + const covers = r => { + const total = r.queryTerms ?? 1; + if (total <= 1) return true; + return (r.matchedTerms ?? 0) / total >= PAGE_COVERAGE; + }; + const pages = results - .filter(r => r.domain === 'template' && r.kind !== 'block' && r.score >= PAGE_FLOOR) + .filter( + r => + r.domain === 'template' && + r.kind !== 'block' && + r.score >= PAGE_FLOOR && + covers(r), + ) .slice(0, 3); const blocks = results .filter(r => r.domain === 'template' && r.kind === 'block' && r.score >= DOMAIN_FLOOR) @@ -68,6 +101,17 @@ export async function buildKit(query, options = {}) { .slice(0, 6); const directMatch = pages.length > 0 && pages[0].score >= PAGE_DIRECT; + // What to try when the kit comes back thin. Keyword search over a design + // system misses in a predictable way — the reader's words and the package's + // often do not overlap — and an agent reading an empty kit concludes the + // package has nothing and falls back on its own memory of it, which is the + // failure this command exists to prevent. Say so, and name the way to browse. + const hint = + pages.length + blocks.length + domain.length < THIN_KIT + ? 'Few matches. This is keyword search, not semantic — try other wordings, ' + + 'or browse with `astryx component --list` and `astryx template --list`.' + : undefined; + return { type: 'build.kit', data: { @@ -81,6 +125,7 @@ export async function buildKit(query, options = {}) { domain, frame: FRAME, foundation: FOUNDATION, + hint, }, }; } diff --git a/packages/cli/api/search/search.mjs b/packages/cli/api/search/search.mjs index da12a7d607cee..eeb5f21708a14 100644 --- a/packages/cli/api/search/search.mjs +++ b/packages/cli/api/search/search.mjs @@ -21,23 +21,29 @@ * 80 name Levenshtein distance 1 * 70 keyword substring / distance 1 * 60 name substring (>=4 chars, >=50% coverage) - * 50 description / prose mentions the term + * 50 description mentions the term + * 45 usage guidance mentions the term * 40 name Levenshtein distance 2 * 30 keyword Levenshtein distance 2 * 20 name Levenshtein distance 3 * * Name + keyword signals always outweigh description/prose, so an exact match * sorts above an incidental mention. + * + * Description and guidance are separate tiers on purpose. A component's own + * one-line description saying "notification" is a claim about what it IS; the + * same word inside another component's best-practice advice is a passing + * mention. Scored equally, `Toast` — "a brief, non-blocking notification" — + * ties with `Card`, `Dialog` and `Item`, which merely mention notifications in + * their guidance, and ties break alphabetically, so Toast falls off the end of + * its own best query. */ import {pathToFileURL} from 'node:url'; import {findCoreDir} from '../../foundation/fs/paths.mjs'; -import { - discoverComponents, - findComponentReadme, - resolveImportPath, -} from '../../foundation/discovery/component-discovery.mjs'; +import {resolveImportPath} from '../../foundation/discovery/component-discovery.mjs'; import {discoverHooks, findHookDoc} from '../../foundation/discovery/hook-discovery.mjs'; +import {Project} from '../../foundation/config/project.mjs'; import {levenshteinDistance} from '../../foundation/text/string-utils.mjs'; import {discoverTemplates, extractComponents} from '../template/template.mjs'; import {loadDocsCatalog, loadTopicDoc} from '../docs/_adapter.mjs'; @@ -54,6 +60,7 @@ import {ERROR_CODES} from '../../foundation/response/error-codes.mjs'; * @property {string} [description] * @property {string[]} [prose] * @property {string} [_import] + * @property {string} [_package] * @property {string} [_title] * @property {string} [_displayName] * @property {'page'|'block'} [_kind] @@ -173,10 +180,15 @@ export function tokenizeQuery(term) { */ /** * Minimum per-token score (in the multi-word pass) to count as a real match. - * 50 = a genuine name/keyword/description hit; below that is loose Levenshtein - * fuzz that would otherwise turn gibberish queries into noise. + * 45 = a genuine name/keyword/description/guidance hit; below that is loose + * Levenshtein fuzz that would otherwise turn gibberish queries into noise. + * + * It sits at the guidance tier rather than above it because build() sends + * multi-word natural language almost exclusively, and guidance text is where + * the reader's vocabulary usually lives — a gate above 45 would index that + * text and then never count it. */ -const MIN_TOKEN_SCORE = 50; +const MIN_TOKEN_SCORE = 45; /** * Best score for a token against a candidate, fanning out through synonyms @@ -200,20 +212,39 @@ function bestForToken(tok, candidate) { return best; } +/** + * A scored hit, with how much of the query it actually covered. `matched` / + * `total` are what let a consumer tell a candidate that answered the whole + * question from one that caught a single incidental word. + * @typedef {object} ScoredHit + * @property {number} score + * @property {string} reason + * @property {number} matched query concepts this candidate hit + * @property {number} total query concepts in play + */ + /** * @param {string} term - Lowercased full query. * @param {string[]} tokens - Content tokens from tokenizeQuery(term). * @param {Candidate} candidate - * @returns {{score: number, reason: string} | null} + * @returns {ScoredHit | null} */ export function scoreQuery(term, tokens, candidate) { - const full = scoreCandidate(term, candidate); + const total = Math.max(tokens.length, 1); + /** + * A whole-phrase hit covers the whole query by definition — the entire term + * matched one signal — so it is stamped at full coverage. + * @param {{score: number, reason: string} | null} hit + */ + const whole = hit => (hit ? {...hit, matched: total, total} : null); + + const full = whole(scoreCandidate(term, candidate)); // 0–1 content tokens: keep whole-phrase fuzzy matching (typo tolerance for // single words), but if stopwords left exactly one DIFFERENT token (e.g. // "pricing page" → "pricing"), score that token too and take the stronger. if (tokens.length <= 1) { - const single = tokens.length === 1 ? bestForToken(tokens[0], candidate) : null; + const single = tokens.length === 1 ? whole(bestForToken(tokens[0], candidate)) : null; if (full && (!single || full.score >= single.score)) return full; return single; } @@ -247,6 +278,8 @@ export function scoreQuery(term, tokens, candidate) { return { score: tokenScore, reason: `matches ${matched}/${tokens.length} terms: ${hitTerms.join(', ')}`, + matched, + total: tokens.length, }; } @@ -315,18 +348,21 @@ export function scoreCandidate(term, {name, keywords = [], description = '', pro // ── Prose / description signals (stem-tolerant whole word) ────── // Match the term's stem as a whole word, tolerating plural/gerund suffixes // so "chart" matches "charts" and "filter" matches "filtering". + // + // Both tiers are considered; `consider` keeps the strongest. Guidance is + // checked even when the description already hit, because the two are + // separate claims and the description's higher tier wins on its own. if (term.length >= 3) { const root = stem(term); const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(`\\b${escaped}(s|es|ing|ed|ies)?\\b`); if (description && re.test(description.toLowerCase())) { consider(50, `description mentions "${term}"`); - } else { - for (const blob of prose) { - if (blob && re.test(String(blob).toLowerCase())) { - consider(50, `docs mention "${term}"`); - break; - } + } + for (const blob of prose) { + if (blob && re.test(String(blob).toLowerCase())) { + consider(45, `guidance mentions "${term}"`); + break; } } } @@ -350,34 +386,80 @@ async function loadModuleDoc(docPath, exportName = 'docs') { } /** - * Build component candidates: name + keywords + usage/description from the - * component's .doc.mjs. - * @param {string} coreDir + * Build component candidates: name + keywords + usage/description + usage + * guidance, for Core AND every configured integration. + * + * Component records come from `Project`, which is what makes an integration's + * components searchable at all. `search` used to call `discoverComponents(coreDir)` + * directly, so a package listed in `astryx.config.mjs` was visible to + * `component` and `template` and invisible to the one command whose job is + * finding things — the CLI's own search command even loaded a Project already, + * purely to print integration warnings next to results that could not contain + * an integration's components. + * + * `prose` carries features and best-practice text. It is indexed because the + * reader's vocabulary usually lives there rather than in the one-line + * description — `Banner` calls itself "a persistent message" and only its + * guidance names "form errors, system updates, maintenance notices" — and it + * is scored below the description tier because a passing mention in advice is + * weaker evidence than a component's own summary. + * + * @param {Project | null} project + * @param {string | null} coreDir * @returns {Promise} */ -async function gatherComponents(coreDir) { - const grouped = discoverComponents(coreDir); - const names = Object.values(grouped).flat(); +async function gatherComponents(project, coreDir) { + /** @type {Array<{name: string, package: string, docPath: string|null}>} */ + let records = []; + if (project) { + try { + records = await project.components(); + } catch { + // A discovery failure contributes no components; `doctor` owns reporting. + return []; + } + } + /** @type {Candidate[]} */ const candidates = []; - for (const comp of names) { - const readme = findComponentReadme(coreDir, comp); + for (const record of records) { /** @type {string[]} */ let keywords = []; let description = ''; - if (readme && readme.endsWith('.doc.mjs')) { - const doc = await loadModuleDoc(readme); + /** @type {string[]} */ + let prose = []; + /** @type {string | undefined} */ + let importPath = undefined; + + if (record.docPath) { + const doc = await loadModuleDoc(record.docPath); if (doc) { keywords = Array.isArray(doc.keywords) ? doc.keywords : []; description = doc.usage?.description || doc.description || ''; + prose = [ + ...(Array.isArray(doc.features) ? doc.features : []), + ...(Array.isArray(doc.usage?.bestPractices) ? doc.usage.bestPractices : []).map( + (/** @type {any} */ practice) => + typeof practice === 'string' ? practice : (practice?.description ?? ''), + ), + ].filter(Boolean); + // An integration's doc declares its own import specifier; Core's is + // derived from the package layout. + if (typeof doc.import === 'string') importPath = doc.import; } } + if (importPath == null && coreDir) { + importPath = resolveImportPath(coreDir, record.name); + } + candidates.push({ domain: 'component', - name: comp, + name: record.name, keywords, description, - _import: resolveImportPath(coreDir, comp), + prose, + _import: importPath, + _package: record.package, }); } return candidates; @@ -399,12 +481,21 @@ async function gatherHooks(coreDir) { /** @type {string[]} */ let keywords = []; let description = ''; + /** @type {string[]} */ + let prose = []; let importPath = '@astryxdesign/core/hooks'; if (docPath) { const doc = await loadModuleDoc(docPath); if (doc) { keywords = Array.isArray(doc.keywords) ? doc.keywords : []; description = doc.usage?.description || doc.description || ''; + prose = [ + ...(Array.isArray(doc.features) ? doc.features : []), + ...(Array.isArray(doc.usage?.bestPractices) ? doc.usage.bestPractices : []).map( + (/** @type {any} */ practice) => + typeof practice === 'string' ? practice : (practice?.description ?? ''), + ), + ].filter(Boolean); importPath = doc.importPath || importPath; } } @@ -413,6 +504,7 @@ async function gatherHooks(coreDir) { name: hookName, keywords, description, + prose, _import: importPath, }); } @@ -427,16 +519,23 @@ async function gatherHooks(coreDir) { * built-in one — otherwise the replacement is served by `astryx docs` but * invisible to the command whose job is finding it. * @param {string} cwd + * @param {Project | null} [project] already-loaded project, to avoid re-walking integrations * @returns {Promise} */ -async function gatherDocs(cwd) { +async function gatherDocs(cwd, project = null) { /** @type {Candidate[]} */ const candidates = []; let entries; try { - entries = (await loadDocsCatalog(cwd)).entries(); + entries = (await (project ? project.docs() : loadDocsCatalog(cwd))).entries(); } catch { - return candidates; + // `loadDocsCatalog` falls back to the built-in topics when a project's + // catalog cannot be built; take the same path rather than indexing nothing. + try { + entries = (await loadDocsCatalog(cwd)).entries(); + } catch { + return candidates; + } } for (const entry of entries) { let doc = null; @@ -510,26 +609,32 @@ async function gatherTemplates(cwd) { /** * Map a scored candidate to its public, actionable result shape. Each result - * carries enough to act on it: the domain, name, a one-line description, and - * the follow-up command (and import path where relevant). + * carries enough to act on it: the domain, name, a one-line description, how + * much of the query it covered, and the follow-up command (and import path + * where relevant). * * @param {Candidate} c - candidate - * @param {number} score - * @param {string} reason + * @param {ScoredHit} hit */ -function toResult(c, score, reason) { +function toResult(c, hit) { const base = { domain: c.domain, name: c.name, - score, - reason, + score: hit.score, + reason: hit.reason, description: c.description || '', + // Coverage, structured. The reason string has always said "matches 1/3 + // terms"; a consumer deciding whether a hit is worth acting on should not + // have to parse English back out of it. + matchedTerms: hit.matched, + queryTerms: hit.total, }; switch (c.domain) { case 'component': return { ...base, import: c._import, + package: c._package, command: `astryx component ${c.name}`, }; case 'hook': @@ -607,13 +712,25 @@ export async function search(query, options = {}) { throw new AstryxError('Could not find @astryxdesign/core package'); } + // One Project for the whole search: it resolves the config and every + // integration package, and both the component and doc gatherers need it. + // Loading it per gatherer would repeat that walk on every query, which the + // latency-sensitive callers (build, and any server holding the API in + // process) pay for directly. + let project = null; + try { + project = await Project.load(cwd); + } catch { + // No config, or a config that will not load: Core-only search still works. + } + // Gather candidates from each requested domain in parallel. /** @param {string} d */ const wants = d => !type || type === d; const [components, hooks, docTopics, templates] = await Promise.all([ - wants('component') ? gatherComponents(coreDir) : [], + wants('component') ? gatherComponents(project, coreDir) : [], wants('hook') ? gatherHooks(coreDir) : [], - wants('doc') ? gatherDocs(cwd) : [], + wants('doc') ? gatherDocs(cwd, project) : [], wants('template') ? gatherTemplates(cwd) : [], ]); @@ -626,7 +743,7 @@ export async function search(query, options = {}) { const scored = []; for (const candidate of all) { const hit = scoreQuery(term, tokens, candidate); - if (hit) scored.push(toResult(candidate, hit.score, hit.reason)); + if (hit) scored.push(toResult(candidate, hit)); } // Sort by score desc, then domain (stable order), then name. diff --git a/packages/cli/api/search/search.test.mjs b/packages/cli/api/search/search.test.mjs index d5e8279570d0c..a2545ba4533fb 100644 --- a/packages/cli/api/search/search.test.mjs +++ b/packages/cli/api/search/search.test.mjs @@ -12,7 +12,9 @@ * same contract as `astryx search` on the command line. */ -import {describe, it, expect} from 'vitest'; +import {describe, it, expect, beforeAll, afterAll} from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {search, SEARCH_DOMAINS} from './search.mjs'; @@ -92,3 +94,122 @@ describe('search leaf — limit validation (API matches the CLI contract)', () = }); }, SLOW); }); + +describe('search leaf — coverage is reported, not just implied', () => { + it('carries matchedTerms/queryTerms on every result', async () => { + const r = await search('data table with filters', {cwd}); + expect(r.data.results.length).toBeGreaterThan(0); + for (const hit of r.data.results) { + expect(typeof hit.matchedTerms).toBe('number'); + expect(typeof hit.queryTerms).toBe('number'); + expect(hit.queryTerms).toBeGreaterThanOrEqual(1); + expect(hit.matchedTerms).toBeGreaterThanOrEqual(1); + expect(hit.matchedTerms).toBeLessThanOrEqual(hit.queryTerms); + } + }, SLOW); + + it('reports full coverage for a whole-phrase match', async () => { + const r = await search('button', {cwd, type: 'component'}); + const exact = r.data.results.find(x => x.name === 'Button'); + expect(exact).toBeDefined(); + expect(exact?.matchedTerms).toBe(exact?.queryTerms); + }, SLOW); + + it('names the owning package on component results', async () => { + const r = await search('button', {cwd, type: 'component'}); + const exact = r.data.results.find(x => x.name === 'Button'); + expect(exact?.package).toBe('@astryxdesign/core'); + }, SLOW); +}); + +describe('search leaf — usage guidance is indexed, below the description tier', () => { + it('ranks a component on wording that appears only in its best-practice advice', async () => { + // `Banner` calls itself "a persistent message"; only its guidance names the + // situations a reader actually asks about. Before guidance was indexed, + // this query could not reach it. + const r = await search('maintenance notices', {cwd, type: 'component'}); + expect(r.data.results.some(x => x.name === 'Banner')).toBe(true); + }, SLOW); + + it('keeps a description hit above a component that only mentions the word in advice', async () => { + // The two tiers, on real Core data. `CheckboxInput` names notifications in + // its own description; `Card` only mentions them in best-practice advice. + // Scored on one tier both sat at 50 and the tie broke alphabetically, so + // the passing mention won. Now 50 beats 45 on the evidence, not the name. + const r = await search('notification', {cwd, type: 'component'}); + const rank = (/** @type {string} */ name) => r.data.results.findIndex(x => x.name === name); + const described = rank('CheckboxInput'); + const advised = rank('Card'); + expect(described).toBeGreaterThanOrEqual(0); + expect(advised).toBeGreaterThanOrEqual(0); + expect(described).toBeLessThan(advised); + }, SLOW); +}); + +describe('search leaf — integration components are searchable', () => { + // `search` used to gather components straight off findCoreDir(cwd), so a + // package listed in astryx.config.mjs was reachable by `component` and + // `template` and invisible to the one command whose job is finding things. + // The fixture is a minimal integration: a manifest, a doc, and the same-stem + // source file Project requires before it will trust the pair. + const fixture = path.join(os.tmpdir(), `astryx-search-integration-${process.pid}`); + const pkg = path.join(fixture, 'node_modules/@acme/widgets'); + + beforeAll(() => { + fs.mkdirSync(path.join(pkg, 'src'), {recursive: true}); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({name: '@acme/widgets', version: '1.0.0', type: 'module'}), + ); + fs.writeFileSync( + path.join(pkg, 'astryx.integration.mjs'), + "export default {components: './src'};\n", + ); + fs.writeFileSync( + path.join(pkg, 'src/AcmeDiffLink.doc.mjs'), + `export const docs = { + name: 'AcmeDiffLink', + displayName: 'Acme Diff Link', + import: '@acme/widgets/DiffLink', + keywords: ['diff', 'revision'], + usage: { + description: 'Renders a link to a code review revision.', + bestPractices: [ + {guidance: true, description: 'Use inside a table of pending code reviews.'}, + ], + }, +}; +`, + ); + fs.writeFileSync(path.join(pkg, 'src/AcmeDiffLink.tsx'), 'export const AcmeDiffLink = () => null;\n'); + fs.writeFileSync( + path.join(fixture, 'package.json'), + JSON.stringify({name: 'fixture', private: true, version: '1.0.0', type: 'module'}), + ); + fs.writeFileSync(path.join(fixture, 'astryx.config.mjs'), "export default {integrations: ['@acme/widgets']};\n"); + // Core has to resolve from the fixture for search to run at all. + fs.mkdirSync(path.join(fixture, 'node_modules/@astryxdesign'), {recursive: true}); + fs.symlinkSync( + path.join(REPO, 'packages/core'), + path.join(fixture, 'node_modules/@astryxdesign/core'), + 'dir', + ); + }); + + afterAll(() => { + fs.rmSync(fixture, {recursive: true, force: true}); + }); + + it('finds a component an integration owns, with its package and import path', async () => { + const r = await search('diff revision', {cwd: fixture, type: 'component'}); + const hit = r.data.results.find(x => x.name === 'AcmeDiffLink'); + expect(hit).toBeDefined(); + expect(hit?.package).toBe('@acme/widgets'); + expect(hit?.import).toBe('@acme/widgets/DiffLink'); + }, SLOW); + + it('still returns Core components alongside them', async () => { + const r = await search('diff revision', {cwd: fixture, type: 'component'}); + expect(r.data.results.some(x => x.package === '@astryxdesign/core')).toBe(true); + }, SLOW); +}); diff --git a/packages/cli/api/search/search.type.mjs b/packages/cli/api/search/search.type.mjs index 4a07f5f056494..7ff05c673c5bf 100644 --- a/packages/cli/api/search/search.type.mjs +++ b/packages/cli/api/search/search.type.mjs @@ -20,8 +20,11 @@ * @property {number} score - Relevance score (higher is better). * @property {string} reason - Human-readable reason the candidate matched (e.g. `keyword "button"`). * @property {string} description - One-line description, when available. + * @property {number} matchedTerms - How many of the query's concepts this result matched. + * @property {number} queryTerms - How many concepts the query had. `matchedTerms / queryTerms` is the coverage; a whole-phrase match reports full coverage. * @property {string} command - Follow-up command to act on this result (e.g. `astryx component Button`). * @property {string} [import] - Import path — present for component and hook results. + * @property {string} [package] - Owning package — present for component results; an integration's components report the integration. * @property {string} [title] - Doc title — present for doc results. * @property {string} [displayName] - Friendly display name — present for template results. * @property {'page' | 'block'} [kind] - Template kind (`page` | `block`) — present for template results. diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index 7c010c132b120..accc33756a561 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -104,7 +104,7 @@ export function registerBuild(program) { if (json) return jsonOut(result); - const {query: q, hasResults, directMatch, pages, blocks, domain, frame, foundation} = + const {query: q, hasResults, directMatch, pages, blocks, domain, frame, foundation, hint} = result.data; if (!hasResults) { @@ -193,6 +193,10 @@ export function registerBuild(program) { }), ); + // A thin kit is worth saying out loud. Left unsaid, it reads as "the + // package has nothing for this" rather than "these words missed". + if (hint) out.push(text(hint)); + emit(...out); }, });