From 2e1f3da6cf9e30a5ea8b9141ec00fc658c115b00 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 28 Aug 2026 14:33:11 +0200 Subject: [PATCH] Point a pubspec finding at the dependency, not the config block of the same name Every Flutter app here declares `flutter_launcher_icons` twice: as a dev dependency, and as a TOP-LEVEL configuration block for the same tool. The line lookup broke ties by indentation alone, so the top-level block won and the gate told you to fix a line that carries no version at all -- lyricanki reported `pubspec.yaml:64` while the caret being complained about is at 48. The pubspec parser now says which top-level keys may hold a version, and a key found outside them is only a fallback. `environment` is in that list on purpose: without it the `sdk:` inside a `flutter: {sdk: flutter}` block would outrank the SDK constraint it is meant to find. Only the pubspec parser passes the hint. JSON and TOML indent their section headers differently, so they keep the old behaviour unchanged. 310 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK --- dep_freshness/parsers/_lines.py | 40 ++++++++++++++------ dep_freshness/parsers/pubspec.py | 10 ++++- dep_freshness/tests/test_parsers_edges.py | 46 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 13 deletions(-) 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