Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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)
41 changes: 41 additions & 0 deletions INPUT-SPEC.md
Original file line number Diff line number Diff line change
@@ -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
```
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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
Expand Down
68 changes: 34 additions & 34 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
23 changes: 23 additions & 0 deletions scripts/engagement-guard.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
25 changes: 25 additions & 0 deletions scripts/engagement_guard.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions scripts/git-hooks/pre-push
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions tests/lib/demo-golden.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]
): 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
});
});
80 changes: 80 additions & 0 deletions tests/lib/no-network.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]
): 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();
});
});
Loading