Spreadsheet Auditing skill for Codex
Find the broken formula before the board does. Codex audits a workbook the way a model reviewer does: it maps inputs, calculations and answer cells, runs a bundled offline scanner for error values, #REF!, R1C1-inconsistent formulas, embedded constants, approximate lookups and external links, recalculates a copy in LibreOffice, checks range coverage and logic by hand, reconciles key totals independently, and returns a severity-ranked findings table without touching the original.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- spreadsheet-auditing Installs to ~/.agents/skills/spreadsheet-auditing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install spreadsheet-auditing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project spreadsheet-auditing Installs to .agents/skills/spreadsheet-auditing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/spreadsheet-auditing.zipunzip -o spreadsheet-auditing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) spreadsheet-auditing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $spreadsheet-auditing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Audits Excel workbooks (.xlsx, .xlsm) for formula errors, #REF! and broken references, inconsistent formulas, hard-coded constants, approximate-match lookups, ranges that miss data, external links, hidden sheets and numbers stored as text, using a bundled standard-library scanner plus openpyxl and LibreOffice recalculation, and writes a findings report with severity and fixes. Use when asked to check a financial model or spreadsheet for mistakes, review a workbook before it is shared, find why totals look wrong, or clean up broken links and errors.
Use it when
- A financial model or operational workbook needs review before sharing
- Totals or KPIs in a spreadsheet look wrong and the cause is unknown
- A workbook has error values, broken links or unexplained hidden sheets
- You want a CI gate that rejects workbooks with formula errors
Use something else when
- You need to write or optimize formulas rather than review them: use excel-formulas
- You are generating a new workbook from code: use xlsx-spreadsheets
How to check the result
The scanner exits 0 or every finding is explained, a recalculated copy matches cached values apart from volatile cells, and key outputs match an independent recomputation.
The mistake it prevents
Trusting cached values in a file written by a script, which has no calculated results at all, or signing off a clean scan without checking that SUM ranges cover all the data.
An audit is done when every finding has a cell address, a severity, evidence and a proposed fix; totals have been reconciled independently; and the user knows which issues were found mechanically and which parts of the logic were not reviewed. Never change the workbook under audit; fixes go into a copy, only after the user agrees.
Severity scale
| Severity | Meaning | Examples |
|---|---|---|
| Critical | Output numbers are wrong or can silently become wrong | #REF!, a SUM that misses rows, approximate VLOOKUP on unsorted keys, wrong sign |
| High | Correct today, breaks on the next change | Hard-coded numbers in formulas, inconsistent formula in a block, external links |
| Medium | Hurts review and maintenance | Hidden sheets with live logic, volatile functions, merged cells in data ranges |
| Low | Hygiene | Inconsistent number formats, unused names, missing labels |
Workflow
- [ ] 1. Map the workbook (sheets, inputs, calculations, outputs, links)
- [ ] 2. Run the static scanner
- [ ] 3. Recalculate and compare cached values
- [ ] 4. Review logic by hand where the scanner cannot see
- [ ] 5. Reconcile key outputs independently
- [ ] 6. Write the findings report; fix only on request, in a copy
1. Map the workbook
Ask what the workbook is for and which outputs matter (the “answer cells”). Then list:
- Sheets, their visibility (
hidden,veryHidden), and their role: input, calculation, output, lookup data, scratch. - Defined names and tables, data validation lists, external links, macros (
.xlsmcontainsxl/vbaProject.bin; do not run macros, inspect only). - Where inputs live. Good models keep inputs in one place with a distinct style; scattered inputs are themselves a finding.
2. Static scan (bundled, standard library only)
python3 scripts/xlsx_audit.py model.xlsx # text report, 10 example cells per finding
python3 scripts/xlsx_audit.py --limit 100 model.xlsx # more examples
python3 scripts/xlsx_audit.py --json model.xlsx > audit.json
python3 scripts/xlsx_audit.py --strict model.xlsx # CI gate: warnings fail too
It reads the XML directly, so it works offline on files openpyxl cannot load, and reports:
- Errors: cached error values (
#REF!,#DIV/0!,#N/A,#VALUE!,#NAME?,#SPILL!),#REF!inside formulas or defined names, external workbook links. - Warnings: formulas that differ from both neighbours in their row or column (R1C1
comparison, shared formulas resolved), constants in formula arithmetic (
*1.2,+500), VLOOKUP/HLOOKUP with a missing orTRUEfourth argument, numbers stored as text, formulas without cached values, manual calculation mode. - Info: hidden sheets, volatile functions (
INDIRECT,OFFSET,TODAY,NOW,RAND).
Exit code 1 means errors were found. Treat every warning as a lead to confirm, not a
verdict: a constant like /12 for months may be acceptable if documented.
3. Recalculate
Cached values are whatever the last application computed. A file written by a script (openpyxl, pandas) has formulas with no cached values at all; the scanner flags them. Recalculate in a copy with LibreOffice, then compare:
mkdir -p recalc
soffice --headless --calc --convert-to xlsx --outdir recalc model.xlsx
LibreOffice recalculates on load depending on its settings; if values did not change where they should, note that the recalculation is unverified. Then compare cached values cell by cell:
from openpyxl import load_workbook
a = load_workbook("model.xlsx", data_only=True) # cached values
b = load_workbook("recalc/model.xlsx", data_only=True) # recalculated values
for ws in a.worksheets:
for row in ws.iter_rows():
for c in row:
v2 = b[ws.title][c.coordinate].value
if isinstance(c.value, (int, float)) and isinstance(v2, (int, float)):
if abs(c.value - v2) > 1e-6 * max(1, abs(c.value)):
print(ws.title, c.coordinate, c.value, v2)
elif c.value != v2:
print(ws.title, c.coordinate, repr(c.value), repr(v2))
Differences point at stale values, manual calc mode, functions LibreOffice evaluates differently, or volatile functions. Investigate the first, ignore expected volatile ones. LibreOffice must be installed; installing it needs network and approval.
4. Manual logic review
What no scanner catches; check each against the answer cells’ precedents:
- Range coverage:
SUM(D2:D99)while data runs to row 140. Compare each aggregate’s range end with the last used row of that column (ws.max_rowand the actual last non-empty cell). Prefer whole tables or structured references. - Lookups: exact match (
FALSE/0, orXLOOKUPdefault) on IDs; approximate match only on sorted bands (tax brackets) and documented as such. Check forIFERRORwrapped around lookups that hides missing keys. - Signs and units: costs negative or positive consistently, percentages as 0.05 vs 5, thousands vs units, currencies mixed in one column.
- Time logic: period columns aligned (a formula in March referencing February’s column by accident), growth applied from the right base year, leap years and day counts.
- Double counting: subtotals included in grand totals (
SUMover a block that contains its own subtotal rows). - Circular references: intentional iterations should be documented and bounded; check
whether iterative calculation is enabled (
<calcPr iterate="1">in workbook.xml). - Hidden logic: hidden rows/columns or white-on-white cells inside calculation blocks.
- Overrides: a formula row with one typed number in the middle (the scanner reports it as an inconsistency or a missing formula; confirm it is not intentional).
Use openpyxl (formulas mode, load_workbook(path)) to trace precedents of an answer cell:
parse its formula, follow references, and print the chain with values from the
data_only=True load. Read the chain like a reviewer: does each step mean what its label says?
5. Reconcile independently
Recompute two or three key outputs outside the workbook (pandas or plain Python over the input data) and compare with the workbook’s answer cells. A model that cannot be reconciled is not signed off, regardless of how clean the scan is.
6. Report, and fix only on request
Fixes, when asked: work on a copy (model.audited.xlsx), keep the original untouched,
change one issue at a time, and record before and after values of the answer cells.
Writing with openpyxl drops cached values, charts’ cached data and some features
(pivot caches survive poorly, form controls and certain conditional formats can be lost);
warn the user, or make the edits in Excel/LibreOffice when fidelity matters.
Gotchas
data_only=TruereturnsNonefor formulas in files never opened by a calculating app.- openpyxl does not evaluate formulas; never claim “the totals are correct” from it alone.
- Excel stores
_xlfn.prefixes for newer functions (_xlfn.XLOOKUP); older readers show them as#NAME?. - Dynamic arrays spill; a
#SPILL!error means something sits in the spill range. - Dates are serial numbers; a date column formatted as General looks like numbers in the 45000s.
- Defined names scoped to a sheet can shadow workbook-level names with the same name.
- External links may point to files on someone’s laptop; values are stale copies.
Output: findings report
## Spreadsheet audit: pricing-model-v7.xlsx (audited copy, original untouched)
Scope: sheets Inputs, Calc, Summary; answer cells Summary!C4:C9. Macros present, not run.
| # | Severity | Cell(s) | Finding | Evidence | Proposed fix |
|---|---|---|---|---|---|
| 1 | Critical | Calc!F2:F140 -> Summary!C5 | SUM stops at row 99; rows 100-140 excluded | Data ends at row 140; total understated by 182,400 | Sum the whole table column |
| 2 | Critical | Calc!H12 | VLOOKUP approximate match on unsorted SKU list | 4th argument omitted | Use XLOOKUP or FALSE |
| 3 | High | Calc!D45 | Formula differs from neighbours (`*1.2` uplift) | Scanner + manual check | Move 1.2 to an input cell |
Recalculation: LibreOffice copy matched cached values except volatile TODAY() cells.
Reconciliation: Summary!C4 matches independent pandas total (1,204,330.00).
Not reviewed: VBA module, chart sources. What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Spreadsheet Auditing"
short_description: "Find the broken formula before the board does"
default_prompt: "$spreadsheet-auditing Audit pricing-model.xlsx for formula mistakes before it goes to the CFO" scripts/xlsx_audit.py
#!/usr/bin/env python3
"""Static audit of an .xlsx/.xlsm workbook using only the standard library.
Usage:
python3 xlsx_audit.py model.xlsx # text report
python3 xlsx_audit.py --json model.xlsx # JSON report
python3 xlsx_audit.py --strict model.xlsx # warnings also fail (exit 1)
python3 xlsx_audit.py --limit 50 model.xlsx # list up to 50 cells per finding
Reads the file's XML directly (no Excel, no openpyxl) and reports:
errors : cached error values (#REF!, #DIV/0!, #N/A, ...), #REF! inside formulas or
defined names, external workbook links
warnings : formulas inconsistent with both neighbours in their row or column,
hard-coded constants in formula arithmetic, approximate-match lookups,
numbers stored as text, formulas without cached values, manual calc mode
info : hidden / very hidden sheets, volatile functions
It does not recalculate; cached values are whatever the last saving application wrote.
Exit codes: 0 = no errors, 1 = errors (or warnings with --strict), 2 = cannot read file.
"""
import argparse
import json
import posixpath
import re
import sys
import zipfile
from collections import defaultdict
import xml.etree.ElementTree as ET
M = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
PR = "{http://schemas.openxmlformats.org/package/2006/relationships}"
STRING_LIT = re.compile(r'"(?:[^"]|"")*"')
QUOTED_SHEET = re.compile(r"'(?:[^']|'')*'!")
CELL_REF = re.compile(r"(?<![A-Za-z0-9_.\[])(\$?)([A-Z]{1,3})(\$?)([0-9]{1,7})(?![A-Za-z0-9_(\[])")
NUM_IN_ARITH = re.compile(
r"(?:(?<=[-+*/^])\s*(\d+(?:\.\d+)?)(?![\d.:A-Za-z_(!]))" # operator then number
r"|(?:(?<![A-Za-z0-9_.$:])(\d+(?:\.\d+)?)\s*(?=[-+*/^]))" # number then operator
)
VOLATILE = re.compile(r"\b(INDIRECT|OFFSET|NOW|TODAY|RAND|RANDBETWEEN|RANDARRAY)\s*\(")
# numeric-looking text; leading-zero codes such as 00123 are treated as deliberate text
NUMERIC_TEXT = re.compile(r"^\s*[-+]?([1-9]\d{0,2}(,\d{3})+|[1-9]\d*|0)(\.\d+)?\s*%?\s*$")
def col_to_num(col):
n = 0
for ch in col:
n = n * 26 + ord(ch) - 64
return n
def split_ref(ref):
m = re.match(r"([A-Z]+)(\d+)$", ref)
return col_to_num(m.group(1)), int(m.group(2))
def clean(formula):
f = formula.replace("_xlfn.", "").replace("_xlws.", "")
f = STRING_LIT.sub('""', f)
return QUOTED_SHEET.sub("'S'!", f)
def to_r1c1(formula, col, row):
def sub(m):
cabs, c, rabs, r = m.groups()
cn, rn = col_to_num(c), int(r)
if cn > 16384:
return m.group(0)
rpart = f"R{rn}" if rabs else f"R[{rn - row}]"
cpart = f"C{cn}" if cabs else f"C[{cn - col}]"
return rpart + cpart
return CELL_REF.sub(sub, clean(formula))
def lookup_issue(formula):
"""Return True if a VLOOKUP/HLOOKUP uses approximate match (3 args, or TRUE/1)."""
f = clean(formula).upper()
for m in re.finditer(r"\b[VH]LOOKUP\s*\(", f):
depth, args, cur, i = 1, [], "", m.end()
while i < len(f) and depth:
ch = f[i]
if ch in "({":
depth += 1
elif ch in ")}":
depth -= 1
if depth == 0:
break
if ch == "," and depth == 1:
args.append(cur)
cur = ""
else:
cur += ch
i += 1
args.append(cur)
if len(args) == 3 or (len(args) >= 4 and args[3].strip() in ("TRUE", "1")):
return True
return False
def read_xml(z, name):
return ET.fromstring(z.read(name))
def rels_for(z, part):
base, fname = posixpath.split(part)
rel = posixpath.join(base, "_rels", fname + ".rels")
out = {}
if rel in z.namelist():
for r in read_xml(z, rel).iter(PR + "Relationship"):
target = r.get("Target", "")
if r.get("TargetMode") != "External":
target = posixpath.normpath(posixpath.join(base, target)) if not target.startswith("/") else target.lstrip("/")
out[r.get("Id")] = (target, r.get("TargetMode"))
return out
def audit(path, limit):
z = zipfile.ZipFile(path)
names = set(z.namelist())
findings = defaultdict(list) # (severity, category) -> [location strings]
def add(sev, cat, loc):
findings[(sev, cat)].append(loc)
wb = read_xml(z, "xl/workbook.xml")
wb_rels = rels_for(z, "xl/workbook.xml")
calc = wb.find(M + "calcPr")
if calc is not None and calc.get("calcMode") == "manual":
add("warning", "workbook saved in manual calculation mode", "workbook")
for dn in wb.iter(M + "definedName"):
text = dn.text or ""
if "#REF!" in text:
add("error", "defined name refers to #REF!", f"name {dn.get('name')}")
if re.search(r"\[\d+\]", text):
add("error", "defined name points to an external workbook", f"name {dn.get('name')}")
for part in sorted(n for n in names if n.startswith("xl/externalLinks/") and n.endswith(".xml")):
targets = [t for t, mode in rels_for(z, part).values()]
add("error", "external workbook link", f"{part} -> {', '.join(targets) or '?'}")
shared = []
if "xl/sharedStrings.xml" in names:
for si in read_xml(z, "xl/sharedStrings.xml").iter(M + "si"):
shared.append("".join(t.text or "" for t in si.iter(M + "t")))
for sheet in wb.iter(M + "sheet"):
sname = sheet.get("name")
state = sheet.get("state", "visible")
if state != "visible":
add("info", f"{state} sheet", sname)
target = wb_rels.get(sheet.get(R + "id"), (None, None))[0]
if not target or target not in names:
continue
cells = {} # (col,row) -> r1c1 form
shared_masters = {} # si -> r1c1 form
pending_shared = []
for c in read_xml(z, target).iter(M + "c"):
ref = c.get("r")
if not ref:
continue
col, row = split_ref(ref)
loc = f"{sname}!{ref}"
t = c.get("t")
v = c.find(M + "v")
f = c.find(M + "f")
if t == "e" and v is not None:
add("error", f"cell shows {v.text}", loc)
text_value = None
if t == "s" and v is not None and (v.text or "").isdigit() and int(v.text) < len(shared):
text_value = shared[int(v.text)]
elif t == "inlineStr":
text_value = "".join(x.text or "" for x in c.iter(M + "t"))
if f is None and text_value is not None and NUMERIC_TEXT.match(text_value):
add("warning", "number stored as text", loc)
if f is None:
continue
ftext = f.text or ""
if f.get("t") == "shared":
si = f.get("si")
if ftext:
shared_masters[si] = to_r1c1(ftext, col, row)
else:
pending_shared.append((si, col, row))
continue
elif f.get("t") == "array" and not ftext:
continue
if (v is None or not (v.text or "").strip()) and t != "str":
add("warning", "formula has no cached value (never calculated)", loc)
if "#REF!" in ftext:
add("error", "formula contains #REF!", loc)
if re.search(r"\[\d+\]", ftext):
add("error", "formula references an external workbook", loc)
if VOLATILE.search(clean(ftext).upper()):
add("info", "volatile function", loc)
if lookup_issue(ftext):
add("warning", "VLOOKUP/HLOOKUP approximate match (missing or TRUE 4th arg)", loc)
nums = [a or b for a, b in NUM_IN_ARITH.findall(CELL_REF.sub("REF", clean(ftext)))]
nums = [n for n in nums if n not in ("0", "1")]
if nums:
add("warning", "hard-coded constant in formula arithmetic", f"{loc} ({', '.join(nums[:3])})")
cells[(col, row)] = to_r1c1(ftext, col, row)
for si, col, row in pending_shared:
if si in shared_masters:
cells[(col, row)] = shared_masters[si]
for (col, row), form in cells.items():
for d1, d2 in (((0, -1), (0, 1)), ((-1, 0), (1, 0))):
a = cells.get((col + d1[0], row + d1[1]))
b = cells.get((col + d2[0], row + d2[1]))
if a is not None and a == b and a != form:
ref = f"{sname}!{num_to_col(col)}{row}"
add("warning", "formula inconsistent with neighbours", ref)
break
report = []
order = {"error": 0, "warning": 1, "info": 2}
for (sev, cat), locs in sorted(findings.items(), key=lambda kv: (order[kv[0][0]], kv[0][1])):
report.append({"severity": sev, "category": cat, "count": len(locs), "examples": locs[:limit]})
return report
def num_to_col(n):
s = ""
while n:
n, r = divmod(n - 1, 26)
s = chr(65 + r) + s
return s
def main():
ap = argparse.ArgumentParser(description="Static audit of an .xlsx workbook")
ap.add_argument("workbook")
ap.add_argument("--json", action="store_true")
ap.add_argument("--strict", action="store_true", help="exit 1 on warnings too")
ap.add_argument("--limit", type=int, default=10, help="example cells listed per finding")
args = ap.parse_args()
try:
report = audit(args.workbook, args.limit)
except (OSError, zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
print(f"error: cannot read {args.workbook} as .xlsx: {exc}", file=sys.stderr)
return 2
errors = sum(r["count"] for r in report if r["severity"] == "error")
warnings = sum(r["count"] for r in report if r["severity"] == "warning")
if args.json:
print(json.dumps({"errors": errors, "warnings": warnings, "findings": report}, indent=2))
else:
for r in report:
print(f"[{r['severity'].upper():7}] {r['category']}: {r['count']}")
for loc in r["examples"]:
print(f" {loc}")
print(f"{errors} error(s), {warnings} warning(s)")
return 1 if errors or (args.strict and warnings) else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.