diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 6c5158ac9e..5e55d1193e 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -1489,24 +1489,66 @@ def _ts_walk_class_members(class_node, source: bytes, path: Path, class_nid: str ) def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolutionFacts) -> None: - js_paths = [ - path for path in paths - if path.suffix in _JS_CACHE_BYPASS_SUFFIXES - ] - if not js_paths: - return - - trees: dict[Path, tuple[bytes, object]] = {} - - for path in js_paths: - resolved_path = path.resolve() + js_paths = [path for path in paths if path.suffix in _JS_CACHE_BYPASS_SUFFIXES] + groups: dict[Path, list[tuple[int, Path]]] = {} + for position, path in enumerate(js_paths): + groups.setdefault(path.resolve(), []).append((position, path)) + + pending: dict[int, _SymbolResolutionFacts] = {} + class_uses: list[_SymbolUseFact] = [] + next_position = 0 + for entries in groups.values(): + # Only paths for one resolved file share a tree. Each occurrence keeps + # its own facts: grouping A, B, alias-of-A must not emit A, alias-of-A, B. + collected = _collect_js_file_group_facts([path for _, path in entries]) + for (position, _), local in zip(entries, collected): + pending[position] = local + while next_position < len(js_paths): + local = pending.pop(next_position, None) + if local is None: + break + facts.declarations.extend(local.declarations) + facts.imports.extend(local.imports) + facts.aliases.extend(local.aliases) + facts.exports.extend(local.exports) + facts.star_exports.extend(local.star_exports) + facts.namespace_exports.extend(local.namespace_exports) + # Existing ordering is all function calls, then all class type uses. + for use in local.uses: + if use.relation == "calls": + facts.uses.append(use) + else: + class_uses.append(use) + next_position += 1 + facts.uses.extend(class_uses) + + +def _collect_js_file_group_facts(paths: list[Path]) -> list[_SymbolResolutionFacts]: + """Collect occurrences of one resolved file without retaining other files' trees.""" + collected = [_SymbolResolutionFacts() for _ in paths] + last_parsed = None + for path, facts in zip(paths, collected): parsed = _parse_js_tree(path) if parsed is None: continue source, root_node = parsed - trees[resolved_path] = parsed - + imports_exports, aliases, exports, classes = [], [], [], [] for node in _walk_js_tree(root_node): + kind = node.type + if kind in ("import_statement", "export_statement"): + imports_exports.append(node) + if kind == "export_statement": + exports.append(node) + if kind == "lexical_declaration": + aliases.append(node) + if kind in ("class_declaration", "abstract_class_declaration", "interface_declaration"): + classes.append(node) + # First-pass facts use each occurrence's own grammar. Exports, calls, + # and class uses reuse the last successful parse, even if a later alias + # fails to parse. This preserves the original multi-pass contract. + last_parsed = source, root_node, exports, classes + + for node in imports_exports: if node.type == "export_statement": for name in _js_exported_declaration_names(node, source): facts.declarations.append( @@ -1544,23 +1586,18 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut ) ) - for node in _walk_js_tree(root_node): + for node in aliases: for alias, target in _js_lexical_aliases(node, source): facts.aliases.append( _SymbolAliasFact(path, alias, target, node.start_point[0] + 1) ) - for path in js_paths: - resolved_path = path.resolve() - parsed = trees.get(resolved_path) - if parsed is None: - continue - source, root_node = parsed - - for node in _walk_js_tree(root_node): - if node.type != "export_statement": - continue + if last_parsed is None: + return collected + source, root_node, exports, classes = last_parsed + for path, facts in zip(paths, collected): + for node in exports: raw_module = _js_module_specifier(node, source) export_clause = _js_export_clause(node) # `export type { X } from ...` / `export type * from ...`: the @@ -1646,12 +1683,7 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut ) ) - for path in js_paths: - resolved_path = path.resolve() - parsed = trees.get(resolved_path) - if parsed is None: - continue - source, root_node = parsed + for path, facts in zip(paths, collected): for source_id, body in _js_top_level_function_bodies(path, root_node, source): for node in _walk_js_tree(body): imported_name = _js_call_identifier(node, source) @@ -1668,20 +1700,9 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut ) ) - for path in js_paths: - resolved_path = path.resolve() - parsed = trees.get(resolved_path) - if parsed is None: - continue - source, root_node = parsed + for path, facts in zip(paths, collected): stem = _file_stem(path) - for node in _walk_js_tree(root_node): - if node.type not in ( - "class_declaration", - "abstract_class_declaration", - "interface_declaration", - ): - continue + for node in classes: name_node = node.child_by_field_name("name") if name_node is None: continue @@ -1690,6 +1711,7 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut continue class_nid = _make_id(stem, class_name) _ts_walk_class_members(node, source, path, class_nid, facts) + return collected def _parse_python_tree(path: Path): try: diff --git a/scripts/benchmark_js_fact_collection.py b/scripts/benchmark_js_fact_collection.py new file mode 100644 index 0000000000..a2a73ce6fe --- /dev/null +++ b/scripts/benchmark_js_fact_collection.py @@ -0,0 +1,194 @@ +"""Compare JS fact collection or extraction in two existing git checkouts. + +Example (run with the same Python environment for both checkouts):: + + python scripts/benchmark_js_fact_collection.py \ + --baseline ../graphify-base --candidate . --source ../openclaw \ + --files 2000 --runs 5 --output /tmp/js-facts.json + +Use --mode graph to also verify ordered serialized extraction output. Each +measurement runs in a fresh process, alternates baseline/candidate order, and +uses the same seeded sample. No filesystem-cache flush is attempted. RSS is +process high-water memory before result serialization, not total system memory +or the sum of graph extraction workers. Results are measurements, not CI gates. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import random +import statistics +import subprocess +import sys +import tempfile + + +def git(checkout: Path, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(checkout), *args], text=True, + ).strip() + + +def worker(source: Path, manifest: Path, mode: str) -> None: + import contextlib + from dataclasses import asdict, fields + from importlib.metadata import version + import resource + import time + + from graphify.extract import _raise_recursion_limit, extract + from graphify.extractors import resolution + from graphify.extractors.models import _SymbolResolutionFacts + from graphify.extractors.resolution import _collect_js_symbol_resolution_facts + + _raise_recursion_limit() + paths = [Path(name) for name in json.loads(manifest.read_text())] + os.chdir(source) + # A new empty cache directory for every graph run prevents one arm from + # benefiting from the other's serialized extraction cache. + with tempfile.TemporaryDirectory() as cache, contextlib.redirect_stdout(sys.stderr): + start = time.perf_counter() + if mode == "facts": + result = _SymbolResolutionFacts() + _collect_js_symbol_resolution_facts(paths, result) + else: + result = extract([source / path for path in paths], root=source, + cache_root=Path(cache), max_workers=2) + elapsed = time.perf_counter() - start + peak_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + + if mode == "facts": + counts = {field.name: len(getattr(result, field.name)) for field in fields(result)} + result = asdict(result) + failed_sources = [] + else: + counts = {name: len(result[name]) for name in ("nodes", "edges")} + failed_sources = result["failed_sources"] + + def encode_path(value): + if isinstance(value, Path): + return str(value.relative_to(source) if value.is_absolute() else value) + raise TypeError(f"Unexpected output type: {type(value).__name__}") + + # Sort object keys only. List order is part of the compatibility contract. + serialized = json.dumps(result, sort_keys=True, separators=(",", ":"), default=encode_path) + print(json.dumps({ + "elapsed_seconds": elapsed, + "peak_rss_mib": peak_rss / (1024 ** 2 if sys.platform == "darwin" else 1024), + "output_sha256": hashlib.sha256(serialized.encode()).hexdigest(), + "output_bytes": len(serialized.encode()), + "counts": counts, + "failed_sources": failed_sources, + "loaded_resolution_sha256": hashlib.sha256( + Path(resolution.__file__).read_bytes()).hexdigest(), + "python": sys.version, + "dependencies": {name: version(name) for name in ( + "tree-sitter", "tree-sitter-javascript", "tree-sitter-typescript", + )}, + })) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", type=Path) + parser.add_argument("--candidate", type=Path) + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--files", type=int, default=2000, help="0 selects all eligible files") + parser.add_argument("--runs", type=int, default=5) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--mode", choices=("facts", "graph"), default="facts") + parser.add_argument("--output", type=Path) + parser.add_argument("--worker-manifest", type=Path, help=argparse.SUPPRESS) + args = parser.parse_args() + source = args.source.resolve() + if args.worker_manifest: + worker(source, args.worker_manifest, args.mode) + return + if not args.baseline or not args.candidate or not args.output: + parser.error("--baseline, --candidate and --output are required") + if args.files < 0 or args.runs < 1: + parser.error("--files must be nonnegative and --runs must be positive") + + checkouts = {"baseline": args.baseline.resolve(), "candidate": args.candidate.resolve()} + # Read tracked paths, so untracked outputs and dependency installations + # never silently change the corpus. Suffixes mirror the collector contract. + suffixes = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", + ".vue", ".svelte"} + tracked = subprocess.check_output(["git", "-C", str(source), "ls-files", "-z"]) + eligible = sorted(name.decode() for name in tracked.split(b"\0") + if name and Path(name.decode()).suffix in suffixes) + if not eligible: + parser.error("source has no tracked JS/TS files") + selected = eligible if not args.files else sorted( + random.Random(args.seed).sample(eligible, min(args.files, len(eligible))) + ) + corpus_hash = hashlib.sha256() + for name in selected: + corpus_hash.update(name.encode() + b"\0") + corpus_hash.update(hashlib.sha256((source / name).read_bytes()).digest()) + receipt = { + "mode": args.mode, "seed": args.seed, "files": selected, + "corpus_sha256": corpus_hash.hexdigest(), + "source_commit": git(source, "rev-parse", "HEAD"), + "source_status": git(source, "status", "--porcelain", "--untracked-files=no"), + "platform": platform.platform(), "machine": platform.machine(), + "checkouts": {arm: { + "commit": git(path, "rev-parse", "HEAD"), + "status": git(path, "status", "--porcelain"), + "resolution_sha256": hashlib.sha256( + (path / "graphify/extractors/resolution.py").read_bytes()).hexdigest(), + } for arm, path in checkouts.items()}, + "measurements": [], + } + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as scratch: + manifest = Path(scratch) / "files.json" + manifest.write_text(json.dumps(selected)) + for repeat in range(args.runs): + order = ("baseline", "candidate") if repeat % 2 == 0 else ("candidate", "baseline") + for arm in order: + env = dict(os.environ, PYTHONPATH=str(checkouts[arm]), PYTHONHASHSEED="0") + run = subprocess.run([ + sys.executable, str(Path(__file__).resolve()), "--source", str(source), + "--mode", args.mode, "--worker-manifest", str(manifest), + ], cwd=checkouts[arm], env=env, capture_output=True, text=True) + if run.returncode: + print(run.stderr, file=sys.stderr) + raise SystemExit(f"{arm} worker exited {run.returncode}") + measurement = dict(json.loads(run.stdout), arm=arm, repeat=repeat + 1) + if measurement["loaded_resolution_sha256"] != receipt["checkouts"][arm]["resolution_sha256"]: + raise SystemExit(f"Wrong collector loaded for {arm}") + receipt["measurements"].append(measurement) + output.write_text(json.dumps(receipt, indent=2) + "\n") + print(f"{arm} {repeat + 1}: {measurement['elapsed_seconds']:.3f}s, " + f"{measurement['peak_rss_mib']:.1f} MiB", flush=True) + + hashes = {item["output_sha256"] for item in receipt["measurements"]} + receipt["ordered_output_equal"] = len(hashes) == 1 + receipt["extraction_complete"] = all( + not item["failed_sources"] for item in receipt["measurements"] + ) + summary = {} + for arm in checkouts: + summary[arm] = {} + for metric in ("elapsed_seconds", "peak_rss_mib"): + values = [item[metric] for item in receipt["measurements"] if item["arm"] == arm] + summary[arm][metric] = { + "median": statistics.median(values), "min": min(values), "max": max(values), + } + receipt["summary"] = summary + output.write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps(receipt["summary"], indent=2)) + if not receipt["ordered_output_equal"]: + raise SystemExit("FAIL: ordered outputs differ; see measurement hashes") + if not receipt["extraction_complete"]: + raise SystemExit("FAIL: extraction has failed sources; see measurement receipts") + + +if __name__ == "__main__": + main() diff --git a/tests/test_js_fact_collection.py b/tests/test_js_fact_collection.py new file mode 100644 index 0000000000..995e9a8935 --- /dev/null +++ b/tests/test_js_fact_collection.py @@ -0,0 +1,319 @@ +"""Streaming facts retain category order and bound retained parse roots.""" +from __future__ import annotations + +import copy +import sys +from pathlib import Path + +import pytest + +from graphify.extractors import resolution +from graphify.extractors.base import _file_stem, _make_id +from graphify.extractors.models import ( + _NamespaceExportFact, + _StarExportFact, + _SymbolAliasFact, + _SymbolDeclarationFact, + _SymbolExportFact, + _SymbolImportFact, + _SymbolResolutionFacts, + _SymbolUseFact, +) + + +def sources(root: Path) -> list[Path]: + texts = { + "base.ts": "export class Base {}\nexport interface Shape {}\nexport function run() {}\n", + "first.ts": 'import {Base, Shape, run} from "./base.js";\n' + 'const alias = run; export {alias};\n' + 'export default class First extends Base implements Shape { field: Shape; }\n' + 'function caller() { alias(); function nested() { run(); } }\n', + "second.ts": 'import {run, Shape} from "./base.js";\n' + 'export * from "./base.js"; export * as ns from "./base.js";\n' + 'export type {Shape} from "./base.js";\n' + 'const arrow = () => run();\n' + 'class Second { method(value: Array): Shape { return value[0]; } }\n', + } + for name, text in texts.items(): + (root / name).write_text(text) + return [root / name for name in texts] + + +def test_exact_facts_preserve_original_collector_contract(tmp_path: Path) -> None: + collector = resolution._collect_js_symbol_resolution_facts + base, first, second = sources(tmp_path) + target = base.resolve() + caller = _make_id(_file_stem(first), "caller") + first_class = _make_id(_file_stem(first), "First") + method = _make_id(_file_stem(second), "Second.method") + # Explicit expected facts checked against 937e59a. Do not calculate this + # oracle with the collector or its group helper: they share syntax indexing. + expected = _SymbolResolutionFacts( + declarations=[ + _SymbolDeclarationFact(base, "Base", 1), + _SymbolDeclarationFact(base, "Shape", 2), + _SymbolDeclarationFact(base, "run", 3), + _SymbolDeclarationFact(first, "First", 3), + ], + imports=[ + _SymbolImportFact(first, "Base", target, "Base", 1), + _SymbolImportFact(first, "Shape", target, "Shape", 1), + _SymbolImportFact(first, "run", target, "run", 1), + _SymbolImportFact(second, "run", target, "run", 1), + _SymbolImportFact(second, "Shape", target, "Shape", 1), + ], + aliases=[_SymbolAliasFact(first, "alias", "run", 2)], + exports=[ + _SymbolExportFact(base, "Base", 1, local_name="Base"), + _SymbolExportFact(base, "Shape", 2, local_name="Shape"), + _SymbolExportFact(base, "run", 3, local_name="run"), + _SymbolExportFact(first, "alias", 2, local_name="alias"), + _SymbolExportFact(first, "First", 3, local_name="First"), + _SymbolExportFact(first, "default", 3, local_name="First"), + _SymbolExportFact(second, "Shape", 3, target_path=target, + target_name="Shape", type_only=True), + ], + star_exports=[_StarExportFact(second, target, 2)], + namespace_exports=[_NamespaceExportFact(second, "ns", target, 2)], + uses=[ + _SymbolUseFact(first, caller, "alias", "calls", "call", 4), + _SymbolUseFact(first, caller, "run", "calls", "call", 4), + _SymbolUseFact(second, _make_id(_file_stem(second), "arrow"), + "run", "calls", "call", 4), + _SymbolUseFact(first, first_class, "Base", "inherits", "type", 3), + _SymbolUseFact(first, first_class, "Shape", "implements", "type", 3), + _SymbolUseFact(first, first_class, "Shape", "references", "field", 3), + _SymbolUseFact(second, method, "Array", "references", "parameter_type", 5), + _SymbolUseFact(second, method, "Shape", "references", "generic_arg", 5), + _SymbolUseFact(second, method, "Shape", "references", "return_type", 5), + ], + ) + actual = _SymbolResolutionFacts() + collector([base, first, second], actual) + assert actual == expected + + # Every existing category must remain a prefix, including Python's + # module_imports, which JS collection does not produce. + actual.module_imports.append((base, first, 1, "existing")) + prefix = copy.deepcopy(actual) + collector([base, first, second], actual) + for name, previous in vars(prefix).items(): + assert getattr(actual, name) == previous + getattr(expected, name) + + +@pytest.mark.parametrize("last_grammar", ["js", "ts", "duplicate"]) +def test_duplicate_symlink_preserves_last_grammar( + tmp_path: Path, requires_symlinks, last_grammar: str, +) -> None: + source = tmp_path / "source.ts" + source.write_text("export interface Shape {}\nexport function run() {}\n") + link = tmp_path / "alias.js" + link.symlink_to(source) + paths = {"js": [source, link], "ts": [link, source], + "duplicate": [source, source]}[last_grammar] + # Declarations use each input's grammar, but exports use the last + # successful parse for that resolved path. These expectations are frozen + # from the original collector, not from the compatibility helper. + expected = _SymbolResolutionFacts() + for path in paths: + if path.suffix == ".ts": + expected.declarations.append(_SymbolDeclarationFact(path, "Shape", 1)) + expected.declarations.append(_SymbolDeclarationFact(path, "run", 2)) + if last_grammar != "js": + expected.exports.append(_SymbolExportFact(path, "Shape", 1, local_name="Shape")) + expected.exports.append(_SymbolExportFact(path, "run", 2, local_name="run")) + actual = _SymbolResolutionFacts() + resolution._collect_js_symbol_resolution_facts(paths, actual) + assert actual == expected + + +def test_class_relations_stay_interleaved_in_file_order(tmp_path: Path) -> None: + first, second = tmp_path / "a.ts", tmp_path / "b.ts" + for path in (first, second): + path.write_text("function caller() { run(); }\n" + "class Child extends Parent { field: Shape; }\n") + expected_calls = [ + _SymbolUseFact(path, _make_id(_file_stem(path), "caller"), + "run", "calls", "call", 1) + for path in (first, second) + ] + expected_types = [ + _SymbolUseFact(first, _make_id(_file_stem(first), "Child"), + "Parent", "inherits", "type", 2), + _SymbolUseFact(first, _make_id(_file_stem(first), "Child"), + "Shape", "references", "field", 2), + _SymbolUseFact(second, _make_id(_file_stem(second), "Child"), + "Parent", "inherits", "type", 2), + _SymbolUseFact(second, _make_id(_file_stem(second), "Child"), + "Shape", "references", "field", 2), + ] + actual = _SymbolResolutionFacts() + resolution._collect_js_symbol_resolution_facts([first, second], actual) + assert actual.uses == expected_calls + expected_types + + +@pytest.mark.skipif(sys.implementation.name != "cpython", reason="uses CPython reference counts") +def test_previous_native_tree_released_before_next_parse(tmp_path: Path, monkeypatch) -> None: + import tree_sitter + + paths = sources(tmp_path) + parser_type = tree_sitter.Parser + retained = [] + parsed_count = 0 + + def release_previous_tree(): + if retained: + # Native Nodes each retain their Tree, including descendants whose + # root has gone away. Only this list and getrefcount's argument may + # still own the tree. Clear our final reference before parsing. + references = sys.getrefcount(retained[0]) + assert references == 2 + retained.clear() + + class TrackedParser: + def __init__(self, *args, **kwargs): + self.parser = parser_type(*args, **kwargs) + + def parse(self, *args, **kwargs): + nonlocal parsed_count + release_previous_tree() + tree = self.parser.parse(*args, **kwargs) + retained.append(tree) + parsed_count += 1 + return tree + + monkeypatch.setattr(tree_sitter, "Parser", TrackedParser) + resolution._collect_js_symbol_resolution_facts(paths, _SymbolResolutionFacts()) + assert parsed_count == len(paths) + release_previous_tree() + + +@pytest.mark.skipif(sys.implementation.name != "cpython", reason="uses CPython reference counts") +def test_duplicate_group_releases_native_trees_before_unrelated_file( + tmp_path: Path, requires_symlinks, monkeypatch, +) -> None: + import tree_sitter + + source, unrelated = tmp_path / "source.ts", tmp_path / "unrelated.ts" + source.write_text("export interface Shape {}\nexport function run() {}\n") + unrelated.write_text("export function independent() {}\n") + alias = tmp_path / "alias.js" + alias.symlink_to(source) + parser_type = tree_sitter.Parser + retained = [] + parsed_count = 0 + unrelated_references = [] + + def release_group(): + # This intentionally depends on CPython and tree-sitter's ownership + # contract. An extra reference can be a leaked Node; do not tolerate it. + references = [sys.getrefcount(tree) for tree in retained] + assert all(count == 3 for count in references) # list, loop local, argument + retained.clear() + + class TrackedParser: + def __init__(self, *args, **kwargs): + self.parser = parser_type(*args, **kwargs) + + def parse(self, source_bytes, *args, **kwargs): + nonlocal parsed_count + if source_bytes == unrelated.read_bytes(): + unrelated_references.extend(sys.getrefcount(tree) for tree in retained) + retained.clear() + tree = self.parser.parse(source_bytes, *args, **kwargs) + retained.append(tree) + parsed_count += 1 + return tree + + monkeypatch.setattr(tree_sitter, "Parser", TrackedParser) + resolution._collect_js_symbol_resolution_facts( + [source, unrelated, alias, source], _SymbolResolutionFacts(), + ) + assert parsed_count == 4 + assert unrelated_references and all(count == 3 for count in unrelated_references) + release_group() + + +@pytest.mark.parametrize("last_grammar", ["js", "ts", "duplicate"]) +def test_interleaved_groups_preserve_every_fact_category( + tmp_path: Path, requires_symlinks, last_grammar: str, +) -> None: + source, other, target = [tmp_path / name for name in ("source.ts", "other.ts", "target.ts")] + target.write_text("export function run() {}\n") + text = ('export interface Shape {}\n' + 'import {run} from "./target.ts";\n' + 'const alias = run; export {alias};\n' + 'export * from "./target.ts"; export * as ns from "./target.ts";\n' + 'function caller() { alias(); }\n' + 'class Child extends Parent { field: Shape; }\n') + source.write_text(text) + other.write_text(text) + link = tmp_path / "alias.js" + link.symlink_to(source) + ignored = tmp_path / "ignored.py" + paths = {"js": [source, ignored, other, link, other], + "ts": [link, ignored, other, source, other], + "duplicate": [source, ignored, other, source, other]}[last_grammar] + # Each category follows input occurrence order, even across two interleaved + # groups. First-pass declarations use the input grammar, while exports use + # the last successful grammar for that resolved file. + expected = _SymbolResolutionFacts() + class_uses = [] + for path in paths: + if path == ignored: + continue + if path.suffix == ".ts": + expected.declarations.append(_SymbolDeclarationFact(path, "Shape", 1)) + expected.imports.append(_SymbolImportFact(path, "run", target.resolve(), "run", 2)) + expected.aliases.append(_SymbolAliasFact(path, "alias", "run", 3)) + if path == other or last_grammar != "js": + expected.exports.append(_SymbolExportFact(path, "Shape", 1, local_name="Shape")) + expected.exports.append(_SymbolExportFact(path, "alias", 3, local_name="alias")) + expected.star_exports.append(_StarExportFact(path, target.resolve(), 4)) + expected.namespace_exports.append(_NamespaceExportFact(path, "ns", target.resolve(), 4)) + expected.uses.append(_SymbolUseFact( + path, _make_id(_file_stem(path), "caller"), "alias", "calls", "call", 5, + )) + class_id = _make_id(_file_stem(path), "Child") + class_uses.append(_SymbolUseFact(path, class_id, "Parent", "inherits", "type", 6)) + if path == other or last_grammar != "js": + class_uses.append(_SymbolUseFact(path, class_id, "Shape", "references", "field", 6)) + expected.uses.extend(class_uses) + actual = _SymbolResolutionFacts() + resolution._collect_js_symbol_resolution_facts(paths, actual) + assert actual == expected + + +@pytest.mark.parametrize("failed", ["first", "last", "all"]) +def test_group_reuses_last_successful_parse_after_failures( + tmp_path: Path, requires_symlinks, monkeypatch, failed: str, +) -> None: + source, other = tmp_path / "source.ts", tmp_path / "other.ts" + source.write_text("export interface Shape {}\nexport function run() {}\n") + other.write_text("export function independent() {}\n") + alias = tmp_path / "alias.js" + alias.symlink_to(source) + paths = [source, other, alias] + failed_paths = {"first": {source}, "last": {alias}, "all": {source, alias}}[failed] + parse = resolution._parse_js_tree + monkeypatch.setattr(resolution, "_parse_js_tree", + lambda path: None if path in failed_paths else parse(path)) + expected = _SymbolResolutionFacts() + for path in paths: + if path == other: + expected.declarations.append(_SymbolDeclarationFact(path, "independent", 1)) + expected.exports.append(_SymbolExportFact(path, "independent", 1, + local_name="independent")) + continue + if path not in failed_paths: + if path == source: + expected.declarations.append(_SymbolDeclarationFact(path, "Shape", 1)) + expected.declarations.append(_SymbolDeclarationFact(path, "run", 2)) + if failed == "all": + continue + if failed == "last": + expected.exports.append(_SymbolExportFact(path, "Shape", 1, local_name="Shape")) + expected.exports.append(_SymbolExportFact(path, "run", 2, local_name="run")) + actual = _SymbolResolutionFacts() + resolution._collect_js_symbol_resolution_facts(paths, actual) + assert actual == expected