Skip to content

Commit f5971cb

Browse files
authored
Merge pull request #192 from Wolfvin/fix/issue-189-affected-bfs-keys
fix(affected): parse multi-line, side-effect, and re-export imports (closes #189)
2 parents db28a72 + 7c98c3c commit f5971cb

2 files changed

Lines changed: 232 additions & 2 deletions

File tree

scripts/dependents_engine.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,8 +571,33 @@ def _parse_js_imports(file_path: str, rel_path: str, workspace: str) -> List[str
571571
imports = []
572572
file_dir = os.path.dirname(rel_path)
573573

574-
# ES imports
575-
for m in re.finditer(r'import\s+.*?from\s+["\']([^"\']+)["\']', content):
574+
# Issue #189: previous regex used `.*?` which doesn't match newlines,
575+
# so multi-line imports like `import {\n foo,\n} from './bar'` were
576+
# silently skipped. This left reverse_graph incomplete, which made
577+
# `codelens affected` return affected_count: 0 even when real
578+
# dependents existed. Use `[\s\S]*?` to match across newlines.
579+
#
580+
# ES imports (with from) — handles single-line AND multi-line forms.
581+
for m in re.finditer(r'import\s+[\s\S]*?from\s+["\']([^"\']+)["\']', content):
582+
resolved = _resolve_relative_import(m.group(1), file_dir, workspace)
583+
if resolved:
584+
imports.append(resolved)
585+
586+
# Issue #189: side-effect imports — `import './polyfills'` (no `from`).
587+
# Must run AFTER the `from` form so we don't double-resolve, but the
588+
# regex is disjoint: it requires a quote immediately after `import\s+`,
589+
# which the `{ ... } from` form cannot match.
590+
for m in re.finditer(r'import\s+["\']([^"\']+)["\']', content):
591+
resolved = _resolve_relative_import(m.group(1), file_dir, workspace)
592+
if resolved:
593+
imports.append(resolved)
594+
595+
# Issue #189: re-exports — `export { x } from './bar'`,
596+
# `export * from './bar'`, `export type { Foo } from './bar'`.
597+
# These create a real dependency edge (the re-exporting module
598+
# depends on the source module), so the reverse graph must include
599+
# them or `codelens affected` will miss downstream consumers.
600+
for m in re.finditer(r'export\s+[\s\S]*?from\s+["\']([^"\']+)["\']', content):
576601
resolved = _resolve_relative_import(m.group(1), file_dir, workspace)
577602
if resolved:
578603
imports.append(resolved)

tests/test_affected_command.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,3 +621,208 @@ def test_ts_no_workspace_arg_uses_cwd(self, ts_workspace):
621621
assert result["workspace"] == os.path.abspath(ts_workspace)
622622
assert "auth/google-auth-cache.ts" in result["changed_files"]
623623
assert result["unresolved"] == []
624+
625+
626+
# ─── Issue #189: BFS never starts when import parser misses TS patterns ──
627+
#
628+
# PR #184 fixed the workspace-as-first-arg heuristic so the existing
629+
# ts_workspace fixture (single-line `import { x } from './y'`) works. But
630+
# `_parse_js_imports` still missed three common TS/JS import shapes:
631+
#
632+
# 1. Multi-line imports — `import {\n foo,\n} from './bar'`
633+
# Root cause: regex used `.*?` which doesn't cross newlines.
634+
# 2. Side-effect imports — `import './polyfills'` (no `from` clause).
635+
# Root cause: regex required `from`.
636+
# 3. Re-exports — `export { x } from './bar'`, `export * from './bar'`.
637+
# Root cause: regex only matched `import`, not `export ... from`.
638+
#
639+
# When the parser misses an import, the reverse_graph loses an edge. If the
640+
# changed file's only inbound edge was the missed import, BFS finds no
641+
# dependents and `affected_count` stays at 0 — even though real dependents
642+
# exist. This is the "BFS never starts" symptom described in issue #189.
643+
644+
645+
class TestIssue189ImportParserPatterns:
646+
"""Regression tests for issue #189: `_parse_js_imports` must handle
647+
multi-line imports, side-effect imports, and re-exports — otherwise
648+
`_build_import_graph` produces an incomplete reverse_graph and
649+
`codelens affected` returns ``affected_count: 0`` even when real
650+
dependents exist.
651+
"""
652+
653+
@pytest.fixture
654+
def ts_workspace_multiline(self, tmp_path):
655+
"""TS workspace where login.ts uses a multi-line import statement.
656+
657+
Mirrors the existing `ts_workspace` fixture but exercises the
658+
multi-line import shape that real-world TS code uses pervasively
659+
(Prettier default, ESLint `multi-line` rule, etc.).
660+
"""
661+
ws = tmp_path / "ws_multiline"
662+
ws.mkdir()
663+
(ws / "auth").mkdir()
664+
(ws / "tests").mkdir()
665+
(ws / "auth" / "google-auth-cache.ts").write_text(
666+
"export const cache = new Map<string, string>();\n"
667+
"export function getCachedToken(key: string): string | null {\n"
668+
" return cache.get(key) || null;\n"
669+
"}\n",
670+
encoding="utf-8",
671+
)
672+
(ws / "auth" / "login.ts").write_text(
673+
"import {\n"
674+
" getCachedToken,\n"
675+
"} from './google-auth-cache';\n"
676+
"export function login(user: string): boolean {\n"
677+
" const token = getCachedToken(user);\n"
678+
" return token !== null;\n"
679+
"}\n",
680+
encoding="utf-8",
681+
)
682+
(ws / "tests" / "login.test.ts").write_text(
683+
"import { login } from '../auth/login';\n"
684+
"test('login returns false for unknown user', () => {\n"
685+
" expect(login('unknown')).toBe(false);\n"
686+
"});\n",
687+
encoding="utf-8",
688+
)
689+
return str(ws)
690+
691+
@pytest.fixture
692+
def ts_workspace_side_effect(self, tmp_path):
693+
"""TS workspace where app.ts has a side-effect import (no `from`)."""
694+
ws = tmp_path / "ws_side_effect"
695+
ws.mkdir()
696+
(ws / "auth").mkdir()
697+
(ws / "tests").mkdir()
698+
(ws / "auth" / "polyfills.ts").write_text(
699+
"export const POLYFILL_VERSION = '1.0.0';\n",
700+
encoding="utf-8",
701+
)
702+
(ws / "auth" / "app.ts").write_text(
703+
"import './polyfills';\n"
704+
"export function app(): string { return 'ok'; }\n",
705+
encoding="utf-8",
706+
)
707+
(ws / "tests" / "app.test.ts").write_text(
708+
"import { app } from '../auth/app';\n"
709+
"test('app works', () => { expect(app()).toBe('ok'); });\n",
710+
encoding="utf-8",
711+
)
712+
return str(ws)
713+
714+
@pytest.fixture
715+
def ts_workspace_reexport(self, tmp_path):
716+
"""TS workspace using `export { x } from './core'` (barrel file)."""
717+
ws = tmp_path / "ws_reexport"
718+
ws.mkdir()
719+
(ws / "auth").mkdir()
720+
(ws / "tests").mkdir()
721+
(ws / "auth" / "core.ts").write_text(
722+
"export const TOKEN_KEY = 'cl_token';\n",
723+
encoding="utf-8",
724+
)
725+
(ws / "auth" / "index.ts").write_text(
726+
"export { TOKEN_KEY } from './core';\n",
727+
encoding="utf-8",
728+
)
729+
(ws / "tests" / "index.test.ts").write_text(
730+
"import { TOKEN_KEY } from '../auth/index';\n"
731+
"test('key is string', () => { expect(typeof TOKEN_KEY).toBe('string'); });\n",
732+
encoding="utf-8",
733+
)
734+
return str(ws)
735+
736+
def test_multiline_import_resolves_and_bfs_finds_dependents(
737+
self, ts_workspace_multiline,
738+
):
739+
"""Multi-line `import {\n foo \n} from './bar'` must be parsed.
740+
741+
Without the fix, `_parse_js_imports` skipped this import (regex
742+
`.*?` did not cross newlines), `reverse_graph` had no edge from
743+
`google-auth-cache.ts` to `login.ts`, and `affected_count` was 0.
744+
"""
745+
result = get_affected_files(
746+
changed_files=["auth/google-auth-cache.ts"],
747+
workspace=ts_workspace_multiline,
748+
depth=5,
749+
)
750+
assert result["status"] == "ok"
751+
assert result["unresolved"] == [], (
752+
"changed file must resolve; unresolved means graph keys are "
753+
"incomplete (the BFS-never-starts symptom from issue #189)"
754+
)
755+
assert result["stats"]["affected_count"] >= 1, (
756+
"BFS must find the transitive test dependent via the multi-line "
757+
"import in login.ts"
758+
)
759+
assert "tests/login.test.ts" in result["affected"]
760+
761+
def test_side_effect_import_resolves_and_bfs_finds_dependents(
762+
self, ts_workspace_side_effect,
763+
):
764+
"""`import './polyfills'` (no `from`) must be parsed as an edge."""
765+
result = get_affected_files(
766+
changed_files=["auth/polyfills.ts"],
767+
workspace=ts_workspace_side_effect,
768+
depth=5,
769+
)
770+
assert result["status"] == "ok"
771+
assert result["unresolved"] == []
772+
assert result["stats"]["affected_count"] >= 1, (
773+
"side-effect imports create a real dependency edge — BFS must "
774+
"find the transitive test dependent"
775+
)
776+
assert "tests/app.test.ts" in result["affected"]
777+
778+
def test_reexport_creates_dependency_edge(
779+
self, ts_workspace_reexport,
780+
):
781+
"""`export { x } from './core'` must add core.ts → index.ts edge."""
782+
result = get_affected_files(
783+
changed_files=["auth/core.ts"],
784+
workspace=ts_workspace_reexport,
785+
depth=5,
786+
)
787+
assert result["status"] == "ok"
788+
assert result["unresolved"] == []
789+
assert result["stats"]["affected_count"] >= 1, (
790+
"re-exports create a real dependency edge — BFS must find the "
791+
"transitive test dependent via the barrel file"
792+
)
793+
assert "tests/index.test.ts" in result["affected"]
794+
795+
def test_existing_ts_workspace_fixture_still_finds_affected(self, ts_workspace):
796+
"""DoD sanity check: the existing fixture from PR #184 must still
797+
return ``affected_count >= 1`` after the parser changes.
798+
799+
This is the explicit Definition-of-Done assertion from issue #189:
800+
"Use existing ts_workspace fixture in tests/test_affected_command.py
801+
(from PR #184) and assert affected_count >= 1".
802+
"""
803+
result = get_affected_files(
804+
changed_files=["auth/google-auth-cache.ts"],
805+
workspace=ts_workspace,
806+
depth=5,
807+
)
808+
assert result["status"] == "ok"
809+
assert result["unresolved"] == []
810+
assert result["stats"]["affected_count"] >= 1
811+
assert "tests/login.test.ts" in result["affected"]
812+
813+
def test_multiline_include_source_returns_all_dependents(
814+
self, ts_workspace_multiline,
815+
):
816+
"""`--include-source` must also surface non-test dependents reached
817+
via multi-line imports (cross-check against the existing
818+
`test_ts_include_source_returns_all_dependents`).
819+
"""
820+
result = get_affected_files(
821+
changed_files=["auth/google-auth-cache.ts"],
822+
workspace=ts_workspace_multiline,
823+
depth=5,
824+
include_source=True,
825+
)
826+
affected = set(result["affected"])
827+
assert "auth/login.ts" in affected
828+
assert "tests/login.test.ts" in affected

0 commit comments

Comments
 (0)