@contentful/experience-design-system-cli 2.11.4-dev-build-355c69b.0 → 2.11.4-dev-build-f1df48f.0

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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.11.4-dev-build-355c69b.0",
3
+ "version": "2.11.4-dev-build-f1df48f.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -99,7 +99,16 @@ export function registerAnalyzeCommand(program) {
99
99
  .description('Extract component definitions from a project')
100
100
  .requiredOption('--project <path>', 'Path to the project root')
101
101
  .option('--dir <path>', 'Path to the component source directory relative to the project root')
102
+ .option('--resolve-unreachable <mode>', "Retry pass for unresolved Svelte Props types: 'auto' (default), 'always', or 'never'", 'auto')
102
103
  .action(async (opts) => {
104
+ const resolveUnreachable = (() => {
105
+ const v = opts.resolveUnreachable ?? 'auto';
106
+ if (v !== 'auto' && v !== 'always' && v !== 'never') {
107
+ process.stderr.write(`Error: --resolve-unreachable must be one of 'auto', 'always', 'never' (got '${v}')\n`);
108
+ process.exit(1);
109
+ }
110
+ return v;
111
+ })();
103
112
  const projectRoot = resolve(opts.project);
104
113
  const outDir = join(projectRoot, '.contentful');
105
114
  let sourceDirectory;
@@ -123,7 +132,7 @@ export function registerAnalyzeCommand(program) {
123
132
  if (!process.stdout.isTTY) {
124
133
  process.stderr.write(`progress=extract:${filesProcessed}/${sourceFiles.length}:${componentsFound}\n`);
125
134
  }
126
- });
135
+ }, { resolveUnreachable, projectRoot });
127
136
  await mkdir(outDir, { recursive: true });
128
137
  const db = openPipelineDb();
129
138
  const { sessionId } = getOrCreateSession(db, undefined, undefined, {
@@ -159,15 +168,25 @@ export function registerAnalyzeCommand(program) {
159
168
  filterWarnings.push(`${component.name}: ${reviewNotes}`);
160
169
  }
161
170
  }
171
+ // Preserve any extractor-level review reasons (e.g. `props-type-unresolved`
172
+ // from the Svelte parser) by merging them into the post-processing recompute.
173
+ // Without this, recomputing here clobbers the per-extractor signal.
174
+ const extractorReasons = component.reviewReasons ?? [];
162
175
  const { confidence, reasons } = computeExtractionScore(component, {
163
- additionalIssueCount: wrapperConfidenceToIssueCount(inspection.wrapperConfidence),
164
- additionalReasons: inspection.reviewReasons,
176
+ additionalIssueCount: wrapperConfidenceToIssueCount(inspection.wrapperConfidence) + extractorReasons.length,
177
+ additionalReasons: [...extractorReasons, ...inspection.reviewReasons],
165
178
  });
166
179
  filteredComponents.push({
167
180
  ...component,
168
181
  extractionConfidence: confidence,
169
182
  reviewReasons: reasons,
170
- needsReview: deriveNeedsReview(confidence) || inspection.wrapperConfidence >= 4 || inspection.keepDespiteZeroSurface,
183
+ needsReview: deriveNeedsReview(confidence) ||
184
+ inspection.wrapperConfidence >= 4 ||
185
+ inspection.keepDespiteZeroSurface ||
186
+ // An extractor-level type-resolution failure is a strong signal regardless
187
+ // of the otherwise-derived confidence threshold; force review.
188
+ extractorReasons.includes('props-type-unresolved') ||
189
+ (component.needsReview ?? false),
171
190
  });
172
191
  }
173
192
  const validatedComponents = validateExtractedComponents(filteredComponents);
@@ -1,6 +1,6 @@
1
- import type { ComponentExtractionResult } from '../../types.js';
1
+ import type { ComponentExtractionResult, ExtractorOptions } from '../../types.js';
2
2
  export type ExtractProgress = {
3
3
  filesProcessed: number;
4
4
  componentsFound: number;
5
5
  };
6
- export declare function extractComponents(filePaths: string[], onProgress?: (progress: ExtractProgress) => void): Promise<ComponentExtractionResult>;
6
+ export declare function extractComponents(filePaths: string[], onProgress?: (progress: ExtractProgress) => void, opts?: ExtractorOptions): Promise<ComponentExtractionResult>;
@@ -224,7 +224,7 @@ function choosePreferredComponent(existing, candidate) {
224
224
  reason: `kept ${existing.source} over ${candidate.source} by stable first-seen order`,
225
225
  };
226
226
  }
227
- export async function extractComponents(filePaths, onProgress) {
227
+ export async function extractComponents(filePaths, onProgress, opts) {
228
228
  const filesByExtractor = new Map();
229
229
  for (const extractor of extractors) {
230
230
  filesByExtractor.set(extractor, []);
@@ -254,7 +254,7 @@ export async function extractComponents(filePaths, onProgress) {
254
254
  perExtractorFiles.set(extractor, p.filesProcessed);
255
255
  perExtractorComponents.set(extractor, p.componentsFound);
256
256
  onProgress?.({ filesProcessed: totalFilesProcessed, componentsFound: totalComponentsFound });
257
- });
257
+ }, opts);
258
258
  return result;
259
259
  }));
260
260
  const allWarnings = [];
@@ -1,5 +1,5 @@
1
- import type { ComponentExtractionResult } from '../../types.js';
1
+ import type { ComponentExtractionResult, ExtractorOptions } from '../../types.js';
2
2
  export declare function extractSvelteComponents(filePaths: string[], onProgress?: (p: {
3
3
  filesProcessed: number;
4
4
  componentsFound: number;
5
- }) => void): Promise<ComponentExtractionResult>;
5
+ }) => void, opts?: ExtractorOptions): Promise<ComponentExtractionResult>;
@@ -1,17 +1,19 @@
1
1
  import { basename, dirname, resolve, join } from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { existsSync, statSync } from 'node:fs';
3
+ import { existsSync, readFileSync, statSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
4
5
  import os from 'node:os';
5
6
  import { parse as parseSvelte } from 'svelte/compiler';
6
7
  import { Project, Node, ScriptTarget, ModuleKind, ts } from 'ts-morph';
7
8
  import { computeExtractionScore, deriveNeedsReview } from './scoring.js';
8
9
  const SVELTE_EXTRACT_CONCURRENCY = Number(process.env['EDS_EXTRACT_CONCURRENCY'] ?? 0) || os.cpus().length;
9
- export async function extractSvelteComponents(filePaths, onProgress) {
10
+ export async function extractSvelteComponents(filePaths, onProgress, opts) {
10
11
  const svelteFiles = filePaths.filter((f) => f.endsWith('.svelte'));
11
12
  if (svelteFiles.length === 0)
12
13
  return { components: [], warnings: [] };
13
14
  const warnings = [];
14
15
  const components = [];
16
+ const retryContexts = new Map();
15
17
  let filesProcessed = 0;
16
18
  let componentsFound = 0;
17
19
  const queue = [...svelteFiles];
@@ -22,28 +24,460 @@ export async function extractSvelteComponents(filePaths, onProgress) {
22
24
  break;
23
25
  try {
24
26
  const source = await readFile(filePath, 'utf-8');
25
- const { component, warnings: fileWarnings } = await extractFromSvelteFile(filePath, source);
27
+ const { component, warnings: fileWarnings, retryContext } = await extractFromSvelteFile(filePath, source);
26
28
  warnings.push(...fileWarnings);
27
29
  if (component) {
28
30
  components.push(component);
29
31
  componentsFound++;
30
32
  }
33
+ if (retryContext)
34
+ retryContexts.set(filePath, retryContext);
31
35
  }
32
36
  catch (e) {
33
- warnings.push(`Failed to extract from ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
37
+ warnings.push(`${getSvelteComponentName(filePath)}: failed to extract from ${filePath} ${e instanceof Error ? e.message : String(e)}`);
34
38
  }
35
39
  filesProcessed++;
36
40
  onProgress?.({ filesProcessed, componentsFound });
37
41
  }
38
42
  }
39
43
  await Promise.all(Array.from({ length: Math.min(SVELTE_EXTRACT_CONCURRENCY, svelteFiles.length) }, worker));
44
+ const finalWarnings = await maybeRunResolveUnreachableRetry(components, warnings, retryContexts, opts);
40
45
  return {
41
46
  components: components.sort((a, b) => a.name.localeCompare(b.name)),
42
- warnings,
47
+ warnings: collapseUnresolvedTypeWarnings(finalWarnings),
43
48
  };
44
49
  }
50
+ // ---------------------------------------------------------------------------
51
+ // Resolve-unreachable retry pass (Approach B)
52
+ //
53
+ // After the normal pass, components flagged `props-type-unresolved` get a
54
+ // second look:
55
+ //
56
+ // Step 1 — tsconfig pass: walk up from projectRoot to the nearest
57
+ // tsconfig.json and build ONE shared ts-morph Project loaded with that
58
+ // tsconfig. Re-run resolution per-component. Recovers cases where the type
59
+ // resolver needed path aliases / baseUrl / a real TS program.
60
+ //
61
+ // Step 2 — node_modules pass: for components that still won't resolve,
62
+ // locate the imported package via Node's resolver, find its .d.ts entry,
63
+ // add it to the shared Project, and retry. Recovers cross-package extends
64
+ // like skeleton-svelte's `extends Omit<ZagProps, 'id'>` from @zag-js/*.
65
+ //
66
+ // Auto mode triggers only when ≥20% of svelte components in the run share
67
+ // the unresolved-type pattern, so the cost of loading a full TS program is
68
+ // paid only when it's likely to help.
69
+ // ---------------------------------------------------------------------------
70
+ async function maybeRunResolveUnreachableRetry(components, warnings, retryContexts, opts) {
71
+ const mode = opts?.resolveUnreachable ?? 'auto';
72
+ if (mode === 'never')
73
+ return warnings;
74
+ const isUnresolved = (c) => (c.reviewReasons ?? []).includes('props-type-unresolved') && !!retryContexts.get(c.source);
75
+ const unresolvedCount = components.filter(isUnresolved).length;
76
+ if (unresolvedCount === 0)
77
+ return warnings;
78
+ if (mode === 'auto') {
79
+ // Threshold: ≥20% of svelte components in the run.
80
+ const ratio = unresolvedCount / components.length;
81
+ if (ratio < 0.2)
82
+ return warnings;
83
+ }
84
+ // Step 1 — tsconfig pass.
85
+ const projectRoot = opts?.projectRoot;
86
+ let project = null;
87
+ let tsconfigPath = null;
88
+ if (projectRoot) {
89
+ tsconfigPath = findNearestTsconfig(projectRoot);
90
+ }
91
+ if (tsconfigPath) {
92
+ try {
93
+ project = new Project({
94
+ tsConfigFilePath: tsconfigPath,
95
+ skipAddingFilesFromTsConfig: false,
96
+ compilerOptions: {
97
+ // Allow declaration-only resolution and JS sources.
98
+ allowJs: true,
99
+ jsx: ts.JsxEmit.Preserve,
100
+ },
101
+ });
102
+ }
103
+ catch {
104
+ project = null;
105
+ }
106
+ }
107
+ let recoveredViaTsconfig = 0;
108
+ if (project) {
109
+ for (const component of components) {
110
+ if (!isUnresolved(component))
111
+ continue;
112
+ const ctx = retryContexts.get(component.source);
113
+ if (!ctx)
114
+ continue;
115
+ const recovered = await retryComponentWithProject(component, ctx, project, warnings);
116
+ if (recovered)
117
+ recoveredViaTsconfig++;
118
+ }
119
+ }
120
+ // Step 2 — node_modules pass for whatever's still unresolved.
121
+ let recoveredViaNodeModules = 0;
122
+ const stillUnresolved = components.filter(isUnresolved);
123
+ if (stillUnresolved.length > 0) {
124
+ if (!project) {
125
+ // No tsconfig available — fall back to a lightweight project so we still
126
+ // get the node_modules pass. Force Node module resolution so that
127
+ // bare-specifier imports (e.g. `fake-svelte-pkg`) inside the synthetic
128
+ // file find their .d.ts entry on disk.
129
+ project = new Project({
130
+ compilerOptions: {
131
+ strict: false,
132
+ target: ScriptTarget.ESNext,
133
+ module: ModuleKind.ESNext,
134
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
135
+ allowJs: true,
136
+ jsx: ts.JsxEmit.Preserve,
137
+ },
138
+ useInMemoryFileSystem: false,
139
+ skipAddingFilesFromTsConfig: true,
140
+ });
141
+ }
142
+ for (const component of stillUnresolved) {
143
+ const ctx = retryContexts.get(component.source);
144
+ if (!ctx)
145
+ continue;
146
+ // Locate the imported types referenced by the user's Props annotation
147
+ // and add their .d.ts files to the shared project.
148
+ const added = addImportedDeclarationsToProject(project, ctx);
149
+ if (added === 0)
150
+ continue;
151
+ const recovered = await retryComponentWithProject(component, ctx, project, warnings);
152
+ if (recovered)
153
+ recoveredViaNodeModules++;
154
+ }
155
+ }
156
+ // Only surface the informational summary when the retry actually had
157
+ // something to work with — i.e. a tsconfig was loaded OR we managed to
158
+ // recover at least one component via node_modules. Otherwise we'd be
159
+ // adding a top-level warning that says "we did nothing", which clutters
160
+ // the output (and breaks the per-component-prefix convention the TUI
161
+ // relies on for grouping).
162
+ const didMeaningfulWork = !!tsconfigPath || recoveredViaNodeModules > 0;
163
+ if (didMeaningfulWork) {
164
+ const remaining = components.filter(isUnresolved).length;
165
+ const parts = [];
166
+ if (tsconfigPath) {
167
+ parts.push(`tsconfig at ${tsconfigPath} recovered ${recoveredViaTsconfig} component(s)`);
168
+ }
169
+ parts.push(`node_modules pass recovered ${recoveredViaNodeModules} more`);
170
+ parts.push(`${remaining} component(s) remain unresolved`);
171
+ warnings.push(`Unresolved-type retry pass (mode=${mode}): ${parts.join('; ')}.`);
172
+ }
173
+ return warnings;
174
+ }
175
+ function findNearestTsconfig(startDir) {
176
+ let dir = startDir;
177
+ // Cap at a reasonable depth to avoid scanning into / on weird inputs.
178
+ for (let i = 0; i < 16; i++) {
179
+ const candidate = join(dir, 'tsconfig.json');
180
+ if (existsSync(candidate))
181
+ return candidate;
182
+ const parent = dirname(dir);
183
+ if (!parent || parent === dir)
184
+ return null;
185
+ dir = parent;
186
+ }
187
+ return null;
188
+ }
189
+ /**
190
+ * Re-run resolution for a single component against a shared Project.
191
+ * On success, mutate the component to replace props/slots/reviewReasons and
192
+ * remove the per-component "declared Props type ... resolved to ..." warning.
193
+ */
194
+ async function retryComponentWithProject(component, ctx, project, warnings) {
195
+ const snippetLocals = mergeSets(collectSnippetImportLocals(ctx.instance), ctx.moduleScript ? collectSnippetImportLocals(ctx.moduleScript) : new Set());
196
+ let members = null;
197
+ try {
198
+ members = await resolveViaTypeChecker(ctx.annotation, ctx.instance, ctx.moduleScript, ctx.filePath, ctx.source, snippetLocals, project);
199
+ }
200
+ catch {
201
+ members = null;
202
+ }
203
+ if (!members || members.length === 0)
204
+ return false;
205
+ // If the only members are Snippet-typed (the partial-heritage signal that
206
+ // tripped the original warning), we haven't actually recovered useful props.
207
+ if (members.every((m) => m.isSnippet))
208
+ return false;
209
+ const { props, snippetSlots } = extractFromTypeMembersOnly(members);
210
+ // Merge any template <slot> entries that survived the original pass — we
211
+ // can't easily re-derive those without re-parsing the fragment, but the
212
+ // existing component already has them.
213
+ const templateSlots = component.slots.filter((s) => !snippetSlots.some((ss) => ss.name === s.name));
214
+ component.props = props;
215
+ component.slots = mergeSlots(snippetSlots, templateSlots).slots;
216
+ // Drop props-type-unresolved from reasons; recompute confidence.
217
+ const remainingReasons = (component.reviewReasons ?? []).filter((r) => r !== 'props-type-unresolved');
218
+ const score = computeExtractionScore(component, {
219
+ additionalIssueCount: remainingReasons.length,
220
+ additionalReasons: remainingReasons,
221
+ });
222
+ component.extractionConfidence = score.confidence;
223
+ component.reviewReasons = score.reasons;
224
+ component.needsReview = deriveNeedsReview(score.confidence);
225
+ // Remove the per-component unresolved-type warning so it doesn't muddy the
226
+ // summary line / TUI grouping after recovery.
227
+ const componentName = component.name;
228
+ const idx = warnings.findIndex((w) => w.startsWith(`${componentName}: declared Props type `) && /resolved to /.test(w));
229
+ if (idx >= 0)
230
+ warnings.splice(idx, 1);
231
+ return true;
232
+ }
233
+ /**
234
+ * Walk every type reference inside the user's Props annotation, find the
235
+ * matching ImportDeclaration in instance/module scripts, resolve the
236
+ * specifier via Node's resolver, locate sibling .d.ts files, and add them to
237
+ * the shared Project. Returns the number of newly-added files.
238
+ */
239
+ function addImportedDeclarationsToProject(project, ctx) {
240
+ // Start with names referenced directly in the annotation (e.g. `Props`).
241
+ const importedNames = collectReferencedTypeNames(ctx.annotation);
242
+ // Also pull in names referenced from any LOCAL type/interface declaration the
243
+ // annotation points at — that's where heritage clauses (`extends FakeProps`)
244
+ // and intersections live, and they're the ones that name the imported type
245
+ // we actually need to add to the project.
246
+ for (const name of [...importedNames]) {
247
+ const localDecl = findLocalTypeDeclaration(ctx.instance, name, ctx.moduleScript) ??
248
+ (ctx.moduleScript ? findLocalTypeDeclaration(ctx.moduleScript, name, ctx.instance) : null);
249
+ if (localDecl) {
250
+ for (const referenced of collectReferencedTypeNames(localDecl))
251
+ importedNames.add(referenced);
252
+ }
253
+ }
254
+ if (importedNames.size === 0)
255
+ return 0;
256
+ const imports = collectImportSpecifiersForNames(ctx.instance, importedNames);
257
+ if (ctx.moduleScript) {
258
+ for (const [k, v] of collectImportSpecifiersForNames(ctx.moduleScript, importedNames))
259
+ imports.set(k, v);
260
+ }
261
+ if (imports.size === 0)
262
+ return 0;
263
+ const req = createRequire(ctx.filePath);
264
+ let added = 0;
265
+ for (const specifier of imports.values()) {
266
+ if (specifier.startsWith('.'))
267
+ continue; // already attempted by relative resolver path
268
+ const dtsPath = locateDtsForSpecifier(req, specifier, ctx.filePath);
269
+ if (!dtsPath)
270
+ continue;
271
+ try {
272
+ const sf = project.addSourceFileAtPathIfExists(dtsPath);
273
+ if (sf)
274
+ added++;
275
+ }
276
+ catch {
277
+ // Ignore — best-effort.
278
+ }
279
+ }
280
+ return added;
281
+ }
282
+ /**
283
+ * Walk a TS type-annotation AST (or interface declaration) and gather every
284
+ * referenced type name. Handles:
285
+ * - TSTypeReference (`Foo`, `Foo<Bar>`)
286
+ * - TSExpressionWithTypeArguments (the `extends Foo` form on interfaces)
287
+ * That second case is critical: it's how `interface Props extends FakeProps {}`
288
+ * surfaces the imported `FakeProps` name we need to add to the project.
289
+ */
290
+ function collectReferencedTypeNames(node) {
291
+ const names = new Set();
292
+ walk(node);
293
+ return names;
294
+ function walk(n) {
295
+ if (!n || typeof n !== 'object')
296
+ return;
297
+ if (n.type === 'TSTypeReference') {
298
+ const tn = n['typeName']?.['name'];
299
+ if (tn)
300
+ names.add(tn);
301
+ }
302
+ if (n.type === 'TSExpressionWithTypeArguments') {
303
+ const exprName = n['expression']?.['name'];
304
+ if (exprName)
305
+ names.add(exprName);
306
+ }
307
+ for (const value of Object.values(n)) {
308
+ if (Array.isArray(value)) {
309
+ for (const v of value) {
310
+ if (v && typeof v === 'object')
311
+ walk(v);
312
+ }
313
+ }
314
+ else if (value && typeof value === 'object' && value.type) {
315
+ walk(value);
316
+ }
317
+ }
318
+ }
319
+ }
320
+ /**
321
+ * For each named identifier in `names`, find the import declaration that
322
+ * brought it into scope and return a map of `localName -> moduleSpecifier`.
323
+ */
324
+ function collectImportSpecifiersForNames(script, names) {
325
+ const out = new Map();
326
+ const body = script['content']?.['body'];
327
+ if (!body)
328
+ return out;
329
+ for (const stmt of body) {
330
+ if (stmt.type !== 'ImportDeclaration')
331
+ continue;
332
+ const specifierValue = stmt['source']?.['value'];
333
+ if (!specifierValue)
334
+ continue;
335
+ const specifiers = stmt['specifiers'] ?? [];
336
+ for (const spec of specifiers) {
337
+ if (spec.type !== 'ImportSpecifier' && spec.type !== 'ImportDefaultSpecifier')
338
+ continue;
339
+ const localName = spec['local']?.['name'];
340
+ if (localName && names.has(localName))
341
+ out.set(localName, specifierValue);
342
+ }
343
+ }
344
+ return out;
345
+ }
346
+ /**
347
+ * Locate a `.d.ts` file for `specifier` resolved relative to `parentFile`.
348
+ * Tries a few strategies in priority order:
349
+ * 1. `package.json#types` / `package.json#exports.types`
350
+ * 2. sibling `.d.ts` / `.d.mts` next to the resolved JS entry
351
+ * 3. `index.d.ts` / `index.d.mts` in the package root
352
+ * Returns null if nothing resolves (no node_modules, dynamic import, etc.).
353
+ */
354
+ function locateDtsForSpecifier(req, specifier, parentFile) {
355
+ let resolvedJs = null;
356
+ try {
357
+ resolvedJs = req.resolve(specifier);
358
+ }
359
+ catch {
360
+ resolvedJs = null;
361
+ }
362
+ // Find the package root (nearest package.json above the resolved file or above the parentFile).
363
+ const seedDir = resolvedJs ? dirname(resolvedJs) : dirname(parentFile);
364
+ const pkgRoot = findPackageRootForSpecifier(seedDir, specifier);
365
+ if (pkgRoot) {
366
+ const pkgJsonPath = join(pkgRoot, 'package.json');
367
+ if (existsSync(pkgJsonPath)) {
368
+ try {
369
+ const pkgRaw = readFileSync(pkgJsonPath, 'utf-8');
370
+ const pkg = JSON.parse(pkgRaw);
371
+ const typesField = pkg.types ?? pkg.typings;
372
+ if (typeof typesField === 'string') {
373
+ const candidate = resolve(pkgRoot, typesField);
374
+ if (existsSync(candidate))
375
+ return candidate;
376
+ }
377
+ const exportsTypes = extractTypesFromExports(pkg.exports);
378
+ if (exportsTypes) {
379
+ const candidate = resolve(pkgRoot, exportsTypes);
380
+ if (existsSync(candidate))
381
+ return candidate;
382
+ }
383
+ }
384
+ catch {
385
+ // Fall through to sibling probing.
386
+ }
387
+ }
388
+ // index.d.ts at the package root.
389
+ for (const entry of ['index.d.ts', 'index.d.mts']) {
390
+ const candidate = join(pkgRoot, entry);
391
+ if (existsSync(candidate))
392
+ return candidate;
393
+ }
394
+ }
395
+ // Sibling probing next to the resolved JS file.
396
+ if (resolvedJs) {
397
+ const noExt = resolvedJs.replace(/\.(m?js|cjs)$/, '');
398
+ for (const ext of ['.d.ts', '.d.mts']) {
399
+ const candidate = `${noExt}${ext}`;
400
+ if (existsSync(candidate))
401
+ return candidate;
402
+ }
403
+ }
404
+ return null;
405
+ }
406
+ function findPackageRootForSpecifier(seedDir, specifier) {
407
+ // For scoped (@x/y) and bare specifiers, the package root is the directory
408
+ // whose path ends with the specifier under a node_modules tree. Walk up.
409
+ let dir = seedDir;
410
+ for (let i = 0; i < 32; i++) {
411
+ if (existsSync(join(dir, 'package.json'))) {
412
+ // Check this is the package matching the specifier (best-effort: name field).
413
+ try {
414
+ const pkgRaw = readFileSync(join(dir, 'package.json'), 'utf-8');
415
+ const pkg = JSON.parse(pkgRaw);
416
+ // Either exact match or a sub-path import (foo/sub) where pkg.name === 'foo'.
417
+ if (pkg.name === specifier)
418
+ return dir;
419
+ if (pkg.name && specifier.startsWith(`${pkg.name}/`))
420
+ return dir;
421
+ }
422
+ catch {
423
+ // Ignore and continue walking.
424
+ }
425
+ }
426
+ const parent = dirname(dir);
427
+ if (!parent || parent === dir)
428
+ return null;
429
+ dir = parent;
430
+ }
431
+ return null;
432
+ }
433
+ function extractTypesFromExports(exportsField) {
434
+ if (!exportsField || typeof exportsField !== 'object')
435
+ return null;
436
+ const exp = exportsField;
437
+ // Look for the "." entry first; fall back to top-level types if shape is conditional.
438
+ const root = (exp['.'] ?? exp);
439
+ if (!root || typeof root !== 'object')
440
+ return null;
441
+ const r = root;
442
+ if (typeof r['types'] === 'string')
443
+ return r['types'];
444
+ // Conditional exports: try import → types, default → types.
445
+ for (const key of ['import', 'default', 'node']) {
446
+ const sub = r[key];
447
+ if (sub && typeof sub === 'object') {
448
+ const t = sub['types'];
449
+ if (typeof t === 'string')
450
+ return t;
451
+ }
452
+ }
453
+ return null;
454
+ }
455
+ /**
456
+ * When a large fraction of components emit the same `declared Props type ...
457
+ * resolved to ... properties` warning (typical of headless libraries like
458
+ * skeleton-svelte that extend types from external packages we can't reach),
459
+ * collapse them into a single summary line at the top + the per-component
460
+ * details below. Per-component reviewReasons and needsReview stay intact;
461
+ * this only affects the warnings array's readability.
462
+ *
463
+ * Threshold: 3 or more identical-shape warnings. Below that, the literal
464
+ * per-component lines are clearer than a summary.
465
+ */
466
+ function collapseUnresolvedTypeWarnings(warnings) {
467
+ const isUnresolvedWarning = (w) => /declared Props type .* resolved to/.test(w);
468
+ const unresolved = warnings.filter(isUnresolvedWarning);
469
+ if (unresolved.length < 3)
470
+ return warnings;
471
+ const summary = `Unresolved component types: ${unresolved.length} components have a declared Props type the parser couldn't fully resolve — ` +
472
+ `most often a cross-package extends pattern (e.g. an interface that extends a type from a node_modules package). ` +
473
+ `See https://github.com/contentful/experience-design-system-sdk-public/pull/44 for context and partner workarounds.`;
474
+ return [summary, ...warnings];
475
+ }
45
476
  async function extractFromSvelteFile(filePath, source) {
46
477
  const warnings = [];
478
+ // Derive the component name up front so warning messages can prefix it for
479
+ // TUI grouping, even when parsing fails.
480
+ const name = getSvelteComponentName(filePath);
47
481
  let ast;
48
482
  try {
49
483
  ast = parseSvelte(source, { modern: true });
@@ -51,16 +485,15 @@ async function extractFromSvelteFile(filePath, source) {
51
485
  catch (e) {
52
486
  return {
53
487
  component: null,
54
- warnings: [`Parse error in ${filePath}: ${e instanceof Error ? e.message : String(e)}`],
488
+ warnings: [`${name}: parse error in ${filePath}: ${e instanceof Error ? e.message : String(e)}`],
55
489
  };
56
490
  }
57
- const name = getSvelteComponentName(filePath);
58
491
  const instance = ast['instance'];
59
492
  const moduleScript = ast['module'];
60
493
  const fragment = ast['fragment'];
61
494
  // Detect Svelte 4 export-let syntax — currently unsupported.
62
495
  if (instance && hasV4ExportLetProps(instance)) {
63
- warnings.push(`Svelte 4 export let syntax not yet supported (file: ${filePath}); see INTEG-4267 for v5-only scope and follow-up`);
496
+ warnings.push(`${name}: Svelte 4 export let syntax not yet supported (${filePath}); see INTEG-4267 for v5-only scope and follow-up`);
64
497
  return { component: null, warnings };
65
498
  }
66
499
  // Find the $props() call (Svelte 5 runes).
@@ -78,6 +511,7 @@ async function extractFromSvelteFile(filePath, source) {
78
511
  instance: instance,
79
512
  moduleScript,
80
513
  filePath,
514
+ componentName: name,
81
515
  source,
82
516
  snippetLocals,
83
517
  });
@@ -95,13 +529,13 @@ async function extractFromSvelteFile(filePath, source) {
95
529
  }
96
530
  else {
97
531
  // No script block at all — nothing to extract on the props side.
98
- warnings.push(`${filePath} has no instance script block; no props extracted`);
532
+ warnings.push(`${name}: no instance script block (${filePath}); no props extracted`);
99
533
  }
100
534
  // --- Slot extraction ---
101
535
  const templateSlots = fragment ? extractTemplateSlots(fragment) : [];
102
536
  const { slots, mixedWarning } = mergeSlots(snippetSlotsFromProps, templateSlots);
103
537
  if (mixedWarning) {
104
- warnings.push(`${filePath}: mixed Snippet and <slot> usage detected; preferring Snippet entries`);
538
+ warnings.push(`${name}: mixed Snippet and <slot> usage detected (${filePath}); preferring Snippet entries`);
105
539
  }
106
540
  const component = {
107
541
  name,
@@ -127,7 +561,25 @@ async function extractFromSvelteFile(filePath, source) {
127
561
  // extractors return — same convention as React, Vue, Astro, Stencil, and
128
562
  // web-components. No per-extractor work needed here.
129
563
  void propNamesTreatedAsSnippet; // currently informational; reserved for future validation
130
- return { component, warnings };
564
+ // Capture context for the resolve-unreachable retry pass. Only meaningful
565
+ // when extraction actually got back the props-type-unresolved signal AND
566
+ // we have the annotation node needed to re-run resolution.
567
+ let retryContext;
568
+ if (extractionReasons.includes('props-type-unresolved') && propsCall && instance) {
569
+ const id = propsCall['id'];
570
+ const annotation = id?.['typeAnnotation']?.['typeAnnotation'];
571
+ if (annotation) {
572
+ retryContext = {
573
+ filePath,
574
+ source,
575
+ instance,
576
+ moduleScript,
577
+ annotation,
578
+ componentName: name,
579
+ };
580
+ }
581
+ }
582
+ return { component, warnings, ...(retryContext ? { retryContext } : {}) };
131
583
  }
132
584
  // ---------------------------------------------------------------------------
133
585
  // Component name
@@ -248,7 +700,7 @@ async function extractPropsFromCall(ctx) {
248
700
  if (unresolved) {
249
701
  const refLabel = describeAnnotationForUser(annotation);
250
702
  const heritageNote = unresolved === 'partial-heritage' ? ' (heritage clauses extending unreachable types)' : '';
251
- warnings.push(`${ctx.filePath}: declared Props type ${refLabel} resolved to ${unresolved === 'empty' ? '0' : 'only Snippet-typed'} properties${heritageNote} — possible cross-package extends or unreachable type. ` +
703
+ warnings.push(`${ctx.componentName}: declared Props type ${refLabel} resolved to ${unresolved === 'empty' ? '0' : 'only Snippet-typed'} properties${heritageNote} (${ctx.filePath}) — possible cross-package extends or unreachable type. ` +
252
704
  `See https://github.com/contentful/experience-design-system-sdk-public/pull/44 for context and partner workarounds.`);
253
705
  additionalReasons.push('props-type-unresolved');
254
706
  }
@@ -261,11 +713,11 @@ async function extractPropsFromCall(ctx) {
261
713
  return { ...extractFromTypeMembersOnly(typeMembers), warnings, additionalReasons };
262
714
  }
263
715
  if (!unresolved) {
264
- warnings.push(`${ctx.filePath}: $props() called without destructuring; cannot extract individual props`);
716
+ warnings.push(`${ctx.componentName}: $props() called without destructuring (${ctx.filePath}); cannot extract individual props`);
265
717
  }
266
718
  return { props: [], snippetNames: new Set(), snippetSlots: [], warnings, additionalReasons };
267
719
  }
268
- warnings.push(`${ctx.filePath}: unrecognized $props() binding pattern (${idType})`);
720
+ warnings.push(`${ctx.componentName}: unrecognized $props() binding pattern '${idType}' (${ctx.filePath})`);
269
721
  return { props: [], snippetNames: new Set(), snippetSlots: [], warnings, additionalReasons };
270
722
  }
271
723
  /**
@@ -363,7 +815,7 @@ function declarationHasHeritage(decl) {
363
815
  }
364
816
  return false;
365
817
  }
366
- async function resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals) {
818
+ async function resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals, externalProject) {
367
819
  const annotationText = sliceSource(source, annotation);
368
820
  if (!annotationText)
369
821
  return null;
@@ -373,17 +825,21 @@ async function resolveViaTypeChecker(annotation, instance, moduleScript, filePat
373
825
  const moduleText = sliceScriptContent(source, moduleScript);
374
826
  const instanceText = sliceScriptContent(source, instance);
375
827
  const synthetic = [moduleText, instanceText, `type __SveltePropsT__ = ${annotationText};`].filter(Boolean).join('\n');
376
- const project = new Project({
377
- compilerOptions: {
378
- strict: false,
379
- target: ScriptTarget.ESNext,
380
- module: ModuleKind.ESNext,
381
- allowJs: true,
382
- jsx: ts.JsxEmit.Preserve,
383
- },
384
- useInMemoryFileSystem: false,
385
- skipAddingFilesFromTsConfig: true,
386
- });
828
+ // Reuse the caller-supplied Project if provided (the resolve-unreachable
829
+ // retry pass shares one Project across all components in the run); otherwise
830
+ // build a one-off project for this single call.
831
+ const project = externalProject ??
832
+ new Project({
833
+ compilerOptions: {
834
+ strict: false,
835
+ target: ScriptTarget.ESNext,
836
+ module: ModuleKind.ESNext,
837
+ allowJs: true,
838
+ jsx: ts.JsxEmit.Preserve,
839
+ },
840
+ useInMemoryFileSystem: false,
841
+ skipAddingFilesFromTsConfig: true,
842
+ });
387
843
  // Place the synthetic file alongside the .svelte so relative imports resolve.
388
844
  const syntheticPath = `${filePath}.__svelte-props__.ts`;
389
845
  let sf;
@@ -710,7 +1166,7 @@ function extractFromDestructure(propsCall, ctx, typeMembers, warnings, additiona
710
1166
  // contract = the destructure list. (Type-members-only path runs separately for the
711
1167
  // `const props: Props = $props()` no-destructure case.)
712
1168
  if (dropsRest) {
713
- warnings.push(`${ctx.filePath}: rest element in $props() destructure dropped (cannot enumerate)`);
1169
+ warnings.push(`${ctx.componentName}: rest element in $props() destructure dropped (${ctx.filePath}); cannot enumerate`);
714
1170
  }
715
1171
  return {
716
1172
  props: props.sort((a, b) => sortStable(a.name, b.name, propertyOrder(properties))),
@@ -54,10 +54,25 @@ export type ExtractorProgress = {
54
54
  filesProcessed: number;
55
55
  componentsFound: number;
56
56
  };
57
+ /**
58
+ * Optional extraction-time settings forwarded from the CLI through the
59
+ * pipeline to each extractor. Only the Svelte extractor currently consumes
60
+ * any of these; other extractors ignore the value.
61
+ */
62
+ export interface ExtractorOptions {
63
+ /**
64
+ * Whether the Svelte extractor should run a retry pass for components whose
65
+ * declared Props type couldn't be resolved on the first pass (cross-package
66
+ * extends, path-alias-only types, etc.). See svelte.ts for the policy.
67
+ */
68
+ resolveUnreachable?: 'auto' | 'always' | 'never';
69
+ /** Absolute project root — used by the retry pass to locate tsconfig.json and node_modules. */
70
+ projectRoot?: string;
71
+ }
57
72
  export interface ComponentExtractor {
58
73
  name: string;
59
74
  fileFilter: (filePath: string) => boolean;
60
- extract(filePaths: string[], onProgress?: (p: ExtractorProgress) => void): Promise<ComponentExtractionResult>;
75
+ extract(filePaths: string[], onProgress?: (p: ExtractorProgress) => void, opts?: ExtractorOptions): Promise<ComponentExtractionResult>;
61
76
  }
62
77
  export interface TokenExtractor {
63
78
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.11.4-dev-build-355c69b.0",
3
+ "version": "2.11.4-dev-build-f1df48f.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "svelte": "^5.56.4",
38
38
  "ts-morph": "^27.0.2",
39
39
  "typescript": "^5.9.3",
40
- "@contentful/experience-design-system-types": "2.11.4-dev-build-355c69b.0"
40
+ "@contentful/experience-design-system-types": "2.11.4-dev-build-f1df48f.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@tsconfig/node24": "^24.0.3",