diff --git a/dep_freshness/parsers/_lines.py b/dep_freshness/parsers/_lines.py index e44433c..fb8cc5d 100644 --- a/dep_freshness/parsers/_lines.py +++ b/dep_freshness/parsers/_lines.py @@ -4,36 +4,52 @@ without a line makes the fix a search, so the key is located textually after the fact; a miss degrades to line 0 rather than failing. -Ties are broken by *indentation*, not by order: a pubspec that declares -`path: ^1.9.1` as a dependency also has a nested `path:` inside a git block six -spaces in, and first-match would point the fix at the wrong line. +Two tie-breaks, in order: + +1. **Inside a declared section beats outside it.** A Flutter app declares + `flutter_launcher_icons` twice: once as a dev dependency and once as a + TOP-LEVEL configuration block for the same tool. Indentation alone points + the fix at the config block, which has no version on it at all. +2. **Then least indented.** A pubspec that declares `path: ^1.9.1` as a + dependency also has a nested `path:` inside a git block six spaces in, and + first-match would point the fix at the wrong line. """ from __future__ import annotations -from pathlib import Path import re +from pathlib import Path _KEY = re.compile(r'^(?P\s*)-?\s*["\']?(?P[A-Za-z0-9_.@/-]+)["\']?\s*[:=]') -def index(path: Path) -> dict[str, int]: - """Map `key` -> the 1-based line where it appears least-indented.""" - best: dict[str, tuple[int, int]] = {} +def index(path: Path, sections: tuple[str, ...] = ()) -> dict[str, int]: + """Map `key` -> the 1-based line where it is most likely declared. + + `sections` names the top-level keys that actually hold dependencies + (`dependencies`, `dev_dependencies`, ...). Anything found outside them is + kept only as a fallback, so a config block sharing a package's name never + wins over the declaration that carries the version. + """ + best: dict[str, tuple[int, int, int]] = {} try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return {} + inside = not sections # with no sections declared, everything counts for number, line in enumerate(text.splitlines(), start=1): match = _KEY.match(line) if not match: continue key = match.group("key") depth = len(match.group("indent").expandtabs(4)) - if key not in best or depth < best[key][0]: - best[key] = (depth, number) - return {key: number for key, (_, number) in best.items()} + if depth == 0 and sections: + inside = key in sections + score = (0 if inside else 1, depth, number) + if key not in best or score < best[key]: + best[key] = score + return {key: number for key, (_, _, number) in best.items()} -def find(path: Path, name: str) -> int: - return index(path).get(name, 0) +def find(path: Path, name: str, sections: tuple[str, ...] = ()) -> int: + return index(path, sections).get(name, 0) diff --git a/dep_freshness/parsers/pubspec.py b/dep_freshness/parsers/pubspec.py index def6b69..198a7c8 100644 --- a/dep_freshness/parsers/pubspec.py +++ b/dep_freshness/parsers/pubspec.py @@ -26,6 +26,14 @@ from dep_freshness.parsers._lines import index from dep_freshness.versions import exact_pin _SECTIONS = (("dependencies", False), ("dev_dependencies", True)) +# Where a version may legitimately be declared. Everything else at top level is +# configuration -- and a Flutter app has a `flutter_launcher_icons:` config +# block sharing its name with the dev dependency, which without this hint wins +# the line lookup by being less indented and sends the fix to a line that +# carries no version at all. +_LINE_SECTIONS = ( + "environment", "dependencies", "dev_dependencies", PUB_OVERRIDE_KEY, +) def _load(path: Path) -> dict: @@ -61,7 +69,7 @@ def parse(path: Path) -> list[Dep]: data = _load(path) if not data: return [] - lines = index(path) + lines = index(path, _LINE_SECTIONS) locks = locked_versions(path.with_name("pubspec.lock")) deps: list[Dep] = [] diff --git a/dep_freshness/tests/test_parsers_edges.py b/dep_freshness/tests/test_parsers_edges.py index c311e1c..da28459 100644 --- a/dep_freshness/tests/test_parsers_edges.py +++ b/dep_freshness/tests/test_parsers_edges.py @@ -94,3 +94,49 @@ def test_line_index_of_an_unreadable_file_is_empty(tmp_path): from dep_freshness.parsers._lines import find, index assert index(tmp_path / "nope") == {} assert find(tmp_path / "nope", "x") == 0 + + +def test_a_config_block_never_wins_the_line_over_the_dependency(tmp_path): + """Every Flutter app here declares `flutter_launcher_icons` twice. + + Once as a dev dependency, and once as a TOP-LEVEL configuration block for + the same tool. Least-indented wins sends the fix to the config block -- + lyricanki reported `pubspec.yaml:64`, the config key, while the version + being complained about lives at line 48. + """ + body = """\ +name: demo +environment: + sdk: ^3.12.2 +dependencies: + flutter: + sdk: flutter +dev_dependencies: + flutter_launcher_icons: ^0.14.4 + +flutter_launcher_icons: + android: true + image_path: "assets/icon/icon.png" +""" + deps = { + d.name: d + for d in pubspec.parse(write(tmp_path, "pubspec.yaml", body)) + } + assert deps["flutter_launcher_icons"].line == 8 + + +def test_the_sdk_line_is_the_environment_one_not_a_plugin_block(tmp_path): + """`flutter: {sdk: flutter}` also declares an `sdk:` key, deeper in.""" + body = """\ +name: demo +environment: + sdk: ^3.12.2 +dependencies: + flutter: + sdk: flutter +""" + deps = { + d.name: d + for d in pubspec.parse(write(tmp_path, "pubspec.yaml", body)) + } + assert deps["dart"].line == 3