diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 9d806318..700455ed 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -108,4 +108,4 @@ jobs: retention-days: 3 - name: Compare outputs and check for regression - run: ./pr_repo/script/diffjson.py out_old out_new $COMPARE_IGNORE + run: ./pr_repo/script/diffjson.py out_old out_new -v diff --git a/script/diffjson.py b/script/diffjson.py index b929f57c..2cddc801 100755 --- a/script/diffjson.py +++ b/script/diffjson.py @@ -1,26 +1,164 @@ #!/usr/bin/env python3 import argparse +from dataclasses import dataclass import json import os import re import sys from pathlib import Path -from typing import Literal +from typing import Any, Literal, Sequence from deepdiff import DeepDiff -# Define status types for clarity -Status = Literal["OK", "BAD", "FILE_ERROR"] +oneliner_LEN = 100 +Status = Literal["OK", "BAD", "FILE_ERROR"] +path_ty = list[str | int] + + +@dataclass +class DiffResult: + status: Status + diff: DeepDiff | None + json1: Any + json2: Any + + color_map = { + "new": "green", + "removed": "red", + "moved": "yellow", + "info": "none", + } + + def format(self, truncate_items: int) -> str: + return self.format_nested(truncate_items) + + def format_flat(self, truncate_items: int) -> str: + flat, remaining_msg = self.collect_flat_raw(truncate_items) + output_lines = [] + for path, value in flat: + color, msg = value[0], value[1] + color = DiffResult.color_map[color] + leader = {"green": "+ ", "red": "- "}.get(color, " ") + line = format_color( + color, leader + f"{''.join(f'[{repr(p)}]' for p in path)}: {msg}" + ) + output_lines.append(line) + if remaining_msg: + output_lines.append(remaining_msg) + return "\n".join(output_lines) + + def collect_flat_raw( + self, truncate_items: int + ) -> tuple[list[tuple[path_ty, Any]], str]: + output: list[tuple[path_ty, Any]] = [] + + def add_item(accessor: str, value: Any) -> None: + output.append((_parse_accessor(accessor), value)) + + # Handle new items (dictionary_item_added and iterable_item_added) + if self.diff is not None and "dictionary_item_added" in self.diff: + for accessor in self.diff["dictionary_item_added"]: + add_item(accessor, ("new", _get_accessor(self.json2, accessor))) + + if self.diff is not None and "iterable_item_added" in self.diff: + for accessor, value in self.diff["iterable_item_added"].items(): + add_item(accessor, ("new", value)) + + # Handle removed items (dictionary_item_removed and iterable_item_removed) + if self.diff is not None and "dictionary_item_removed" in self.diff: + for accessor, value in self.diff["dictionary_item_removed"]: + add_item(accessor, ("removed", _get_accessor(self.json1, accessor))) + + if self.diff is not None and "iterable_item_removed" in self.diff: + for accessor, value in self.diff["iterable_item_removed"].items(): + add_item(accessor, ("removed", value)) + + # Handle changed values + if self.diff is not None and "values_changed" in self.diff: + for accessor, changes in self.diff["values_changed"].items(): + add_item(accessor, ("removed", changes["old_value"])) + add_item(accessor, ("new", changes["new_value"])) + + # Handle items moved (position changes in lists) + if ( + self.diff is not None + and "values_changed" not in self.diff + and "iterable_item_moved" in self.diff + ): + for accessor, changes in self.diff["iterable_item_moved"].items(): + add_item(accessor, ("moved", "Moved location in list.")) -def parse_accessor(accessor_string: str) -> list[str | int]: + # Add truncation notice if needed + if truncate_items > 0 and len(output) > truncate_items: + remaining_msg = f"...({len(output) - truncate_items} more items)" + output = output[:truncate_items] + else: + remaining_msg = "" + return output, remaining_msg + + @staticmethod + def make_nested_oneliner(flat: list[tuple[path_ty, Any]]) -> dict: + output: dict = {} + for path, value in flat: + color, msg = value[0], value[1] + _set_with_ensure_strpath( + output, [str(p) for p in path], (color, _format_value_oneliner(msg)) + ) + return output + + def format_nested(self, truncate_items: int) -> str: + flat, remaining_msg = self.collect_flat_raw(truncate_items) + nested = DiffResult.make_nested_oneliner(flat) + INDENT = " " + + def isleaf(obj: Any) -> bool: + return isinstance(obj, list) + + def _dump_leaf(obj: list, indent: int, path: str) -> str: + output = "" + for index, subobj in enumerate(obj): + color, msg = subobj[0], subobj[1] + color = DiffResult.color_map[color] + leader = {"green": "+ ", "red": "- "}.get(color, " ") + leader += INDENT * indent + l1 = format_color(color, leader + str(msg)) + # l2 = format_color(color, leader + f"# {path=}") + output += l1 # + "\n" + l2 + if index != len(obj) - 1: + output += "\n" + return output + + def _dump(obj: dict, indent: int = 0, path: str = "") -> str: + if isinstance(obj, list): + return _dump_leaf(obj, indent, path) + output = "" + for key, value in obj.items(): + kline = " " + INDENT * indent + f"[{key}]" + v = value + while not isleaf(v): + if len(v) > 1: + break + k, v = next(iter(v.items())) + kline += f"[{k}]" + if len(v) == 1: # parent of only one leaf, colorize it same like leaf + color = v[0][0] + kline = format_color(DiffResult.color_map[color], kline) + output += kline + "\n" + output += _dump(v, indent + 1, path + f"[{key}]") + "\n" + return output.rstrip() + + return _dump(nested) + "\n" + remaining_msg + + +def _parse_accessor(accessor_string: str) -> path_ty: """ Parses a field accessor string like "['key'][0]" into a list ['key', 0]. This allows for programmatic access to nested JSON elements. """ # Regex to find content within brackets, e.g., ['key'] or [0] parts = re.findall(r"\[([^\]]+)\]", accessor_string) - keys = [] + keys: path_ty = [] for part in parts: try: # Try to convert to an integer for list indices @@ -31,7 +169,7 @@ def parse_accessor(accessor_string: str) -> list[str | int]: return keys -def delete_path(data: dict | list, path: list[str | int]): +def _delete_path(data: dict | list, path: path_ty) -> None: """ Deletes a value from a nested dictionary or list based on a path. This function modifies the data in place. If the path is invalid @@ -41,13 +179,18 @@ def delete_path(data: dict | list, path: list[str | int]): return # Traverse to the parent of the target element to delete it - parent = data + parent: Any = data key_to_delete = path[-1] path_to_parent = path[:-1] try: for key in path_to_parent: - parent = parent[key] + if isinstance(parent, dict) and isinstance(key, str): + parent = parent[key] + elif isinstance(parent, list) and isinstance(key, int): + parent = parent[key] + else: + raise TypeError("Invalid path traversal") # Check if the final key/index exists in the parent before deleting if isinstance(parent, dict) and key_to_delete in parent: @@ -63,63 +206,90 @@ def delete_path(data: dict | list, path: list[str | int]): pass -def format_diff_custom(diff: DeepDiff) -> str: +def _get_path(data: dict | list, path: path_ty) -> Any: """ - Formats a DeepDiff object into a custom human-readable string. - This provides a clear, indented view of changes. + Retrieves a value from a nested dictionary or list based on a path. + Returns None if the path is invalid or doesn't exist. """ - output = [] - - # Helper to format a value for printing. Pretty-prints dicts/lists. - def format_value(value): - if isinstance(value, (dict, list)): - return json.dumps(value, indent=2) - return repr(value) - - # Handle changed values - if "values_changed" in diff: - for path, changes in diff["values_changed"].items(): - output.append(f"Value Changed at: {path}") - output.append(f" - old: {format_value(changes['old_value'])}") - output.append(f" + new: {format_value(changes['new_value'])}") - output.append("--------------------") - - # Handle added items to lists/sets - if "iterable_item_added" in diff: - for path, value in diff["iterable_item_added"].items(): - output.append(f"Item Added at: {path}") - output.append(f" + new: {format_value(value)}") - output.append("--------------------") - - # Handle removed items from lists/sets - if "iterable_item_removed" in diff: - for path, value in diff["iterable_item_removed"].items(): - output.append(f"Item Removed at: {path}") - output.append(f" - old: {format_value(value)}") - output.append("--------------------") - - # Handle added keys in dictionaries - if "dictionary_item_added" in diff: - for path in diff["dictionary_item_added"]: - output.append(f"Dictionary Key Added: {path}") - output.append("--------------------") - - # Handle removed keys in dictionaries - if "dictionary_item_removed" in diff: - for path in diff["dictionary_item_removed"]: - output.append(f"Dictionary Key Removed: {path}") - output.append("--------------------") - - # Clean up the last separator for a tidy output - if output and output[-1] == "--------------------": - output.pop() - - return "\n".join(output) - - -def compare_json_files( + current: Any = data + try: + for key in path: + if isinstance(current, dict) and isinstance(key, str): + current = current[key] + elif isinstance(current, list) and isinstance(key, int): + current = current[key] + else: + raise TypeError("Invalid path traversal") + return current + except (KeyError, IndexError, TypeError): + return None + + +def _get_accessor(data: dict | list, accessor_string: str) -> Any: + if accessor_string.startswith("root"): + accessor_string = accessor_string[4:] # Remove 'root' prefix + path = _parse_accessor(accessor_string) + return _get_path(data, path) + + +def _set_with_ensure_strpath(data: dict, str_path: list[str], value: Any) -> bool: + try: + current = data + for key in str_path[:-1]: + current = current.setdefault(key, {}) + final_key = str_path[-1] + if final_key not in current: + current[final_key] = [] + current[final_key].append(value) + return True + except (KeyError, IndexError, TypeError): + return False + + +def _format_value_oneliner(value: Any) -> str: + res = json.dumps(value) + if len(res) < oneliner_LEN: + return res + if isinstance(value, dict): + keys_str = ", ".join(f'"{key}": ...' for key in value.keys()) + res = f"{{ {keys_str} }}" + elif isinstance(value, list): + res = f"[ ({len(value)} items) ]" + if len(res) < oneliner_LEN: + return res + return res[:oneliner_LEN] + f"...({len(res) - oneliner_LEN} more chars)" + + +_color_codes = {} +_reset_code = "" + + +def init_colors(): + global _color_codes, _reset_code + _color_codes = { + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + } + _reset_code = "\033[0m" + + +def format_color(color: str, text: str) -> str: + code = _color_codes.get(color.lower()) + if code is None: + return text + return code + text + _reset_code + + +def print_color(color: str, *args, **kwargs): + sep = kwargs.get("sep", " ") + s = format_color(color, sep.join(str(arg) for arg in args)) + print(s, **{k: v for k, v in kwargs.items() if k not in ("sep")}) + + +def compare_files( file1_path: Path, file2_path: Path, ignore_fields: list[str] | None = None -) -> tuple[Status, DeepDiff | None]: +) -> DiffResult: """ Compares two JSON files, optionally ignoring specified fields. @@ -133,55 +303,94 @@ def compare_json_files( with open(file2_path, "r", encoding="utf-8") as f2: json2 = json.load(f2) except (FileNotFoundError, json.JSONDecodeError): - return "FILE_ERROR", None + return DiffResult("FILE_ERROR", None, {}, {}) # Delete ignored fields from both JSON objects before comparison if ignore_fields: for field_accessor in ignore_fields: - path = parse_accessor(field_accessor) - delete_path(json1, path) - delete_path(json2, path) + path = _parse_accessor(field_accessor) + _delete_path(json1, path) + _delete_path(json2, path) diff = DeepDiff(json1, json2, ignore_order=True) - return ("BAD", diff) if diff else ("OK", None) + return ( + DiffResult("BAD", diff, json1, json2) + if diff + else DiffResult("OK", None, json1, json2) + ) -def process_directory_comparison( - old_dir: Path, new_dir: Path, ignore_fields: list[str] | None = None -) -> bool: - """ - Compares JSON files across two directories and prints results in a list format. - """ - results: dict[str, list[str]] = {"OK": [], "BAD": [], "MISS": [], "NEW": []} - old_files = {p.name for p in old_dir.glob("*.json")} - new_files = {p.name for p in new_dir.glob("*.json")} +def compare_and_report_files( + old_path: Path, + new_path: Path, + ignore_fields: list[str] | None = None, + truncate_items: int = 100, + verbose: bool = False, +) -> int: + result = compare_files(old_path, new_path, ignore_fields) + if result.status == "FILE_ERROR": + print_color( + "red", + f"❌ [ERROR] reading or parsing {old_path} or {new_path}.", + file=sys.stderr, + ) + return 1 - for filename in sorted(old_files.intersection(new_files)): - status, _ = compare_json_files( - old_dir / filename, new_dir / filename, ignore_fields + if result.status == "BAD" and result.diff: + print_color( + "red", f"❌ [DIFF] {str(old_path):<40} <-> {new_path}", file=sys.stderr ) - results["BAD" if status != "OK" else "OK"].append(filename) + if verbose: + new_output = result.format(truncate_items) + new_output = "\n[details] ".join([""] + new_output.splitlines() + [""]) + print(new_output, file=sys.stderr) + return 1 + else: + print_color("green", f"✅ [IDENTICAL] {str(old_path):<40} <-> {new_path}") + return 0 - for filename in sorted(old_files - new_files): - results["MISS"].append(filename) +def get_compare_file_list_bothdir( + old_dir: Path, new_dir: Path +) -> tuple[list[str], list[str], list[tuple[Path, Path]]]: + old_files = {p.name for p in old_dir.glob("*.json")} + new_files = {p.name for p in new_dir.glob("*.json")} + compare_file = [] + miss_file = [] + new_file = [] + for filename in sorted(old_files.intersection(new_files)): + compare_file.append((old_dir / filename, new_dir / filename)) + for filename in sorted(old_files - new_files): + miss_file.append(filename) for filename in sorted(new_files - old_files): - results["NEW"].append(filename) + new_file.append(filename) + return miss_file, new_file, compare_file - for filename in results["OK"]: - print(f"[OK ] {filename}") - for filename in results["NEW"]: - print(f"[NEW ] {filename}") - for filename in results["BAD"]: - print(f"[BAD ] {filename}", file=sys.stderr) - for filename in results["MISS"]: - print(f"[MISS] {filename}", file=sys.stderr) - return bool(results["BAD"] or results["MISS"]) +def get_compare_file_list(path1: Path, path2: Path) -> list[tuple[Path, Path]]: + if not path1.exists() or not path2.exists(): + raise ValueError( + f"Error: Path does not exist: {path1 if not path1.exists() else path2}" + ) + if path1.is_dir() and path2.is_dir(): + miss_files, new_files, compare_files = get_compare_file_list_bothdir( + path1, path2 + ) + for filename in miss_files: + print_color("red", f"❌ [MISS] {filename}", file=sys.stderr) + for filename in new_files: + print_color("red", f"❌ [NEW ] {filename}") + elif path1.is_file() and path2.is_file(): + compare_files = [(path1, path2)] + else: + raise ValueError( + "Error: Both arguments must be files or both must be directories." + ) + return compare_files -def main(): +def main() -> int: parser = argparse.ArgumentParser( description="Compare two JSON files or two directories of JSON files." ) @@ -200,62 +409,37 @@ def main(): "Also reads whitespace-separated values from $DIFFJSON_IGNORE. " "Example: -i \"['metadata']['timestamp']\"", ) + parser.add_argument( + "-t", + "--truncate_items", + type=int, + default=100, + help="Maximum number of items to output. If 0, no truncation. Default: 100", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output for directory comparison.", + ) args = parser.parse_args() # --- Combine ignore fields from CLI and environment variable --- cli_ignore_fields = args.ignore env_ignore_str = os.environ.get("DIFFJSON_IGNORE", "") env_ignore_fields = env_ignore_str.split() if env_ignore_str else [] - - # Combine both sources and remove duplicates - all_ignore_fields = list(set(cli_ignore_fields + env_ignore_fields)) - - path1, path2 = args.path1, args.path2 - - if not path1.exists() or not path2.exists(): - print( - f"Error: Path does not exist: {path1 if not path1.exists() else path2}", - file=sys.stderr, - ) - return 1 - - # --- Handle Directory Comparison --- - if path1.is_dir() and path2.is_dir(): - print(f"Comparing directories:\n- Old: {path1}\n- New: {path2}\n") - if process_directory_comparison(path1, path2, all_ignore_fields): - print("\nComparison finished with errors.", file=sys.stderr) - return 1 - else: - print("\nComparison finished successfully.") - return 0 - - # --- Handle Single File Comparison --- - elif path1.is_file() and path2.is_file(): - status, diff = compare_json_files(path1, path2, all_ignore_fields) - - if status == "FILE_ERROR": - print("Error reading or parsing a file.", file=sys.stderr) - return 1 - - if status == "BAD" and diff: - print( - f"Differences found between '{path1.name}' and '{path2.name}':\n", - file=sys.stderr, - ) - custom_output = format_diff_custom(diff) - print(custom_output, file=sys.stderr) - return 1 - else: - print(f"Files '{path1.name}' and '{path2.name}' are identical.") - return 0 - - # --- Handle Invalid Input --- - else: - print( - "Error: Both arguments must be files or both must be directories.", - file=sys.stderr, + ignore_fields = list(set(cli_ignore_fields + env_ignore_fields)) + + init_colors() + compare_files = get_compare_file_list(args.path1, args.path2) + exit_code = 0 + for file1, file2 in compare_files: + result = compare_and_report_files( + file1, file2, ignore_fields, args.truncate_items, args.verbose ) - return 1 + if result != 0: + exit_code = result + return exit_code if __name__ == "__main__": diff --git a/ts-parser/package.json b/ts-parser/package.json index 2c22c5ef..02583877 100644 --- a/ts-parser/package.json +++ b/ts-parser/package.json @@ -1,6 +1,6 @@ { "name": "abcoder-ts-parser", - "version": "0.0.13", + "version": "0.0.20", "description": "TypeScript AST parser for UNIAST specification", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/ts-parser/src/index.ts b/ts-parser/src/index.ts index b0e01dd5..bdbd0ab4 100644 --- a/ts-parser/src/index.ts +++ b/ts-parser/src/index.ts @@ -11,7 +11,7 @@ const program = new Command(); program .name('abcoder-ts-parser') .description('TypeScript AST parser for UNIAST specification') - .version('0.0.13'); + .version('0.0.20'); program .command('parse') diff --git a/ts-parser/src/parser/FunctionParser.ts b/ts-parser/src/parser/FunctionParser.ts index 7050f090..fc5346c4 100644 --- a/ts-parser/src/parser/FunctionParser.ts +++ b/ts-parser/src/parser/FunctionParser.ts @@ -676,7 +676,62 @@ export class FunctionParser { } for (const typeNode of typeNodes) { - // Handle union and intersection types by extracting individual type references + // First, try to extract the direct type reference from the typeNode itself + // This handles type aliases like "Status" which reference union types + let directSymbol: Symbol | undefined; + + // For TypeReferenceNode, get the symbol from the type name + if (Node.isTypeReference(typeNode)) { + const typeName = typeNode.getTypeName(); + if (Node.isIdentifier(typeName)) { + directSymbol = typeName.getSymbol(); + } else if (Node.isQualifiedName(typeName)) { + directSymbol = typeName.getRight().getSymbol(); + } + } else { + // For other type nodes, try to get symbol from the type itself + const typeObj = typeNode.getType(); + directSymbol = typeObj.getSymbol() || typeNode.getSymbol(); + } + + if (directSymbol) { + const directTypeName = directSymbol.getName(); + if (!this.isPrimitiveType(directTypeName)) { + const [resolvedSymbol, resolvedRealSymbol] = this.symbolResolver.resolveSymbol(directSymbol, typeNode); + if (resolvedSymbol && !resolvedSymbol.isExternal) { + const decls = resolvedRealSymbol?.getDeclarations() || []; + if (decls.length > 0) { + const defStartOffset = decls[0].getStart(); + const defEndOffset = decls[0].getEnd(); + const key = `${resolvedSymbol.moduleName}?${resolvedSymbol.packagePath}#${resolvedSymbol.name}`; + + // Check if this is a self-reference (type reference within its own definition) + const isSelfReference = ( + resolvedSymbol.moduleName === moduleName && + this.getPkgPath(resolvedSymbol.packagePath || packagePath) === packagePath && + defStartOffset <= resolvedSymbol.startOffset && + resolvedSymbol.endOffset <= defEndOffset + ); + + if (!visited.has(key) && !isSelfReference) { + visited.add(key); + const dep: Dependency = { + ModPath: resolvedSymbol.moduleName || moduleName, + PkgPath: this.getPkgPath(resolvedSymbol.packagePath || packagePath), + Name: resolvedSymbol.name, + File: resolvedSymbol.filePath, + Line: resolvedSymbol.line, + StartOffset: resolvedSymbol.startOffset, + EndOffset: resolvedSymbol.endOffset + }; + types.push(dep); + } + } + } + } + } + + // Then handle union and intersection types by extracting individual type references const typeReferences = this.dependencyUtils.extractAtomicTypeReferences(typeNode); for (const typeRef of typeReferences) { @@ -720,11 +775,12 @@ export class FunctionParser { EndOffset: resolvedSymbol.endOffset }; + // Check if this is a self-reference (type reference within its own definition) if ( dep.ModPath === moduleName && dep.PkgPath === packagePath && - defEndOffset <= node.getEnd() && - defStartOffset >= node.getStart() + defStartOffset <= resolvedSymbol.startOffset && + resolvedSymbol.endOffset <= defEndOffset ) { continue; } diff --git a/ts-parser/src/parser/PackageParser.ts b/ts-parser/src/parser/PackageParser.ts index e4367837..de4aa3aa 100644 --- a/ts-parser/src/parser/PackageParser.ts +++ b/ts-parser/src/parser/PackageParser.ts @@ -29,7 +29,7 @@ export class PackageParser { // eslint-disable-next-line @typescript-eslint/no-explicit-any const vars: Record = {}; - for (const sourceFile of sourceFiles) { + for (const sourceFile of sourceFiles) { // Parse functions const fileFunctions = this.functionParser.parseFunctions(sourceFile, moduleName, packagePath); Object.assign(functions, fileFunctions); diff --git a/ts-parser/src/parser/TypeParser.ts b/ts-parser/src/parser/TypeParser.ts index e257002c..81de1db0 100644 --- a/ts-parser/src/parser/TypeParser.ts +++ b/ts-parser/src/parser/TypeParser.ts @@ -7,7 +7,8 @@ import { SyntaxKind, TypeNode, ClassExpression, - Symbol + Symbol, + Node } from 'ts-morph'; import { Type as UniType, Dependency } from '../types/uniast'; import { assignSymbolName, SymbolResolver } from '../utils/symbol-resolver'; @@ -248,9 +249,9 @@ export class TypeParser { TypeKind: 'typedef', Content: content, Methods: {}, - Implements: typeDependencies, + Implements: [], SubStruct: [], - InlineStruct: [] + InlineStruct: typeDependencies }; } @@ -293,19 +294,75 @@ export class TypeParser { const dependencies: Dependency[] = []; const visited = new Set(); - // Extract from identifiers and find their definitions - const types = this.dependencyUtils.extractAtomicTypeReferences(typeNode); + // Collect all type reference nodes (including the root typeNode itself if it's a TypeReference) + const typeReferences: TypeNode[] = []; - for (const t of types) { - const symbol = t.getSymbol(); - if (!symbol) { - continue; + // Handle ExpressionWithTypeArguments (used in extends/implements clauses) + if (Node.isExpressionWithTypeArguments(typeNode)) { + const expression = typeNode.getExpression(); + let symbol: Symbol | undefined; + + if (Node.isIdentifier(expression)) { + symbol = expression.getSymbol(); + } else if (Node.isPropertyAccessExpression(expression)) { + symbol = expression.getSymbol(); + } + + if (symbol) { + const [resolvedSymbol, resolvedRealSymbol] = this.symbolResolver.resolveSymbol(symbol, typeNode); + if (resolvedSymbol && !resolvedSymbol.isExternal) { + const decls = resolvedRealSymbol?.getDeclarations() || []; + if (decls.length > 0) { + const key = `${resolvedSymbol.moduleName}?${resolvedSymbol.packagePath}#${resolvedSymbol.name}`; + + // Check if this is a self-reference: the type reference is within its own definition + const isSelfRef = typeNode.getAncestors().some(ancestor => ancestor === decls[0]); + + if (!visited.has(key) && !isSelfRef) { + visited.add(key); + dependencies.push({ + ModPath: resolvedSymbol.moduleName || moduleName, + PkgPath: this.getPkgPath(resolvedSymbol.packagePath || packagePath), + Name: resolvedSymbol.name, + File: resolvedSymbol.filePath, + Line: resolvedSymbol.line, + StartOffset: resolvedSymbol.startOffset, + EndOffset: resolvedSymbol.endOffset + }); + } + } + } + } + } + + // Handle TypeReference nodes + if (Node.isTypeReference(typeNode)) { + typeReferences.push(typeNode); + } + + // Also get all descendant type references + typeReferences.push(...typeNode.getDescendantsOfKind(SyntaxKind.TypeReference)); + + // Process each type reference + for (const typeRef of typeReferences) { + if (!Node.isTypeReference(typeRef)) continue; + + const typeName = typeRef.getTypeName(); + let symbol: Symbol | undefined; + + if (Node.isIdentifier(typeName)) { + symbol = typeName.getSymbol(); + } else if (Node.isQualifiedName(typeName)) { + symbol = typeName.getRight().getSymbol(); } - const [resolvedSymbol, resolvedRealSymbol] = this.symbolResolver.resolveSymbol(symbol, typeNode); - // if symbol is not external, add it to dependencies + + if (!symbol) continue; + + const [resolvedSymbol, resolvedRealSymbol] = this.symbolResolver.resolveSymbol(symbol, typeRef); if (!resolvedSymbol || resolvedSymbol.isExternal) { continue; } + const key = `${resolvedSymbol.moduleName}?${resolvedSymbol.packagePath}#${resolvedSymbol.name}`; if (visited.has(key)) { continue; @@ -316,8 +373,11 @@ export class TypeParser { continue; } - const defStartOffset = decls[0].getStart(); - const defEndOffset = decls[0].getEnd(); + // Check if this is a self-reference: the type reference is within its own definition + // If typeRef's ancestors include decls[0], it's a self-reference + const isSelfRef = typeRef.getAncestors().some(ancestor => ancestor === decls[0]); + + if (isSelfRef) continue; visited.add(key); const dep: Dependency = { @@ -329,12 +389,7 @@ export class TypeParser { StartOffset: resolvedSymbol.startOffset, EndOffset: resolvedSymbol.endOffset }; - if ( - dep.ModPath === moduleName && - dep.PkgPath === packagePath && - defStartOffset <= resolvedSymbol.startOffset && - resolvedSymbol.endOffset <= defEndOffset - ) continue; + dependencies.push(dep); } diff --git a/ts-parser/src/parser/VarParser.ts b/ts-parser/src/parser/VarParser.ts index f8ceb549..840e21f6 100644 --- a/ts-parser/src/parser/VarParser.ts +++ b/ts-parser/src/parser/VarParser.ts @@ -140,7 +140,21 @@ export class VarParser { const typeNode = varDecl.getTypeNode(); let type: Dependency | undefined; if (typeNode) { - const typeSymbol = typeNode.getSymbol(); + let typeSymbol: Symbol | undefined; + + // For TypeReferenceNode, get the symbol from the type name + if (Node.isTypeReference(typeNode)) { + const typeName = typeNode.getTypeName(); + if (Node.isIdentifier(typeName)) { + typeSymbol = typeName.getSymbol(); + } else if (Node.isQualifiedName(typeName)) { + typeSymbol = typeName.getRight().getSymbol(); + } + } else { + // For other type nodes, try to get symbol from the type itself + typeSymbol = typeNode.getSymbol(); + } + if (typeSymbol) { const [resolvedSymbol, ] = this.symbolResolver.resolveSymbol(typeSymbol, varDecl); if (resolvedSymbol && !resolvedSymbol.isExternal) { @@ -214,7 +228,21 @@ export class VarParser { const typeNode = prop.getTypeNode(); let type: Dependency | undefined; if (typeNode) { - const typeSymbol = typeNode.getSymbol(); + let typeSymbol: Symbol | undefined; + + // For TypeReferenceNode, get the symbol from the type name + if (Node.isTypeReference(typeNode)) { + const typeName = typeNode.getTypeName(); + if (Node.isIdentifier(typeName)) { + typeSymbol = typeName.getSymbol(); + } else if (Node.isQualifiedName(typeName)) { + typeSymbol = typeName.getRight().getSymbol(); + } + } else { + // For other type nodes, try to get symbol from the type itself + typeSymbol = typeNode.getSymbol(); + } + if (typeSymbol) { const [resolvedSymbol, ] = this.symbolResolver.resolveSymbol(typeSymbol, prop); if (resolvedSymbol && !resolvedSymbol.isExternal) { diff --git a/ts-parser/src/parser/test/FunctionParser.test.ts b/ts-parser/src/parser/test/FunctionParser.test.ts index 6497ea8a..d5793d7d 100644 --- a/ts-parser/src/parser/test/FunctionParser.test.ts +++ b/ts-parser/src/parser/test/FunctionParser.test.ts @@ -599,4 +599,189 @@ describe('FunctionParser', () => { cleanup(); }); }); + + describe('type alias dependencies in function parameters and return types', () => { + it('should extract union type alias dependencies from function parameters', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Status = 'normal' | 'abnormal'; + + export const flipStatus = (s: Status): Status => { + return s === 'normal' ? 'abnormal' : 'normal'; + }; + `); + + const parser = new FunctionParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const functions = parser.parseFunctions(sourceFile, 'parser-tests', pkgPath); + + // flipStatus function should exist + const flipStatus = expectToBeDefined(functions['flipStatus']); + expect(flipStatus.Exported).toBe(true); + + // Should have Status in Types array + expect(flipStatus.Types).toBeDefined(); + expect(expectToBeDefined(flipStatus.Types).length).toBeGreaterThan(0); + + const typeNames = expectToBeDefined(flipStatus.Types).map(dep => dep.Name); + expect(typeNames).toContain('Status'); + + cleanup(); + }); + + it('should extract type alias dependencies from complex function signatures', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type UserId = string; + export type UserRole = 'admin' | 'user' | 'guest'; + + export type User = { + id: UserId; + role: UserRole; + name: string; + }; + + export function createUser(id: UserId, role: UserRole, name: string): User { + return { id, role, name }; + } + `); + + const parser = new FunctionParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const functions = parser.parseFunctions(sourceFile, 'parser-tests', pkgPath); + + const createUser = expectToBeDefined(functions['createUser']); + + // Should have all type aliases in Types array + expect(createUser.Types).toBeDefined(); + const typeNames = expectToBeDefined(createUser.Types).map(dep => dep.Name); + + expect(typeNames).toContain('UserId'); + expect(typeNames).toContain('UserRole'); + expect(typeNames).toContain('User'); + + cleanup(); + }); + + it('should not include primitive types in Types array', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export function processData(name: string, age: number, active: boolean): void { + console.log(name, age, active); + } + `); + + const parser = new FunctionParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const functions = parser.parseFunctions(sourceFile, 'parser-tests', pkgPath); + + const processData = expectToBeDefined(functions['processData']); + + // Should not have primitive types + const typeNames = (processData.Types || []).map(dep => dep.Name); + expect(typeNames).not.toContain('string'); + expect(typeNames).not.toContain('number'); + expect(typeNames).not.toContain('boolean'); + expect(typeNames).not.toContain('void'); + + cleanup(); + }); + + it('should extract type aliases from arrow function parameters and return types', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Result = { success: true; data: T } | { success: false; error: string }; + export type UserData = { name: string; email: string }; + + export const fetchUser = async (id: string): Promise> => { + return { success: true, data: { name: 'John', email: 'john@example.com' } }; + }; + `); + + const parser = new FunctionParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const functions = parser.parseFunctions(sourceFile, 'parser-tests', pkgPath); + + const fetchUser = expectToBeDefined(functions['fetchUser']); + + // Should have type aliases in Types array + expect(fetchUser.Types).toBeDefined(); + const typeNames = expectToBeDefined(fetchUser.Types).map(dep => dep.Name); + + expect(typeNames).toContain('Result'); + expect(typeNames).toContain('UserData'); + + cleanup(); + }); + + it('should handle multiple occurrences of the same type alias', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Status = 'active' | 'inactive'; + + export function updateStatus(oldStatus: Status, newStatus: Status): Status { + return newStatus; + } + `); + + const parser = new FunctionParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const functions = parser.parseFunctions(sourceFile, 'parser-tests', pkgPath); + + const updateStatus = expectToBeDefined(functions['updateStatus']); + + // Should have Status only once (deduplication) + expect(updateStatus.Types).toBeDefined(); + const typeNames = expectToBeDefined(updateStatus.Types).map(dep => dep.Name); + const statusCount = typeNames.filter(name => name === 'Status').length; + + expect(statusCount).toBe(1); + + cleanup(); + }); + + it('should filter out self-referencing recursive types', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type TreeNode = { + value: string; + children: TreeNode[]; + }; + + export function processTree(node: TreeNode): void { + console.log(node.value); + } + `); + + const parser = new FunctionParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const functions = parser.parseFunctions(sourceFile, 'parser-tests', pkgPath); + + const processTree = expectToBeDefined(functions['processTree']); + + // Should have TreeNode in Types array + expect(processTree.Types).toBeDefined(); + const typeNames = expectToBeDefined(processTree.Types).map(dep => dep.Name); + + expect(typeNames).toContain('TreeNode'); + + // TreeNode should only appear once (the self-reference in TreeNode definition should be filtered) + const treeNodeCount = typeNames.filter(name => name === 'TreeNode').length; + expect(treeNodeCount).toBe(1); + + cleanup(); + }); + }); }); \ No newline at end of file diff --git a/ts-parser/src/parser/test/TypeParser.test.ts b/ts-parser/src/parser/test/TypeParser.test.ts index 85cbea68..7ea0476a 100644 --- a/ts-parser/src/parser/test/TypeParser.test.ts +++ b/ts-parser/src/parser/test/TypeParser.test.ts @@ -299,7 +299,7 @@ describe('TypeParser', () => { const { project, sourceFile, cleanup } = createTestProject(` class CustomType {} interface CustomInterface {} - + type SimpleAlias = CustomType; type ComplexAlias = { prop: CustomType; @@ -313,37 +313,38 @@ describe('TypeParser', () => { }; }; `); - + const parser = new TypeParser(process.cwd()); let pkgPathAbsFile : string = sourceFile.getFilePath() pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/') const pkgPath = path.relative(process.cwd(), pkgPathAbsFile) - + const types = parser.parseTypes(sourceFile, 'parser-tests', pkgPath); - + const simpleAlias = expectToBeDefined(types['SimpleAlias']); const complexAlias = expectToBeDefined(types['ComplexAlias']); const unionAlias = expectToBeDefined(types['UnionAlias']); const genericAlias = expectToBeDefined(types['GenericAlias']); const nestedAlias = expectToBeDefined(types['NestedAlias']); - - expect(expectToBeDefined(simpleAlias.Implements).length).toBeGreaterThan(0); - expect(expectToBeDefined(complexAlias.Implements).length).toBeGreaterThan(0); - expect(expectToBeDefined(unionAlias.Implements).length).toBeGreaterThan(0); - expect(expectToBeDefined(genericAlias.Implements).length).toBeGreaterThan(0); - expect(expectToBeDefined(nestedAlias.Implements).length).toBeGreaterThan(0); - + + // Type aliases should have dependencies in InlineStruct, not Implements + expect(expectToBeDefined(simpleAlias.InlineStruct).length).toBeGreaterThan(0); + expect(expectToBeDefined(complexAlias.InlineStruct).length).toBeGreaterThan(0); + expect(expectToBeDefined(unionAlias.InlineStruct).length).toBeGreaterThan(0); + expect(expectToBeDefined(genericAlias.InlineStruct).length).toBeGreaterThan(0); + expect(expectToBeDefined(nestedAlias.InlineStruct).length).toBeGreaterThan(0); + const allTypeNames = [ - ...expectToBeDefined(simpleAlias.Implements), - ...expectToBeDefined(complexAlias.Implements), - ...expectToBeDefined(unionAlias.Implements), - ...expectToBeDefined(genericAlias.Implements), - ...expectToBeDefined(nestedAlias.Implements) + ...expectToBeDefined(simpleAlias.InlineStruct), + ...expectToBeDefined(complexAlias.InlineStruct), + ...expectToBeDefined(unionAlias.InlineStruct), + ...expectToBeDefined(genericAlias.InlineStruct), + ...expectToBeDefined(nestedAlias.InlineStruct) ].map(dep => expectToBeDefined(dep).Name); - + expect(allTypeNames).toContain('CustomType'); expect(allTypeNames).toContain('CustomInterface'); - + cleanup(); }); @@ -521,4 +522,187 @@ describe('TypeParser', () => { cleanup(); }); }); + + describe('type alias dependencies in InlineStruct', () => { + it('should extract union type alias dependencies into InlineStruct', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Status = 'normal' | 'abnormal'; + + export type ServerStatus = { + code: number; + status: Status; + }; + `); + + const parser = new TypeParser(process.cwd()); + let pkgPathAbsFile : string = sourceFile.getFilePath() + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/') + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile) + + const types = parser.parseTypes(sourceFile, 'parser-tests', pkgPath); + + // Status type should exist + expect(types['Status']).toBeDefined(); + expect(types['Status'].TypeKind).toBe('typedef'); + + // ServerStatus should exist + const serverStatus = expectToBeDefined(types['ServerStatus']); + expect(serverStatus.TypeKind).toBe('typedef'); + + // ServerStatus should have Status in InlineStruct, not Implements + expect(serverStatus.Implements).toEqual([]); + expect(expectToBeDefined(serverStatus.InlineStruct).length).toBeGreaterThan(0); + + const inlineStructNames = expectToBeDefined(serverStatus.InlineStruct).map(dep => dep.Name); + expect(inlineStructNames).toContain('Status'); + + cleanup(); + }); + + it('should extract complex type alias dependencies into InlineStruct', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type UserId = string; + export type UserRole = 'admin' | 'user' | 'guest'; + + export type User = { + id: UserId; + role: UserRole; + name: string; + }; + + export type UserWithMetadata = User & { + createdAt: Date; + updatedAt: Date; + }; + `); + + const parser = new TypeParser(process.cwd()); + let pkgPathAbsFile : string = sourceFile.getFilePath() + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/') + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile) + + const types = parser.parseTypes(sourceFile, 'parser-tests', pkgPath); + + // User type should have dependencies in InlineStruct + const user = expectToBeDefined(types['User']); + expect(user.Implements).toEqual([]); + expect(expectToBeDefined(user.InlineStruct).length).toBeGreaterThan(0); + + const userInlineNames = expectToBeDefined(user.InlineStruct).map(dep => dep.Name); + expect(userInlineNames).toContain('UserId'); + expect(userInlineNames).toContain('UserRole'); + + // UserWithMetadata should have User in InlineStruct + const userWithMetadata = expectToBeDefined(types['UserWithMetadata']); + expect(userWithMetadata.Implements).toEqual([]); + expect(expectToBeDefined(userWithMetadata.InlineStruct).length).toBeGreaterThan(0); + + const metadataInlineNames = expectToBeDefined(userWithMetadata.InlineStruct).map(dep => dep.Name); + expect(metadataInlineNames).toContain('User'); + + cleanup(); + }); + + it('should not include primitive types in InlineStruct', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Config = { + host: string; + port: number; + enabled: boolean; + }; + `); + + const parser = new TypeParser(process.cwd()); + let pkgPathAbsFile : string = sourceFile.getFilePath() + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/') + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile) + + const types = parser.parseTypes(sourceFile, 'parser-tests', pkgPath); + + const config = expectToBeDefined(types['Config']); + + // Should not have primitive types in InlineStruct + const inlineStructNames = (config.InlineStruct || []).map(dep => dep.Name); + expect(inlineStructNames).not.toContain('string'); + expect(inlineStructNames).not.toContain('number'); + expect(inlineStructNames).not.toContain('boolean'); + + cleanup(); + }); + + it('should handle nested type references in InlineStruct', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Address = { + street: string; + city: string; + }; + + export type ContactInfo = { + email: string; + address: Address; + }; + + export type Person = { + name: string; + contact: ContactInfo; + }; + `); + + const parser = new TypeParser(process.cwd()); + let pkgPathAbsFile : string = sourceFile.getFilePath() + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/') + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile) + + const types = parser.parseTypes(sourceFile, 'parser-tests', pkgPath); + + // ContactInfo should reference Address + const contactInfo = expectToBeDefined(types['ContactInfo']); + expect(expectToBeDefined(contactInfo.InlineStruct).length).toBeGreaterThan(0); + + const contactInfoInlineNames = expectToBeDefined(contactInfo.InlineStruct).map(dep => dep.Name); + expect(contactInfoInlineNames).toContain('Address'); + + // Person should reference ContactInfo + const person = expectToBeDefined(types['Person']); + expect(expectToBeDefined(person.InlineStruct).length).toBeGreaterThan(0); + + const personInlineNames = expectToBeDefined(person.InlineStruct).map(dep => dep.Name); + expect(personInlineNames).toContain('ContactInfo'); + + cleanup(); + }); + + it('should filter out self-referencing recursive types in InlineStruct', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type TreeNode = { + value: string; + children: TreeNode[]; + }; + + export type LinkedListNode = { + data: number; + next: LinkedListNode | null; + }; + `); + + const parser = new TypeParser(process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const types = parser.parseTypes(sourceFile, 'parser-tests', pkgPath); + + // TreeNode should not include itself in InlineStruct (self-reference should be filtered) + const treeNode = expectToBeDefined(types['TreeNode']); + const treeNodeInlineNames = (treeNode.InlineStruct || []).map(dep => dep.Name); + expect(treeNodeInlineNames).not.toContain('TreeNode'); + + // LinkedListNode should not include itself in InlineStruct + const linkedListNode = expectToBeDefined(types['LinkedListNode']); + const linkedListInlineNames = (linkedListNode.InlineStruct || []).map(dep => dep.Name); + expect(linkedListInlineNames).not.toContain('LinkedListNode'); + + cleanup(); + }); + }); }); \ No newline at end of file diff --git a/ts-parser/src/parser/test/VarParser.test.ts b/ts-parser/src/parser/test/VarParser.test.ts index ffa41d1f..4b9f7ad2 100644 --- a/ts-parser/src/parser/test/VarParser.test.ts +++ b/ts-parser/src/parser/test/VarParser.test.ts @@ -276,16 +276,210 @@ describe('VarParser', () => { export { someVar } from './other-module'; export * as namespace from './namespace-module'; `); - + const parser = new VarParser(project, process.cwd()); let pkgPathAbsFile : string = sourceFile.getFilePath() pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/') const pkgPath = path.relative(process.cwd(), pkgPathAbsFile) - + const vars = parser.parseVars(sourceFile, 'parser-tests', pkgPath); - + expect(vars).toBeDefined(); - + + cleanup(); + }); + }); + + describe('type alias dependencies in variable type annotations', () => { + it('should extract union type alias dependencies from variable declarations', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Status = 'normal' | 'abnormal'; + + export const currentStatus: Status = 'normal'; + export let mutableStatus: Status = 'abnormal'; + `); + + const parser = new VarParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const vars = parser.parseVars(sourceFile, 'parser-tests', pkgPath); + + // currentStatus should have Status as type dependency + const currentStatus = expectToBeDefined(vars['currentStatus']); + expect(currentStatus.Type).toBeDefined(); + expect(currentStatus.Type?.Name).toBe('Status'); + expect(currentStatus.IsExported).toBe(true); + + // mutableStatus should also have Status as type dependency + const mutableStatus = expectToBeDefined(vars['mutableStatus']); + expect(mutableStatus.Type).toBeDefined(); + expect(mutableStatus.Type?.Name).toBe('Status'); + + cleanup(); + }); + + it('should extract complex type alias dependencies from variable declarations', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type UserId = string; + export type UserRole = 'admin' | 'user' | 'guest'; + + export type User = { + id: UserId; + role: UserRole; + name: string; + }; + + export const adminUser: User = { + id: 'admin-001', + role: 'admin', + name: 'Admin' + }; + + export const userId: UserId = 'user-123'; + `); + + const parser = new VarParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const vars = parser.parseVars(sourceFile, 'parser-tests', pkgPath); + + // adminUser should reference User type + const adminUser = expectToBeDefined(vars['adminUser']); + expect(adminUser.Type).toBeDefined(); + expect(adminUser.Type?.Name).toBe('User'); + + // userId should reference UserId type + const userId = expectToBeDefined(vars['userId']); + expect(userId.Type).toBeDefined(); + expect(userId.Type?.Name).toBe('UserId'); + + cleanup(); + }); + + it('should not include primitive types as type dependencies', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export const name: string = 'John'; + export const age: number = 30; + export const active: boolean = true; + export const nothing: null = null; + export const undef: undefined = undefined; + `); + + const parser = new VarParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const vars = parser.parseVars(sourceFile, 'parser-tests', pkgPath); + + // None of these should have Type set (primitive types are not tracked) + expect(vars['name'].Type).toBeUndefined(); + expect(vars['age'].Type).toBeUndefined(); + expect(vars['active'].Type).toBeUndefined(); + expect(vars['nothing'].Type).toBeUndefined(); + expect(vars['undef'].Type).toBeUndefined(); + + cleanup(); + }); + + it('should extract type aliases from destructured variables', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type Config = { + host: string; + port: number; + }; + + export type Status = 'running' | 'stopped'; + + export const config: Config = { host: 'localhost', port: 8080 }; + export const status: Status = 'running'; + `); + + const parser = new VarParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const vars = parser.parseVars(sourceFile, 'parser-tests', pkgPath); + + // config should reference Config type + const config = expectToBeDefined(vars['config']); + expect(config.Type).toBeDefined(); + expect(config.Type?.Name).toBe('Config'); + + // status should reference Status type + const statusVar = expectToBeDefined(vars['status']); + expect(statusVar.Type).toBeDefined(); + expect(statusVar.Type?.Name).toBe('Status'); + + cleanup(); + }); + + it('should handle array and generic type aliases', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export type StringArray = Array; + export type NumberList = number[]; + + export const names: StringArray = ['Alice', 'Bob']; + export const ages: NumberList = [25, 30]; + `); + + const parser = new VarParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const vars = parser.parseVars(sourceFile, 'parser-tests', pkgPath); + + // names should reference StringArray type + const names = expectToBeDefined(vars['names']); + expect(names.Type).toBeDefined(); + expect(names.Type?.Name).toBe('StringArray'); + + // ages should reference NumberList type + const ages = expectToBeDefined(vars['ages']); + expect(ages.Type).toBeDefined(); + expect(ages.Type?.Name).toBe('NumberList'); + + cleanup(); + }); + + it('should extract type aliases from interface and class types', () => { + const { project, sourceFile, cleanup } = createTestProject(` + export interface UserInterface { + name: string; + age: number; + } + + export class UserClass { + constructor(public name: string, public age: number) {} + } + + export const user1: UserInterface = { name: 'Alice', age: 25 }; + export const user2: UserClass = new UserClass('Bob', 30); + `); + + const parser = new VarParser(project, process.cwd()); + let pkgPathAbsFile: string = sourceFile.getFilePath(); + pkgPathAbsFile = pkgPathAbsFile.split('/').slice(0, -1).join('/'); + const pkgPath = path.relative(process.cwd(), pkgPathAbsFile); + + const vars = parser.parseVars(sourceFile, 'parser-tests', pkgPath); + + // user1 should reference UserInterface + const user1 = expectToBeDefined(vars['user1']); + expect(user1.Type).toBeDefined(); + expect(user1.Type?.Name).toBe('UserInterface'); + + // user2 should reference UserClass + const user2 = expectToBeDefined(vars['user2']); + expect(user2.Type).toBeDefined(); + expect(user2.Type?.Name).toBe('UserClass'); + cleanup(); }); }); diff --git a/ts-parser/src/utils/dependency-utils.ts b/ts-parser/src/utils/dependency-utils.ts index 0bddc0f9..35c03d1c 100644 --- a/ts-parser/src/utils/dependency-utils.ts +++ b/ts-parser/src/utils/dependency-utils.ts @@ -36,13 +36,25 @@ export class DependencyUtils { if (t.isTypeParameter()) { return; } - + if(t.isUnion()) { + // If the union type has a symbol (i.e., it's a type alias), add it first + const symbol = t.getSymbol(); + if (symbol) { + results.push(t); + } + // Then recursively process union members t.getUnionTypes().forEach(visit); return; } if (t.isIntersection()) { + // If the intersection type has a symbol (i.e., it's a type alias), add it first + const symbol = t.getSymbol(); + if (symbol) { + results.push(t); + } + // Then recursively process intersection members t.getIntersectionTypes().forEach(visit); return; } @@ -70,7 +82,7 @@ export class DependencyUtils { console.error('Error processing type:', t, error); } } - + visit(type); return results; } diff --git a/ts-parser/test-repo/src/test-export-default.ts b/ts-parser/test-repo/src/test-export-default.ts index 78ef9f1c..64ce8e16 100644 --- a/ts-parser/test-repo/src/test-export-default.ts +++ b/ts-parser/test-repo/src/test-export-default.ts @@ -9,3 +9,14 @@ export default foo; export const bar = () => { console.log('baz') } + +export type Status = 'normal' | 'abnormal' + +export type ServerStatus = { + code: number; + status: Status; +} + +export const flipStatus = (s: Status): Status => { + return s === 'normal' ? 'abnormal' : 'normal'; +}