unexplained-cells 0.2.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.
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, macos-latest, windows-latest]
19
+ python-version: ["3.9", "3.11", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - run: python -m pip install --upgrade pip
26
+ - run: pip install -e ".[dev]"
27
+ - run: ruff check .
28
+ - run: pytest -q
@@ -0,0 +1,74 @@
1
+ # Publishes a tagged version to PyPI.
2
+ #
3
+ # There is no password and no API token anywhere in this repository, on anyone's
4
+ # machine, or in GitHub's secrets. Publishing uses PyPI's trusted publishing: PyPI
5
+ # is told once that this specific workflow, in this specific repository, may publish
6
+ # this specific project, and GitHub proves the job's identity at run time. Nothing
7
+ # is stored, so nothing can leak, and a stolen token cannot exist because there is
8
+ # no token.
9
+ #
10
+ # It runs only on a version tag, and only after the full test suite passes on the
11
+ # exact commit being published. A release that cannot pass its own tests does not
12
+ # reach anybody.
13
+
14
+ name: Release
15
+
16
+ on:
17
+ push:
18
+ tags: ["v*"]
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ test:
25
+ name: Test before publishing anything
26
+ runs-on: ${{ matrix.os }}
27
+ strategy:
28
+ fail-fast: true
29
+ matrix:
30
+ os: [ubuntu-latest, macos-latest, windows-latest]
31
+ python-version: ["3.9", "3.13"]
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ - uses: actions/setup-python@v5
35
+ with:
36
+ python-version: ${{ matrix.python-version }}
37
+ - run: python -m pip install --upgrade pip
38
+ - run: pip install -e ".[dev]"
39
+ - run: ruff check .
40
+ - run: pytest -q
41
+
42
+ build:
43
+ name: Build the distribution
44
+ needs: test
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ - uses: actions/setup-python@v5
49
+ with:
50
+ python-version: "3.13"
51
+ - run: python -m pip install --upgrade pip build
52
+ - run: python -m build
53
+ - name: Check the built metadata renders
54
+ run: |
55
+ pip install twine
56
+ twine check dist/*
57
+ - uses: actions/upload-artifact@v4
58
+ with:
59
+ name: distribution
60
+ path: dist/
61
+
62
+ publish:
63
+ name: Publish to PyPI
64
+ needs: build
65
+ runs-on: ubuntu-latest
66
+ environment: pypi
67
+ permissions:
68
+ id-token: write # the only permission trusted publishing needs
69
+ steps:
70
+ - uses: actions/download-artifact@v4
71
+ with:
72
+ name: distribution
73
+ path: dist/
74
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .DS_Store
13
+ # Example workbooks are generated by examples/make_examples.py, never committed,
14
+ # so the repository carries no spreadsheet data of any kind.
15
+ *.xlsx
16
+ *.xlsm
17
+ ~$*
@@ -0,0 +1,19 @@
1
+ # Run Show Your Work over any spreadsheet being committed. It opens each staged
2
+ # .xlsx or .xlsm on your machine, never uploads anything, and fails the commit when
3
+ # a workbook has findings at or above the fail level.
4
+ #
5
+ # In a consuming repository's .pre-commit-config.yaml:
6
+ #
7
+ # repos:
8
+ # - repo: https://github.com/Waiga/show-your-work
9
+ # rev: v0.1.0 # pin to a tag or commit
10
+ # hooks:
11
+ # - id: show-your-work
12
+ # args: [--fail-on, high] # optional; high is the default
13
+ #
14
+ - id: show-your-work
15
+ name: show your work (spreadsheet audit)
16
+ description: Report spreadsheet cells that nobody can explain, and fail on findings.
17
+ entry: show-your-work
18
+ language: python
19
+ files: \.(xlsx|xlsm)$
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waiga Arya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,399 @@
1
+ Metadata-Version: 2.5
2
+ Name: unexplained-cells
3
+ Version: 0.2.0
4
+ Summary: Finds the numbers in a spreadsheet that nobody can explain. Runs entirely on your machine.
5
+ Project-URL: Homepage, https://github.com/Waiga/show-your-work
6
+ Project-URL: Issues, https://github.com/Waiga/show-your-work/issues
7
+ Author: Waiga Arya
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Waiga Arya
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: audit,excel,formula,openpyxl,review,spreadsheet,xlsx
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: End Users/Desktop
34
+ Classifier: Intended Audience :: Financial and Insurance Industry
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
38
+ Requires-Python: >=3.9
39
+ Requires-Dist: openpyxl>=3.1
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=7; extra == 'dev'
42
+ Requires-Dist: ruff>=0.5; extra == 'dev'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # Show Your Work
46
+
47
+ Finds the numbers in a spreadsheet that nobody can explain.
48
+
49
+ Point it at an `.xlsx` file and it reports the things that make a total untrustworthy:
50
+ a formula someone typed over, a `SUM` that stops one row short, a cell that quietly
51
+ does something different from the column it sits in, a loop, a hidden sheet, a link to
52
+ a file that may have moved.
53
+
54
+ It runs entirely on your machine. No upload, no account, no API key, no network call.
55
+
56
+ ```
57
+ $ show-your-work examples/messy-forecast.xlsx
58
+
59
+ Show Your Work — examples/messy-forecast.xlsx
60
+ ========================================================================
61
+ Read 2 sheet(s). 7 finding(s): 3 high, 2 medium, 2 low.
62
+
63
+ HIGH
64
+ ------------------------------------------------------------------------
65
+ HIGH Forecast!D9 [inconsistent_formula]
66
+ Formula differs from the 8 matching formulas in this column
67
+
68
+ HIGH Forecast!D5 [overwritten_formula]
69
+ Typed number inside a column of formulas
70
+
71
+ HIGH Forecast!D12 [total_misses_rows]
72
+ SUM skips 2 rows that sit between its range and the total
73
+ ```
74
+
75
+ That output is real. `examples/messy-forecast.xlsx` is built by
76
+ `examples/make_examples.py` with seven problems planted in it, and
77
+ `examples/clean-forecast.xlsx` is the same sheet built properly, on which the tool
78
+ reports nothing.
79
+
80
+ ```bash
81
+ python examples/make_examples.py
82
+ show-your-work examples/messy-forecast.xlsx # 7 findings, exit 1
83
+ show-your-work examples/clean-forecast.xlsx # nothing found, exit 0
84
+ ```
85
+
86
+ ## A real one
87
+
88
+ The example above is built to be broken. This one was not.
89
+
90
+ [Socio-economic statistics for rural and urban Ontario](https://data.ontario.ca/dataset/c30aa695-4735-466a-bc6e-fd31f1290973),
91
+ published by the Ontario Ministry of Agriculture, Food and Agribusiness under the Open
92
+ Government Licence – Ontario.
93
+
94
+ On the `Population by age` sheet, the 2016 census block occupies columns R to X, and rows
95
+ 5 to 22 are eighteen five-year age bands. Two of them are gone. The age labels in R19 and
96
+ R20 are empty, the Ontario counts in S19 and S20 are empty, and T19 and T20 — which should
97
+ divide the Ontario count by the block total — have a `#REF!` numerator and a saved `#REF!`
98
+ result.
99
+
100
+ Which two bands were lost is legible from the sheet itself. The 2021 block immediately to
101
+ the left still labels its rows 19 and 20 "Both sexes: 70-74 years" and "Both sexes: 75-79
102
+ years", and those are exactly the bands the 2016 sequence skips: it runs 65-69 and then
103
+ jumps to 80-84. The Urban and Rural columns on those same two rows still hold their
104
+ numbers, and on all sixteen surviving 2016 rows the Ontario figure equals Urban plus Rural
105
+ exactly — so the identity that holds everywhere else in the block would reconstruct both
106
+ missing figures from cells that are still there.
107
+
108
+ ```bash
109
+ curl -s -o population_counts.xlsx \
110
+ 'https://data.ontario.ca/dataset/c30aa695-4735-466a-bc6e-fd31f1290973/resource/e07b6d92-31ef-437d-85f2-da88ef563515/download/population_statistics_-_rural_and_urban_ontario_population__counts.en.xlsx'
111
+
112
+ show-your-work population_counts.xlsx | grep 'Population by age!T'
113
+ ```
114
+
115
+ The counts below describe the file as published on 7 September 2026, whose SHA-256 is
116
+ `ae3cc972acb90bfe40aedad5d233475e8db5abb9b690164a9e71d8c4fdd0aa19`. Ontario may correct or
117
+ republish it, in which case the numbers here are of a file that no longer exists and the
118
+ hash is how you can tell. That would be good news.
119
+
120
+ ```
121
+ HIGH Population by age!T19 [error_value]
122
+ HIGH Population by age!T20 [error_value]
123
+ HIGH Population by age!T19 [inconsistent_formula]
124
+ HIGH Population by age!T20 [inconsistent_formula]
125
+ ```
126
+
127
+ **The `grep` is not decoration, and leaving it out would misrepresent the run.** The whole
128
+ workbook produces 195 findings. Fifteen are on this sheet; the other 180 are on two sheets
129
+ that carry many `#REF!` and `#N/A` cells of their own, and 172 of the 195 are those error
130
+ cells. On a file this size the tool hands you a list to search, not an answer. That is the
131
+ honest shape of the result.
132
+
133
+ Of the fifteen on this sheet, the four above are the ones that matter. Ten more are the
134
+ block's header row, reported at medium because a cell bounding a block is where a total
135
+ belongs; the last is a typed-in cell that the sheet's own layout explains. Those ten were
136
+ high until this file was run against the tool, and being wrong about a real published
137
+ workbook is what got the level fixed.
138
+
139
+ Note also which check caught it. `error_value` is the least clever row in the table below,
140
+ and this README's own argument is that the dangerous cell is the one *not* showing an
141
+ error. Both are true. The error is only the marker; the finding is the two missing rows —
142
+ three columns wide, two thirds of the way down a 246-row sheet, in a block that reads as
143
+ complete because every band still present is correct.
144
+
145
+ ## Why
146
+
147
+ A spreadsheet is the only document people quote from without checking how it was built.
148
+ The dangerous cell is never the one showing an error. It is the one showing a number
149
+ that looks exactly like the eleven around it and was typed in by hand three quarters ago.
150
+
151
+ This tool does not tell you a number is wrong. It tells you which numbers the sheet
152
+ cannot account for, so a human can go and look at those instead of all of them.
153
+
154
+ ## Install
155
+
156
+ Python 3.9 or newer. The only dependency is `openpyxl`.
157
+
158
+ ```bash
159
+ pip install unexplained-cells
160
+ ```
161
+
162
+ Or from source:
163
+
164
+ ```bash
165
+ git clone https://github.com/Waiga/show-your-work
166
+ cd show-your-work
167
+ pip install -e .
168
+ ```
169
+
170
+ The package is called `unexplained-cells`, which is what it reports. The name
171
+ `show-your-work` on PyPI belongs to [showyourwork](https://pypi.org/project/showyourwork/),
172
+ an established and unrelated project for reproducible scientific articles, and a
173
+ near-miss name beside theirs would help nobody. The command you type is still
174
+ `show-your-work`.
175
+
176
+ ## Use
177
+
178
+ ```bash
179
+ show-your-work model.xlsx # findings, no cell contents
180
+ show-your-work model.xlsx --verbose # add why each finding matters
181
+ show-your-work model.xlsx --show-values # include the actual cell contents
182
+ show-your-work model.xlsx --format json # for scripts
183
+ show-your-work a.xlsx b.xlsx c.xlsx # several files in one run
184
+ ```
185
+
186
+ More than one file may be given. Each is opened and reported on its own, one
187
+ unreadable file does not stop the rest, and the exit code is the worst any single
188
+ file earned. In `--format json`, one file prints a single object and several print an
189
+ array, so the whole of standard output stays valid JSON either way.
190
+
191
+ Exit codes make it usable in a pipeline:
192
+
193
+ | Code | Meaning |
194
+ |---|---|
195
+ | `0` | nothing found at or above the fail level |
196
+ | `1` | findings at or above the fail level |
197
+ | `2` | a file could not be read, or refused before reading, or the command line was wrong |
198
+
199
+ `--fail-on` sets that level: `high` (default), `medium`, `low`, or `never`.
200
+
201
+ A file that is not a valid `.xlsx`, or that is shaped like a decompression bomb or an
202
+ XML entity-expansion payload, is refused with a plain message and exit `2`. It is never
203
+ a silent empty report, because "nothing was examined" must not read like "nothing was
204
+ found".
205
+
206
+ ```yaml
207
+ # fail a pull request that introduces a hardcoded number into a model
208
+ - run: show-your-work models/pricing.xlsx --fail-on high
209
+ ```
210
+
211
+ ### As a pre-commit hook
212
+
213
+ This repository ships a [pre-commit](https://pre-commit.com) hook, so every `.xlsx` or
214
+ `.xlsm` being committed is audited before it lands. In a consuming repository's
215
+ `.pre-commit-config.yaml`:
216
+
217
+ ```yaml
218
+ repos:
219
+ - repo: https://github.com/Waiga/show-your-work
220
+ rev: v0.2.0 # pin to a tag or commit
221
+ hooks:
222
+ - id: show-your-work
223
+ args: [--fail-on, high] # optional; high is the default
224
+ ```
225
+
226
+ pre-commit passes every staged spreadsheet to one run of the tool, and a commit fails
227
+ when any of them has a finding at or above the fail level.
228
+
229
+ ### In GitHub Actions
230
+
231
+ The same command runs on the spreadsheets in a repository. This reusable job audits
232
+ every `.xlsx` under the checkout and fails the build on any high finding:
233
+
234
+ ```yaml
235
+ # .github/workflows/spreadsheets.yml
236
+ name: Audit spreadsheets
237
+
238
+ on:
239
+ pull_request:
240
+ push:
241
+ branches: [main]
242
+
243
+ permissions:
244
+ contents: read
245
+
246
+ jobs:
247
+ show-your-work:
248
+ runs-on: ubuntu-latest
249
+ steps:
250
+ - uses: actions/checkout@v4
251
+ - uses: actions/setup-python@v5
252
+ with:
253
+ python-version: "3.13"
254
+ - run: pip install git+https://github.com/Waiga/show-your-work
255
+ - name: Audit every spreadsheet in the repo
256
+ run: |
257
+ shopt -s globstar nullglob
258
+ files=(**/*.xlsx **/*.xlsm)
259
+ if [ ${#files[@]} -eq 0 ]; then
260
+ echo "No spreadsheets to audit."
261
+ exit 0
262
+ fi
263
+ show-your-work "${files[@]}" --fail-on high
264
+ ```
265
+
266
+ The tool never uploads the file or makes a network call, so it needs no secrets and the
267
+ `contents: read` permission above is all it uses.
268
+
269
+ ## What it checks
270
+
271
+ | Check | Level | What it means |
272
+ |---|---|---|
273
+ | `overwritten_formula` | high / medium | A typed number sits inside a run of identical formulas. Someone replaced a calculation with a figure, so the sheet no longer explains it and it will not update. **High** when formulas sit on both sides of it. **Medium** at the top or bottom of a run, where a typed number is more often deliberate. A starting value that the formulas below it refer back to, such as an opening balance, is not reported at all. |
274
+ | `inconsistent_formula` | high / medium | One formula differs from the many matching formulas around it. Invisible on screen, and the pattern most often found behind a wrong total. Medium when the odd cell is the first or last of the block, where a header or a total belongs. A total closing the block is exempt however it is written: `=SUM(...)`, the Lotus-style `=+SUM(...)` and `=ROUND(SUM(...),0)` are all read as totals. |
275
+ | `total_misses_rows` | high / medium | A `SUM` range stops short of the numbers next to it, checked both down a column and across a row. Rows added to a table fall outside a total nobody extended. A subtotal in the gap is ignored, because stacked sections are meant to be built that way. So is a row the formula itself names, as in `=SUM(D93:D103)-D104`, where row 104 is a deduction added outside the range on purpose. So is a partial aggregate repeated in three or more cells, which is a deliberate subset rather than a slip. |
276
+ | `circular_reference` | high | Cells depend on themselves, directly or through a chain. Excel shows zero rather than an error. |
277
+ | `iterative_calculation` | medium | Excel's iterative calculation setting is on. It is only needed when formulas depend on each other, and it makes results depend on how many passes Excel was told to run. |
278
+ | `error_value` | high | A saved result is `#REF!`, `#DIV/0!`, `#VALUE!` or similar. |
279
+ | `broken_defined_name` | high | A named range points at deleted cells. |
280
+ | `number_stored_as_text` | medium | Digits stored as text in a numeric column. `SUM` and lookups skip them silently. |
281
+ | `external_link` | medium | A formula reads from another workbook. The figure is whatever that file last saved. |
282
+ | `hidden_sheet` | medium / high | A sheet is hidden. "Very hidden" sheets cannot be revealed from the Excel menu at all. |
283
+ | `hidden_rows`, `hidden_columns` | medium / low | Content excluded from view but still inside totals. |
284
+ | `active_filter` | medium / low | A filter is applied, so the rows on screen may not be all the rows. |
285
+ | `volatile_function` | low | `TODAY`, `NOW`, `RAND`, `OFFSET`, `INDIRECT`. The file can show different numbers tomorrow with no edit in between. |
286
+ | `merged_cells` | low | Merged blocks spanning rows, which read as blanks inside ranges. |
287
+ | `macro_enabled` | medium | An `.xlsm` file. Code in it may change values on open. |
288
+ | `protected_sheet` | low | Recorded for context. It does not block any other check. |
289
+
290
+ **Most spreadsheets are not models, and three of these checks need one.** In a sweep of 527
291
+ valid public government `.xlsx` files, only 21% contained a formula of any kind. On the
292
+ other 79% — flat, value-only exports — `overwritten_formula`, `inconsistent_formula` and
293
+ `total_misses_rows` are structurally incapable of firing: there is no formula to overwrite,
294
+ no repeated pattern to break, and no range to fall short of. Those files can still produce
295
+ `error_value`, `number_stored_as_text`, `hidden_sheet` and the rest, but the three checks
296
+ this tool exists for have nothing to read, and a clean report on such a file means only
297
+ that the value-based checks found nothing. That sample leaned toward budget and statistics
298
+ files, so 21% is what those 527 workbooks showed, not a rate for spreadsheets in general.
299
+
300
+ ## What it does not check
301
+
302
+ This list is part of the tool, not a disclaimer. Every run prints it.
303
+
304
+ - **Whether any number is correct.** A hardcoded figure may well be the right one. The
305
+ tool reports that a number is unexplained, never that it is wrong.
306
+ - **Whether the formula logic matches the intent.** A model can be perfectly consistent
307
+ and completely wrong about the business.
308
+ - **Anything outside the cells.** Charts, pivot caches, VBA code and embedded objects
309
+ are not inspected.
310
+ - **Calculated results, when the file has none.** A workbook generated by a program and
311
+ never opened in Excel stores formulas without results. Value-based checks cannot run,
312
+ and the report says so rather than reporting a pass.
313
+ - **`.xls` files.** The old binary format is not supported. Re-save as `.xlsx`.
314
+
315
+ Absence of a finding is reported as "not checked", never as a confirmed no.
316
+
317
+ ## The boundary — what the tool structurally cannot see
318
+
319
+ The section above is what the tool chooses not to judge. This section is different: it is
320
+ what the tool *cannot* reach, because the file format hides it from the way the tool
321
+ reads a workbook. It reads cells: a formula's text and, when Excel saved them, the last
322
+ results. Anything that is not a cell, or that lives only in a part the reader does not
323
+ open, is outside its reach.
324
+
325
+ - **Macros.** The tool detects that an `.xlsm` carries a VBA project and flags it, so you
326
+ know code is present. It does not read, run, or reason about that code. A macro can
327
+ rewrite any value on open, and the tool cannot tell you whether it does.
328
+ - **External workbook links.** A formula that reads from another file is flagged, but the
329
+ other file is never opened. The number you see is whatever that file last saved into
330
+ this one; if it moved or changed, the tool cannot resolve the link to find out.
331
+ - **Array and dynamic-array formulas.** A legacy array (Ctrl+Shift+Enter) formula reaches
332
+ the reader as an object rather than plain text, so the consistency, total and
333
+ overwrite checks skip it, and the cells it spills into read as blank. A modern
334
+ dynamic-array formula such as `SORT`, `FILTER`, `UNIQUE` or `SEQUENCE` is stored only
335
+ on its anchor cell; Excel produces the spilled cells at open time, so the tool sees
336
+ neither the spilled values nor how far they reach. Neither kind is analysed.
337
+ - **Cached results versus live computation.** The tool reads the results Excel last saved,
338
+ never a value it computed itself. A workbook written by a program and never opened in
339
+ Excel has formulas with no saved results; the value-based checks cannot run and the
340
+ report says so. When saved results do exist, they are only as current as the last save,
341
+ and a formula changed since then may show a stale number the tool takes at face value.
342
+ - **A one-off partial aggregate.** A total that deliberately sums part of a range —
343
+ `=SUM(B2:D2)` in a table that runs out to F — is indistinguishable from a total that
344
+ stops short by mistake. The file records the range, never the intent. The tool settles
345
+ it by repetition: the same partial-aggregate formula appearing in three or more cells is
346
+ a design decision and is not reported, on the reasoning that a slip happens once. A
347
+ genuinely one-off deliberate partial aggregate is therefore still reported, and nothing
348
+ in the file could tell it apart. The repetition test compares formulas with their row
349
+ numbers stripped, so a partial aggregate filled *down* a column collapses to one shape
350
+ and is recognised; the same one filled *across* a row does not, and is still reported.
351
+ - **A formula that differs only in which row it anchors to.** Formulas are compared with
352
+ their row numbers stripped, which is what lets a filled-down column collapse to a single
353
+ shape. The cost is that `=B8/B$4` and `=B8/B$99` are the same shape, so a total pointed
354
+ at the wrong anchor row is not an inconsistency this check can see. It is exactly the
355
+ kind of mistake the tool exists to catch, and it is the one shape it is blind to.
356
+ - **Pivot caches.** A pivot table keeps its own snapshot of the source data. The tool does
357
+ not open that cache, so it cannot tell whether a pivot is stale or what it summarises.
358
+ - **Charts.** Charts, and the series and cached points inside them, are not read. A chart
359
+ can plot numbers that no longer match the cells it was built from, and the tool will
360
+ not see the difference.
361
+
362
+ ## Privacy
363
+
364
+ Cell contents are withheld from the report unless you pass `--show-values`. Findings
365
+ identify a cell by its address and describe the shape of the problem, so a report can be
366
+ pasted into a ticket or a chat without carrying the data with it.
367
+
368
+ Nothing is written anywhere except standard output. The workbook is opened read-only and
369
+ is never modified.
370
+
371
+ ## How this compares
372
+
373
+ Spreadsheet auditing is an old field and this tool is not the first to look for these
374
+ things. Most of what checks a workbook today is either a paid Windows add-in
375
+ ([PerfectXL](https://www.perfectxl.com/), [Operis Analysis Kit](https://www.operisanalysiskit.com/),
376
+ [Spreadsheet Detective](https://spreadsheetdetective.com/main/)), a feature of a
377
+ particular Excel licence tier
378
+ ([Spreadsheet Inquire](https://support.microsoft.com/en-us/office/analyze-a-workbook-with-spreadsheet-inquire-5991e8fa-f1c1-401a-ae3f-469384ae3e3b)),
379
+ or a website you upload the file to.
380
+
381
+ The detection here is not novel. What is different is that it is free, open, runs as a
382
+ command, never sends the file anywhere, and returns an exit code you can put in CI.
383
+
384
+ Two of the checks come from published work rather than invention:
385
+ `inconsistent_formula` targets the error class studied by
386
+ [ExceLint](https://github.com/ExceLint/ExceLint) (OOPSLA 2018), and the error taxonomy
387
+ behind the severity levels follows
388
+ [Panko and Halverson](https://arxiv.org/pdf/0809.3613). `active_filter` is the one check
389
+ with no published evidence behind it; it is included on judgement, and rated low.
390
+
391
+ ## Contributing
392
+
393
+ Useful tests, reproducible examples, honest limitations, and false-positive reports are
394
+ first-class contributions. A workbook that produces a wrong finding is the single most
395
+ valuable thing you can open an issue with, provided it contains no real data.
396
+
397
+ ## Licence
398
+
399
+ MIT.