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 +97 -1
- package/dist/adapters/mysql.d.ts +21 -0
- package/dist/adapters/mysql.js +72 -0
- package/dist/adapters/sqlite.d.ts +18 -0
- package/dist/adapters/sqlite.js +49 -0
- package/dist/cli.js +47 -13
- package/dist/format.js +11 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/mcp.js +39 -7
- package/dist/scan.d.ts +8 -0
- package/dist/scan.js +42 -0
- package/dist/similarity.d.ts +34 -0
- package/dist/similarity.js +81 -0
- package/dist/types.d.ts +20 -0
- package/dist/ui.d.ts +19 -0
- package/dist/ui.js +80 -0
- package/package.json +15 -5
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
|
+
[](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://
|
|
15
|
-
|
|
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://
|
|
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 (
|
|
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
|
-
|
|
42
|
-
|
|
44
|
+
const table = v[`${kind}-table`];
|
|
45
|
+
const needsTable = () => {
|
|
43
46
|
if (!table)
|
|
44
|
-
throw new Error(`--${kind}-table is required for a
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
}
|