shadowjoin 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,18 @@ npx shadowjoin scan \
17
17
  --right crm_export.csv --right-key account_name
18
18
  ```
19
19
 
20
+ [![shadowjoin measuring a country-name join: 205 of 249 matched exactly, which looks fine, while five real matches were being dropped including Vietnam, spelled Viet Nam by the UN](assets/brag.gif)](https://github.com/harsh-chandak/shadowjoin/blob/main/assets/brag.mp4)
21
+
22
+ <sub>Fourteen seconds, no sound. Every figure in it is verbatim CLI output from
23
+ the committed example below — [higher-resolution
24
+ version](https://github.com/harsh-chandak/shadowjoin/blob/main/assets/brag.mp4).</sub>
25
+
26
+ The progress output above is real: shadowjoin shows what it is doing while it
27
+ works. All of it is suppressed automatically when stdout is not a terminal, so
28
+ piping to a file, to `jq`, or running in CI produces byte-identical output to
29
+ before — and there is **no postinstall script**, because a tool you point at a
30
+ production database has no business running code at install time.
31
+
20
32
  ## What it found on public data
21
33
 
22
34
  Joining the **S&P 500 constituent list** against **NYSE + Nasdaq listings** on
@@ -54,6 +66,58 @@ node dist/cli.js scan \
54
66
  --right examples/listings.json --right-key name --right-id symbol --right-truth symbol
55
67
  ```
56
68
 
69
+ ### A join you would actually ship
70
+
71
+ The example above is deliberately unflattering — a short index name against a
72
+ full security description. Here is the realistic case.
73
+
74
+ **Unicode CLDR country names** (what your UI displays) joined to **UN official
75
+ country names** (what your reference data uses), on the country name, with ISO
76
+ 3166 alpha-3 as ground truth. That is a join people write every week.
77
+
78
+ ```
79
+ exact join matches 205 / 249 82.33%
80
+ recovered by shadow join 5 2.01% precision 100%
81
+ still unexplained 39
82
+
83
+ rules worth adopting:
84
+ 1. & -> and +4
85
+ 2. remove all whitespace +1
86
+ ```
87
+
88
+ It looks healthy. 82% matched, no errors, nobody investigating. And it is
89
+ silently dropping `Antigua & Barbuda`, `Trinidad & Tobago`,
90
+ `São Tomé & Príncipe`, `Turks & Caicos Islands` and `Vietnam` — the last one
91
+ because the UN spells it `Viet Nam`.
92
+
93
+ Two things this example shows that the first one does not:
94
+
95
+ **Applying every rule finds nothing.** The full rule set recovers zero here; the
96
+ two-rule subset recovers five. Over-normalization collapses keys that should stay
97
+ distinct, and shadowjoin names the better subset instead of letting you assume
98
+ more rules is better.
99
+
100
+ **It separates mechanical misses from semantic ones.** The 39 it cannot explain
101
+ are not normalization problems at all:
102
+
103
+ ```
104
+ Vatican City vs Holy See
105
+ North Korea vs Democratic People's Republic of Korea
106
+ Cape Verde vs Cabo Verde
107
+ Bosnia vs Bosnia and Herzegovina
108
+ Hong Kong vs China, Hong Kong Special Administrative Region
109
+ ```
110
+
111
+ No string transform recovers those. They need an alias table or a real entity
112
+ resolution library. Knowing *which* of your misses are mechanical and which are
113
+ semantic is the difference between a one-line fix and a project.
114
+
115
+ ```bash
116
+ node dist/cli.js scan \
117
+ --left examples/countries_ui.csv --left-key country --left-truth iso3 \
118
+ --right examples/countries_reference.csv --right-key country --right-truth iso3
119
+ ```
120
+
57
121
  > That particular join is deliberately unflattering — a short index name against a
58
122
  > full security description. It is a worked example of the *measurement*, not a
59
123
  > claim that anyone ships that join. Point it at your own data to get a figure
@@ -119,9 +183,28 @@ npx shadowjoin scan \
119
183
  # in CI: fail the build if the floor climbs above 2%
120
184
  npx shadowjoin scan --left a.csv --left-key name --right b.csv --right-key name --max-loss 2
121
185
 
186
+ # MySQL and SQLite work the same way
187
+ npx shadowjoin scan \
188
+ --left 'mysql://ro@host/db' --left-table accounts --left-key company_name \
189
+ --right ./reference.sqlite --right-table vendors --right-key name
190
+
122
191
  npx shadowjoin rules # list the normalization rules
123
192
  ```
124
193
 
194
+ **Sources:** CSV, JSON, Postgres, MySQL and SQLite. SQLite uses Node's built-in
195
+ `node:sqlite` (Node 22.5+), so it needs nothing installed; Postgres and MySQL
196
+ need `pg` or `mysql2`, which are optional peers.
197
+
198
+ **Anything else** works through the library in three lines, because the core
199
+ takes two arrays rather than a connection:
200
+
201
+ ```js
202
+ import { scan } from "shadowjoin";
203
+ const [rows] = await anyDriver.query("SELECT id, company_name FROM accounts");
204
+ const left = rows.map((r) => ({ id: String(r.id), key: r.company_name }));
205
+ scan(left, right);
206
+ ```
207
+
125
208
  ### Library
126
209
 
127
210
  ```js
@@ -136,6 +219,19 @@ const key = (value) => normalize(value, rules);
136
219
  toSql("company_name", rules); // the same thing as Postgres SQL
137
220
  ```
138
221
 
222
+ ### Claude Code plugin (easiest)
223
+
224
+ One command installs the skill and the MCP server together, and prompts for your
225
+ config path:
226
+
227
+ ```bash
228
+ claude plugin marketplace add harsh-chandak/shadowjoin
229
+ claude plugin install shadowjoin@harsh-chandak
230
+ ```
231
+
232
+ Also listed in the [official MCP registry](https://registry.modelcontextprotocol.io)
233
+ as `io.github.harsh-chandak/shadowjoin`.
234
+
139
235
  ### MCP server
140
236
 
141
237
  Lets an agent measure a join, look at real examples, propose a fix, and then
@@ -147,7 +243,7 @@ not something it writes.
147
243
  "mcpServers": {
148
244
  "shadowjoin": {
149
245
  "command": "npx",
150
- "args": ["-y", "-p", "shadowjoin", "shadowjoin-mcp"],
246
+ "args": ["-y", "-p", "shadowjoin", "-p", "@modelcontextprotocol/sdk", "shadowjoin-mcp"],
151
247
  "env": { "SHADOWJOIN_CONFIG": "/abs/path/to/shadowjoin.config.json" }
152
248
  }
153
249
  }
@@ -0,0 +1,21 @@
1
+ import type { Row } from "../types.js";
2
+ export interface MysqlSource {
3
+ /** mysql:// connection string. Prefer a replica and a read-only user. */
4
+ connectionString: string;
5
+ table: string;
6
+ keyColumn: string;
7
+ idColumn?: string;
8
+ truthColumn?: string;
9
+ limit?: number;
10
+ /** Per-statement cap in ms. Default 30000. */
11
+ statementTimeoutMs?: number;
12
+ /** shadowjoin refuses a user that can write to the table it is about to read. */
13
+ allowWritableRole?: boolean;
14
+ }
15
+ /**
16
+ * Read join keys out of MySQL inside a READ ONLY transaction with a statement
17
+ * timeout, and refuse a user that holds write privileges on the target.
18
+ *
19
+ * Requires the optional peer dependency `mysql2`.
20
+ */
21
+ export declare function rowsFromMysql(source: MysqlSource): Promise<Row[]>;
@@ -0,0 +1,72 @@
1
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
2
+ function quoteIdent(name) {
3
+ const parts = name.split(".");
4
+ for (const part of parts) {
5
+ if (!IDENT.test(part)) {
6
+ throw new Error(`Refusing identifier "${name}". Use a plain table or column name; shadowjoin does not accept arbitrary SQL.`);
7
+ }
8
+ }
9
+ return parts.map((p) => `\`${p}\``).join(".");
10
+ }
11
+ /**
12
+ * Read join keys out of MySQL inside a READ ONLY transaction with a statement
13
+ * timeout, and refuse a user that holds write privileges on the target.
14
+ *
15
+ * Requires the optional peer dependency `mysql2`.
16
+ */
17
+ export async function rowsFromMysql(source) {
18
+ let mysql;
19
+ try {
20
+ mysql = await import("mysql2/promise");
21
+ }
22
+ catch {
23
+ throw new Error("The MySQL adapter needs the optional peer dependency `mysql2`. Install it with: npm i mysql2");
24
+ }
25
+ const table = quoteIdent(source.table);
26
+ const key = quoteIdent(source.keyColumn);
27
+ const id = source.idColumn ? quoteIdent(source.idColumn) : null;
28
+ const truth = source.truthColumn ? quoteIdent(source.truthColumn) : null;
29
+ const limit = Math.max(1, Math.min(source.limit ?? 100_000, 5_000_000));
30
+ const timeout = source.statementTimeoutMs ?? 30_000;
31
+ const conn = await mysql.createConnection(source.connectionString);
32
+ try {
33
+ if (!source.allowWritableRole) {
34
+ const [grants] = await conn.query("SHOW GRANTS FOR CURRENT_USER()");
35
+ const writable = grants.some((g) => {
36
+ const text = String(Object.values(g)[0] ?? "").toUpperCase();
37
+ if (!/\bON\b/.test(text))
38
+ return false;
39
+ return /\b(ALL PRIVILEGES|INSERT|UPDATE|DELETE)\b/.test(text);
40
+ });
41
+ if (writable) {
42
+ throw new Error(`The connected MySQL user holds write privileges. shadowjoin only reads — use a SELECT-only user (see the README) or pass allowWritableRole: true to override.`);
43
+ }
44
+ }
45
+ await conn.query(`SET SESSION max_execution_time = ${Number(timeout)}`);
46
+ await conn.query("START TRANSACTION READ ONLY");
47
+ const select = [
48
+ `${key} AS \`key\``,
49
+ id ? `CAST(${id} AS CHAR) AS \`id\`` : `CAST(ROW_NUMBER() OVER () AS CHAR) AS \`id\``,
50
+ truth ? `CAST(${truth} AS CHAR) AS \`truth\`` : `NULL AS \`truth\``,
51
+ ].join(", ");
52
+ const [rows] = await conn.query(`SELECT ${select} FROM ${table} WHERE ${key} IS NOT NULL LIMIT ${limit}`);
53
+ await conn.query("COMMIT");
54
+ return rows
55
+ .filter((r) => String(r.key ?? "").trim() !== "")
56
+ .map((r) => ({
57
+ id: String(r.id),
58
+ key: String(r.key).trim(),
59
+ ...(r.truth ? { truth: String(r.truth).trim() } : {}),
60
+ }));
61
+ }
62
+ catch (err) {
63
+ try {
64
+ await conn.query("ROLLBACK");
65
+ }
66
+ catch { /* connection may already be gone */ }
67
+ throw err;
68
+ }
69
+ finally {
70
+ await conn.end();
71
+ }
72
+ }
@@ -0,0 +1,18 @@
1
+ import type { Row } from "../types.js";
2
+ export interface SqliteSource {
3
+ /** Path to the database file. */
4
+ file: string;
5
+ table: string;
6
+ keyColumn: string;
7
+ idColumn?: string;
8
+ truthColumn?: string;
9
+ limit?: number;
10
+ }
11
+ /**
12
+ * Read join keys out of a SQLite file. Opens the database READ ONLY.
13
+ *
14
+ * Uses Node's built-in `node:sqlite`, so there is nothing to install — but that
15
+ * module landed in Node 22.5, so older runtimes get a clear error rather than a
16
+ * confusing one.
17
+ */
18
+ export declare function rowsFromSqlite(source: SqliteSource): Promise<Row[]>;
@@ -0,0 +1,49 @@
1
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
2
+ function quoteIdent(name) {
3
+ if (!IDENT.test(name)) {
4
+ throw new Error(`Refusing identifier "${name}". Use a plain table or column name; shadowjoin does not accept arbitrary SQL.`);
5
+ }
6
+ return `"${name}"`;
7
+ }
8
+ /**
9
+ * Read join keys out of a SQLite file. Opens the database READ ONLY.
10
+ *
11
+ * Uses Node's built-in `node:sqlite`, so there is nothing to install — but that
12
+ * module landed in Node 22.5, so older runtimes get a clear error rather than a
13
+ * confusing one.
14
+ */
15
+ export async function rowsFromSqlite(source) {
16
+ let DatabaseSync;
17
+ try {
18
+ ({ DatabaseSync } = await import("node:sqlite"));
19
+ }
20
+ catch {
21
+ throw new Error(`The SQLite adapter needs Node's built-in node:sqlite, added in Node 22.5. This process is ${process.version}. Upgrade Node, or export the table to CSV and point shadowjoin at that.`);
22
+ }
23
+ const table = quoteIdent(source.table);
24
+ const key = quoteIdent(source.keyColumn);
25
+ const id = source.idColumn ? quoteIdent(source.idColumn) : null;
26
+ const truth = source.truthColumn ? quoteIdent(source.truthColumn) : null;
27
+ const limit = Math.max(1, Math.min(source.limit ?? 100_000, 5_000_000));
28
+ const db = new DatabaseSync(source.file, { readOnly: true });
29
+ try {
30
+ const select = [
31
+ `${key} AS key`,
32
+ id ? `CAST(${id} AS TEXT) AS id` : `CAST(rowid AS TEXT) AS id`,
33
+ truth ? `CAST(${truth} AS TEXT) AS truth` : `NULL AS truth`,
34
+ ].join(", ");
35
+ const rows = db
36
+ .prepare(`SELECT ${select} FROM ${table} WHERE ${key} IS NOT NULL LIMIT ${limit}`)
37
+ .all();
38
+ return rows
39
+ .filter((r) => String(r.key ?? "").trim() !== "")
40
+ .map((r) => ({
41
+ id: String(r.id),
42
+ key: String(r.key).trim(),
43
+ ...(r.truth ? { truth: String(r.truth).trim() } : {}),
44
+ }));
45
+ }
46
+ finally {
47
+ db.close();
48
+ }
49
+ }
package/dist/cli.js CHANGED
@@ -6,20 +6,23 @@ import { formatReport } from "./format.js";
6
6
  import { ALL_RULE_IDS, OPTIONAL_RULES } from "./normalize.js";
7
7
  import { rowsFromCsv } from "./adapters/csv.js";
8
8
  import { rowsFromJson } from "./adapters/json.js";
9
+ import { banner, footer, isTty, Progress } from "./ui.js";
10
+ import { createRequire } from "node:module";
11
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
9
12
  const USAGE = `
10
13
  shadowjoin — measure the matches your exact join is silently dropping.
11
14
 
12
15
  npx shadowjoin scan --left <src> --left-key <col> --right <src> --right-key <col>
13
16
 
14
- Sources are .csv, .json (array of objects), or a postgres:// connection string
15
- (which also needs --left-table / --right-table).
17
+ Sources are .csv, .json (array of objects), .db/.sqlite, or a postgres:// or
18
+ mysql:// connection string. Database sources also need --left-table/--right-table.
16
19
 
17
- --left, --right file path or postgres:// connection string
20
+ --left, --right file path, or postgres:// / mysql:// URL
18
21
  --left-key, --right-key column holding the join key (required)
19
22
  --left-id, --right-id column holding a stable id
20
23
  --left-truth, --right-truth column holding ground truth; supply it on BOTH
21
24
  sides and every recovery is verified, not guessed
22
- --left-table, --right-table table name (postgres sources only)
25
+ --left-table, --right-table table name (database sources only)
23
26
 
24
27
  --rules <a,b,c> limit to these rules (default: all; see \`shadowjoin rules\`)
25
28
  --samples <n> example recoveries to print (default 20)
@@ -38,19 +41,30 @@ async function load(src, kind, v, limit) {
38
41
  if (!keyColumn)
39
42
  throw new Error(`--${kind}-key is required`);
40
43
  const opts = { keyColumn, idColumn: v[`${kind}-id`], truthColumn: v[`${kind}-truth`] };
41
- if (src.startsWith("postgres://") || src.startsWith("postgresql://")) {
42
- const table = v[`${kind}-table`];
44
+ const table = v[`${kind}-table`];
45
+ const needsTable = () => {
43
46
  if (!table)
44
- throw new Error(`--${kind}-table is required for a postgres source`);
47
+ throw new Error(`--${kind}-table is required for a database source`);
48
+ return table;
49
+ };
50
+ if (src.startsWith("postgres://") || src.startsWith("postgresql://")) {
45
51
  const { rowsFromPostgres } = await import("./adapters/postgres.js");
46
- return rowsFromPostgres({ connectionString: src, table, limit, ...opts });
52
+ return rowsFromPostgres({ connectionString: src, table: needsTable(), limit, ...opts });
53
+ }
54
+ if (src.startsWith("mysql://")) {
55
+ const { rowsFromMysql } = await import("./adapters/mysql.js");
56
+ return rowsFromMysql({ connectionString: src, table: needsTable(), limit, ...opts });
47
57
  }
48
58
  const ext = extname(src).toLowerCase();
59
+ if (ext === ".db" || ext === ".sqlite" || ext === ".sqlite3") {
60
+ const { rowsFromSqlite } = await import("./adapters/sqlite.js");
61
+ return rowsFromSqlite({ file: src, table: needsTable(), limit, ...opts });
62
+ }
49
63
  if (ext === ".json")
50
64
  return rowsFromJson(src, opts);
51
65
  if (ext === ".csv" || ext === ".tsv" || ext === "")
52
66
  return rowsFromCsv(src, opts);
53
- throw new Error(`Unsupported source "${src}". Use .csv, .json, or a postgres:// connection string.`);
67
+ throw new Error(`Unsupported source "${src}". Use .csv, .json, .db/.sqlite, or a postgres:// or mysql:// connection string.`);
54
68
  }
55
69
  async function main() {
56
70
  const { values, positionals } = parseArgs({
@@ -67,7 +81,9 @@ async function main() {
67
81
  });
68
82
  const command = positionals[0] ?? (values.left ? "scan" : "help");
69
83
  if (values.help || command === "help") {
84
+ process.stdout.write(banner(VERSION));
70
85
  console.log(USAGE);
86
+ process.stdout.write(footer(VERSION));
71
87
  return 0;
72
88
  }
73
89
  if (command === "rules") {
@@ -89,16 +105,34 @@ async function main() {
89
105
  }
90
106
  const v = values;
91
107
  const limit = values.limit ? Number(values.limit) : 100_000;
92
- const [left, right] = await Promise.all([
93
- load(values.left, "left", v, limit),
94
- load(values.right, "right", v, limit),
95
- ]);
108
+ const quiet = Boolean(values.json) || !isTty();
109
+ if (!quiet)
110
+ process.stdout.write(banner(VERSION));
111
+ const progress = new Progress();
112
+ if (!quiet)
113
+ progress.start(`reading ${values.left}`);
114
+ let left, right;
115
+ try {
116
+ left = await load(values.left, "left", v, limit);
117
+ if (!quiet)
118
+ progress.step(`reading ${values.right}`);
119
+ right = await load(values.right, "right", v, limit);
120
+ }
121
+ catch (err) {
122
+ progress.abort();
123
+ throw err;
124
+ }
96
125
  const report = scan(left, right, {
126
+ onPhase: quiet ? undefined : (label) => progress.step(label),
97
127
  rules: values.rules ? values.rules.split(",").map((s) => s.trim()).filter(Boolean) : ALL_RULE_IDS,
98
128
  maxSamplePairs: values.samples ? Number(values.samples) : 20,
99
129
  redactSamples: Boolean(values.redact),
100
130
  });
131
+ if (!quiet)
132
+ progress.stop();
101
133
  console.log(values.json ? JSON.stringify(report, null, 2) : formatReport(report));
134
+ if (!quiet)
135
+ process.stdout.write(footer(VERSION));
102
136
  if (values["max-loss"] !== undefined) {
103
137
  const threshold = Number(values["max-loss"]);
104
138
  if (report.lowerBoundLossPct > threshold) {
package/dist/format.js CHANGED
@@ -44,6 +44,17 @@ export function formatReport(report) {
44
44
  }
45
45
  out.push("");
46
46
  }
47
+ if (report.nearMisses.length) {
48
+ out.push(" closest neighbours among rows NO RULE explains (suggestions, not counted):");
49
+ const mark = { verified: "ok ", contradicted: "BAD", ambiguous: "?? ", unverifiable: " " };
50
+ for (const nm of report.nearMisses.slice(0, 10)) {
51
+ out.push(` ${mark[nm.verification]} ${pad(nm.leftKey, 34)} ~ ${pad(nm.rightKey, 38)} ${nm.similarity}`);
52
+ }
53
+ if (report.noCandidate > 0) {
54
+ out.push(` ${report.noCandidate} more have no string similarity at all — those need an alias table.`);
55
+ }
56
+ out.push("");
57
+ }
47
58
  for (const w of report.warnings)
48
59
  out.push(` ! ${w}`);
49
60
  if (report.warnings.length)
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ export { BASE_RULES, OPTIONAL_RULES, ALL_RULE_IDS, normalize, rulesByIds, toSql
4
4
  export { formatReport } from "./format.js";
5
5
  export { parseCsv, rowsFromCsv, rowsFromCsvText } from "./adapters/csv.js";
6
6
  export type { CsvOptions } from "./adapters/csv.js";
7
- export type { Report, Row, Rule, RuleContribution, SamplePair, Verification } from "./types.js";
7
+ export { trigrams, dice, TrigramIndex } from "./similarity.js";
8
+ export type { NearMiss, Report, Row, Rule, RuleContribution, SamplePair, StackStep, Verification } from "./types.js";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { scan } from "./scan.js";
2
2
  export { BASE_RULES, OPTIONAL_RULES, ALL_RULE_IDS, normalize, rulesByIds, toSql } from "./normalize.js";
3
3
  export { formatReport } from "./format.js";
4
4
  export { parseCsv, rowsFromCsv, rowsFromCsvText } from "./adapters/csv.js";
5
+ export { trigrams, dice, TrigramIndex } from "./similarity.js";
package/dist/mcp.js CHANGED
@@ -11,13 +11,36 @@
11
11
  import { readFile } from "node:fs/promises";
12
12
  import { resolve } from "node:path";
13
13
  import { randomUUID } from "node:crypto";
14
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
14
+ import { createRequire } from "node:module";
17
15
  import { scan } from "./scan.js";
18
16
  import { ALL_RULE_IDS, rulesByIds, toSql } from "./normalize.js";
19
17
  import { rowsFromCsv } from "./adapters/csv.js";
20
18
  import { rowsFromJson } from "./adapters/json.js";
19
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
20
+ /**
21
+ * The SDK is an optional peer, so the core library and CLI stay dependency-free.
22
+ * Import it dynamically to turn a missing install into an instruction rather
23
+ * than a module-resolution stack trace, which is what an MCP client shows the
24
+ * user as nothing more than "Connection closed".
25
+ */
26
+ async function loadSdk() {
27
+ try {
28
+ const [{ Server }, { StdioServerTransport }, types] = await Promise.all([
29
+ import("@modelcontextprotocol/sdk/server/index.js"),
30
+ import("@modelcontextprotocol/sdk/server/stdio.js"),
31
+ import("@modelcontextprotocol/sdk/types.js"),
32
+ ]);
33
+ return { Server, StdioServerTransport, ...types };
34
+ }
35
+ catch {
36
+ process.stderr.write("shadowjoin-mcp needs @modelcontextprotocol/sdk, which is an optional peer dependency.\n" +
37
+ "Run it with the SDK provided:\n" +
38
+ " npx -y -p shadowjoin -p @modelcontextprotocol/sdk shadowjoin-mcp\n" +
39
+ "or install it alongside shadowjoin:\n" +
40
+ " npm i shadowjoin @modelcontextprotocol/sdk\n");
41
+ process.exit(1);
42
+ }
43
+ }
21
44
  const CONFIG_PATH = resolve(process.env.SHADOWJOIN_CONFIG ?? "shadowjoin.config.json");
22
45
  async function loadConfig() {
23
46
  try {
@@ -38,11 +61,19 @@ async function loadSide(side, config, limit) {
38
61
  throw new Error(`Unknown source "${side.source}". Configured: ${Object.keys(config.sources).join(", ") || "(none)"}`);
39
62
  }
40
63
  const opts = { keyColumn: side.column, idColumn: side.idColumn, truthColumn: side.truthColumn };
41
- if (def.type === "postgres") {
64
+ if (def.type === "postgres" || def.type === "mysql" || def.type === "sqlite") {
42
65
  if (!side.table)
43
66
  throw new Error(`Source "${side.source}" is a database; "table" is required.`);
44
- const { rowsFromPostgres } = await import("./adapters/postgres.js");
45
- return rowsFromPostgres({ connectionString: def.connectionString, table: side.table, limit, ...opts });
67
+ if (def.type === "postgres") {
68
+ const { rowsFromPostgres } = await import("./adapters/postgres.js");
69
+ return rowsFromPostgres({ connectionString: def.connectionString, table: side.table, limit, ...opts });
70
+ }
71
+ if (def.type === "mysql") {
72
+ const { rowsFromMysql } = await import("./adapters/mysql.js");
73
+ return rowsFromMysql({ connectionString: def.connectionString, table: side.table, limit, ...opts });
74
+ }
75
+ const { rowsFromSqlite } = await import("./adapters/sqlite.js");
76
+ return rowsFromSqlite({ file: def.path, table: side.table, limit, ...opts });
46
77
  }
47
78
  return def.type === "json" ? rowsFromJson(def.path, opts) : rowsFromCsv(def.path, opts);
48
79
  }
@@ -125,7 +156,8 @@ const TOOLS = [
125
156
  },
126
157
  },
127
158
  ];
128
- const server = new Server({ name: "shadowjoin", version: "0.1.0" }, { capabilities: { tools: {} } });
159
+ const { Server, StdioServerTransport, CallToolRequestSchema, ListToolsRequestSchema } = await loadSdk();
160
+ const server = new Server({ name: "shadowjoin", version: VERSION }, { capabilities: { tools: {} } });
129
161
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
130
162
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
131
163
  const { name } = request.params;
package/dist/scan.d.ts CHANGED
@@ -6,6 +6,14 @@ export interface ScanOptions {
6
6
  maxSamplePairs?: number;
7
7
  /** Replace key values in samples with a short hash. Counts are unaffected. */
8
8
  redactSamples?: boolean;
9
+ /** Look for trigram neighbours among rows no rule explains. Default true. */
10
+ nearMisses?: boolean;
11
+ /** Minimum Dice score for a near miss. Default 0.45. */
12
+ nearMissThreshold?: number;
13
+ /** Cap on near misses reported. Default 25. */
14
+ maxNearMisses?: number;
15
+ /** Called as each phase begins, so a CLI can show progress. */
16
+ onPhase?: (label: string) => void;
9
17
  }
10
18
  /**
11
19
  * Compare an exact join against a shadow matcher and report what the exact
package/dist/scan.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ALL_RULE_IDS, normalize, rulesByIds } from "./normalize.js";
2
+ import { TrigramIndex } from "./similarity.js";
2
3
  /** Tickers, SKUs and ids differ cosmetically across sources; compare them loosely. */
3
4
  function canonicalTruth(value) {
4
5
  return value.replace(/[^\p{L}\p{N}]+/gu, "").toUpperCase();
@@ -54,7 +55,9 @@ export function scan(left, right, options = {}) {
54
55
  if (left.length === 0 || right.length === 0) {
55
56
  warnings.push("One side of the join is empty; there is nothing to measure.");
56
57
  }
58
+ const phase = options.onPhase ?? (() => { });
57
59
  // 1. The baseline: the join exactly as it runs today.
60
+ phase("measuring the join as it runs today");
58
61
  const exactIndex = buildIndex(right, null);
59
62
  const missed = [];
60
63
  let exactMatches = 0;
@@ -66,6 +69,7 @@ export function scan(left, right, options = {}) {
66
69
  }
67
70
  // 2. Each rule measured independently against that baseline, so the number
68
71
  // beside a rule is what the rule alone buys and not a product of ordering.
72
+ phase(`testing ${rules.length} rules against that baseline`);
69
73
  const attribution = new Map();
70
74
  const byRule = rules.map((rule) => {
71
75
  const index = buildIndex(right, [rule]);
@@ -90,6 +94,7 @@ export function scan(left, right, options = {}) {
90
94
  // 3. Greedy forward selection: which rules, in which order, actually pay.
91
95
  // Most recoveries need a combination, so per-rule figures alone are not
92
96
  // enough to act on.
97
+ phase("selecting the rules worth adopting");
93
98
  const stack = [];
94
99
  const covered = new Set();
95
100
  const chosen = [];
@@ -176,6 +181,35 @@ export function scan(left, right, options = {}) {
176
181
  }
177
182
  const recovered = attribution.size;
178
183
  const unexplained = missed.length - recovered;
184
+ // 6. The residue. No normalization rule explains these, so look for the
185
+ // closest trigram neighbour instead. Reported as advice, never counted:
186
+ // a suggestion that has not cleared the rule bar must not move the floor.
187
+ const nearMisses = [];
188
+ let noCandidate = 0;
189
+ if (options.nearMisses !== false && unexplained > 0) {
190
+ phase(`analysing the ${unexplained} rows no rule explains`);
191
+ const index = new TrigramIndex(right);
192
+ const maxNear = options.maxNearMisses ?? 25;
193
+ for (const row of missed) {
194
+ if (attribution.has(row.id))
195
+ continue;
196
+ const hit = index.best(row.key, { threshold: options.nearMissThreshold ?? 0.45 });
197
+ if (!hit) {
198
+ noCandidate++;
199
+ continue;
200
+ }
201
+ if (nearMisses.length < maxNear) {
202
+ nearMisses.push({
203
+ leftId: row.id,
204
+ leftKey: options.redactSamples ? redact(row.key) : row.key,
205
+ rightId: hit.row.id,
206
+ rightKey: options.redactSamples ? redact(hit.row.key) : hit.row.key,
207
+ similarity: hit.similarity,
208
+ verification: verify(row, [hit.row]),
209
+ });
210
+ }
211
+ }
212
+ }
179
213
  const pct = (n) => (left.length === 0 ? 0 : Number(((n / left.length) * 100).toFixed(2)));
180
214
  if (rules.length > 1) {
181
215
  warnings.push("Per-rule counts are measured independently against the exact baseline, so they overlap and will not sum to the total.");
@@ -183,6 +217,12 @@ export function scan(left, right, options = {}) {
183
217
  if (chosen.length > 0 && chosen.length < rules.length && fullStackRecovered < covered.size) {
184
218
  warnings.push(`Applying every rule recovers ${covered.size - fullStackRecovered} fewer match(es) than the best subset. Over-normalization is costing you matches — rerun with --rules ${chosen.map((r) => r.id).join(",")}`);
185
219
  }
220
+ if (nearMisses.length > 0) {
221
+ warnings.push(`${nearMisses.length} unexplained row(s) have a close trigram neighbour. These are suggestions, NOT counted in the floor — confirm them before adding an alias.`);
222
+ }
223
+ if (noCandidate > 0) {
224
+ warnings.push(`${noCandidate} unexplained row(s) have no string similarity to anything on the right (e.g. "Vatican City" vs "Holy See"). No normalization rule will ever recover those; they need an alias table.`);
225
+ }
186
226
  if (contradicted > 0) {
187
227
  warnings.push(`${contradicted} recovered match(es) are contradicted by ground truth — the shadow matcher is over-merging. Review samplePairs before acting.`);
188
228
  }
@@ -212,6 +252,8 @@ export function scan(left, right, options = {}) {
212
252
  ? null
213
253
  : Number(((verified / (verified + contradicted)) * 100).toFixed(2)),
214
254
  samplePairs,
255
+ nearMisses,
256
+ noCandidate,
215
257
  warnings,
216
258
  rulesApplied: ruleIds,
217
259
  };
@@ -0,0 +1,34 @@
1
+ import type { Row } from "./types.js";
2
+ /**
3
+ * Trigram similarity, for the rows no normalization rule explains.
4
+ *
5
+ * Implemented in-process rather than delegating to Postgres `pg_trgm`, so the
6
+ * same residue analysis runs on CSVs, on JSON and on databases that have no
7
+ * such extension.
8
+ */
9
+ /** Postgres-style padding so short strings and word starts are weighted sensibly. */
10
+ export declare function trigrams(value: string): Set<string>;
11
+ /** Sørensen–Dice: 2|A∩B| / (|A|+|B|). 1 is identical, 0 shares nothing. */
12
+ export declare function dice(a: Set<string>, b: Set<string>): number;
13
+ export interface NearMissOptions {
14
+ /** Minimum Dice score to report. Default 0.45. */
15
+ threshold?: number;
16
+ /** Cap on candidates scored per unexplained row. Default 200. */
17
+ maxCandidates?: number;
18
+ }
19
+ export interface NearMissHit {
20
+ row: Row;
21
+ similarity: number;
22
+ }
23
+ /**
24
+ * An inverted trigram index over the right-hand side. Built once, queried per
25
+ * unexplained row, so cost grows with the residue rather than with every pair.
26
+ */
27
+ export declare class TrigramIndex {
28
+ private readonly rows;
29
+ private readonly postings;
30
+ private readonly grams;
31
+ constructor(rows: Row[]);
32
+ /** Best scoring right-hand row for `key`, or null if nothing clears the bar. */
33
+ best(key: string, options?: NearMissOptions): NearMissHit | null;
34
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Trigram similarity, for the rows no normalization rule explains.
3
+ *
4
+ * Implemented in-process rather than delegating to Postgres `pg_trgm`, so the
5
+ * same residue analysis runs on CSVs, on JSON and on databases that have no
6
+ * such extension.
7
+ */
8
+ /** Postgres-style padding so short strings and word starts are weighted sensibly. */
9
+ export function trigrams(value) {
10
+ const padded = ` ${value.trim().toLowerCase()} `;
11
+ const out = new Set();
12
+ for (let i = 0; i + 3 <= padded.length; i++)
13
+ out.add(padded.slice(i, i + 3));
14
+ return out;
15
+ }
16
+ /** Sørensen–Dice: 2|A∩B| / (|A|+|B|). 1 is identical, 0 shares nothing. */
17
+ export function dice(a, b) {
18
+ if (a.size === 0 || b.size === 0)
19
+ return 0;
20
+ let shared = 0;
21
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
22
+ for (const g of small)
23
+ if (large.has(g))
24
+ shared++;
25
+ return (2 * shared) / (a.size + b.size);
26
+ }
27
+ /**
28
+ * An inverted trigram index over the right-hand side. Built once, queried per
29
+ * unexplained row, so cost grows with the residue rather than with every pair.
30
+ */
31
+ export class TrigramIndex {
32
+ rows;
33
+ postings = new Map();
34
+ grams = [];
35
+ constructor(rows) {
36
+ this.rows = rows;
37
+ rows.forEach((row, i) => {
38
+ const g = trigrams(row.key);
39
+ this.grams.push(g);
40
+ for (const gram of g) {
41
+ const bucket = this.postings.get(gram);
42
+ if (bucket)
43
+ bucket.push(i);
44
+ else
45
+ this.postings.set(gram, [i]);
46
+ }
47
+ });
48
+ }
49
+ /** Best scoring right-hand row for `key`, or null if nothing clears the bar. */
50
+ best(key, options = {}) {
51
+ const threshold = options.threshold ?? 0.45;
52
+ const maxCandidates = options.maxCandidates ?? 200;
53
+ const query = trigrams(key);
54
+ // Skipping near-universal trigrams keeps large indexes fast, but on a small
55
+ // right-hand side every bucket looks universal and the filter would discard
56
+ // everything. Only apply it once there are enough rows for it to mean something.
57
+ const universal = this.rows.length >= 20 ? this.rows.length / 2 : Infinity;
58
+ const overlap = new Map();
59
+ for (const gram of query) {
60
+ const bucket = this.postings.get(gram);
61
+ if (!bucket || bucket.length > universal)
62
+ continue;
63
+ for (const i of bucket)
64
+ overlap.set(i, (overlap.get(i) ?? 0) + 1);
65
+ }
66
+ if (overlap.size === 0)
67
+ return null;
68
+ // Score the most promising candidates only; shared-gram count bounds Dice.
69
+ const ranked = [...overlap.entries()]
70
+ .sort((a, b) => b[1] - a[1])
71
+ .slice(0, maxCandidates);
72
+ let best = null;
73
+ for (const [i] of ranked) {
74
+ const score = dice(query, this.grams[i]);
75
+ if (score >= threshold && (best === null || score > best.similarity)) {
76
+ best = { row: this.rows[i], similarity: Number(score.toFixed(3)) };
77
+ }
78
+ }
79
+ return best;
80
+ }
81
+ }
package/dist/types.d.ts CHANGED
@@ -49,6 +49,22 @@ export interface SamplePair {
49
49
  candidates: number;
50
50
  verification: Verification;
51
51
  }
52
+ /**
53
+ * A row no rule explains, paired with its closest trigram neighbour.
54
+ *
55
+ * Deliberately NOT counted in `recovered` or `lowerBoundLossPct`: these are
56
+ * unverified suggestions below the confidence bar, and letting them inflate the
57
+ * headline would turn a defensible floor into a guess.
58
+ */
59
+ export interface NearMiss {
60
+ leftId: string;
61
+ leftKey: string;
62
+ rightId: string;
63
+ rightKey: string;
64
+ /** Sørensen-Dice over character trigrams. 1 is identical. */
65
+ similarity: number;
66
+ verification: Verification;
67
+ }
52
68
  export interface Report {
53
69
  generatedAt: string;
54
70
  leftRows: number;
@@ -82,6 +98,10 @@ export interface Report {
82
98
  /** verified / (verified + contradicted), or null when no ground truth was supplied. */
83
99
  precision: number | null;
84
100
  samplePairs: SamplePair[];
101
+ /** Closest trigram neighbours for rows no rule explains. Advisory, not counted. */
102
+ nearMisses: NearMiss[];
103
+ /** Unexplained rows with no trigram neighbour at all — these need an alias table. */
104
+ noCandidate: number;
85
105
  warnings: string[];
86
106
  rulesApplied: string[];
87
107
  }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export declare const isTty: () => boolean;
2
+ export declare function banner(version: string): string;
3
+ export declare function footer(version: string): string;
4
+ /** A spinner that degrades to nothing when it isn't being watched by a human. */
5
+ export declare class Progress {
6
+ private timer;
7
+ private frame;
8
+ private label;
9
+ private readonly started;
10
+ private readonly done;
11
+ start(label: string): void;
12
+ /** Mark the current step finished and begin the next one. */
13
+ step(label: string): void;
14
+ stop(): void;
15
+ /** Clear the spinner without claiming the step succeeded. */
16
+ abort(): void;
17
+ private clear;
18
+ private paint;
19
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Terminal presentation. Everything here is silent unless stdout is a TTY, so
3
+ * piping to a file, to jq, or running in CI produces exactly the same bytes it
4
+ * always did.
5
+ */
6
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const DIM = "\x1b[2m", RESET = "\x1b[0m", BOLD = "\x1b[1m";
8
+ const BLUE = "\x1b[38;5;75m", GREY = "\x1b[38;5;244m", AMBER = "\x1b[38;5;214m";
9
+ export const isTty = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
10
+ export function banner(version) {
11
+ if (!isTty())
12
+ return "";
13
+ return [
14
+ "",
15
+ ` ${BLUE}▌${RESET}${BOLD} shadowjoin ${RESET}${GREY}v${version}${RESET}`,
16
+ ` ${BLUE}▌${RESET}${DIM} the matches your join never told you it was losing${RESET}`,
17
+ "",
18
+ ].join("\n");
19
+ }
20
+ export function footer(version) {
21
+ if (!isTty())
22
+ return "";
23
+ return ` ${GREY}shadowjoin v${version} · MIT · Harsh N. Chandak · harsh-chandak.com${RESET}\n`;
24
+ }
25
+ /** A spinner that degrades to nothing when it isn't being watched by a human. */
26
+ export class Progress {
27
+ timer = null;
28
+ frame = 0;
29
+ label = "";
30
+ started = Date.now();
31
+ done = [];
32
+ start(label) {
33
+ this.label = label;
34
+ if (!isTty())
35
+ return;
36
+ if (this.timer)
37
+ return;
38
+ this.timer = setInterval(() => this.paint(), 80);
39
+ this.timer.unref?.();
40
+ this.paint();
41
+ }
42
+ /** Mark the current step finished and begin the next one. */
43
+ step(label) {
44
+ if (isTty()) {
45
+ this.clear();
46
+ process.stdout.write(` ${BLUE}✓${RESET} ${DIM}${this.label}${RESET}\n`);
47
+ }
48
+ this.done.push(this.label);
49
+ this.label = label;
50
+ if (isTty())
51
+ this.paint();
52
+ }
53
+ stop() {
54
+ if (this.timer) {
55
+ clearInterval(this.timer);
56
+ this.timer = null;
57
+ }
58
+ if (!isTty())
59
+ return;
60
+ this.clear();
61
+ const ms = Date.now() - this.started;
62
+ process.stdout.write(` ${BLUE}✓${RESET} ${DIM}${this.label}${RESET} ${GREY}${ms}ms${RESET}\n`);
63
+ }
64
+ /** Clear the spinner without claiming the step succeeded. */
65
+ abort() {
66
+ if (this.timer) {
67
+ clearInterval(this.timer);
68
+ this.timer = null;
69
+ }
70
+ if (isTty())
71
+ this.clear();
72
+ }
73
+ clear() {
74
+ process.stdout.write("\r\x1b[2K");
75
+ }
76
+ paint() {
77
+ const f = FRAMES[this.frame++ % FRAMES.length];
78
+ process.stdout.write(`\r\x1b[2K ${AMBER}${f}${RESET} ${this.label}`);
79
+ }
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadowjoin",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Measure the matches your exact join is silently dropping. Runs a shadow matcher alongside your real join key and reports a verified floor on lost matches, plus the normalization rule that would recover each one.",
5
5
  "keywords": [
6
6
  "entity-resolution",
@@ -14,7 +14,9 @@
14
14
  "csv",
15
15
  "mcp",
16
16
  "data-validation",
17
- "recall"
17
+ "recall",
18
+ "mysql",
19
+ "sqlite"
18
20
  ],
19
21
  "license": "MIT",
20
22
  "type": "module",
@@ -30,7 +32,9 @@
30
32
  "exports": {
31
33
  ".": "./dist/index.js",
32
34
  "./mcp": "./dist/mcp.js",
33
- "./adapters/postgres": "./dist/adapters/postgres.js"
35
+ "./adapters/postgres": "./dist/adapters/postgres.js",
36
+ "./adapters/mysql": "./dist/adapters/mysql.js",
37
+ "./adapters/sqlite": "./dist/adapters/sqlite.js"
34
38
  },
35
39
  "files": [
36
40
  "dist",
@@ -40,11 +44,12 @@
40
44
  ],
41
45
  "scripts": {
42
46
  "build": "tsc",
43
- "test": "node --test test/",
47
+ "test": "node --test test/*.test.js",
44
48
  "prepublishOnly": "npm run build"
45
49
  },
46
50
  "peerDependencies": {
47
51
  "@modelcontextprotocol/sdk": ">=1",
52
+ "mysql2": ">=3",
48
53
  "pg": ">=8"
49
54
  },
50
55
  "peerDependenciesMeta": {
@@ -53,12 +58,16 @@
53
58
  },
54
59
  "@modelcontextprotocol/sdk": {
55
60
  "optional": true
61
+ },
62
+ "mysql2": {
63
+ "optional": true
56
64
  }
57
65
  },
58
66
  "devDependencies": {
59
67
  "@modelcontextprotocol/sdk": "^1.32.1",
60
68
  "@types/node": "^22.0.0",
61
69
  "@types/pg": "^8.23.1",
70
+ "mysql2": "^3.24.5",
62
71
  "typescript": "^5.6.0"
63
72
  },
64
73
  "repository": {
@@ -69,5 +78,6 @@
69
78
  "author": "Harsh N. Chandak",
70
79
  "bugs": {
71
80
  "url": "https://github.com/harsh-chandak/shadowjoin/issues"
72
- }
81
+ },
82
+ "mcpName": "io.github.harsh-chandak/shadowjoin"
73
83
  }