From d8ce7b5e0f71b1723bfca32e8de383b7dc78baa9 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Mon, 3 Aug 2026 13:17:31 -0400 Subject: [PATCH 1/6] Engagement scaffold: gitignore + deploy guard --- .gitignore | 8 ++++++++ scripts/engagement_guard.py | 25 +++++++++++++++++++++++++ scripts/git-hooks/pre-push | 24 ++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 scripts/engagement_guard.py create mode 100755 scripts/git-hooks/pre-push diff --git a/.gitignore b/.gitignore index 6e114d8..7375ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,11 @@ credentials.* .env.* *.pem secrets.* + +# --- lailara engagement scaffold --- +# Client engagement data is runtime-only: never commit it, never deploy it. +client-data/ +client-output/ +/engagement.yml +/engagement.yaml +# (engagement.demo.yml and engagement.example.yml stay committable) diff --git a/scripts/engagement_guard.py b/scripts/engagement_guard.py new file mode 100644 index 0000000..ec77677 --- /dev/null +++ b/scripts/engagement_guard.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Lailara engagement deploy guard (Python, stdlib-only). + +Exit 2 if an ACTIVE (non-demo) client engagement.yml is present in the current +directory. No-op otherwise, so demo builds and clean CI checkouts are unaffected. +Self-contained (no dependency on the installed lailara_engagement package) so it can +run in any repo's deploy/build environment. +""" +import os +import re +import sys + +for _f in ("engagement.yml", "engagement.yaml"): + if os.path.isfile(_f): + with open(_f, encoding="utf-8-sig") as _fh: + _txt = _fh.read() + if re.search(r"^\s*demo:\s*true\s*$", _txt, re.M): + continue # demo config -> safe + sys.stderr.write( + f"ENGAGEMENT GUARD: active client engagement config present ({_f}). " + "Client mode is runtime-only and must never deploy. Deactivate it " + "(set 'demo: true', or use engagement.demo.yml) before deploying.\n" + ) + raise SystemExit(2) +raise SystemExit(0) diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 0000000..9be9de9 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,24 @@ +#!/bin/sh +# Lailara engagement deploy guard (git pre-push hook). +# +# Refuses to push while an ACTIVE (non-demo) client engagement.yml is present in +# the working tree. Every tool repo auto-deploys on push, so blocking the push +# blocks the deploy — client mode is runtime-only and must never ship. +# +# No-op when no engagement.yml exists (demo builds and clean CI checkouts push +# normally), so demo behavior is unchanged. +# +# Activated per repo with: git config core.hooksPath scripts/git-hooks +set -e +for f in engagement.yml engagement.yaml; do + if [ -f "$f" ]; then + if grep -Eq '^[[:space:]]*demo:[[:space:]]*true[[:space:]]*$' "$f"; then + continue # demo config -> safe + fi + echo "ENGAGEMENT GUARD: active client engagement config present ($f)." >&2 + echo "Client mode is runtime-only and must never deploy. Deactivate it" >&2 + echo "(set 'demo: true', or remove/rename to engagement.demo.yml) before pushing." >&2 + exit 2 + fi +done +exit 0 From 598acab1e207df1c72cce3da73fea0882cc6a25e Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Wed, 5 Aug 2026 17:02:41 -0400 Subject: [PATCH 2/6] fix: severity_counts foots to total_issues (findings + dups + schema) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The severity breakdown drawn from high_issues/medium_issues/low_issues undercounts: those are findings-only, but duplicates, fuzzy duplicates, and schema violations carry a severity too, so H+M+L did not reconcile to the headline total_issues (52 vs 59 on the messy sample — the 7 duplicates were missing). Adds AuditResult.severity_counts, which counts every issue type by severity and foots exactly (23+20+16 == 59). Regression-locked. The Finding-typed high/medium/low properties are unchanged (non-breaking for the published API). Version 1.2.1 -> 1.3.0 (staged, not published). Full suite 267 passed. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++++++++++ data_hygiene_auditor/api.py | 23 +++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_api.py | 13 +++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d607c3f..fa22ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.3.0] - 2026-08-05 + +### Added +- `AuditResult.severity_counts` — a High/Medium/Low breakdown that counts **every** + issue type (findings, duplicates, fuzzy duplicates, schema violations), so it + reconciles to `total_issues`. The findings-only `high_issues` / `medium_issues` + / `low_issues` properties undercounted, because duplicates and schema violations + carry a severity but are not findings — a severity breakdown drawn from them did + not foot to the headline total. `severity_counts` is the reconciling view. + ## [1.2.1] - 2026-07-28 ### Fixed diff --git a/data_hygiene_auditor/api.py b/data_hygiene_auditor/api.py index ae62ec9..c6bcd7d 100644 --- a/data_hygiene_auditor/api.py +++ b/data_hygiene_auditor/api.py @@ -192,6 +192,29 @@ def medium_issues(self) -> List[Finding]: def low_issues(self) -> List[Finding]: return [f for f in self.findings if f.is_low] + @property + def severity_counts(self) -> Dict[str, int]: + """Issue counts by severity across EVERY issue type — findings, + duplicates, fuzzy duplicates, and schema violations — so the breakdown + foots to ``total_issues``. + + ``high_issues`` / ``medium_issues`` / ``low_issues`` are the *findings* + view (Finding objects only); duplicates and schema violations carry a + severity too, so a High/Medium/Low breakdown drawn only from findings + undercounts and does not reconcile to the headline total. This property + is the reconciling breakdown: ``sum(severity_counts.values()) == + total_issues``. + """ + counts = {"High": 0, "Medium": 0, "Low": 0} + for s in self.sheets: + for collection in (s.findings, s.duplicates, s.fuzzy_duplicates, + s.schema_violations): + for issue in collection: + sev = getattr(issue, "severity", None) + if sev in counts: + counts[sev] += 1 + return counts + def to_dict(self) -> Dict[str, Any]: """Return the raw audit results dict.""" return self._raw diff --git a/pyproject.toml b/pyproject.toml index 23c8251..09c2df4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "data-hygiene-auditor" -version = "1.2.1" +version = "1.3.0" description = "A linter for your data — detect mixed formats, misused fields, placeholder floods, and phantom duplicates in Excel and CSV files" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_api.py b/tests/test_api.py index 3a84c95..4553a96 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -124,6 +124,19 @@ def test_severity_filters(self): assert all(f.is_medium for f in mediums) assert all(f.is_low for f in lows) + def test_severity_counts_foot_to_total_issues(self): + # Regression: the H/M/L breakdown must reconcile to total_issues. The + # findings-only view (high_issues/…) undercounts because duplicates and + # schema violations carry severity too but are not findings. + result = audit_file(str(SAMPLE_PATH)) + counts = result.severity_counts + assert set(counts) == {"High", "Medium", "Low"} + assert sum(counts.values()) == result.total_issues + # And it strictly exceeds the findings-only sum whenever non-finding + # issues exist (the sample has duplicates). + findings_only = len(result.high_issues) + len(result.medium_issues) + len(result.low_issues) + assert sum(counts.values()) >= findings_only + def test_to_dict(self): result = audit_file(str(SAMPLE_PATH)) d = result.to_dict() From 6a8dc37eacdffdba3d17c827ce188137c282f05b Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Wed, 5 Aug 2026 20:51:18 -0400 Subject: [PATCH 3/6] docs(samples): regenerate showcase reports from 1.3.0 + fix README date-pattern count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt 6 step 0.f. The committed samples/output/ reports were last regenerated at 1.2.0; regenerate them from current source so the shipped showcase reflects the 1.3.0 tool (visible counts unchanged — 59 issues 23H/20M/16L, health 42/100 — since they already footed). README "What It Detects" said 6 date patterns; DATE_PATTERNS actually has 9 (phone 7 / currency 6 were correct). Verified with engagement-template/scripts/scan_binaries_for_drift.py: 0 retired tokens across all 6 tracked binaries (char counts confirm real extraction), the scenario the 2026-08-04 binary scan named as most likely to reintroduce a figure. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++ README.md | 2 +- .../sample_messy_data_audit_findings.xlsx | Bin 12642 -> 12650 bytes .../sample_messy_data_audit_report.html | 26 +++++++++++------- .../output/sample_messy_data_audit_report.pdf | Bin 53217 -> 53215 bytes .../sample_realistic_data_audit_findings.xlsx | Bin 8843 -> 8846 bytes .../sample_realistic_data_audit_report.html | 14 +++++++--- .../sample_realistic_data_audit_report.pdf | Bin 36521 -> 36518 bytes 8 files changed, 35 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa22ba7..98ccb5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed +- README "What It Detects" now states **9** date patterns (was 6), matching + `DATE_PATTERNS` in `detection.py` (7 phone / 6 currency were already correct). + +### Changed +- Regenerated the committed `samples/output/` reports from current source so the + shipped showcase artifacts reflect the 1.3.0 tool. + ## [1.3.0] - 2026-08-05 ### Added diff --git a/README.md b/README.md index fe0e91e..b3d326b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A single run produces three reports tailored to three audiences: an **HTML repor ## What It Detects -**Mixed Formats** — Identifies dates, phone numbers, and currency values stored in inconsistent formats within the same column. For example, `2023-01-15` alongside `Jan 15, 2023` and `01/15/2023` in one date field. The auditor recognizes 6 date patterns, 7 phone patterns, and 6 currency patterns. +**Mixed Formats** — Identifies dates, phone numbers, and currency values stored in inconsistent formats within the same column. For example, `2023-01-15` alongside `Jan 15, 2023` and `01/15/2023` in one date field. The auditor recognizes 9 date patterns, 7 phone patterns, and 6 currency patterns. **Misused Fields** — Flags data stored in the wrong column: reference codes in name fields, free text in currency columns, invalid email addresses, and mixed boolean representations (`Y/N` vs `1/0` vs `Active/Inactive` in the same field). diff --git a/samples/output/sample_messy_data_audit_findings.xlsx b/samples/output/sample_messy_data_audit_findings.xlsx index fdcec2aa6a373f2a88631c057fe9387eb3903d61..0eadb87b0f2a1efcda4d6ed36b7b52f24db8a9ea 100644 GIT binary patch delta 10537 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 mH*fot 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 A!~g&Q diff --git a/samples/output/sample_messy_data_audit_report.html b/samples/output/sample_messy_data_audit_report.html index 6b42b91..a6ab618 100644 --- a/samples/output/sample_messy_data_audit_report.html +++ b/samples/output/sample_messy_data_audit_report.html @@ -217,6 +217,11 @@ flex-shrink: 0; } .score-ring svg { display: block; transform: rotate(-90deg); } +.score-scale { + font-size: 12px; + color: var(--text-secondary, #595959); + margin-top: 6px; +} .score-ring .score-value { position: absolute; top: 50%; @@ -414,7 +419,7 @@

Data Hygiene Audit Report

-

sample_messy_data.xlsx — 2026-07-27 16:58:34

+

sample_messy_data.xlsx — 2026-08-05 20:45:07

@@ -431,6 +436,7 @@

Data Hygiene Audit Report

Significant Issues
This dataset has serious quality problems.
+
Health score, 0–100 — 90+ clean · 70–89 needs attention · 40–69 significant issues · below 40 critical
@@ -489,7 +495,7 @@

Sheet: Customers lambda x: "coded" if isinstance(x, str) and "-" in x else "numeric" )

-
+
FirstName name @@ -522,7 +528,7 @@

Sheet: Customers )

Medium Suspicious repetition — "Doe" appears 3 times (11.5%)
Why this matters: When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.
Suggested Fix (flag_repetitions)
Flag 3 rows where "LastName" = "Doe" (11.5%) for manual review
df["_LastName_review"] = (
     df["LastName"] == "Doe"
 )
-
+
Email email @@ -542,7 +548,7 @@

Sheet: Customers )

Medium Suspicious repetition — "test@test.com" appears 3 times (11.5%)
Why this matters: When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.
Suggested Fix (flag_repetitions)
Flag 3 rows where "Email" = "test@test.com" (11.5%) for manual review
df["_Email_review"] = (
     df["Email"] == "test@test.com"
 )
-
+
Phone phone @@ -566,7 +572,7 @@

Sheet: Customers )

Medium Suspicious repetition — "555-555-5555" appears 3 times (11.5%)
Why this matters: When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.
Suggested Fix (flag_repetitions)
Flag 3 rows where "Phone" = "555-555-5555" (11.5%) for manual review
df["_Phone_review"] = (
     df["Phone"] == "555-555-5555"
 )
-
+
JoinDate date @@ -585,7 +591,7 @@

Sheet: Customers )

Medium Suspicious repetition — "2023-01-15" appears 3 times (11.5%)
Why this matters: When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.
Suggested Fix (flag_repetitions)
Flag 3 rows where "JoinDate" = "2023-01-15" (11.5%) for manual review
df["_JoinDate_review"] = (
     df["JoinDate"] == "2023-01-15"
 )
-
+
AccountBalance currency @@ -609,7 +615,7 @@

Sheet: Customers )

Medium Suspicious repetition — "$1,250.00" appears 3 times (11.5%)
Why this matters: When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.
Suggested Fix (flag_repetitions)
Flag 3 rows where "AccountBalance" = "$1,250.00" (11.5%) for manual review
df["_AccountBalance_review"] = (
     df["AccountBalance"] == "$1,250.00"
 )
-
+
Status categorical @@ -766,7 +772,7 @@

Sheet: Orders df["Amount"].str.replace(r"[^\d.]", "", regex=True) .astype(float) )

-
+
ShipDate date @@ -780,7 +786,7 @@

Sheet: Orders
6 distinct  |  75.0% unique  |  avg len 9.9
Low High missing rate — 2 of 10 values missing (20.0%)
Why this matters: High rates of missing data reduce the reliability of any analysis built on this field. Missing values can skew averages, break joins between tables, and cause downstream systems to error out or produce incomplete results.
Suggested Fix (fill_missing)
Fill 2 missing values in "ShipDate" (20.0%)
df["ShipDate"] = df["ShipDate"].fillna(df["ShipDate"].mode()[0])
High Mixed date formats — 3 of 8 values deviate from YYYY-MM-DD
FormatCount
YYYY-MM-DD5
MM/DD/YYYY1
Mon DD, YYYY1
M/D/YYYY1
Why this matters: Mixed date formats cause sorting failures, broken filters, and incorrect calculations. A date stored as text ("Jan 15, 2023") won't sort chronologically next to "2023-01-15". Downstream tools, APIs, and reports will misparse or reject inconsistent dates.
Suggested Fix (normalize_dates)
Standardize all dates in "ShipDate" to YYYY-MM-DD format
df["ShipDate"] = pd.to_datetime(
     df["ShipDate"], format="mixed", dayfirst=False
 ).dt.strftime("%Y-%m-%d")

-
+
Status categorical @@ -812,7 +818,7 @@

Sheet: Orders OrderIDCustomerIDOrderDateAmountShipDateStatus ORD-006CUST-0102023-01-01$0.002023-01-01TestORD-007CUST-0102023-01-01$0.002023-01-01Test
Why this matters: Exact duplicate rows are the clearest sign of a data quality issue — they can result from double-submissions, ETL failures, or missing unique constraints. Every duplicate inflates counts and distorts any metric built on this data.
Suggested Fix (drop_exact_duplicates)
Remove 2 exact duplicate rows (rows 7, 8)
df = df.drop_duplicates(keep="first").reset_index(drop=True)