@astryxdesign/cli 0.7.2-canary.c9f2659 → 0.7.2-canary.cbb7fa3

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 (72) hide show
  1. package/api/component/_adapter.d.mts +41 -1
  2. package/api/component/_adapter.mjs +93 -7
  3. package/api/component/component.mjs +22 -5
  4. package/api/component/component.test.mjs +420 -167
  5. package/api/component/integration-export-name.test.mjs +644 -0
  6. package/api/component/list/list.mjs +42 -4
  7. package/api/gap-report/gap-report.mjs +67 -4
  8. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  9. package/api/integration/add-contribution.mjs +7 -3
  10. package/api/integration/pack-check.mjs +61 -107
  11. package/api/integration/pack-check.test.mjs +359 -0
  12. package/api/search/search.d.mts +9 -0
  13. package/api/search/search.mjs +108 -16
  14. package/api/search/search.test.mjs +36 -0
  15. package/api/swizzle/copy/copy.mjs +28 -14
  16. package/api/template/template.test.mjs +120 -3
  17. package/api/theme/build/build.family.test.mjs +16 -0
  18. package/api/theme/build/build.mjs +77 -18
  19. package/api/theme/themeBuild.doc.mjs +3 -3
  20. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  21. package/assets/codemods/transforms/next/index.mjs +11 -1
  22. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  23. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  24. package/assets/docs/tree/checks.doc.mjs +1 -1
  25. package/assets/docs/tree/component-family.doc.mjs +5 -1
  26. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  27. package/assets/docs/tree/single-component.doc.mjs +1 -1
  28. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  29. package/assets/docs/tree/versioning.doc.mjs +1 -0
  30. package/assets/docs/working-with-ai.doc.mjs +43 -4
  31. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  32. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  33. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  34. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  35. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  36. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  37. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  38. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  39. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  40. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  41. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  42. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  43. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  44. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  45. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  46. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  47. package/assets/templates/pages/detail-page/page.tsx +3 -3
  48. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  49. package/authoring/doctypes/base/type.ts +6 -0
  50. package/authoring/doctypes/component/component.doc.mjs +4 -4
  51. package/authoring/doctypes/component/type.ts +6 -3
  52. package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
  53. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  54. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  55. package/clients/cli/lib/component-format.mjs +5 -1
  56. package/foundation/discovery/component-discovery.d.mts +172 -10
  57. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  58. package/foundation/discovery/component-discovery.mjs +863 -55
  59. package/foundation/discovery/component-discovery.test.mjs +96 -0
  60. package/foundation/discovery/component-replacement.mjs +18 -0
  61. package/foundation/discovery/component-replacement.test.mjs +40 -0
  62. package/foundation/discovery/module-exports.d.mts +27 -0
  63. package/foundation/discovery/module-exports.mjs +356 -31
  64. package/foundation/discovery/module-exports.test.mjs +172 -0
  65. package/foundation/discovery/template-adapter.mjs +74 -5
  66. package/foundation/doc-compiler/doc-loads.test.mjs +5 -1
  67. package/foundation/doc-compiler/inputs.mjs +23 -0
  68. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  69. package/foundation/integrations/contribution-inventory.mjs +6 -1
  70. package/foundation/xle/registry.mjs +4 -0
  71. package/foundation/xle/registry.test.mjs +28 -0
  72. package/package.json +10 -10
@@ -16,6 +16,7 @@ import {
16
16
  discoverComponents,
17
17
  discoverExternalComponentsGrouped,
18
18
  discoverValidIntegrationComponents,
19
+ familyMember,
19
20
  findComponentReadme,
20
21
  findExternalComponentDoc,
21
22
  resolveImportPath,
@@ -66,6 +67,33 @@ async function legacyImport(ext, name, coreDir, docOpts) {
66
67
  */
67
68
  const coreEntry = doc => ({name: doc.name, package: CORE_PACKAGE, ...doc});
68
69
 
70
+ /**
71
+ * The full-level entry for a component documented as a member of its family's
72
+ * doc (VStack in Stack's doc): a component doc built from the member's own
73
+ * entry, under its own name, display name and import, shaped like every other
74
+ * full entry. Null when the doc is the component's own.
75
+ * @param {any} docs
76
+ * @param {string} name
77
+ * @param {string} coreDir
78
+ * @returns {any}
79
+ */
80
+ function memberEntry(docs, name, coreDir) {
81
+ const member = familyMember(docs, name);
82
+ if (!member) return null;
83
+ return {
84
+ name,
85
+ package: CORE_PACKAGE,
86
+ displayName: member.displayName ?? name,
87
+ ...(docs.group ? {group: docs.group} : {}),
88
+ ...(docs.category ? {category: docs.category} : {}),
89
+ usage: member.usage ?? {description: member.description ?? ''},
90
+ props: member.props ?? [],
91
+ ...(member.examples ? {examples: member.examples} : {}),
92
+ ...(docs.theming ? {theming: docs.theming} : {}),
93
+ import: resolveImportPath(coreDir, name),
94
+ };
95
+ }
96
+
69
97
  /**
70
98
  * The entry for a Core slot an integration component replaces
71
99
  * (spec:AST-035 FR11), at the list's detail level: the replacement's own name,
@@ -177,7 +205,11 @@ export async function componentList(
177
205
  entries.push({
178
206
  name: comp,
179
207
  package: CORE_PACKAGE,
180
- description: docs.usage?.description || docs.description || '',
208
+ description:
209
+ familyMember(docs, comp)?.description ||
210
+ docs.usage?.description ||
211
+ docs.description ||
212
+ '',
181
213
  import: resolveImportPath(coreDir, comp),
182
214
  });
183
215
  } catch {
@@ -215,7 +247,8 @@ export async function componentList(
215
247
  const readme = findComponentReadme(coreDir, comp);
216
248
  if (readme && readme.endsWith('.doc.mjs')) {
217
249
  try {
218
- entries.push(coreEntry(await loadComponentDoc(readme, {zh, lang, dense})));
250
+ const docs = await loadComponentDoc(readme, {zh, lang, dense});
251
+ entries.push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
219
252
  } catch {
220
253
  entries.push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
221
254
  }
@@ -269,7 +302,11 @@ export async function componentList(
269
302
  result[cat].push({
270
303
  name: comp,
271
304
  package: CORE_PACKAGE,
272
- description: docs.usage?.description || docs.description || '',
305
+ description:
306
+ familyMember(docs, comp)?.description ||
307
+ docs.usage?.description ||
308
+ docs.description ||
309
+ '',
273
310
  import: resolveImportPath(coreDir, comp),
274
311
  });
275
312
  } catch {
@@ -310,7 +347,8 @@ export async function componentList(
310
347
  const readme = findComponentReadme(coreDir, comp);
311
348
  if (readme && readme.endsWith('.doc.mjs')) {
312
349
  try {
313
- result[cat].push(coreEntry(await loadComponentDoc(readme, {zh, lang, dense})));
350
+ const docs = await loadComponentDoc(readme, {zh, lang, dense});
351
+ result[cat].push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
314
352
  } catch {
315
353
  result[cat].push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
316
354
  }
@@ -17,7 +17,18 @@
17
17
  import {Worker} from 'node:worker_threads';
18
18
  import {AstryxError} from '../error.mjs';
19
19
  import {Project} from '../../foundation/config/project.mjs';
20
- import {CORE_PACKAGE} from '../../foundation/discovery/component-discovery.mjs';
20
+ import {
21
+ CORE_PACKAGE,
22
+ discoverIntegrationComponents,
23
+ docStem,
24
+ findComponentReadme,
25
+ findIntegrationComponent,
26
+ integrationRecordHold,
27
+ isIntegrationMember,
28
+ unscopedComponentOwners,
29
+ } from '../../foundation/discovery/component-discovery.mjs';
30
+ import {legacyExternalKeeps} from '../component/_adapter.mjs';
31
+ import {findCoreDir} from '../../foundation/fs/paths.mjs';
21
32
  import {captureEnv} from '../../foundation/debug/event.mjs';
22
33
  import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
23
34
  import {parseGapReportReceipt} from '../../authoring/gap-report/parse.mjs';
@@ -178,13 +189,65 @@ async function selectTarget(project, component, packageName) {
178
189
  coreName,
179
190
  );
180
191
  if (replacement) return targetForPackage(project, replacement.package);
192
+ // The owner rule runs over every owner discovery finds, loadable or not
193
+ // (a doc recorded under another export still answers to its file's name);
194
+ // then only owners whose docs load route a report.
195
+ const wanted = bareComponent.toLowerCase();
196
+ const loads = new Set(records.map(r => `${r.package}\0${r.docPath}`));
197
+ const recorded = project.loadedIntegrations.flatMap(integration =>
198
+ discoverIntegrationComponents(integration).flatMap(r =>
199
+ r.name.toLowerCase() === wanted
200
+ ? [{...r, hold: integrationRecordHold(r)}]
201
+ : docStem(r.docPath).toLowerCase() === wanted && !isIntegrationMember(r)
202
+ ? [{...r, hold: 'doc'}]
203
+ : [],
204
+ ),
205
+ );
206
+ // The checks below find files by name, so they run on every spelling the
207
+ // case-blind match above found, as well as the one typed.
208
+ const spellings = new Set([
209
+ bareComponent,
210
+ ...recorded.flatMap(r =>
211
+ [r.name, docStem(r.docPath)].filter(n => n.toLowerCase() === wanted),
212
+ ),
213
+ ]);
214
+ // Core also holds a name only its doc lookup finds, as in detail: a name a
215
+ // doc records yields to it, though it routes no report itself.
216
+ const coreDir = findCoreDir(project.cwd);
217
+ const coreDocs =
218
+ coreDir != null &&
219
+ [...spellings].some(name => findComponentReadme(coreDir, name) != null);
220
+ const ruled = unscopedComponentOwners([
221
+ ...records
222
+ .filter(
223
+ r => r.package === CORE_PACKAGE && r.name.toLowerCase() === wanted,
224
+ )
225
+ .map(r => ({...r, hold: 'core'})),
226
+ ...recorded,
227
+ // A hidden doc named after its file still holds that name, as the
228
+ // component lookup finds it; it routes no report.
229
+ ...project.loadedIntegrations.flatMap(integration =>
230
+ [...spellings].flatMap(spelling => {
231
+ const found = findIntegrationComponent(integration, spelling);
232
+ return found?.hold === 'file'
233
+ ? [{...found, package: integration.name}]
234
+ : [];
235
+ }),
236
+ ),
237
+ ]).filter(r => !coreDocs || r.hold !== 'doc');
238
+ // A legacy docs package keeps an export name a doc records, as in detail.
181
239
  const owners = [
182
240
  ...new Set(
183
- records
241
+ ([...spellings].some(name =>
242
+ legacyExternalKeeps(project.cwd, name, ruled),
243
+ )
244
+ ? []
245
+ : ruled
246
+ )
184
247
  .filter(
185
- record => record.name.toLowerCase() === bareComponent.toLowerCase(),
248
+ r => r.hold === 'core' || loads.has(`${r.package}\0${r.docPath}`),
186
249
  )
187
- .map(record => record.package),
250
+ .map(r => r.package),
188
251
  ),
189
252
  ];
190
253
 
@@ -98,6 +98,72 @@ describe('integrationAddComponent name collisions', () => {
98
98
  expect(snapshot()).toEqual(before);
99
99
  });
100
100
 
101
+ it('adds a name a family doc lists but the module it imports does not export', async () => {
102
+ fs.writeFileSync(
103
+ path.join(tmpDir, 'package.json'),
104
+ `${JSON.stringify({name: '@acme/widgets', version: '1.0.0', files: ['dist'], exports: {'./tabs': './components/tabs/index.ts'}}, null, 2)}\n`,
105
+ );
106
+ const tabs = path.join(tmpDir, 'components', 'tabs');
107
+ fs.mkdirSync(tabs, {recursive: true});
108
+ fs.writeFileSync(
109
+ path.join(tabs, 'AcmeTabs.doc.mjs'),
110
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTabExtra', description: 'x'}]};\n",
111
+ );
112
+ fs.writeFileSync(
113
+ path.join(tabs, 'AcmeTabs.tsx'),
114
+ 'export function AcmeTabs() { return null; }\nexport function AcmeTabExtra() { return null; }\n',
115
+ );
116
+ fs.writeFileSync(
117
+ path.join(tabs, 'index.ts'),
118
+ "export {AcmeTabs} from './AcmeTabs';\n",
119
+ );
120
+
121
+ const result = await integrationAdd('component', 'AcmeTabExtra', {
122
+ cwd: tmpDir,
123
+ dryRun: true,
124
+ });
125
+ expect(result.data.files).toContain('components/AcmeTabExtra.doc.mjs');
126
+ });
127
+
128
+ it('refuses a member a family exports through built output', async () => {
129
+ fs.writeFileSync(
130
+ path.join(tmpDir, 'package.json'),
131
+ `${JSON.stringify({name: '@acme/widgets', version: '1.0.0', files: ['dist'], exports: {'./tabs': './dist/tabs/index.js'}}, null, 2)}\n`,
132
+ );
133
+ const tabs = path.join(tmpDir, 'components', 'tabs');
134
+ fs.mkdirSync(tabs, {recursive: true});
135
+ fs.writeFileSync(
136
+ path.join(tabs, 'AcmeTabs.doc.mjs'),
137
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTab', description: 'x'}]};\n",
138
+ );
139
+ fs.writeFileSync(
140
+ path.join(tabs, 'AcmeTabs.tsx'),
141
+ 'export function AcmeTabs() { return null; }\n',
142
+ );
143
+ fs.writeFileSync(
144
+ path.join(tabs, 'AcmeTab.tsx'),
145
+ 'export function AcmeTab() { return null; }\n',
146
+ );
147
+ const dist = path.join(tmpDir, 'dist', 'tabs');
148
+ fs.mkdirSync(dist, {recursive: true});
149
+ fs.writeFileSync(
150
+ path.join(dist, 'index.js'),
151
+ "export {AcmeTabs} from './tabs.js';\nexport {AcmeTab} from './tab.js';\n",
152
+ );
153
+ fs.writeFileSync(
154
+ path.join(dist, 'tabs.js'),
155
+ 'export function AcmeTabs() { return null; }\n',
156
+ );
157
+ fs.writeFileSync(
158
+ path.join(dist, 'tab.js'),
159
+ 'export function AcmeTab() { return null; }\n',
160
+ );
161
+
162
+ await expect(
163
+ integrationAdd('component', 'AcmeTab', {cwd: tmpDir, dryRun: true}),
164
+ ).rejects.toThrow('Refusing to add component "AcmeTab"');
165
+ });
166
+
101
167
  it('still adds a component whose name is free', async () => {
102
168
  writeAuthoredComponent('components/AcmeWidget/AcmeWidget.doc.mjs');
103
169
 
@@ -264,9 +264,13 @@ async function addComponent(name, options) {
264
264
  ERROR_CODES.ERR_FILE_EXISTS,
265
265
  );
266
266
  }
267
- // Discovery keys components by doc stem anywhere under the root, so a second
268
- // doc with this name would shadow one of the two.
269
- const existingDoc = findIntegrationComponentDoc({components: root}, name);
267
+ // A second doc under a name discovery already records, by a doc's file name
268
+ // anywhere under the root or by a name a doc documents, would shadow one of
269
+ // the two.
270
+ const existingDoc = findIntegrationComponentDoc(
271
+ {name: owner, components: root, __packageDir: packageDir},
272
+ name,
273
+ );
270
274
  if (existingDoc != null) {
271
275
  throw new AstryxError(
272
276
  `Refusing to add component "${name}": ${projectPath(path.relative(packageDir, existingDoc))} already defines it.`,
@@ -24,7 +24,6 @@ import * as path from 'node:path';
24
24
  import * as os from 'node:os';
25
25
  import {fileURLToPath} from 'node:url';
26
26
  import {spawnSync} from 'node:child_process';
27
- import jscodeshift from 'jscodeshift';
28
27
  import {assertWithin} from '../../foundation/fs/path-safety.mjs';
29
28
  import {resolvePackageDir} from '../../foundation/integrations/integrations.mjs';
30
29
  import {
@@ -41,6 +40,7 @@ import {
41
40
  } from '../../foundation/discovery/docs-discovery.mjs';
42
41
  import {discoverIntegrationThemes} from '../../foundation/discovery/theme-discovery.mjs';
43
42
  import {inspectPackageExport} from '../../foundation/discovery/package-exports.mjs';
43
+ import {moduleExportsName} from '../../foundation/discovery/module-exports.mjs';
44
44
  import {
45
45
  ThemeImportError,
46
46
  resolveThemeImports,
@@ -51,6 +51,9 @@ import {
51
51
  } from '../theme/build/build.mjs';
52
52
  import {
53
53
  discoverIntegrationComponents,
54
+ docStem,
55
+ familyImports,
56
+ isIntegrationMember,
54
57
  resolveIntegrationImportPath,
55
58
  } from '../../foundation/discovery/component-discovery.mjs';
56
59
  import {loadComponentReplacements} from '../component/_adapter.mjs';
@@ -307,98 +310,6 @@ function extractTarball(tgzPath, extractDir) {
307
310
  }
308
311
  }
309
312
 
310
- const MODULE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.tsx', '.jsx'];
311
- const moduleParser = jscodeshift.withParser('tsx');
312
-
313
- /** @param {unknown} specifier @param {string} fromFile */
314
- function resolveLocalModule(specifier, fromFile) {
315
- if (typeof specifier !== 'string' || !specifier.startsWith('.')) return null;
316
- const base = path.resolve(path.dirname(fromFile), specifier);
317
- const candidates = [
318
- base,
319
- ...MODULE_EXTENSIONS.map(extension => `${base}${extension}`),
320
- ...MODULE_EXTENSIONS.map(extension => path.join(base, `index${extension}`)),
321
- ];
322
- return candidates.find(candidate => fs.existsSync(candidate)) ?? null;
323
- }
324
-
325
- /** @param {any} declaration @param {string} exportName */
326
- function declarationExports(declaration, exportName) {
327
- if (!declaration) return false;
328
- if (['FunctionDeclaration', 'ClassDeclaration'].includes(declaration.type)) {
329
- return declaration.id?.name === exportName;
330
- }
331
- if (declaration.type !== 'VariableDeclaration') return false;
332
- return declaration.declarations.some(
333
- (/** @type {any} */ item) =>
334
- item.id?.type === 'Identifier' && item.id.name === exportName,
335
- );
336
- }
337
-
338
- /**
339
- * Check one resolved module's static export surface without executing package
340
- * code. Local ESM re-exports are followed recursively.
341
- *
342
- * @param {string} file
343
- * @param {string} exportName
344
- * @param {Set<string>} [seen]
345
- */
346
- function moduleExportsName(file, exportName, seen = new Set()) {
347
- const identity = `${file}\0${exportName}`;
348
- if (seen.has(identity) || !fs.existsSync(file)) return false;
349
- seen.add(identity);
350
-
351
- let root;
352
- try {
353
- root = moduleParser(fs.readFileSync(file, 'utf-8'));
354
- } catch {
355
- return false;
356
- }
357
-
358
- let found = false;
359
- root
360
- .find(moduleParser.ExportNamedDeclaration)
361
- .forEach((/** @type {any} */ exportPath) => {
362
- if (found) return;
363
- const node = exportPath.node;
364
- if (declarationExports(node.declaration, exportName)) {
365
- found = true;
366
- return;
367
- }
368
- for (const specifier of node.specifiers ?? []) {
369
- const exported = specifier.exported?.name ?? specifier.exported?.value;
370
- if (exported !== exportName) continue;
371
- if (!node.source) {
372
- found = true;
373
- return;
374
- }
375
- const target = resolveLocalModule(node.source.value, file);
376
- const imported =
377
- specifier.local?.name ?? specifier.local?.value ?? exportName;
378
- if (target && moduleExportsName(target, imported, seen)) {
379
- found = true;
380
- return;
381
- }
382
- }
383
- });
384
- if (found) return true;
385
- if (
386
- exportName === 'default' &&
387
- root.find(moduleParser.ExportDefaultDeclaration).size() > 0
388
- ) {
389
- return true;
390
- }
391
-
392
- root
393
- .find(moduleParser.ExportAllDeclaration)
394
- .forEach((/** @type {any} */ exportPath) => {
395
- if (found) return;
396
- const target = resolveLocalModule(exportPath.node.source?.value, file);
397
- if (target && moduleExportsName(target, exportName, seen)) found = true;
398
- });
399
- return found;
400
- }
401
-
402
313
  /**
403
314
  * Whether any of these doc files has a section that sets `id`.
404
315
  * @param {string[]} files
@@ -472,15 +383,20 @@ async function validatePackedComponentExports(integration, scratchBase) {
472
383
  const issues = [];
473
384
  /** @type {Array<{name: string, specifier: string}>} */
474
385
  const components = [];
386
+ // A family doc backs several records: load it, and report it, once.
387
+ const loads = new Map();
475
388
  for (const record of records) {
389
+ const first = !loads.has(record.docPath);
390
+ if (first) loads.set(record.docPath, loadComponentDoc(record.docPath));
476
391
  let docs;
477
392
  try {
478
- docs = await loadComponentDoc(record.docPath);
393
+ docs = await loads.get(record.docPath);
479
394
  } catch (err) {
395
+ if (!first) continue;
480
396
  issues.push(
481
397
  error(
482
398
  'component_doc_unloadable',
483
- `Component "${record.name}" doc at "${record.docPath}" could not be loaded: ${err instanceof Error ? err.message : String(err)}`,
399
+ `Component "${docStem(record.docPath)}" doc at "${record.docPath}" could not be loaded: ${err instanceof Error ? err.message : String(err)}`,
484
400
  ),
485
401
  );
486
402
  continue;
@@ -660,11 +576,10 @@ async function validateIncompleteThemeExports(
660
576
  const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
661
577
  try {
662
578
  if (outputs.some(output => output.kind !== 'font stylesheet')) {
663
- const receipt = await themeBuild(
664
- source,
665
- {out: expectedCss},
666
- {cwd: packageDir},
667
- );
579
+ // The scratch directory is the write root; the package stays the cwd,
580
+ // so the build selects the package's Core and its paths as before.
581
+ const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
582
+ const receipt = await themeBuild(source, scratchBuild, {cwd: packageDir});
668
583
  if (receipt == null || receipt.type !== 'theme.build') {
669
584
  throw new Error('the theme build produced no complete receipt');
670
585
  }
@@ -847,11 +762,12 @@ async function validatePackedThemeExports(
847
762
  );
848
763
  const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
849
764
  try {
850
- const receipt = await themeBuild(
851
- source,
852
- {out: expectedCss},
853
- {cwd: localIntegration.__packageDir},
854
- );
765
+ // The scratch directory is the write root; the package stays the cwd,
766
+ // so the build selects the package's Core and its paths as before.
767
+ const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
768
+ const receipt = await themeBuild(source, scratchBuild, {
769
+ cwd: localIntegration.__packageDir,
770
+ });
855
771
  if (receipt == null || receipt.type !== 'theme.build') {
856
772
  issues.push(
857
773
  error(
@@ -1085,6 +1001,24 @@ export async function integrationPackCheck(options = {}) {
1085
1001
  const fileInv = computeRequiredFiles(loaded);
1086
1002
  const {identities: localIdentities, errors: localDiscoveryErrors} =
1087
1003
  await collectIdentities(loaded);
1004
+ // A family lists its members from the module its import names, which the
1005
+ // pack lifecycle may build or rebuild, so they are compared as local
1006
+ // discovery lists them after the pack.
1007
+ const familiesBefore = familyImports(loaded).map(family => family.docPath);
1008
+ /**
1009
+ * @param {Array<{name: string, docPath: string|null}>} records
1010
+ * @param {Set<string>} families
1011
+ */
1012
+ const membersOf = (records, families) =>
1013
+ records
1014
+ .filter(
1015
+ r =>
1016
+ r.docPath != null &&
1017
+ families.has(r.docPath) &&
1018
+ isIntegrationMember(r),
1019
+ )
1020
+ .map(r => r.name);
1021
+ const recordsBefore = discoverIntegrationComponents(loaded);
1088
1022
  for (const discoveryError of localDiscoveryErrors) {
1089
1023
  issues.push(
1090
1024
  error(
@@ -1345,7 +1279,27 @@ export async function integrationPackCheck(options = {}) {
1345
1279
  );
1346
1280
  }
1347
1281
 
1348
- issues.push(...compareIdentities(localIdentities, packedIdentities));
1282
+ const families = new Set([
1283
+ ...familiesBefore,
1284
+ ...familyImports(loaded).map(family => family.docPath),
1285
+ ]);
1286
+ let localComponents = localIdentities.components;
1287
+ if (families.size > 0) {
1288
+ const stale = new Set(membersOf(recordsBefore, families));
1289
+ const built = new Set(
1290
+ membersOf(discoverIntegrationComponents(loaded), families),
1291
+ );
1292
+ const {identities: afterPack} = await collectIdentities(loaded);
1293
+ localComponents = [
1294
+ ...new Set([
1295
+ ...localComponents.filter(name => !stale.has(name)),
1296
+ ...afterPack.components.filter(name => built.has(name)),
1297
+ ]),
1298
+ ].sort();
1299
+ }
1300
+ // The receipt reports the local inventory that was compared.
1301
+ const comparedLocal = {...localIdentities, components: localComponents};
1302
+ issues.push(...compareIdentities(comparedLocal, packedIdentities));
1349
1303
  issues.push(
1350
1304
  ...(await validatePackedThemeExports(
1351
1305
  loaded,
@@ -1367,7 +1321,7 @@ export async function integrationPackCheck(options = {}) {
1367
1321
  pkgVersion,
1368
1322
  fileInv,
1369
1323
  packResult,
1370
- localIdentities,
1324
+ comparedLocal,
1371
1325
  packedIdentities,
1372
1326
  issues,
1373
1327
  );