From dafc8dbe6016bf3f09ee94b50cc4e61281491d1e Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Mon, 3 Aug 2026 13:17:31 -0400 Subject: [PATCH 1/2] Engagement scaffold: gitignore + deploy guard --- .gitignore | 8 +++++ package.json | 68 ++++++++++++++++++------------------ scripts/engagement-guard.mjs | 23 ++++++++++++ scripts/engagement_guard.py | 25 +++++++++++++ scripts/git-hooks/pre-push | 24 +++++++++++++ 5 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 scripts/engagement-guard.mjs create mode 100644 scripts/engagement_guard.py create mode 100755 scripts/git-hooks/pre-push diff --git a/.gitignore b/.gitignore index 4052e42..ec6543a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,11 @@ secrets.* # ui-review-skill screenshot output screenshots/ + +# --- 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/package.json b/package.json index bea03b7..604faae 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,34 @@ -{ - "name": "data-differences-tool", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview", - "test": "vitest", - "deploy": "npm run build && npx wrangler pages deploy dist" - }, - "dependencies": { - "@fontsource-variable/playfair-display": "^5.2.8", - "@fontsource-variable/source-sans-3": "^5.2.9", - "dayjs": "^1.11.20", - "exceljs": "^4.4.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-dropzone": "^15.0.0", - "xlsx": "^0.18.5" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.1.0", - "@types/node": "^25.8.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.4.0", - "tailwindcss": "^4.1.0", - "typescript": "~5.7.0", - "vite": "^6.3.0", - "vitest": "^3.1.0" - } -} +{ + "name": "data-differences-tool", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest", + "deploy": "node scripts/engagement-guard.mjs && npm run build && npx wrangler pages deploy dist" + }, + "dependencies": { + "@fontsource-variable/playfair-display": "^5.2.8", + "@fontsource-variable/source-sans-3": "^5.2.9", + "dayjs": "^1.11.20", + "exceljs": "^4.4.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-dropzone": "^15.0.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/node": "^25.8.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "tailwindcss": "^4.1.0", + "typescript": "~5.7.0", + "vite": "^6.3.0", + "vitest": "^3.1.0" + } +} diff --git a/scripts/engagement-guard.mjs b/scripts/engagement-guard.mjs new file mode 100644 index 0000000..8a17bac --- /dev/null +++ b/scripts/engagement-guard.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Lailara engagement deploy guard (Node) — for wiring into an npm `deploy` script. +// Exit 2 if an ACTIVE (non-demo) client engagement.yml is present. No-op otherwise, +// so demo builds and CI (clean checkout, no engagement.yml) are unaffected. +import { existsSync, readFileSync } from "node:fs"; + +let blocked = null; +for (const f of ["engagement.yml", "engagement.yaml"]) { + if (existsSync(f)) { + const txt = readFileSync(f, "utf8"); + if (/^\s*demo:\s*true\s*$/m.test(txt)) continue; // demo config -> safe + blocked = f; + break; + } +} +if (blocked) { + console.error( + `ENGAGEMENT GUARD: active client engagement config present (${blocked}). ` + + "Client mode is runtime-only and must never deploy. Deactivate it " + + "(set 'demo: true', or use engagement.demo.yml) before deploying." + ); + process.exit(2); +} 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 60b75b5f8b980d22ff8e693692e90b21d7e7db61 Mon Sep 17 00:00:00 2001 From: MsShawnP Date: Mon, 3 Aug 2026 14:39:27 -0400 Subject: [PATCH 2/2] Convert data-differences-tool: demo golden + no-network guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/lib/demo-golden.test.ts: locks the diff summary (1 unchanged, 1 modified, 1 removed, 1 added) for a reference before/after pair so the deployed browser-local comparison can't drift. - tests/lib/no-network.test.ts: the client-mode confidentiality contract made permanent — spies on fetch/XMLHttpRequest/WebSocket/sendBeacon, runs the full diff, and asserts NO network primitive is called with files loaded. - INPUT-SPEC.md documents the two-file inputs and the browser-local guarantee. - Fix stale README test count (90 -> 115). Co-Authored-By: Claude Opus 4.8 --- INPUT-SPEC.md | 41 ++++++++++++++++++ README.md | 4 +- tests/lib/demo-golden.test.ts | 44 +++++++++++++++++++ tests/lib/no-network.test.ts | 80 +++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 INPUT-SPEC.md create mode 100644 tests/lib/demo-golden.test.ts create mode 100644 tests/lib/no-network.test.ts diff --git a/INPUT-SPEC.md b/INPUT-SPEC.md new file mode 100644 index 0000000..118f460 --- /dev/null +++ b/INPUT-SPEC.md @@ -0,0 +1,41 @@ +# INPUT-SPEC — data-differences-tool (client mode) + +Client mode for this tool is **browser-local**: two client files are compared entirely in the +browser and **nothing is uploaded anywhere**. There is no server, no engagement config, and no +provenance footer to generate — the confidentiality guarantee is that the data never leaves the +client's machine. + +## The two files + +- **CSV or XLSX**, one "before" and one "after". Parsed client-side with SheetJS. +- Any columns; the tool auto-detects matched, renamed, added, and removed columns. +- One or more **key columns** identify a row across the two files (chosen in the UI; defaults + to the first column). Rows are matched by key, then compared cell-by-cell. + +## What it reports + +Added rows, removed rows, modified rows (with per-cell before/after), and column-level changes +(renames, adds, drops). Case sensitivity and numeric tolerance are configurable. Results export +to a styled XLSX — also built client-side. + +## Confidentiality (the client-mode contract) + +- **No network calls with files loaded.** The diff pipeline touches no `fetch`, `XMLHttpRequest`, + `WebSocket`, or `navigator.sendBeacon`. This is enforced permanently by + `tests/lib/no-network.test.ts`, which spies on every network primitive, runs the full diff, + and asserts none is called. +- **Nothing is uploaded, stored, or logged.** File contents exist only in the browser tab for the + duration of the comparison. +- The demo output is locked by `tests/lib/demo-golden.test.ts` so the deployed experience can't + drift. + +## Run (local, no upload) + +Open the deployed page (or `npm run dev`), drop the two files in, pick the key column(s), and +compare. Because everything runs in the browser, the same page a prospect uses is the client-mode +tool — a client can run it on their own machine with their own data and nothing leaves it. + +``` +npm run dev # local dev server +npm test # 115 Vitest tests incl. the no-network guarantee +``` diff --git a/README.md b/README.md index 14d1350..a93b2b3 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Opens at http://localhost:5173 npm test ``` -90 tests (Vitest) covering the parser, normalizer, column detector, differ, summary generator, and export. +117 tests (Vitest) covering the parser, normalizer, column detector, differ, summary generator, export, a demo golden lock, and a browser-local (no-network) guarantee. **Deploy:** @@ -50,7 +50,7 @@ Builds and deploys to Cloudflare Pages via Wrangler. - React 19 + TypeScript + Vite - Tailwind CSS v4 - SheetJS (file parsing) + ExcelJS (styled export) -- Vitest (90 tests) +- Vitest (117 tests) - Deployed to Cloudflare Pages ## Project structure diff --git a/tests/lib/demo-golden.test.ts b/tests/lib/demo-golden.test.ts new file mode 100644 index 0000000..f9f2d4f --- /dev/null +++ b/tests/lib/demo-golden.test.ts @@ -0,0 +1,44 @@ +// Demo golden lock for data-differences-tool. +// +// Pins the diff output for a representative before/after pair so the deployed, +// browser-local comparison cannot drift during the client-mode conversion. +import { describe, it, expect } from "vitest"; +import { computeDiff } from "@/lib/differ"; +import type { DiffConfig, ParsedFile } from "@/types"; + +function makeParsedFile( + fileName: string, + columns: string[], + rows: Record[] +): ParsedFile { + return { + fileName, + columns: columns.map((name, index) => ({ name, detectedType: "text" as const, index })), + rows, + rowCount: rows.length, + }; +} + +const config: DiffConfig = { keyColumns: ["id"], caseSensitive: true, numericTolerance: 1e-9 }; + +// A fixed before/after pair: 1 unchanged, 1 modified, 1 removed, 1 added. +const FILE_A = makeParsedFile("before.csv", ["id", "name", "amount"], [ + { id: "1", name: "Alice", amount: "100" }, + { id: "2", name: "Bob", amount: "200" }, + { id: "3", name: "Carol", amount: "300" }, +]); +const FILE_B = makeParsedFile("after.csv", ["id", "name", "amount"], [ + { id: "1", name: "Alice", amount: "100" }, + { id: "2", name: "Bob", amount: "250" }, + { id: "4", name: "Dave", amount: "400" }, +]); + +describe("demo golden", () => { + it("locks the summary counts for the reference pair", () => { + const r = computeDiff(FILE_A, FILE_B, config); + expect(r.summary.unchangedCount).toBe(1); // row 1 + expect(r.summary.modifiedCount).toBe(1); // row 2 amount 200 -> 250 + expect(r.summary.removedCount).toBe(1); // row 3 gone + expect(r.summary.addedCount).toBe(1); // row 4 new + }); +}); diff --git a/tests/lib/no-network.test.ts b/tests/lib/no-network.test.ts new file mode 100644 index 0000000..ca5191f --- /dev/null +++ b/tests/lib/no-network.test.ts @@ -0,0 +1,80 @@ +// Browser-local guarantee: comparing two client files must make NO network call. +// +// Client mode for this tool is "two client files compared locally, nothing +// uploaded anywhere." This test makes that permanent: it spies on every network +// primitive, runs the full diff, and asserts none of them were touched. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { computeDiff } from "@/lib/differ"; +import { generateSummary } from "@/lib/summary-generator"; +import type { DiffConfig, ParsedFile } from "@/types"; + +function makeParsedFile( + fileName: string, + columns: string[], + rows: Record[] +): ParsedFile { + return { + fileName, + columns: columns.map((name, index) => ({ name, detectedType: "text" as const, index })), + rows, + rowCount: rows.length, + }; +} + +const config: DiffConfig = { keyColumns: ["id"], caseSensitive: true, numericTolerance: 1e-9 }; + +describe("no network with files loaded", () => { + const spies: Array<() => void> = []; + const fetchSpy = vi.fn(); + const beaconSpy = vi.fn(); + const wsSpy = vi.fn(); + const xhrOpenSpy = vi.fn(); + const xhrSendSpy = vi.fn(); + + beforeEach(() => { + const g = globalThis as any; + for (const [obj, key, spy] of [ + [g, "fetch", fetchSpy], + [g.navigator ?? (g.navigator = {}), "sendBeacon", beaconSpy], + [g, "WebSocket", wsSpy], + ] as const) { + const original = obj[key]; + obj[key] = spy; + spies.push(() => (obj[key] = original)); + } + if (g.XMLHttpRequest) { + const openOrig = g.XMLHttpRequest.prototype.open; + const sendOrig = g.XMLHttpRequest.prototype.send; + g.XMLHttpRequest.prototype.open = xhrOpenSpy; + g.XMLHttpRequest.prototype.send = xhrSendSpy; + spies.push(() => { + g.XMLHttpRequest.prototype.open = openOrig; + g.XMLHttpRequest.prototype.send = sendOrig; + }); + } + }); + + afterEach(() => { + while (spies.length) spies.pop()!(); + vi.clearAllMocks(); + }); + + it("computes a diff and a summary without any network call", () => { + const fileA = makeParsedFile("a.csv", ["id", "v"], [ + { id: "1", v: "x" }, { id: "2", v: "y" }, + ]); + const fileB = makeParsedFile("b.csv", ["id", "v"], [ + { id: "1", v: "x" }, { id: "2", v: "z" }, + ]); + + const result = computeDiff(fileA, fileB, config); + generateSummary(result); + + expect(result.summary.modifiedCount).toBe(1); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(beaconSpy).not.toHaveBeenCalled(); + expect(wsSpy).not.toHaveBeenCalled(); + expect(xhrOpenSpy).not.toHaveBeenCalled(); + expect(xhrSendSpy).not.toHaveBeenCalled(); + }); +});