sundgren 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. sundgren-0.1.0/.github/workflows/ci.yml +53 -0
  2. sundgren-0.1.0/.github/workflows/release.yml +58 -0
  3. sundgren-0.1.0/.gitignore +8 -0
  4. sundgren-0.1.0/DECISIONS.md +218 -0
  5. sundgren-0.1.0/LICENSE +201 -0
  6. sundgren-0.1.0/PKG-INFO +125 -0
  7. sundgren-0.1.0/README.md +100 -0
  8. sundgren-0.1.0/pyproject.toml +66 -0
  9. sundgren-0.1.0/src/sundgren/__init__.py +3 -0
  10. sundgren-0.1.0/src/sundgren/__main__.py +3 -0
  11. sundgren-0.1.0/src/sundgren/cli.py +459 -0
  12. sundgren-0.1.0/src/sundgren/core/__init__.py +1 -0
  13. sundgren-0.1.0/src/sundgren/core/diff.py +536 -0
  14. sundgren-0.1.0/src/sundgren/core/hashing.py +21 -0
  15. sundgren-0.1.0/src/sundgren/core/ingest/__init__.py +1 -0
  16. sundgren-0.1.0/src/sundgren/core/ingest/base.py +11 -0
  17. sundgren-0.1.0/src/sundgren/core/ingest/lcov.py +211 -0
  18. sundgren-0.1.0/src/sundgren/core/junit.py +81 -0
  19. sundgren-0.1.0/src/sundgren/core/linemap.py +103 -0
  20. sundgren-0.1.0/src/sundgren/core/model.py +120 -0
  21. sundgren-0.1.0/src/sundgren/core/normalize.py +101 -0
  22. sundgren-0.1.0/src/sundgren/core/patch.py +318 -0
  23. sundgren-0.1.0/src/sundgren/core/report/__init__.py +1 -0
  24. sundgren-0.1.0/src/sundgren/core/report/delta.schema.json +420 -0
  25. sundgren-0.1.0/src/sundgren/core/report/json_report.py +156 -0
  26. sundgren-0.1.0/src/sundgren/core/report/text_report.py +98 -0
  27. sundgren-0.1.0/src/sundgren/core/rollup.py +188 -0
  28. sundgren-0.1.0/src/sundgren/core/symbols.py +90 -0
  29. sundgren-0.1.0/src/sundgren/lang/__init__.py +1 -0
  30. sundgren-0.1.0/src/sundgren/lang/python/__init__.py +13 -0
  31. sundgren-0.1.0/src/sundgren/lang/python/presets.toml +15 -0
  32. sundgren-0.1.0/src/sundgren/lang/python/selector.py +46 -0
  33. sundgren-0.1.0/src/sundgren/lang/python/symbolizer.py +74 -0
  34. sundgren-0.1.0/src/sundgren/lang/rust/__init__.py +7 -0
  35. sundgren-0.1.0/src/sundgren/lang/rust/presets.toml +14 -0
  36. sundgren-0.1.0/src/sundgren/run/__init__.py +1 -0
  37. sundgren-0.1.0/src/sundgren/run/guards.py +26 -0
  38. sundgren-0.1.0/src/sundgren/run/presets.py +144 -0
  39. sundgren-0.1.0/src/sundgren/run/runner.py +164 -0
  40. sundgren-0.1.0/src/sundgren/run/selectors.py +61 -0
  41. sundgren-0.1.0/src/sundgren/run/template.py +39 -0
  42. sundgren-0.1.0/src/sundgren/run/workspace.py +180 -0
  43. sundgren-0.1.0/tests/e2e/conftest.py +11 -0
  44. sundgren-0.1.0/tests/e2e/test_shed_py.py +440 -0
  45. sundgren-0.1.0/tests/e2e/test_shed_rs.py +97 -0
  46. sundgren-0.1.0/tests/fixtures/junit/nextest.xml +44 -0
  47. sundgren-0.1.0/tests/fixtures/junit/pytest.xml +43 -0
  48. sundgren-0.1.0/tests/fixtures/lcov/coveragepy.lcov +54 -0
  49. sundgren-0.1.0/tests/fixtures/lcov/duplicate_sf.lcov +83 -0
  50. sundgren-0.1.0/tests/fixtures/lcov/lcov2_fn.lcov +42 -0
  51. sundgren-0.1.0/tests/fixtures/lcov/llvm_cov.lcov +59 -0
  52. sundgren-0.1.0/tests/fixtures/lcov/tarpaulin.lcov +20 -0
  53. sundgren-0.1.0/tests/fixtures/patches/bad.patch +12 -0
  54. sundgren-0.1.0/tests/fixtures/patches/drop_zero_case.patch +12 -0
  55. sundgren-0.1.0/tests/fixtures/patches/ignore_divide_rs.patch +12 -0
  56. sundgren-0.1.0/tests/fixtures/patches/no_op.patch +12 -0
  57. sundgren-0.1.0/tests/fixtures/patches/skip_divide.patch +12 -0
  58. sundgren-0.1.0/tests/fixtures/patches/skip_divide_mnemonic.patch +12 -0
  59. sundgren-0.1.0/tests/fixtures/patches/touch_mid.patch +20 -0
  60. sundgren-0.1.0/tests/fixtures/patches/touch_mid_offset.patch +20 -0
  61. sundgren-0.1.0/tests/fixtures/patches/touch_source.patch +17 -0
  62. sundgren-0.1.0/tests/fixtures/patches/unit/binary.patch +13 -0
  63. sundgren-0.1.0/tests/fixtures/patches/unit/deleted_file.patch +11 -0
  64. sundgren-0.1.0/tests/fixtures/patches/unit/format_patch.patch +27 -0
  65. sundgren-0.1.0/tests/fixtures/patches/unit/git_binary.patch +29 -0
  66. sundgren-0.1.0/tests/fixtures/patches/unit/multi_file.patch +36 -0
  67. sundgren-0.1.0/tests/fixtures/patches/unit/multi_hunk.patch +29 -0
  68. sundgren-0.1.0/tests/fixtures/patches/unit/new_file.patch +9 -0
  69. sundgren-0.1.0/tests/fixtures/patches/unit/no_newline_eof.patch +20 -0
  70. sundgren-0.1.0/tests/fixtures/patches/unit/plain_diff.patch +11 -0
  71. sundgren-0.1.0/tests/fixtures/patches/unit/pure_add.patch +13 -0
  72. sundgren-0.1.0/tests/fixtures/patches/unit/pure_delete.patch +13 -0
  73. sundgren-0.1.0/tests/fixtures/patches/unit/pure_rename.patch +4 -0
  74. sundgren-0.1.0/tests/fixtures/patches/unit/quoted_paths.patch +19 -0
  75. sundgren-0.1.0/tests/fixtures/patches/unit/rename_with_edits.patch +16 -0
  76. sundgren-0.1.0/tests/fixtures/patches/unit/single_hunk.patch +16 -0
  77. sundgren-0.1.0/tests/fixtures/shed_py/.coveragerc +3 -0
  78. sundgren-0.1.0/tests/fixtures/shed_py/pyproject.toml +2 -0
  79. sundgren-0.1.0/tests/fixtures/shed_py/shed/__init__.py +0 -0
  80. sundgren-0.1.0/tests/fixtures/shed_py/shed/calc.py +15 -0
  81. sundgren-0.1.0/tests/fixtures/shed_py/tests/test_calc.py +15 -0
  82. sundgren-0.1.0/tests/fixtures/shed_py_runs/drop_zero_case/base.lcov +34 -0
  83. sundgren-0.1.0/tests/fixtures/shed_py_runs/drop_zero_case/base.xml +1 -0
  84. sundgren-0.1.0/tests/fixtures/shed_py_runs/drop_zero_case/patched.lcov +34 -0
  85. sundgren-0.1.0/tests/fixtures/shed_py_runs/drop_zero_case/patched.xml +1 -0
  86. sundgren-0.1.0/tests/fixtures/shed_py_runs/skip_divide/base.lcov +34 -0
  87. sundgren-0.1.0/tests/fixtures/shed_py_runs/skip_divide/base.xml +1 -0
  88. sundgren-0.1.0/tests/fixtures/shed_py_runs/skip_divide/patched.lcov +34 -0
  89. sundgren-0.1.0/tests/fixtures/shed_py_runs/skip_divide/patched.xml +1 -0
  90. sundgren-0.1.0/tests/fixtures/shed_rs/.gitignore +1 -0
  91. sundgren-0.1.0/tests/fixtures/shed_rs/Cargo.toml +7 -0
  92. sundgren-0.1.0/tests/fixtures/shed_rs/src/lib.rs +34 -0
  93. sundgren-0.1.0/tests/support/__init__.py +5 -0
  94. sundgren-0.1.0/tests/support/shed.py +152 -0
  95. sundgren-0.1.0/tests/unit/test_cli_patch.py +123 -0
  96. sundgren-0.1.0/tests/unit/test_cli_version.py +13 -0
  97. sundgren-0.1.0/tests/unit/test_diff.py +567 -0
  98. sundgren-0.1.0/tests/unit/test_guards.py +45 -0
  99. sundgren-0.1.0/tests/unit/test_json_report.py +352 -0
  100. sundgren-0.1.0/tests/unit/test_junit.py +146 -0
  101. sundgren-0.1.0/tests/unit/test_layering.py +59 -0
  102. sundgren-0.1.0/tests/unit/test_lcov.py +315 -0
  103. sundgren-0.1.0/tests/unit/test_linemap.py +178 -0
  104. sundgren-0.1.0/tests/unit/test_normalize.py +133 -0
  105. sundgren-0.1.0/tests/unit/test_patch.py +296 -0
  106. sundgren-0.1.0/tests/unit/test_presets.py +146 -0
  107. sundgren-0.1.0/tests/unit/test_python_symbolizer.py +288 -0
  108. sundgren-0.1.0/tests/unit/test_rollup.py +214 -0
  109. sundgren-0.1.0/tests/unit/test_runner.py +206 -0
  110. sundgren-0.1.0/tests/unit/test_selectors.py +103 -0
  111. sundgren-0.1.0/tests/unit/test_symbols.py +206 -0
  112. sundgren-0.1.0/tests/unit/test_template.py +71 -0
  113. sundgren-0.1.0/tests/unit/test_text_report.py +118 -0
  114. sundgren-0.1.0/tests/unit/test_workspace.py +291 -0
  115. sundgren-0.1.0/uv.lock +683 -0
@@ -0,0 +1,53 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ env:
13
+ UV_LOCKED: "1"
14
+
15
+ jobs:
16
+ lint:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
21
+ - run: uv sync
22
+ - run: uv run ruff check .
23
+ - run: uv run ruff format --check .
24
+ - run: uv run mypy
25
+
26
+ test:
27
+ runs-on: ubuntu-latest
28
+ strategy:
29
+ fail-fast: false
30
+ matrix:
31
+ python: ["3.11", "3.12", "3.13", "3.14"]
32
+ steps:
33
+ - uses: actions/checkout@v7
34
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
35
+ with:
36
+ python-version: ${{ matrix.python }}
37
+ - run: uv sync
38
+ - run: uv run pytest tests/unit
39
+ - run: uv run pytest tests/e2e/test_shed_py.py -rs
40
+
41
+ e2e-rust:
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v7
45
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
46
+ - uses: dtolnay/rust-toolchain@stable
47
+ with:
48
+ components: llvm-tools-preview
49
+ - uses: taiki-e/install-action@v2
50
+ with:
51
+ tool: cargo-llvm-cov,cargo-tarpaulin
52
+ - run: uv sync
53
+ - run: uv run pytest tests/e2e/test_shed_rs.py -rs
@@ -0,0 +1,58 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ env:
11
+ UV_LOCKED: "1"
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
19
+ - run: uv sync
20
+ - name: Tag matches package version
21
+ run: |
22
+ version=$(uv run python -c "import sundgren; print(sundgren.__version__)")
23
+ test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME != v$version"; exit 1; }
24
+ - run: uv run pytest tests/unit
25
+ - run: uv build
26
+ - uses: actions/upload-artifact@v7
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ publish:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ environment:
35
+ name: pypi
36
+ url: https://pypi.org/p/sundgren
37
+ permissions:
38
+ id-token: write # PyPI Trusted Publishing
39
+ steps:
40
+ - uses: actions/download-artifact@v8
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+ - uses: pypa/gh-action-pypi-publish@release/v1
45
+
46
+ github-release:
47
+ needs: publish
48
+ runs-on: ubuntu-latest
49
+ permissions:
50
+ contents: write
51
+ steps:
52
+ - uses: actions/download-artifact@v8
53
+ with:
54
+ name: dist
55
+ path: dist/
56
+ - run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --generate-notes
57
+ env:
58
+ GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .pytest_cache/
8
+ .coverage
@@ -0,0 +1,218 @@
1
+ # Decisions
2
+
3
+ Choices made where the scaffold spec was silent or where the implementation deviates from it.
4
+ Spec deviations are marked **(deviation)**.
5
+
6
+ ## Build and layout
7
+
8
+ - hatchling build. `coverage` and `pytest` are dev dependencies only, for the e2e target project;
9
+ sundgren never imports them.
10
+ - Language packs register their own `presets.toml` (`register_builtin_presets`), so `run` never
11
+ globs `lang/`. Only `cli.py` imports `lang`.
12
+
13
+ ## CLI and run mode
14
+
15
+ - `--format` defaults to json. `{python}` is `python` on PATH, else `python3`; `--python` overrides.
16
+ - Run mode builds line maps from the diff git actually applied, not from the user's patch file:
17
+ `git apply --index`, then `git diff --cached` in the patched worktree. That diff's shape is pinned
18
+ against user git config (prefixes, colour, external diff, textconv, rename detection, context).
19
+ An offset or a non-`a/`/`b/` prefix in the user's patch therefore can't skew the line maps.
20
+ - A hunk applied at an offset adds the warning `patch_offset`. `i/`/`w/` prefixes don't warn.
21
+ - `meta.patch.files` comes from the applied diff; `meta.patch.sha256` is the user's file.
22
+ - Patches are read as bytes and decoded as UTF-8 with `surrogateescape`, without newline
23
+ translation, so CRs and non-UTF-8 path bytes survive.
24
+ - Exit codes: a bad `--base` ref is 2, a patch that doesn't apply is 3, any other git failure is 1.
25
+ - `meta.base_ref` is the full sha. `meta.commands` expands only `{python}` and `{tests}`, because temp
26
+ paths would break determinism.
27
+ - `sundgren diff --patch` uses the user's patch as it is and assumes it applies exactly to the base
28
+ tree.
29
+
30
+ ## Presets, templates, runner
31
+
32
+ - Only `commands` (non-empty) and `lcov` are required in a preset. Unknown keys are rejected.
33
+ Defaults: description "", env {}, junit/selector None, tests_style "args", shell false, hints ().
34
+ - If `sundgren.toml` and `[tool.sundgren.presets]` define the same name, `sundgren.toml` wins. Two
35
+ language packs defining the same name differently raise `PresetError`.
36
+ - Templates expand in one regex pass, so an expanded value containing `{out}` is not expanded again.
37
+ In shell mode only `{tests}` is quoted.
38
+ - The runner runs every command; a side fails only when its lcov is missing. `returncode` is the
39
+ first non-zero code. Commands get stdin from /dev/null. A command that can't start is logged as
40
+ 127 (missing) or 126 (other OSError).
41
+ - Relative lcov/junit templates resolve against the worktree. Leftover output files are deleted
42
+ before the commands run. Repeats go to `run-1`, `run-2`, ….
43
+ - Each side runs with its own `CARGO_TARGET_DIR`: slower, but the trees never share build artifacts.
44
+ - Both worktrees are checked out at the resolved base sha. If setup fails, everything is cleaned up,
45
+ even with `--keep-worktrees`. With `--workdir`, only `base/` and `patched/` are removed.
46
+
47
+ ## Test selection (Python)
48
+
49
+ - **(deviation)** A touched test file that was added, deleted or renamed gives `run_all`. The same
50
+ selection runs on both sides, so a new test file would otherwise fail on the base side.
51
+ - With `tests_style = "args"`, test paths missing on a side are dropped for that side. If none
52
+ remain, that side counts as empty coverage with a `nothing_to_run` warning.
53
+
54
+ ## Patch parsing and line maps
55
+
56
+ - Paths drop their first component as `git apply -p1` does (`a/`, `i/`, `old/`, anything); a bare
57
+ name is kept. `rename from/to` names are never stripped. `/dev/null` is `None`.
58
+ - `new file mode` / `deleted file mode` override the `---`/`+++` paths; `rename from/to` and
59
+ `---`/`+++` override the `diff --git` header.
60
+ - Plain (non-git) diffs never rename: both sides take the `+++` name, or the `---` name when it is a
61
+ prefix of it (`diff -u x.orig x` is a change to `x`).
62
+ - C-quoted paths are decoded (escapes, octal bytes, UTF-8 with surrogateescape).
63
+ - Text before the first diff is skipped (format-patch email, commit message, diffstat, signature).
64
+ Unknown lines inside a git header are skipped.
65
+ - Binary diffs give `binary=True`, no hunks, and a warning; their line map has no changed lines.
66
+ Copies are treated as renames with a warning. Mode-only changes have no hunks and no warning.
67
+ - The hunk header's counts decide which lines belong to a hunk. Malformed input raises `ValueError`
68
+ with a 1-based line number. This includes extra hunk lines after an undercounting header, which is
69
+ stricter than `git apply`.
70
+ - A hunk whose `new_start` disagrees with `old_start` plus the drift so far is placed from
71
+ `old_start` and warns.
72
+ - Line maps are keyed by both old and new path. On a key collision, the map whose `new_path` is the
73
+ key wins.
74
+ - `LineMap.to_new`/`to_old` use a counting rule from `deleted_old`/`added_new`; `old_to_new` holds
75
+ in-hunk pairs only. A randomized check against `difflib` (1600 diffs, context 0–3) found no
76
+ disagreement.
77
+
78
+ ## lcov ingest
79
+
80
+ - BRDA: line and block split from the left, taken from the right, so branch ids may contain commas.
81
+ Block stays a string.
82
+ - FN: `start,<digits>,name` is lcov 2.x; anything else is 1.x, so names may contain commas. FNL/FNA
83
+ (lcov 2.2) are supported.
84
+ - A FNDA with no FN of that name is dropped (debug log); a FN with no FNDA has `hits=None`.
85
+ - Repeats within one SF section are summed. Duplicate SF sections merge with `merge_file_coverage`
86
+ (`-` + n = n, `-` + `-` = `-`).
87
+ - Malformed known records raise `ValueError` (exit 1); unknown records are skipped with a debug log.
88
+ - A leading BOM is stripped. Lines split on `\n` only, not on `\f`, `\x85` and the like.
89
+ - `dump` is deterministic and round-trips: `parse(dump(s)) == s`.
90
+ - Rust function names stay mangled.
91
+
92
+ ## Path normalization
93
+
94
+ - Lexical only: `\` becomes `/`, `.` and empty components are dropped, and `..` pops. The only file
95
+ system access is `realpath`.
96
+ - The longest matching root wins, on component boundaries only. Every absolute root also matches
97
+ its `realpath`, and an unmatched absolute path is retried via `realpath` (`/tmp` vs `/private/tmp`).
98
+ - An absolute path matching no root, or a relative path escaping via `..`, is external.
99
+ - `C:/…` is absolute, with a case-insensitive drive letter; the rest of a Windows path stays
100
+ case-sensitive.
101
+
102
+ ## JUnit
103
+
104
+ - pytest 9 writes xfail as `<skipped type="pytest.xfail">`, so `type` decides: `pytest.xfail` gives
105
+ xfailed and `pytest.skip` gives skipped, whatever the message says. An untyped `<skipped>` follows
106
+ the spec's message-prefix rule.
107
+ - **(deviation)** A strict XPASS (`<failure message="[XPASS(strict)]…">`) is `xpassed`, not
108
+ `failed`. A non-strict xpass can't be detected and reads as passed.
109
+ - Severity order: passed < skipped < xfailed < xpassed < failed < error. Duplicate ids (reruns,
110
+ failure plus teardown error) and multiple result children resolve to the most severe.
111
+ - Test id is `classname::name`, or `name` when classname is empty. Testcases are found at any
112
+ nesting depth, and suite names are ignored.
113
+ - A testcase without a name is dropped with a debug log. Malformed XML or a wrong root raises
114
+ `ValueError`, and the CLI then drops the file with a `junit_unreadable` warning.
115
+
116
+ ## Function spans
117
+
118
+ - Function status counts only `Span.body_lines`, so signature and decorator lines, which run at
119
+ import in Python, don't count.
120
+ - Python symbolizer (`ast`): names match `__qualname__`; `start` is the first decorator line. A
121
+ leading docstring is skipped when another statement follows. A one-liner has `body_start = None`.
122
+ - Every parse failure becomes `SyntaxError`, including `RecursionError` and `MemoryError` on deeply
123
+ nested source, so `resolve_spans` falls back to lcov FN. Warnings from parsing the target's
124
+ source are silenced. A leading BOM is stripped.
125
+ - `LcovFnSymbolizer` sets `body_start = start + 1` for multi-line spans: tarpaulin and coverage.py
126
+ report the FN line as hit even when the body never runs. A missing end runs to the next distinct
127
+ FN start − 1, and the last function runs to the last executable line.
128
+
129
+ ## Diff
130
+
131
+ - File pairing: patched paths first, via `linemap_for(P).old_path`; then unclaimed base paths. A
132
+ base file colliding with an already-paired path is dropped with an `unpaired_file` warning.
133
+ - A side is *measured*, *inferred* (missing from coverage but present under a known root),
134
+ *nonexistent* (per the line map or the root), or *unmeasured* (root unknown, line map silent).
135
+ - An inferred side takes the other side's executable lines and branches, mapped through the line
136
+ map, with count 0, and warns `inferred_file`. When the measured side has nothing executable,
137
+ nothing is inferred and there is no warning.
138
+ - An unchanged line executable on one side only is `unmeasured`, with one warning per file (none
139
+ when the count is 0).
140
+ - A changed line counts as deleted plus added, so an edited `if` line gives branch_deleted plus
141
+ branch_new.
142
+ - Branches match on (mapped line, block, mapped id), and every `line N` in a base branch id is
143
+ mapped through the line map. coverage.py ids name the target line (`jump to line 7`), so a shift
144
+ would otherwise produce false lost/gained. A target that doesn't map leaves the branch unmatched.
145
+ - Branch categories: `branch_kept_hit`, `branch_kept_miss`, `branch_lost`, `branch_gained`,
146
+ `branch_new`, `branch_deleted`, `branch_flaky`, `branch_unmeasured`. Entries carry `base_line`
147
+ (an addition to spec §7).
148
+ - Flaky (hit in some repeats but not all) lines are removed from `lines` entirely, including the
149
+ new/deleted counts, and listed in `files[].flaky`. Stats come from run 0.
150
+ - `source_changed`: sha256 differs when both shas are known; otherwise true if the file is absent
151
+ on one side or its line map has changes. A pure rename is not a source change.
152
+ - Tests: when both outcome maps are None the list is `[]`. When only one exists it's `[]` plus a
153
+ `tests_one_side` warning. Otherwise the result is the union of ids, with `not_run` for a missing
154
+ side.
155
+ - Warnings are `{code, message}`, deduplicated, sorted, and use repo-relative paths only.
156
+ - Sort order: lines by line then base_line, with deleted lines (`line` null) last. Branches follow,
157
+ then by block and a natural-sorted branch id.
158
+
159
+ ## Functions and tree
160
+
161
+ - Functions match by qualified name (first span per name), then by largest line overlap after
162
+ mapping. That overlap step pairs coverage.py FN names (`outer.inner`) with symbolizer names
163
+ (`outer.<locals>.inner`).
164
+ - `went_dark`/`lit_up` take precedence. Otherwise `mixed`, `partial_loss` or `partial_gain` follow
165
+ from the body's lost/gained counts, else `unchanged`. Flaky lines are ignored.
166
+ - A side with unknown spans borrows the other side's spans through the line map.
167
+ - `tree` covers every non-external file on either side, not just changed ones. Dir stats are sums.
168
+ A file and a dir may share a name (file `a` deleted, `a/b` added).
169
+
170
+ ## Reports
171
+
172
+ - Default output lists only files with a non-kept change, drops `kept_*` lines, kept branches and
173
+ `unchanged` functions, and lists only changed test outcomes. `--full` keeps everything; `summary`
174
+ and `tree` are never filtered.
175
+ - `--counts` fills `count_changed` and also lists files whose only change is hit counts.
176
+ - `summary.functions` has 7 statuses (no `unchanged`). Coverage % is from the root tree stats,
177
+ rounded to 1 decimal, and null when nothing is executable.
178
+ - `dumps` is `json.dumps(sort_keys=True, indent=2)` plus a trailing newline. The schema is Draft
179
+ 2020-12 with `additionalProperties: false`, and meta fields are conditional on mode.
180
+ - The text report renders from the JSON dict, not the Delta. Hints are printed only when there are
181
+ warnings or a listed file has `inferred_side`.
182
+
183
+ ## Tests and fixtures
184
+
185
+ - `shed_py` is the spec's §12.1 code verbatim, including its header comment, so `divide` is at
186
+ lines 5–8.
187
+ - `shed_rs` returns `Result` from `divide`. No `Cargo.lock` is committed.
188
+ - Rust E2E covers both `cargo-llvm-cov` and `cargo-tarpaulin`; each is skipped if not installed.
189
+ CI installs both. Tarpaulin skips when it can't trace (no ptrace). The stderr pattern is a guess,
190
+ since no machine without ptrace was available.
191
+ - E2E runs sundgren in a clean env (no `GIT_*`, `PYTEST_*`, `COVERAGE_*`, `PYTHONPATH`, user git
192
+ config) with a private TMPDIR. After every run it asserts that no worktree or temp dir is left.
193
+ One scenario uses a deliberately hostile git config.
194
+ - Saved JUnit fixtures have neutral hostname, timestamps and paths.
195
+
196
+ ## Known limitations (v1)
197
+
198
+ - Functions with the same name in one lcov file collapse into one record, as lcov keys by name.
199
+ - Rust closures have FN records with no end, so the enclosing function is cut at the closure's
200
+ start. Fixing this needs a Rust symbolizer.
201
+ - A one-line function can't be `went_dark` from line data, because its def line runs at import.
202
+ - A deleted file plus a rename onto the same path (or a file turning into a symlink) can't both be
203
+ represented in the line maps.
204
+ - A 1.x FN name starting with `<digits>,` is read as 2.x.
205
+ - The Python symbolizer parses with the running interpreter's grammar, so newer syntax falls back
206
+ to lcov FN.
207
+ - lcov 2.2 FNL/FNA support was written from the documented format, without real output to check
208
+ against. The llvm-cov unit fixture and nextest JUnit are hand-written; real llvm-cov output is
209
+ only exercised by the CI e2e run, which asserts loosely (no exact function names).
210
+
211
+ ## Spec examples vs. real output
212
+
213
+ - skip_divide loses 3 lines, not the 6 in the §7 example, because the def line runs at import. The
214
+ lost branches have taken `[1, null]` (coverage.py writes `-`), not `[1, 0]`.
215
+ - **(deviation)** touch_source: the new function's def line is `new_hit`, because it runs at import;
216
+ only its body is `new_miss`.
217
+ - With tarpaulin, `divide`'s signature line stays hit when its test is ignored, which is why
218
+ `LcovFnSymbolizer` skips the FN line.
sundgren-0.1.0/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.5
2
+ Name: sundgren
3
+ Version: 0.1.0
4
+ Summary: CLI tool for measuring how code patches affect line coverage.
5
+ Project-URL: Homepage, https://github.com/axean/sundgren
6
+ Project-URL: Source, https://github.com/axean/sundgren
7
+ Project-URL: Issues, https://github.com/axean/sundgren/issues
8
+ Author: Axel E. Andersson
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: coverage,diff,lcov,patch,pytest,rust,testing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: POSIX
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+
26
+ # sundgren
27
+
28
+ *Sundgren* is Swedish for "sound branch". Given a repo, a base ref and a patch (usually one that
29
+ changes tests), it answers: **what happened to source coverage because of this patch?**
30
+
31
+ It runs a coverage command on the base tree and on the patched tree (git worktrees), diffs the two
32
+ lcov files with line numbers mapped through the patch, and writes a JSON report
33
+ (`sundgren.delta/1`) or a text summary.
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ uv tool install sundgren # or: pipx install sundgren
39
+ ```
40
+
41
+ Sundgren never imports coverage or pytest itself. They run as commands in your project's
42
+ environment: `{python}` is the `python` on your `PATH` (or `python3`), override with `--python`.
43
+
44
+ ## Python
45
+
46
+ ```sh
47
+ sundgren run --patch skip_divide.patch # preset "pytest" is picked from pyproject.toml
48
+ sundgren run --patch p.patch --base main --format text
49
+ ```
50
+
51
+ The `pytest` preset runs `coverage run --branch -m pytest <selected tests>` then `coverage lcov`.
52
+ It only runs the test files the patch touches; if the patch touches `conftest.py`, any other `.py`
53
+ file or pytest config, it runs everything. Override with `--tests tests/test_x.py ...`.
54
+
55
+ - Set `[run] source = <your package>` in `.coveragerc`, so never-imported files are reported.
56
+ - Editable installs measure your main checkout, not the worktree. Sundgren detects this and exits 1;
57
+ fix with `--env PYTHONPATH={worktree}/src`.
58
+
59
+ ## Rust
60
+
61
+ ```sh
62
+ sundgren run --patch ignore_divide.patch # cargo-llvm-cov
63
+ sundgren run --patch ignore_divide.patch --preset cargo-tarpaulin
64
+ sundgren run --patch p.patch --tests divide # name filters, passed after --
65
+ ```
66
+
67
+ Each run uses its own `CARGO_TARGET_DIR`, so both sides build from scratch. Function names are
68
+ stored mangled.
69
+
70
+ ## Any language: `--cmd`
71
+
72
+ Anything that writes lcov works:
73
+
74
+ ```sh
75
+ sundgren run --patch p.patch \
76
+ --cmd "npx jest --coverage --coverageReporters=lcov --coverageDirectory={out}" \
77
+ --lcov "{out}/lcov.info"
78
+ ```
79
+
80
+ Placeholders: `{python}`, `{out}` (per-run output dir), `{worktree}`, `{lcov}`, `{junit}`,
81
+ `{tests}`. A token that is exactly `{tests}` expands to one argv entry per selected test (none if
82
+ nothing is selected). `--shell` runs commands through `/bin/sh -c`. `--junit TEMPLATE` adds test
83
+ outcomes to the report. `--env K=V` sets (templated) environment variables.
84
+
85
+ Custom presets go in `sundgren.toml` (`[presets.<name>]`) or `[tool.sundgren.presets.<name>]` in
86
+ `pyproject.toml`, with the same keys as `src/sundgren/lang/python/presets.toml`. `sundgren presets`
87
+ lists them.
88
+
89
+ ## Other commands
90
+
91
+ ```sh
92
+ sundgren diff base.lcov patched.lcov --patch p.patch --base-root A --patched-root B
93
+ sundgren ingest coverage.lcov --root . # normalized snapshot as JSON
94
+ ```
95
+
96
+ Options shared by `run` and `diff`: `--format json|text`, `-o OUT`, `--full` (include unchanged
97
+ lines, functions and tests), `--counts` (hit-count changes on lines that stayed hit), `--repeat N`
98
+ (`run` only; lines whose status varies across runs are reported as `flaky`).
99
+
100
+ Exit codes: 0 ok, 1 tool or run failure, 2 usage error, 3 patch doesn't apply.
101
+
102
+ ## Report
103
+
104
+ `files[].lines[].category` is one of `lost`, `gained`, `new_hit`, `new_miss`, `deleted_hit`,
105
+ `deleted_miss`, `kept_hit`, `kept_miss`, `unmeasured` (genhtml: LBC, GBC, GNC, UNC, DCB, DUB, CBC,
106
+ UBC). Functions are `went_dark`, `lit_up`, `partial_loss`, `partial_gain`, `mixed`, `unchanged`,
107
+ `added` or `removed`. `tree` rolls the line stats up per directory. Schema:
108
+ `src/sundgren/core/report/delta.schema.json`.
109
+
110
+ ## Development
111
+
112
+ ```sh
113
+ uv sync # dev env in .venv
114
+ uv run pytest
115
+ uv run ruff check . && uv run ruff format --check .
116
+ uv run mypy
117
+ ```
118
+
119
+ Layers: `core` (no languages, no subprocesses) ← `run` (worktrees, command templates) ← `lang`
120
+ (presets, selectors, symbolizers). Only `cli.py` imports `lang`. `tests/unit/test_layering.py`
121
+ enforces this. See `DECISIONS.md` for choices the spec left open.
122
+
123
+ ## License
124
+
125
+ Apache-2.0. See `LICENSE`.