@astryxdesign/cli 0.6.3-canary.a078d7a → 0.6.3-canary.aa04154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/api/build/_adapter.d.mts +50 -0
  3. package/api/build/_adapter.mjs +60 -0
  4. package/api/build/build.doc.mjs +13 -7
  5. package/api/build/build.test.mjs +184 -6
  6. package/api/build/build.type.d.mts +57 -2
  7. package/api/build/build.type.mjs +30 -7
  8. package/api/build/help/help.d.mts +4 -0
  9. package/api/build/help/help.mjs +24 -11
  10. package/api/build/kit/kit.d.mts +4 -1
  11. package/api/build/kit/kit.mjs +165 -49
  12. package/api/build/kit/rank.d.mts +44 -0
  13. package/api/build/kit/rank.mjs +432 -0
  14. package/api/build/kit/rank.test.mjs +196 -0
  15. package/api/integration/authoring-checks.mjs +42 -22
  16. package/api/integration/authoring-checks.test.mjs +8 -1
  17. package/api/integration/authoring-checks.type.d.mts +1 -3
  18. package/api/integration/authoring-checks.type.mjs +7 -4
  19. package/api/integration/integrationTemplateConflicts.doc.d.mts +3 -0
  20. package/api/integration/integrationTemplateConflicts.doc.mjs +8 -6
  21. package/api/integration/template-conflict-compatibility.test.mjs +73 -0
  22. package/api/search/search.d.mts +33 -0
  23. package/api/search/search.mjs +134 -42
  24. package/api/template/template-integration.test.mjs +50 -0
  25. package/assets/docs/layout.doc.dense.mjs +2 -2
  26. package/assets/docs/layout.doc.mjs +1 -1
  27. package/assets/docs/working-with-ai.doc.mjs +3 -3
  28. package/clients/cli/commands/build.doc.mjs +11 -6
  29. package/clients/cli/commands/build.mjs +105 -70
  30. package/clients/cli/commands/build.text-fields.test.mjs +41 -0
  31. package/clients/cli/commands/doctor-integration.test.mjs +7 -0
  32. package/clients/cli/commands/doctor.mjs +39 -17
  33. package/foundation/agent-docs/agent-docs.d.mts +3 -2
  34. package/foundation/agent-docs/agent-docs.mjs +12 -9
  35. package/foundation/agent-docs/agent-docs.test.mjs +9 -0
  36. package/foundation/discovery/template-conflict-release.d.mts +13 -0
  37. package/foundation/discovery/template-conflict-release.mjs +40 -0
  38. package/foundation/discovery/template-conflict-release.test.mjs +40 -0
  39. package/foundation/response/response-types.doc.d.mts +5 -1
  40. package/foundation/response/response-types.doc.mjs +8 -4
  41. package/foundation/text/string-utils.d.mts +8 -0
  42. package/foundation/text/string-utils.mjs +18 -0
  43. package/package.json +9 -9
package/README.md CHANGED
@@ -80,7 +80,7 @@ Options:
80
80
  | Command | Description |
81
81
  | ------------- | ----------------------------------------------------------------------------- |
82
82
  | `blog` | Read the Astryx blog from the published feed |
83
- | `build` | Build a page: composition kit for an idea, or the workflow playbook (no args) |
83
+ | `build` | Build a page: the template to start from, or the workflow playbook (no query) |
84
84
  | `component` | List components or print component docs |
85
85
  | `discover` | Discover external packages and components |
86
86
  | `docs` | Print reference docs |
@@ -441,7 +441,7 @@ Every response has a `type` discriminant. The full set is below (generated from
441
441
  | `discover.search` | The echoed query plus the matching {package, component} pairs, when a free-text term matches several components. |
442
442
  | `search` | The echoed query, `matchCount` (total matches, before `limit`), and results, a ranked SearchResultEntry[] bounded by `limit`: each {domain, name, score, reason, description, command}, plus import (components, hooks), title, parent (the command that opens the level above), package (for a docs-tree hit), and, for a hit on one section, section (docs), or displayName and kind (templates). |
443
443
  | `build.help` | The how-to-build-a-page playbook, emitted when no query is given: `playbook: true`, a title, the ordered steps (title, commands, optional returns), the on-system rules, and related lookups. Commands are bare subcommands for the caller to render with its own invocation. |
444
- | `build.kit` | The composition kit: echoed query, hasResults, matchCount (total matched, never a cap), directMatch, pages (closest templates), blocks (drop-in patterns) and domain (idea components/hooks) as SearchResultEntry[], frame and foundation name arrays, and hint {reason, commands} when thin. |
444
+ | `build.kit` | The template to start from and its kit: query, hasResults, matchCount (never a cap), directMatch, start {name, command, basis, reason, alternatives, ...}, pages (search's closest templates), blocks and domain as SearchResultEntry[], frame, foundation, and hint {reason, commands} when thin. |
445
445
  | `swizzle.list` | The names of swizzlable components discoverable from cwd's @astryxdesign/core. |
446
446
  | `swizzle.copy` | An eject receipt: component name, owning package, output directory, files-copied count, the written file names, whether any file uses StyleX, and an optional maintainer note. |
447
447
  | `gap-report.categories` | The fixed gap category values and human-readable labels. |
@@ -470,7 +470,7 @@ Every response has a `type` discriminant. The full set is below (generated from
470
470
  | `integration.add` | A contribution-writer receipt: kind, name, optional root {path, created}, integration-manifest path, every affected project-relative path, written, and dryRun. |
471
471
  | `integration.pack-check` | The packed-package check: name, version, packable, tarball {filename, fileCount, size, unpackedSize} or null, inventory {manifest, roots [{kind, path, expectedFiles, missingFiles, complete}], expectedFiles, packedFiles}, contributions {local, packed}, each null or {themes [{slug, exportName}], components, templates [{id, type, name}], codemods [{version, id}], docs, agentDocsAppend}, and issues [{code, severity, message}]. |
472
472
  | `integration.validate` | The validation result: the package name and version (both null when no local manifest is found) plus issues, an AstryxIntegrationIssue[] of {code, severity: warning \| error, message}. |
473
- | `integration.template-conflicts` | The integration identity, issues, and conflicts as {severity: info \| warning, relationship: replaces \| accidental, replaces?, command}. |
473
+ | `integration.template-conflicts` | The integration identity, structural issues, and non-blocking Core template-id conflicts as {id, severity: warning, integrationPackage, integrationType, integrationName, coreMatches, message, command}. |
474
474
  | `integration.component-conflicts` | The integration identity, structural issues, and non-blocking conflicts where an integration component name is also owned by Core; each conflict includes the exact package-qualified command. |
475
475
  | `integration.doc-conflicts` | The integration identity, structural issues, and Core doc overlaps. Each finding includes `severity` (`info` \| `error`) and `relationship` (`replaces` \| `extends` \| `accidental`). |
476
476
  | `layout.expand` | The expansion: parsed form, generated TSX code, componentsUsed, states (count of useState hooks scaffolded), todos, blocksReferenced (each {name, mode}), warnings, and written (the output path, or null when nothing was written). |
@@ -0,0 +1,50 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * A page template the kit can recommend starting from.
6
+ * @typedef {object} PageTemplate
7
+ * @property {string} name The template's own id, as search reports it.
8
+ * @property {string} command `astryx template <id> --type page`, the command that selects exactly this template: an integration replacement is selected by the Core id it replaces, and `--type page` keeps a block with the same id from making it ambiguous. Search prints template commands the same way.
9
+ * @property {string} displayName Human-facing name.
10
+ * @property {string} description What the page is: its layout and the ideas it serves.
11
+ * @property {string} category The template's own `Family - Variant` label; empty when it declares none.
12
+ */
13
+ /**
14
+ * Every ready page template the project can scaffold, in discovery order. This
15
+ * is the default discovery view: an active integration replacement stands in
16
+ * for the Core template it replaces, as it does for `astryx template <id>`.
17
+ *
18
+ * Discovery failures leave the kit without a start rather than failing the
19
+ * command: the kit still carries its search matches, and `template --list`
20
+ * reports what went wrong.
21
+ *
22
+ * @param {string} cwd
23
+ * @returns {Promise<PageTemplate[]>}
24
+ */
25
+ export function loadPageTemplates(cwd: string): Promise<PageTemplate[]>;
26
+ /**
27
+ * A page template the kit can recommend starting from.
28
+ */
29
+ export type PageTemplate = {
30
+ /**
31
+ * The template's own id, as search reports it.
32
+ */
33
+ name: string;
34
+ /**
35
+ * `astryx template <id> --type page`, the command that selects exactly this template: an integration replacement is selected by the Core id it replaces, and `--type page` keeps a block with the same id from making it ambiguous. Search prints template commands the same way.
36
+ */
37
+ command: string;
38
+ /**
39
+ * Human-facing name.
40
+ */
41
+ displayName: string;
42
+ /**
43
+ * What the page is: its layout and the ideas it serves.
44
+ */
45
+ description: string;
46
+ /**
47
+ * The template's own `Family - Variant` label; empty when it declares none.
48
+ */
49
+ category: string;
50
+ };
@@ -0,0 +1,60 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The build subject's environment access: the page templates a project
5
+ * can scaffold.
6
+ *
7
+ * @input Template discovery for `cwd` — the CLI's own templates plus any that
8
+ * the project's configured integrations contribute.
9
+ * @output Ready page templates as `{name, displayName, description, category,
10
+ * command}`, where `command` is the `astryx template` command that selects
11
+ * exactly that template.
12
+ * @position Beside build.mjs (api/build/). The kit leaf reads templates only
13
+ * through here, because a subject's `_adapter.mjs` is its only environment
14
+ * access. Search keeps its own discovery; this adds none of its own, it
15
+ * reuses the template subject's.
16
+ */
17
+
18
+ import {discoverTemplates} from '../template/template.mjs';
19
+
20
+ /**
21
+ * A page template the kit can recommend starting from.
22
+ * @typedef {object} PageTemplate
23
+ * @property {string} name The template's own id, as search reports it.
24
+ * @property {string} command `astryx template <id> --type page`, the command that selects exactly this template: an integration replacement is selected by the Core id it replaces, and `--type page` keeps a block with the same id from making it ambiguous. Search prints template commands the same way.
25
+ * @property {string} displayName Human-facing name.
26
+ * @property {string} description What the page is: its layout and the ideas it serves.
27
+ * @property {string} category The template's own `Family - Variant` label; empty when it declares none.
28
+ */
29
+
30
+ /**
31
+ * Every ready page template the project can scaffold, in discovery order. This
32
+ * is the default discovery view: an active integration replacement stands in
33
+ * for the Core template it replaces, as it does for `astryx template <id>`.
34
+ *
35
+ * Discovery failures leave the kit without a start rather than failing the
36
+ * command: the kit still carries its search matches, and `template --list`
37
+ * reports what went wrong.
38
+ *
39
+ * @param {string} cwd
40
+ * @returns {Promise<PageTemplate[]>}
41
+ */
42
+ export async function loadPageTemplates(cwd) {
43
+ let templates;
44
+ try {
45
+ templates = await discoverTemplates(cwd);
46
+ } catch {
47
+ return [];
48
+ }
49
+ return templates
50
+ .filter(t => t.type === 'page' && t.isReady !== false)
51
+ .map(t => ({
52
+ name: t.dirName,
53
+ displayName: t.displayName || t.name,
54
+ description: t.description || '',
55
+ category: t.category || '',
56
+ // The id `template()` resolves back to this entry: an active replacement
57
+ // owns the Core id it names, so that id selects it, not its own.
58
+ command: `astryx template ${t.replaces ?? t.dirName} --type page`,
59
+ }));
60
+ }
@@ -14,13 +14,16 @@ export const doc = {
14
14
  namespace: 'cli/api',
15
15
  displayName: 'build()',
16
16
  summary:
17
- 'Page-building assistant: the how-to-build playbook, or a composition kit for an idea.',
17
+ 'Page-building assistant: the how-to-build playbook, or the page template to start from for an idea.',
18
18
  description:
19
- 'The "assemble a page" entry point. Called with no query it returns the ' +
19
+ 'The "build a page" entry point. Called with no query it returns the ' +
20
20
  'how-to-build-a-page playbook as data: the workflow steps with their ' +
21
- 'commands, the on-system rules, and related lookups. Called with a query it runs the unified search and groups the ' +
22
- 'hits into a composition KIT: the closest page templates, drop-in blocks, ' +
23
- 'and idea-specific components/hooks, plus the always-on frame + foundation.',
21
+ 'commands, the on-system rules, and related lookups. Called with a query it names the page template to ' +
22
+ 'START from (always one: the page template a ranker built for long descriptions puts first, else the app ' +
23
+ 'shell) and the next two templates, ' +
24
+ 'and the unified search grouped around it: the other close page templates, drop-in blocks, and ' +
25
+ 'idea-specific components/hooks, plus the always-on frame + foundation. A template carries the page ' +
26
+ 'frame and spacing, so the kit never recommends composing a page from components.',
24
27
  importPath: '@astryxdesign/cli/api',
25
28
  signature:
26
29
  'build(query?: string, options?: BuildOptions): Promise<BuildHelpResponse | BuildKitResponse>',
@@ -60,7 +63,7 @@ export const doc = {
60
63
  {
61
64
  type: 'build.kit',
62
65
  description:
63
- 'The grouped composition kit: the echoed query, hasResults/matchCount/directMatch fields, the closest page templates (≤3), drop-in block patterns (≤5), idea-specific components/hooks (≤6), and the always-on frame + foundation component-name arrays. Carries `hint` only when the kit came back thin — what to try instead, so a caller does not read a near-empty kit as "the package has nothing".',
66
+ "The page template to start from and the kit around it: the echoed query, hasResults/matchCount/directMatch fields, `start` (the template to scaffold, the `template <id> --type page <path>` command that selects it, whether the page ranker's pick is also search's direct match, the closest page, or the fallback app shell, and the ranker's next two `alternatives`), search's closest page templates (≤3), drop-in block patterns (≤5), idea-specific components/hooks (≤6), and the always-on frame + foundation component-name arrays. Carries `hint` only when the kit came back thin — what to try instead, so a caller does not read a near-empty kit as \"the package has nothing\".",
64
67
  },
65
68
  ],
66
69
  throws: [
@@ -71,7 +74,10 @@ export const doc = {
71
74
  ],
72
75
  examples: [
73
76
  {label: 'Get the playbook', code: 'const r = await build();'},
74
- {label: 'Compose a page', code: "await build('analytics dashboard');"},
77
+ {
78
+ label: 'Find the template to start from',
79
+ code: "const {data} = await build('analytics dashboard');\n// data.start.command: 'astryx template dashboard --type page <path>'",
80
+ },
75
81
  {
76
82
  label: 'Restrict + limit',
77
83
  code: "await build('pricing', {type: 'template', limit: 10});",
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file Tests for the build API (playbook + composition kit).
4
+ * @file Tests for the build API (playbook + the template-first kit).
5
5
  */
6
6
 
7
7
  import {describe, it, expect, vi} from 'vitest';
@@ -37,6 +37,15 @@ describe('build API', () => {
37
37
  for (const {command} of commands) expect(command).not.toMatch(/^(astryx|npx|pnpm|yarn|bunx?)\b/);
38
38
  });
39
39
 
40
+ it('the playbook scaffolds the named template before composing', async () => {
41
+ const r = await build();
42
+ if (r.type !== 'build.help') throw new Error(r.type);
43
+ const commands = r.data.steps.map(s => s.commands.map(c => c.command));
44
+ expect(commands[0][0]).toMatch(/^build /);
45
+ expect(commands[1]).toContain('template <name> <path>');
46
+ expect(JSON.stringify(r.data)).not.toMatch(/--skeleton|reference code/);
47
+ });
48
+
40
49
  it('query → build.kit with raw entries + static frame/foundation', async () => {
41
50
  const r = await build('dashboard', {cwd: REPO});
42
51
  expect(r.type).toBe('build.kit');
@@ -76,10 +85,10 @@ describe('build API', () => {
76
85
  expect(blocks.length).toBeLessThanOrEqual(5);
77
86
  expect(domain.length).toBeLessThanOrEqual(6);
78
87
 
79
- // Score floors: pages ≥ PAGE_FLOOR(50); blocks/domain ≥ DOMAIN_FLOOR(55).
88
+ // Score floors: pages ≥ PAGE_FLOOR(50); blocks/domain ≥ DOMAIN_FLOOR(60).
80
89
  for (const p of pages) expect(p.score).toBeGreaterThanOrEqual(50);
81
- for (const b of blocks) expect(b.score).toBeGreaterThanOrEqual(55);
82
- for (const d of domain) expect(d.score).toBeGreaterThanOrEqual(55);
90
+ for (const b of blocks) expect(b.score).toBeGreaterThanOrEqual(60);
91
+ for (const d of domain) expect(d.score).toBeGreaterThanOrEqual(60);
83
92
 
84
93
  // directMatch iff the top page is a confident match (PAGE_DIRECT = 95).
85
94
  expect(directMatch).toBe(pages.length > 0 && pages[0].score >= 95);
@@ -201,7 +210,7 @@ describe('build kit — a thin kit says what to try next', () => {
201
210
  it('hints for a matched-then-filtered query: hasResults true, nothing offerable', async () => {
202
211
  // The case most likely to be misread, and the reason the threshold counts
203
212
  // what SURVIVED the floors rather than what search returned.
204
- const r = await build('blockchain', {cwd: REPO});
213
+ const r = await build('hydration', {cwd: REPO});
205
214
  expect(r.type).toBe('build.kit');
206
215
  if (r.type !== 'build.kit') return;
207
216
  expect(r.data.hasResults).toBe(true);
@@ -224,12 +233,28 @@ describe('build kit — a thin kit says what to try next', () => {
224
233
  }
225
234
  });
226
235
 
236
+ it('still starts from the closest page on a loose match, and scaffolds it', async () => {
237
+ // A skeleton is a 35-line excerpt: a reader who studies it and composes
238
+ // the rest loses the spacing the template exists to carry. A loose match
239
+ // is still the best start there is, so `start` scaffolds it.
240
+ const r = await build('executive summary', {cwd: REPO});
241
+ expect(r.type).toBe('build.kit');
242
+ if (r.type !== 'build.kit') return;
243
+ expect(r.data.directMatch).toBe(false);
244
+ expect(r.data.start).toMatchObject({
245
+ name: 'dashboard-scorecard',
246
+ basis: 'closest',
247
+ command: 'astryx template dashboard-scorecard --type page <path>',
248
+ });
249
+ });
250
+
227
251
  it('recommends scaffolding on a direct match', async () => {
228
252
  const r = await build('contact form', {cwd: REPO});
229
253
  expect(r.type).toBe('build.kit');
230
254
  if (r.type !== 'build.kit') return;
231
255
  expect(r.data.directMatch).toBe(true);
232
256
  expect(r.data.pages.length).toBeGreaterThan(0);
257
+ expect(r.data.start).toMatchObject({name: r.data.pages[0].name, basis: 'direct'});
233
258
  for (const page of r.data.pages) {
234
259
  expect(page.command).not.toMatch(/--skeleton/);
235
260
  }
@@ -238,12 +263,13 @@ describe('build kit — a thin kit says what to try next', () => {
238
263
  it('keeps the recommendation package-manager-agnostic', async () => {
239
264
  // Appending a flag must not turn into prefixing an invocation; that stays
240
265
  // the renderer's job.
241
- const r = await build('notifications', {cwd: REPO});
266
+ const r = await build('executive summary', {cwd: REPO});
242
267
  expect(r.type).toBe('build.kit');
243
268
  if (r.type !== 'build.kit') return;
244
269
  for (const page of r.data.pages) {
245
270
  expect(page.command).not.toMatch(/^(pnpm|npm|yarn|bun|npx)\b/);
246
271
  }
272
+ expect(r.data.start?.command).not.toMatch(/^(pnpm|npm|yarn|bun|npx)\b/);
247
273
  });
248
274
 
249
275
  it('keeps recovery commands bare, for the caller to render', async () => {
@@ -258,3 +284,155 @@ describe('build kit — a thin kit says what to try next', () => {
258
284
  }
259
285
  });
260
286
  });
287
+
288
+ describe('build kit — every page starts from a template', () => {
289
+ it('falls back to the app shell when no page template matches', async () => {
290
+ // Before, an unmatched idea got "compose from AppShell": the one path
291
+ // with no frame, no spacing, and no section rhythm.
292
+ const r = await build('zzznomatch99', {cwd: REPO});
293
+ expect(r.type).toBe('build.kit');
294
+ if (r.type !== 'build.kit') return;
295
+ expect(r.data.hasResults).toBe(false);
296
+ expect(r.data.start).toMatchObject({
297
+ name: 'shell-top-nav',
298
+ basis: 'fallback',
299
+ command: 'astryx template shell-top-nav --type page <path>',
300
+ });
301
+ expect(r.data.start?.description).toBeTruthy();
302
+ });
303
+
304
+ it('starts a long idea from the family its words name', async () => {
305
+ // The coverage gate cannot tell layout words from subject matter: every
306
+ // dashboard covers one term of three, so search offers no page at all.
307
+ // The ranker still starts the page from the dashboard.
308
+ const r = await build('quarterly revenue dashboard', {cwd: REPO});
309
+ expect(r.type).toBe('build.kit');
310
+ if (r.type !== 'build.kit') return;
311
+ expect(r.data.start).toMatchObject({name: 'dashboard', basis: 'closest'});
312
+ expect(r.data.directMatch).toBe(false);
313
+ });
314
+
315
+ it('does not start from a direct match that is not ready yet, and says so', async () => {
316
+ const r = await build('incident console', {cwd: REPO});
317
+ expect(r.type).toBe('build.kit');
318
+ if (r.type !== 'build.kit') return;
319
+ expect(r.data.directMatch).toBe(true);
320
+ expect(r.data.pages[0].name).toBe('incident-console');
321
+ expect(r.data.start?.name).not.toBe('incident-console');
322
+ expect(r.data.start?.reason).toMatch(/`incident-console` matches but is not ready yet/);
323
+ });
324
+
325
+ it('does not start from a page that matched one incidental word', async () => {
326
+ // A work-item detail page mentions a feed in its description. That is a
327
+ // worse start for a news feed than the app shell.
328
+ const r = await build('news feed', {cwd: REPO});
329
+ expect(r.type).toBe('build.kit');
330
+ if (r.type !== 'build.kit') return;
331
+ expect(r.data.start?.basis).toBe('fallback');
332
+ });
333
+
334
+ it('starts a part from the page it is placed in, else from the app shell', async () => {
335
+ const placed = await build('an empty state for a settings page', {cwd: REPO});
336
+ if (placed.type !== 'build.kit') throw new Error(placed.type);
337
+ expect(placed.data.start).toMatchObject({name: 'settings', basis: 'closest'});
338
+ const loose = await build('a date range picker', {cwd: REPO});
339
+ if (loose.type !== 'build.kit') throw new Error(loose.type);
340
+ expect(loose.data.start).toMatchObject({name: 'shell-top-nav', basis: 'fallback'});
341
+ });
342
+
343
+ it('names a direct match the ranker outweighed in the reason', async () => {
344
+ const r = await build('dashboard with a login form', {cwd: REPO});
345
+ if (r.type !== 'build.kit') throw new Error(r.type);
346
+ expect(r.data.directMatch).toBe(true);
347
+ expect(r.data.pages[0].name).toBe('login');
348
+ expect(r.data.start).toMatchObject({name: 'dashboard', basis: 'closest'});
349
+ expect(r.data.start?.reason).toContain('`login`');
350
+ });
351
+
352
+ it('names the loose page matches when it falls back to the shell', async () => {
353
+ const r = await build('news feed', {cwd: REPO});
354
+ if (r.type !== 'build.kit') throw new Error(r.type);
355
+ expect(r.data.pages.length).toBeGreaterThan(0);
356
+ expect(r.data.start?.basis).toBe('fallback');
357
+ for (const page of r.data.pages) expect(r.data.start?.reason).toContain(`\`${page.name}\``);
358
+ });
359
+
360
+ it('never gives a reason that denies a match the response reports', async () => {
361
+ for (const q of ['news feed', 'dashboard with a login form', 'user profile', 'incident console']) {
362
+ const r = await build(q, {cwd: REPO});
363
+ if (r.type !== 'build.kit') throw new Error(r.type);
364
+ if (r.data.pages.length) expect(r.data.start?.reason).not.toMatch(/No template matched/);
365
+ if (r.data.directMatch) expect(r.data.start?.reason).not.toMatch(/none is exactly this page/);
366
+ }
367
+ });
368
+
369
+ it('names no start when the kit is narrowed to components', async () => {
370
+ const r = await build('dashboard', {cwd: REPO, type: 'component'});
371
+ expect(r.type).toBe('build.kit');
372
+ if (r.type !== 'build.kit') return;
373
+ expect(r.data.start).toBeNull();
374
+ });
375
+
376
+
377
+
378
+ it('keeps incidental description matches out of blocks and components', async () => {
379
+ // Toast, Popover and TextInput all say "brief" somewhere in their
380
+ // descriptions; none of them is part of a brief.
381
+ const r = await build('weekly brief', {cwd: REPO});
382
+ expect(r.type).toBe('build.kit');
383
+ if (r.type !== 'build.kit') return;
384
+ const names = [...r.data.blocks, ...r.data.domain].map(e => e.name);
385
+ for (const noise of ['Toast', 'Popover', 'TextInput']) expect(names).not.toContain(noise);
386
+ });
387
+
388
+ it('names the ranker\'s next two templates beside every start', async () => {
389
+ for (const idea of ['quarterly revenue dashboard', 'contact form']) {
390
+ const r = await build(idea, {cwd: REPO});
391
+ expect(r.type).toBe('build.kit');
392
+ if (r.type !== 'build.kit') return;
393
+ const alternatives = r.data.start?.alternatives ?? [];
394
+ expect(alternatives.length).toBeGreaterThan(0);
395
+ expect(alternatives.length).toBeLessThanOrEqual(2);
396
+ for (const alt of alternatives) {
397
+ expect(alt.name).not.toBe(r.data.start?.name);
398
+ expect(alt.description).toBeTruthy();
399
+ expect(alt.command).toBe(`astryx template ${alt.name} --type page <path>`);
400
+ }
401
+ }
402
+ });
403
+
404
+ it('calls a start direct only when search and the ranker agree', async () => {
405
+ const r = await build('contact form', {cwd: REPO});
406
+ expect(r.type).toBe('build.kit');
407
+ if (r.type !== 'build.kit') return;
408
+ expect(r.data.directMatch).toBe(true);
409
+ expect(r.data.start).toMatchObject({name: r.data.pages[0].name, basis: 'direct'});
410
+ });
411
+
412
+ it('never lets a noisy search match pick the start', async () => {
413
+ // Search once matched "site" to the gallery's "side" and started a
414
+ // navigation bar from a gallery. The ranker alone picks the start now.
415
+ const r = await build('horizontal site navigation with a current section indicator', {cwd: REPO});
416
+ expect(r.type).toBe('build.kit');
417
+ if (r.type !== 'build.kit') return;
418
+ expect(r.data.start?.name).toBe('shell-top-nav');
419
+ expect(r.data.pages.map(p => p.name)).not.toContain('side-gallery');
420
+ });
421
+
422
+ it('starts a component in a container from a template with that frame', async () => {
423
+ // "in a modal": the modal is the frame, so the dialog template leads
424
+ // instead of the app shell.
425
+ const r = await build('saved drafts in a modal with resume and delete row actions', {cwd: REPO});
426
+ expect(r.type).toBe('build.kit');
427
+ if (r.type !== 'build.kit') return;
428
+ expect(r.data.start?.name).toBe('settings-dialog');
429
+ });
430
+
431
+ it('does not start from a template named only by a word that modifies another', async () => {
432
+ // "product" describes the response; it is not a product page.
433
+ const r = await build('an interactive command catalog with side-by-side product response and trace', {cwd: REPO});
434
+ expect(r.type).toBe('build.kit');
435
+ if (r.type !== 'build.kit') return;
436
+ expect(r.data.start?.name).not.toMatch(/^product-/);
437
+ });
438
+ });
@@ -6,7 +6,7 @@
6
6
  */
7
7
  export type BuildPlaybookCommand = {
8
8
  /**
9
- * Bare subcommand with `<placeholder>` arguments (e.g. `template <name> --skeleton`) and no package-manager prefix — render it with your own CLI invocation.
9
+ * Bare subcommand with `<placeholder>` arguments (e.g. `template <name> <path>`) and no package-manager prefix — render it with your own CLI invocation.
10
10
  */
11
11
  command: string;
12
12
  /**
@@ -45,7 +45,61 @@ export type BuildHelpResponse = {
45
45
  };
46
46
  };
47
47
  /**
48
- * astryx --json build "<idea>" — the composition kit for what you're building.
48
+ * The page template a kit recommends starting from.
49
+ */
50
+ export type BuildStart = {
51
+ /**
52
+ * Template id, as `astryx template <name>` takes it.
53
+ */
54
+ name: string;
55
+ /**
56
+ * Human-facing template name.
57
+ */
58
+ displayName: string;
59
+ /**
60
+ * What the page is: its layout and the ideas it serves.
61
+ */
62
+ description: string;
63
+ /**
64
+ * The scaffold command that selects exactly this template, `astryx template <id> --type page <path>`: `<id>` is the Core id an integration replacement stands in for, else the template's own id, `<path>` is a placeholder for the file or folder to write the template to, and the `astryx` prefix is for the caller to replace with its own invocation.
65
+ */
66
+ command: string;
67
+ /**
68
+ * Why this template. The page ranker picks every start: it weighs each matched word by how rare it is among page templates, favors the family the idea's head names and the container it names ("in a modal"), and discounts words that only modify another. `direct` when its pick is also search's direct match (`directMatch`); `closest` when it is not; `fallback` when no template has the evidence to lead and the page starts from the app shell.
69
+ */
70
+ basis: "direct" | "closest" | "fallback";
71
+ /**
72
+ * One sentence saying the same as `basis`, for a reader.
73
+ */
74
+ reason: string;
75
+ /**
76
+ * The ranker's next closest page templates (≤2), for when the start's layout is wrong.
77
+ */
78
+ alternatives: BuildAlternative[];
79
+ };
80
+ /**
81
+ * A page template to consider instead of the start.
82
+ */
83
+ export type BuildAlternative = {
84
+ /**
85
+ * Template id, as `astryx template <name>` takes it.
86
+ */
87
+ name: string;
88
+ /**
89
+ * Human-facing template name.
90
+ */
91
+ displayName: string;
92
+ /**
93
+ * What the page is: its layout and the ideas it serves.
94
+ */
95
+ description: string;
96
+ /**
97
+ * The scaffold command, in the same form as the start's.
98
+ */
99
+ command: string;
100
+ };
101
+ /**
102
+ * astryx --json build "<idea>" — the page template to start from, and the kit around it.
49
103
  *
50
104
  * Entries are raw `SearchResultEntry` objects (no package-manager-prefixed
51
105
  * command strings — the CLI adds those); `frame`/`foundation` are static
@@ -58,6 +112,7 @@ export type BuildKitResponse = {
58
112
  hasResults: boolean;
59
113
  matchCount: number;
60
114
  directMatch: boolean;
115
+ start: BuildStart | null;
61
116
  pages: import("../search/search.type.mjs").SearchResultEntry[];
62
117
  blocks: import("../search/search.type.mjs").SearchResultEntry[];
63
118
  domain: import("../search/search.type.mjs").SearchResultEntry[];
@@ -3,14 +3,13 @@
3
3
  /**
4
4
  * @file Colocated types for the `build` command — source of truth for the
5
5
  * `build.help` (playbook) and `build.kit` (composition kit) JSON responses.
6
- * Re-exported by types/build.d.ts.
7
6
  */
8
7
 
9
8
  /**
10
9
  * A command the playbook tells the caller to run.
11
10
  *
12
11
  * @typedef {object} BuildPlaybookCommand
13
- * @property {string} command Bare subcommand with `<placeholder>` arguments (e.g. `template <name> --skeleton`) and no package-manager prefix — render it with your own CLI invocation.
12
+ * @property {string} command Bare subcommand with `<placeholder>` arguments (e.g. `template <name> <path>`) and no package-manager prefix — render it with your own CLI invocation.
14
13
  * @property {string} [purpose] What running it is for.
15
14
  */
16
15
 
@@ -37,7 +36,30 @@
37
36
  */
38
37
 
39
38
  /**
40
- * astryx --json build "<idea>" — the composition kit for what you're building.
39
+ * The page template a kit recommends starting from.
40
+ *
41
+ * @typedef {object} BuildStart
42
+ * @property {string} name Template id, as `astryx template <name>` takes it.
43
+ * @property {string} displayName Human-facing template name.
44
+ * @property {string} description What the page is: its layout and the ideas it serves.
45
+ * @property {string} command The scaffold command that selects exactly this template, `astryx template <id> --type page <path>`: `<id>` is the Core id an integration replacement stands in for, else the template's own id, `<path>` is a placeholder for the file or folder to write the template to, and the `astryx` prefix is for the caller to replace with its own invocation.
46
+ * @property {'direct' | 'closest' | 'fallback'} basis Why this template. The page ranker picks every start: it weighs each matched word by how rare it is among page templates, favors the family the idea's head names and the container it names ("in a modal"), and discounts words that only modify another. `direct` when its pick is also search's direct match (`directMatch`); `closest` when it is not; `fallback` when no template has the evidence to lead and the page starts from the app shell.
47
+ * @property {string} reason One sentence saying the same as `basis`, for a reader.
48
+ * @property {BuildAlternative[]} alternatives The ranker's next closest page templates (≤2), for when the start's layout is wrong.
49
+ */
50
+
51
+ /**
52
+ * A page template to consider instead of the start.
53
+ *
54
+ * @typedef {object} BuildAlternative
55
+ * @property {string} name Template id, as `astryx template <name>` takes it.
56
+ * @property {string} displayName Human-facing template name.
57
+ * @property {string} description What the page is: its layout and the ideas it serves.
58
+ * @property {string} command The scaffold command, in the same form as the start's.
59
+ */
60
+
61
+ /**
62
+ * astryx --json build "<idea>" — the page template to start from, and the kit around it.
41
63
  *
42
64
  * Entries are raw `SearchResultEntry` objects (no package-manager-prefixed
43
65
  * command strings — the CLI adds those); `frame`/`foundation` are static
@@ -47,14 +69,15 @@
47
69
  * @property {'build.kit'} type
48
70
  * @property {object} data
49
71
  * @property {string} data.query
50
- * @property {boolean} data.hasResults False when search returned nothing (renderer shows "No matches").
72
+ * @property {boolean} data.hasResults False when search returned nothing. The kit still names a template in `start`.
51
73
  * @property {number} data.matchCount Total ranked search matches for the query — counted before the search `limit`, the kit's score floors, and its per-group caps, so it is never a cap read back.
52
74
  * @property {boolean} data.directMatch True when the top page template is a confident direct match.
53
- * @property {import('../search/search.type.mjs').SearchResultEntry[]} data.pages Closest page templates (≤3). Each entry's `command` carries `--skeleton` when `directMatch` is false, so it recommends reading the layout rather than scaffolding it.
75
+ * @property {BuildStart | null} data.start The page template to start from: the ranker's pick, else the app shell. Null only when the kit is narrowed to components or hooks (`type`), or when the project has no page template to offer.
76
+ * @property {import('../search/search.type.mjs').SearchResultEntry[]} data.pages Closest page templates by search (≤3). Each entry's `command` carries `--skeleton` when `directMatch` is false, so it previews the layout; `start.command` is the scaffold.
54
77
  * @property {import('../search/search.type.mjs').SearchResultEntry[]} data.blocks Drop-in block patterns covering parts of the idea (≤5).
55
78
  * @property {import('../search/search.type.mjs').SearchResultEntry[]} data.domain Idea-specific components/hooks (≤6), excluding frame/foundation.
56
- * @property {string[]} data.frame Always-on page-shell component names.
57
- * @property {string[]} data.foundation Always-on layout/typography/action component names.
79
+ * @property {string[]} data.frame Always-on page-shell component names. Every page template already uses them.
80
+ * @property {string[]} data.foundation Always-on layout/typography/action component names. Every page template already uses them.
58
81
  * @property {{reason: string, commands: string[]}} [data.hint] Present only when the kit is thin. `reason` says why, `commands` are bare subcommands (e.g. `component --list`) for the caller to render with its own invocation — so a reader is never handed a command that does not resolve in their project.
59
82
  */
60
83
 
@@ -10,6 +10,10 @@
10
10
  * package-manager prefix, which keeps the JSON environment-agnostic; the CLI
11
11
  * renders each one with the caller's invocation, as it does build.kit's
12
12
  * `hint.commands`.
13
+ *
14
+ * The workflow starts every page from a template, because a template already
15
+ * has the frame, spacing, and section rhythm that a page composed from
16
+ * components has to rediscover.
13
17
  */
14
18
  /**
15
19
  * The page-building playbook (emitted when `build` runs with no query).