@contentful/experience-design-system-cli 2.11.4-dev-build-8f1adf3.0 → 2.11.4-dev-build-8cd3b18.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-8f1adf3.0",
3
+ "version": "2.11.4-dev-build-8cd3b18.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { existsSync, statSync } from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import { parse as parseSvelte } from 'svelte/compiler';
6
- import { Project, Node } from 'ts-morph';
6
+ import { Project, Node, ScriptTarget, ModuleKind, ts } from 'ts-morph';
7
7
  import { computeExtractionScore, deriveNeedsReview } from './scoring.js';
8
8
  const SVELTE_EXTRACT_CONCURRENCY = Number(process.env['EDS_EXTRACT_CONCURRENCY'] ?? 0) || os.cpus().length;
9
9
  export async function extractSvelteComponents(filePaths, onProgress) {
@@ -71,6 +71,7 @@ async function extractFromSvelteFile(filePath, source) {
71
71
  let props = [];
72
72
  let propNamesTreatedAsSnippet = new Set();
73
73
  let snippetSlotsFromProps = [];
74
+ const extractionReasons = [];
74
75
  if (propsCall) {
75
76
  const result = await extractPropsFromCall({
76
77
  propsCall,
@@ -84,6 +85,8 @@ async function extractFromSvelteFile(filePath, source) {
84
85
  propNamesTreatedAsSnippet = result.snippetNames;
85
86
  snippetSlotsFromProps = result.snippetSlots;
86
87
  warnings.push(...result.warnings);
88
+ if (result.additionalReasons)
89
+ extractionReasons.push(...result.additionalReasons);
87
90
  }
88
91
  else if (instance) {
89
92
  // No $props() call. We've already ruled out v4 above; this means $props was used
@@ -107,11 +110,17 @@ async function extractFromSvelteFile(filePath, source) {
107
110
  props,
108
111
  slots,
109
112
  };
110
- // Score & flag review.
111
- const score = computeExtractionScore(component);
113
+ // Score & flag review. Forward extraction-time reasons (e.g. props-type-unresolved)
114
+ // so they count toward the confidence score AND surface in reviewReasons.
115
+ const score = computeExtractionScore(component, {
116
+ additionalIssueCount: extractionReasons.length,
117
+ additionalReasons: extractionReasons,
118
+ });
112
119
  component.extractionConfidence = score.confidence;
113
120
  component.reviewReasons = score.reasons;
114
- component.needsReview = deriveNeedsReview(score.confidence);
121
+ // A type-resolution failure is a strong signal something is wrong; force
122
+ // human review even when other heuristics keep confidence above the threshold.
123
+ component.needsReview = deriveNeedsReview(score.confidence) || extractionReasons.includes('props-type-unresolved');
115
124
  // Validation issues (EMPTY_COMPONENT_NAME / EMPTY_PROP_NAME / PROP_SLOT_NAME_COLLISION /
116
125
  // DUPLICATE_COMPONENT_NAME / EMPTY_COMPONENT / EMPTY_SLOT_NAME) are populated
117
126
  // centrally by validateExtractedComponents() in analyze/command.ts after all
@@ -220,19 +229,83 @@ async function extractPropsFromCall(ctx) {
220
229
  const typeMembers = annotation
221
230
  ? await resolveTypeMembers(annotation, ctx.instance, ctx.moduleScript, ctx.filePath, ctx.source)
222
231
  : null;
232
+ // Detect "we tried to resolve a real type and got nothing back" — distinct from
233
+ // the user genuinely typing an empty literal. Surface as both a warning (CLI
234
+ // stderr / analyze report) and a review reason (TUI / downstream gating).
235
+ const unresolved = classifyUnresolved(annotation, typeMembers, ctx.instance, ctx.moduleScript);
236
+ const additionalReasons = [];
237
+ if (unresolved) {
238
+ const refLabel = describeAnnotationForUser(annotation);
239
+ const heritageNote = unresolved === 'partial-heritage' ? ' (heritage clauses extending unreachable types)' : '';
240
+ 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. ` +
241
+ `See https://github.com/contentful/experience-design-system-sdk-public/pull/44 for context and partner workarounds.`);
242
+ additionalReasons.push('props-type-unresolved');
243
+ }
223
244
  if (idType === 'ObjectPattern') {
224
- return extractFromDestructure(ctx.propsCall, ctx, typeMembers, warnings);
245
+ return extractFromDestructure(ctx.propsCall, ctx, typeMembers, warnings, additionalReasons);
225
246
  }
226
247
  if (idType === 'Identifier') {
227
248
  // const props: Props = $props(); — no destructure, no defaults, no per-name binding.
228
- if (typeMembers) {
229
- return extractFromTypeMembersOnly(typeMembers, ctx.snippetLocals, warnings);
249
+ if (typeMembers && typeMembers.length > 0) {
250
+ return { ...extractFromTypeMembersOnly(typeMembers), warnings, additionalReasons };
251
+ }
252
+ if (!unresolved) {
253
+ warnings.push(`${ctx.filePath}: $props() called without destructuring; cannot extract individual props`);
230
254
  }
231
- warnings.push(`${ctx.filePath}: $props() called without destructuring; cannot extract individual props`);
232
- return { props: [], snippetNames: new Set(), snippetSlots: [], warnings };
255
+ return { props: [], snippetNames: new Set(), snippetSlots: [], warnings, additionalReasons };
233
256
  }
234
257
  warnings.push(`${ctx.filePath}: unrecognized $props() binding pattern (${idType})`);
235
- return { props: [], snippetNames: new Set(), snippetSlots: [], warnings };
258
+ return { props: [], snippetNames: new Set(), snippetSlots: [], warnings, additionalReasons };
259
+ }
260
+ /**
261
+ * Classify whether the user's declared Props type failed to resolve usefully.
262
+ *
263
+ * - `'empty'`: the resolver returned no members for a non-trivial annotation.
264
+ * - `'partial-heritage'`: the source declaration has heritage clauses (extends /
265
+ * intersection in module-script) but the resolver only surfaced Snippet-typed
266
+ * members — strong signal that one or more parents pointed at unreachable
267
+ * types (e.g. cross-package node_modules) and were silently dropped.
268
+ * - `null`: nothing to surface — either the user genuinely typed an empty
269
+ * literal, omitted the annotation, or the resolver returned regular props.
270
+ */
271
+ function classifyUnresolved(annotation, members, instance, moduleScript) {
272
+ if (!annotation)
273
+ return null;
274
+ if (annotation.type === 'TSTypeLiteral') {
275
+ const litMembers = annotation['members'] ?? [];
276
+ if (litMembers.length === 0)
277
+ return null; // user genuinely typed `{}`
278
+ }
279
+ if (members === null || members.length === 0)
280
+ return 'empty';
281
+ // Partial-heritage signal: declaration has extends and every surviving member
282
+ // is Snippet-typed. Real prop interfaces almost never declare every prop as a
283
+ // Snippet, so this is a strong "something fell off the resolution edge" hint.
284
+ if (annotation.type === 'TSTypeReference') {
285
+ const refName = annotation['typeName']?.['name'] ?? null;
286
+ if (refName) {
287
+ const decl = findLocalTypeDeclaration(instance, refName, moduleScript);
288
+ if (decl && declarationHasHeritage(decl) && members.every((m) => m.isSnippet)) {
289
+ return 'partial-heritage';
290
+ }
291
+ }
292
+ }
293
+ return null;
294
+ }
295
+ function describeAnnotationForUser(annotation) {
296
+ if (!annotation)
297
+ return '<unknown>';
298
+ if (annotation.type === 'TSTypeReference') {
299
+ const name = annotation['typeName']?.['name'] ?? null;
300
+ return name ? `'${name}'` : '<unnamed reference>';
301
+ }
302
+ if (annotation.type === 'TSIntersectionType')
303
+ return '<intersection>';
304
+ if (annotation.type === 'TSUnionType')
305
+ return '<union>';
306
+ if (annotation.type === 'TSTypeLiteral')
307
+ return '<inline literal>';
308
+ return `<${annotation.type}>`;
236
309
  }
237
310
  async function resolveTypeMembers(annotation, instance, moduleScript, filePath, source) {
238
311
  // Snippet imports may live in either script block; collect from both.
@@ -290,7 +363,13 @@ async function resolveViaTypeChecker(annotation, instance, moduleScript, filePat
290
363
  const instanceText = sliceScriptContent(source, instance);
291
364
  const synthetic = [moduleText, instanceText, `type __SveltePropsT__ = ${annotationText};`].filter(Boolean).join('\n');
292
365
  const project = new Project({
293
- compilerOptions: { strict: false, target: 99, module: 99, allowJs: true, jsx: 1 },
366
+ compilerOptions: {
367
+ strict: false,
368
+ target: ScriptTarget.ESNext,
369
+ module: ModuleKind.ESNext,
370
+ allowJs: true,
371
+ jsx: ts.JsxEmit.Preserve,
372
+ },
294
373
  useInMemoryFileSystem: false,
295
374
  skipAddingFilesFromTsConfig: true,
296
375
  });
@@ -563,7 +642,7 @@ function extractJsdocText(comments) {
563
642
  // ---------------------------------------------------------------------------
564
643
  // Extraction: ObjectPattern destructure + type-members
565
644
  // ---------------------------------------------------------------------------
566
- function extractFromDestructure(propsCall, ctx, typeMembers, warnings) {
645
+ function extractFromDestructure(propsCall, ctx, typeMembers, warnings, additionalReasons) {
567
646
  const id = propsCall['id'];
568
647
  const properties = id['properties'] ?? [];
569
648
  const typeByName = new Map();
@@ -627,9 +706,10 @@ function extractFromDestructure(propsCall, ctx, typeMembers, warnings) {
627
706
  snippetNames,
628
707
  snippetSlots,
629
708
  warnings,
709
+ ...(additionalReasons && additionalReasons.length > 0 ? { additionalReasons } : {}),
630
710
  };
631
711
  }
632
- function extractFromTypeMembersOnly(typeMembers, _snippetLocals, _warnings) {
712
+ function extractFromTypeMembersOnly(typeMembers) {
633
713
  const props = [];
634
714
  const snippetNames = new Set();
635
715
  const snippetSlots = [];
@@ -759,13 +839,22 @@ function resolveLocalScriptModule(importingFilePath, specifier) {
759
839
  }
760
840
  function readMembersFromExternalFile(filePath, exportName) {
761
841
  const project = new Project({
762
- compilerOptions: { strict: false, target: 99, module: 99, allowJs: true },
842
+ compilerOptions: {
843
+ strict: false,
844
+ target: ScriptTarget.ESNext,
845
+ module: ModuleKind.ESNext,
846
+ allowJs: true,
847
+ },
763
848
  useInMemoryFileSystem: false,
764
849
  skipAddingFilesFromTsConfig: true,
765
850
  });
766
851
  const sf = project.addSourceFileAtPathIfExists(filePath);
767
852
  if (!sf)
768
853
  return null;
854
+ // Collect Snippet locals from the resolved file's own imports so that
855
+ // aliased imports (`import { Snippet as LocalSnippet } from 'svelte'`) are
856
+ // honored. Mirrors collectSnippetImportLocals() but adapted to ts-morph.
857
+ const snippetLocals = collectSnippetLocalsFromSourceFile(sf);
769
858
  // Use getExportedDeclarations() so re-export chains (`export { ... } from './x'`,
770
859
  // `export * from './x'`, named or namespace) resolve transparently. ts-morph
771
860
  // follows them recursively and returns the underlying declaration.
@@ -775,18 +864,39 @@ function readMembersFromExternalFile(filePath, exportName) {
775
864
  return null;
776
865
  for (const decl of decls) {
777
866
  if (Node.isInterfaceDeclaration(decl)) {
778
- return readInterfaceMembers(decl);
867
+ // The declaration may live in a different source file than `sf` if the
868
+ // export chain walked across files; pull snippet locals from that file
869
+ // so aliased imports are recognized.
870
+ const declSf = decl.getSourceFile();
871
+ const declLocals = declSf === sf ? snippetLocals : collectSnippetLocalsFromSourceFile(declSf);
872
+ return readInterfaceMembers(decl, declLocals);
779
873
  }
780
874
  if (Node.isTypeAliasDeclaration(decl)) {
781
875
  const typeNode = decl.getTypeNode();
782
876
  if (typeNode && Node.isTypeLiteral(typeNode)) {
783
- return readTypeLiteralMembers(typeNode);
877
+ const declSf = decl.getSourceFile();
878
+ const declLocals = declSf === sf ? snippetLocals : collectSnippetLocalsFromSourceFile(declSf);
879
+ return readTypeLiteralMembers(typeNode, declLocals);
784
880
  }
785
881
  }
786
882
  }
787
883
  return null;
788
884
  }
789
- function readInterfaceMembers(iface) {
885
+ function collectSnippetLocalsFromSourceFile(sf) {
886
+ const locals = new Set();
887
+ for (const importDecl of sf.getImportDeclarations()) {
888
+ if (importDecl.getModuleSpecifierValue() !== 'svelte')
889
+ continue;
890
+ for (const named of importDecl.getNamedImports()) {
891
+ if (named.getName() === 'Snippet') {
892
+ const aliasNode = named.getAliasNode();
893
+ locals.add(aliasNode ? aliasNode.getText() : named.getName());
894
+ }
895
+ }
896
+ }
897
+ return locals;
898
+ }
899
+ function readInterfaceMembers(iface, snippetLocals) {
790
900
  return iface.getProperties().map((prop) => {
791
901
  const typeNode = prop.getTypeNode();
792
902
  const typeText = typeNode ? typeNode.getText() : prop.getType().getText(prop);
@@ -797,7 +907,7 @@ function readInterfaceMembers(iface) {
797
907
  name: prop.getName(),
798
908
  optional: prop.hasQuestionToken(),
799
909
  typeText,
800
- isSnippet: typeText === 'Snippet' || /^Snippet</.test(typeText),
910
+ isSnippet: isSnippetTypeText(typeText, snippetLocals),
801
911
  ...(allowed ? { allowedValues: allowed } : {}),
802
912
  ...(description ? { description } : {}),
803
913
  line: prop.getStartLineNumber(),
@@ -805,7 +915,7 @@ function readInterfaceMembers(iface) {
805
915
  };
806
916
  });
807
917
  }
808
- function readTypeLiteralMembers(typeNode) {
918
+ function readTypeLiteralMembers(typeNode, snippetLocals) {
809
919
  return typeNode.getMembers().flatMap((m) => {
810
920
  if (!Node.isPropertySignature(m))
811
921
  return [];
@@ -819,7 +929,7 @@ function readTypeLiteralMembers(typeNode) {
819
929
  name: m.getName(),
820
930
  optional: m.hasQuestionToken(),
821
931
  typeText,
822
- isSnippet: typeText === 'Snippet' || /^Snippet</.test(typeText),
932
+ isSnippet: isSnippetTypeText(typeText, snippetLocals),
823
933
  ...(allowed ? { allowedValues: allowed } : {}),
824
934
  ...(description ? { description } : {}),
825
935
  line: m.getStartLineNumber(),
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-8f1adf3.0",
3
+ "version": "2.11.4-dev-build-8cd3b18.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-8f1adf3.0"
40
+ "@contentful/experience-design-system-types": "2.11.4-dev-build-8cd3b18.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@tsconfig/node24": "^24.0.3",