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.

  • Documents & Office
  • $spreadsheet-auditing
  • 167 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- spreadsheet-auditing

Installs to ~/.agents/skills/spreadsheet-auditing/ for every project on this machine.

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

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.

~/.agents/skills/spreadsheet-auditing/SKILL.md

--- name: spreadsheet-auditing description: Audits Excel workbooks (.xlsx, .xlsm) for formula errors, #REF! and broken references, inconsistent formulas, hard-coded constants, approximate-match… license: MIT ---

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

SeverityMeaningExamples
CriticalOutput numbers are wrong or can silently become wrong#REF!, a SUM that misses rows, approximate VLOOKUP on unsorted keys, wrong sign
HighCorrect today, breaks on the next changeHard-coded numbers in formulas, inconsistent formula in a block, external links
MediumHurts review and maintenanceHidden sheets with live logic, volatile functions, merged cells in data ranges
LowHygieneInconsistent 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 (.xlsm contains xl/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 or TRUE fourth 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_row and the actual last non-empty cell). Prefer whole tables or structured references.
  • Lookups: exact match (FALSE/0, or XLOOKUP default) on IDs; approximate match only on sorted bands (tax brackets) and documented as such. Check for IFERROR wrapped 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 (SUM over 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=True returns None for 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.yamlgenerated from this page

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.py258 lines, 10.2 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem