Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/build-kit-coverage-and-hint.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .changeset/search-integrations-and-guidance.md
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions packages/cli/api/build/build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
1 change: 1 addition & 0 deletions packages/cli/api/build/build.type.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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".
*/

/**
Expand Down
47 changes: 46 additions & 1 deletion packages/cli/api/build/kit/kit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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)
Expand All @@ -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: {
Expand All @@ -81,6 +125,7 @@ export async function buildKit(query, options = {}) {
domain,
frame: FRAME,
foundation: FOUNDATION,
hint,
},
};
}
Loading
Loading