shadowjoin 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +324 -2
- package/SKILL.md +66 -0
- package/dist/adapters/csv.d.ts +13 -0
- package/dist/adapters/csv.js +73 -0
- package/dist/adapters/json.d.ts +4 -0
- package/dist/adapters/json.js +25 -0
- package/dist/adapters/mysql.d.ts +21 -0
- package/dist/adapters/mysql.js +72 -0
- package/dist/adapters/postgres.d.ts +28 -0
- package/dist/adapters/postgres.js +72 -0
- package/dist/adapters/sqlite.d.ts +18 -0
- package/dist/adapters/sqlite.js +49 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +145 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +63 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +229 -0
- package/dist/normalize.d.ts +19 -0
- package/dist/normalize.js +102 -0
- package/dist/scan.d.ts +25 -0
- package/dist/scan.js +260 -0
- package/dist/similarity.d.ts +34 -0
- package/dist/similarity.js +81 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +19 -0
- package/dist/ui.js +80 -0
- package/package.json +81 -4
|
@@ -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 Postgres. Opens a READ ONLY transaction with a
|
|
13
|
+
* statement and lock timeout, so it cannot write, cannot block a writer, and
|
|
14
|
+
* cannot run away with your CPU.
|
|
15
|
+
*
|
|
16
|
+
* Requires the optional peer dependency `pg`.
|
|
17
|
+
*/
|
|
18
|
+
export async function rowsFromPostgres(source) {
|
|
19
|
+
let pg;
|
|
20
|
+
try {
|
|
21
|
+
pg = await import("pg");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error("The Postgres adapter needs the optional peer dependency `pg`. Install it with: npm i pg");
|
|
25
|
+
}
|
|
26
|
+
const table = quoteIdent(source.table);
|
|
27
|
+
const key = quoteIdent(source.keyColumn);
|
|
28
|
+
const id = source.idColumn ? quoteIdent(source.idColumn) : null;
|
|
29
|
+
const truth = source.truthColumn ? quoteIdent(source.truthColumn) : null;
|
|
30
|
+
const limit = Math.max(1, Math.min(source.limit ?? 100_000, 5_000_000));
|
|
31
|
+
const timeout = source.statementTimeoutMs ?? 30_000;
|
|
32
|
+
const client = new pg.Client({ connectionString: source.connectionString });
|
|
33
|
+
await client.connect();
|
|
34
|
+
try {
|
|
35
|
+
if (!source.allowWritableRole) {
|
|
36
|
+
const { rows } = await client.query(`SELECT (has_table_privilege($1,'INSERT')
|
|
37
|
+
OR has_table_privilege($1,'UPDATE')
|
|
38
|
+
OR has_table_privilege($1,'DELETE')) AS writable`, [source.table]);
|
|
39
|
+
if (rows[0]?.writable) {
|
|
40
|
+
throw new Error(`The connected role can write to ${source.table}. shadowjoin only reads — point it at a read-only role (see the README) or pass allowWritableRole: true to override.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await client.query("BEGIN TRANSACTION READ ONLY");
|
|
44
|
+
await client.query(`SET LOCAL statement_timeout = ${Number(timeout)}`);
|
|
45
|
+
await client.query("SET LOCAL lock_timeout = 1000");
|
|
46
|
+
await client.query("SET LOCAL idle_in_transaction_session_timeout = 60000");
|
|
47
|
+
const select = [
|
|
48
|
+
`${key} AS key`,
|
|
49
|
+
id ? `${id}::text AS id` : `row_number() OVER ()::text AS id`,
|
|
50
|
+
truth ? `${truth}::text AS truth` : `NULL::text AS truth`,
|
|
51
|
+
].join(", ");
|
|
52
|
+
const { rows } = await client.query(`SELECT ${select} FROM ${table} WHERE ${key} IS NOT NULL LIMIT ${limit}`);
|
|
53
|
+
await client.query("COMMIT");
|
|
54
|
+
return rows
|
|
55
|
+
.filter((r) => (r.key ?? "").trim() !== "")
|
|
56
|
+
.map((r) => ({
|
|
57
|
+
id: r.id,
|
|
58
|
+
key: String(r.key).trim(),
|
|
59
|
+
...(r.truth ? { truth: r.truth.trim() } : {}),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
try {
|
|
64
|
+
await client.query("ROLLBACK");
|
|
65
|
+
}
|
|
66
|
+
catch { /* connection may already be gone */ }
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await client.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.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { extname } from "node:path";
|
|
4
|
+
import { scan } from "./scan.js";
|
|
5
|
+
import { formatReport } from "./format.js";
|
|
6
|
+
import { ALL_RULE_IDS, OPTIONAL_RULES } from "./normalize.js";
|
|
7
|
+
import { rowsFromCsv } from "./adapters/csv.js";
|
|
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;
|
|
12
|
+
const USAGE = `
|
|
13
|
+
shadowjoin — measure the matches your exact join is silently dropping.
|
|
14
|
+
|
|
15
|
+
npx shadowjoin scan --left <src> --left-key <col> --right <src> --right-key <col>
|
|
16
|
+
|
|
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.
|
|
19
|
+
|
|
20
|
+
--left, --right file path, or postgres:// / mysql:// URL
|
|
21
|
+
--left-key, --right-key column holding the join key (required)
|
|
22
|
+
--left-id, --right-id column holding a stable id
|
|
23
|
+
--left-truth, --right-truth column holding ground truth; supply it on BOTH
|
|
24
|
+
sides and every recovery is verified, not guessed
|
|
25
|
+
--left-table, --right-table table name (database sources only)
|
|
26
|
+
|
|
27
|
+
--rules <a,b,c> limit to these rules (default: all; see \`shadowjoin rules\`)
|
|
28
|
+
--samples <n> example recoveries to print (default 20)
|
|
29
|
+
--redact hash key values in samples; counts unaffected
|
|
30
|
+
--limit <n> max rows per side (default 100000)
|
|
31
|
+
--json emit the raw report as JSON
|
|
32
|
+
--max-loss <pct> exit 1 if the lower bound exceeds this (for CI)
|
|
33
|
+
|
|
34
|
+
shadowjoin rules list the normalization rules
|
|
35
|
+
|
|
36
|
+
The figure it reports is a FLOOR. The shadow matcher's own recall is unknown,
|
|
37
|
+
so the true loss is at least this large and may be larger.
|
|
38
|
+
`;
|
|
39
|
+
async function load(src, kind, v, limit) {
|
|
40
|
+
const keyColumn = v[`${kind}-key`];
|
|
41
|
+
if (!keyColumn)
|
|
42
|
+
throw new Error(`--${kind}-key is required`);
|
|
43
|
+
const opts = { keyColumn, idColumn: v[`${kind}-id`], truthColumn: v[`${kind}-truth`] };
|
|
44
|
+
const table = v[`${kind}-table`];
|
|
45
|
+
const needsTable = () => {
|
|
46
|
+
if (!table)
|
|
47
|
+
throw new Error(`--${kind}-table is required for a database source`);
|
|
48
|
+
return table;
|
|
49
|
+
};
|
|
50
|
+
if (src.startsWith("postgres://") || src.startsWith("postgresql://")) {
|
|
51
|
+
const { rowsFromPostgres } = await import("./adapters/postgres.js");
|
|
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 });
|
|
57
|
+
}
|
|
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
|
+
}
|
|
63
|
+
if (ext === ".json")
|
|
64
|
+
return rowsFromJson(src, opts);
|
|
65
|
+
if (ext === ".csv" || ext === ".tsv" || ext === "")
|
|
66
|
+
return rowsFromCsv(src, opts);
|
|
67
|
+
throw new Error(`Unsupported source "${src}". Use .csv, .json, .db/.sqlite, or a postgres:// or mysql:// connection string.`);
|
|
68
|
+
}
|
|
69
|
+
async function main() {
|
|
70
|
+
const { values, positionals } = parseArgs({
|
|
71
|
+
allowPositionals: true,
|
|
72
|
+
options: {
|
|
73
|
+
left: { type: "string" }, "left-key": { type: "string" }, "left-id": { type: "string" },
|
|
74
|
+
"left-truth": { type: "string" }, "left-table": { type: "string" },
|
|
75
|
+
right: { type: "string" }, "right-key": { type: "string" }, "right-id": { type: "string" },
|
|
76
|
+
"right-truth": { type: "string" }, "right-table": { type: "string" },
|
|
77
|
+
rules: { type: "string" }, samples: { type: "string" }, limit: { type: "string" },
|
|
78
|
+
redact: { type: "boolean" }, json: { type: "boolean" },
|
|
79
|
+
"max-loss": { type: "string" }, help: { type: "boolean", short: "h" },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const command = positionals[0] ?? (values.left ? "scan" : "help");
|
|
83
|
+
if (values.help || command === "help") {
|
|
84
|
+
process.stdout.write(banner(VERSION));
|
|
85
|
+
console.log(USAGE);
|
|
86
|
+
process.stdout.write(footer(VERSION));
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (command === "rules") {
|
|
90
|
+
console.log("\n Normalization rules (each measured independently against the exact baseline):\n");
|
|
91
|
+
for (const r of OPTIONAL_RULES)
|
|
92
|
+
console.log(` ${r.id.padEnd(24)}${r.label}`);
|
|
93
|
+
console.log("");
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
if (command !== "scan") {
|
|
97
|
+
console.error(`Unknown command "${command}".`);
|
|
98
|
+
console.log(USAGE);
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
if (!values.left || !values.right) {
|
|
102
|
+
console.error("Both --left and --right are required.\n");
|
|
103
|
+
console.log(USAGE);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
const v = values;
|
|
107
|
+
const limit = values.limit ? Number(values.limit) : 100_000;
|
|
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
|
+
}
|
|
125
|
+
const report = scan(left, right, {
|
|
126
|
+
onPhase: quiet ? undefined : (label) => progress.step(label),
|
|
127
|
+
rules: values.rules ? values.rules.split(",").map((s) => s.trim()).filter(Boolean) : ALL_RULE_IDS,
|
|
128
|
+
maxSamplePairs: values.samples ? Number(values.samples) : 20,
|
|
129
|
+
redactSamples: Boolean(values.redact),
|
|
130
|
+
});
|
|
131
|
+
if (!quiet)
|
|
132
|
+
progress.stop();
|
|
133
|
+
console.log(values.json ? JSON.stringify(report, null, 2) : formatReport(report));
|
|
134
|
+
if (!quiet)
|
|
135
|
+
process.stdout.write(footer(VERSION));
|
|
136
|
+
if (values["max-loss"] !== undefined) {
|
|
137
|
+
const threshold = Number(values["max-loss"]);
|
|
138
|
+
if (report.lowerBoundLossPct > threshold) {
|
|
139
|
+
console.error(`\nFAIL: lower bound on lost matches is ${report.lowerBoundLossPct}%, above the --max-loss threshold of ${threshold}%.`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
main().then((code) => process.exit(code), (err) => { console.error(`\nshadowjoin: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(2); });
|
package/dist/format.d.ts
ADDED
package/dist/format.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const pad = (s, n) => s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length);
|
|
2
|
+
const lpad = (s, n) => s.length >= n ? s : " ".repeat(n - s.length) + s;
|
|
3
|
+
/** Render a report as plain text for a terminal. */
|
|
4
|
+
export function formatReport(report) {
|
|
5
|
+
const out = [];
|
|
6
|
+
const n = (v) => v.toLocaleString("en-US");
|
|
7
|
+
out.push("");
|
|
8
|
+
out.push(` left rows ${n(report.leftRows)} right rows ${n(report.rightRows)}`);
|
|
9
|
+
out.push("");
|
|
10
|
+
out.push(` exact join matches ${lpad(n(report.exactMatches), 9)} ${report.exactMatchRate}%`);
|
|
11
|
+
out.push(` recovered by shadow join ${lpad(n(report.recovered), 9)} ${report.lowerBoundLossPct}%`);
|
|
12
|
+
out.push(` still unexplained ${lpad(n(report.unexplained), 9)}`);
|
|
13
|
+
out.push(` reachable match rate ${lpad(report.reachableMatchRate + "%", 9)}`);
|
|
14
|
+
out.push("");
|
|
15
|
+
out.push(` LOWER BOUND ON LOST MATCHES: ${report.lowerBoundLossPct}% of left rows`);
|
|
16
|
+
if (report.precision !== null) {
|
|
17
|
+
out.push(` verified against ground truth: ${n(report.verified)} correct, ${n(report.contradicted)} wrong, ${n(report.ambiguous)} ambiguous (precision ${report.precision}%)`);
|
|
18
|
+
}
|
|
19
|
+
out.push("");
|
|
20
|
+
if (report.byRule.length) {
|
|
21
|
+
out.push(` ${pad("rule", 54)}${lpad("alone", 8)}${lpad("ok", 7)}${lpad("wrong", 7)}`);
|
|
22
|
+
out.push(" " + "-".repeat(76));
|
|
23
|
+
for (const r of report.byRule) {
|
|
24
|
+
out.push(` ${pad(r.label, 54)}${lpad(n(r.recovered), 8)}${lpad(n(r.verified), 7)}${lpad(n(r.contradicted), 7)}`);
|
|
25
|
+
}
|
|
26
|
+
out.push("");
|
|
27
|
+
}
|
|
28
|
+
if (report.stack.length) {
|
|
29
|
+
out.push(" rules worth adopting, in order (greedy by marginal recovery):");
|
|
30
|
+
out.push(` ${pad("", 4)}${pad("rule", 54)}${lpad("+rows", 9)}${lpad("running", 9)}`);
|
|
31
|
+
out.push(" " + "-".repeat(76));
|
|
32
|
+
report.stack.forEach((s2, i) => {
|
|
33
|
+
out.push(` ${pad(String(i + 1) + ".", 4)}${pad(s2.label, 54)}${lpad("+" + n(s2.marginal), 9)}${lpad(n(s2.cumulative), 9)}`);
|
|
34
|
+
});
|
|
35
|
+
out.push("");
|
|
36
|
+
}
|
|
37
|
+
if (report.samplePairs.length) {
|
|
38
|
+
out.push(" sample recoveries (verify these by hand before acting):");
|
|
39
|
+
const mark = { verified: "ok ", contradicted: "BAD", ambiguous: "?? ", unverifiable: " " };
|
|
40
|
+
for (const s of report.samplePairs.slice(0, 12)) {
|
|
41
|
+
const multi = s.candidates > 1 ? ` [${s.candidates} candidates]` : "";
|
|
42
|
+
out.push(` ${mark[s.verification]} ${s.leftKey}`);
|
|
43
|
+
out.push(` -> ${s.rightKey} (${s.rule})${multi}`);
|
|
44
|
+
}
|
|
45
|
+
out.push("");
|
|
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
|
+
}
|
|
58
|
+
for (const w of report.warnings)
|
|
59
|
+
out.push(` ! ${w}`);
|
|
60
|
+
if (report.warnings.length)
|
|
61
|
+
out.push("");
|
|
62
|
+
return out.join("\n");
|
|
63
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { scan } from "./scan.js";
|
|
2
|
+
export type { ScanOptions } from "./scan.js";
|
|
3
|
+
export { BASE_RULES, OPTIONAL_RULES, ALL_RULE_IDS, normalize, rulesByIds, toSql } from "./normalize.js";
|
|
4
|
+
export { formatReport } from "./format.js";
|
|
5
|
+
export { parseCsv, rowsFromCsv, rowsFromCsvText } from "./adapters/csv.js";
|
|
6
|
+
export type { CsvOptions } from "./adapters/csv.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
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { scan } from "./scan.js";
|
|
2
|
+
export { BASE_RULES, OPTIONAL_RULES, ALL_RULE_IDS, normalize, rulesByIds, toSql } from "./normalize.js";
|
|
3
|
+
export { formatReport } from "./format.js";
|
|
4
|
+
export { parseCsv, rowsFromCsv, rowsFromCsvText } from "./adapters/csv.js";
|
|
5
|
+
export { trigrams, dice, TrigramIndex } from "./similarity.js";
|
package/dist/mcp.d.ts
ADDED
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* shadowjoin MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Two deliberate constraints, because this points at databases:
|
|
6
|
+
* 1. There is NO tool that executes arbitrary SQL. An agent can name a
|
|
7
|
+
* configured source plus a table and column, and nothing else.
|
|
8
|
+
* 2. No tool accepts a connection string. Credentials live in a local config
|
|
9
|
+
* file and never enter a tool call, because tool calls land in transcripts.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { resolve } from "node:path";
|
|
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";
|
|
17
|
+
import { scan } from "./scan.js";
|
|
18
|
+
import { ALL_RULE_IDS, rulesByIds, toSql } from "./normalize.js";
|
|
19
|
+
import { rowsFromCsv } from "./adapters/csv.js";
|
|
20
|
+
import { rowsFromJson } from "./adapters/json.js";
|
|
21
|
+
const CONFIG_PATH = resolve(process.env.SHADOWJOIN_CONFIG ?? "shadowjoin.config.json");
|
|
22
|
+
async function loadConfig() {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
25
|
+
if (!parsed.sources || typeof parsed.sources !== "object")
|
|
26
|
+
throw new Error("missing `sources`");
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
throw new Error(`Could not read ${CONFIG_PATH}. Create it with your named sources, e.g.\n` +
|
|
31
|
+
`{"sources":{"prod":{"type":"postgres","connectionString":"postgres://readonly@host/db"}}}\n` +
|
|
32
|
+
`(${err instanceof Error ? err.message : String(err)})`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function loadSide(side, config, limit) {
|
|
36
|
+
const def = config.sources[side.source];
|
|
37
|
+
if (!def) {
|
|
38
|
+
throw new Error(`Unknown source "${side.source}". Configured: ${Object.keys(config.sources).join(", ") || "(none)"}`);
|
|
39
|
+
}
|
|
40
|
+
const opts = { keyColumn: side.column, idColumn: side.idColumn, truthColumn: side.truthColumn };
|
|
41
|
+
if (def.type === "postgres" || def.type === "mysql" || def.type === "sqlite") {
|
|
42
|
+
if (!side.table)
|
|
43
|
+
throw new Error(`Source "${side.source}" is a database; "table" is required.`);
|
|
44
|
+
if (def.type === "postgres") {
|
|
45
|
+
const { rowsFromPostgres } = await import("./adapters/postgres.js");
|
|
46
|
+
return rowsFromPostgres({ connectionString: def.connectionString, table: side.table, limit, ...opts });
|
|
47
|
+
}
|
|
48
|
+
if (def.type === "mysql") {
|
|
49
|
+
const { rowsFromMysql } = await import("./adapters/mysql.js");
|
|
50
|
+
return rowsFromMysql({ connectionString: def.connectionString, table: side.table, limit, ...opts });
|
|
51
|
+
}
|
|
52
|
+
const { rowsFromSqlite } = await import("./adapters/sqlite.js");
|
|
53
|
+
return rowsFromSqlite({ file: def.path, table: side.table, limit, ...opts });
|
|
54
|
+
}
|
|
55
|
+
return def.type === "json" ? rowsFromJson(def.path, opts) : rowsFromCsv(def.path, opts);
|
|
56
|
+
}
|
|
57
|
+
const runs = new Map();
|
|
58
|
+
function summarize(runId, report) {
|
|
59
|
+
return {
|
|
60
|
+
runId,
|
|
61
|
+
leftRows: report.leftRows,
|
|
62
|
+
rightRows: report.rightRows,
|
|
63
|
+
exactMatches: report.exactMatches,
|
|
64
|
+
exactMatchRate: report.exactMatchRate,
|
|
65
|
+
recovered: report.recovered,
|
|
66
|
+
lowerBoundLossPct: report.lowerBoundLossPct,
|
|
67
|
+
reachableMatchRate: report.reachableMatchRate,
|
|
68
|
+
unexplained: report.unexplained,
|
|
69
|
+
precision: report.precision,
|
|
70
|
+
verified: report.verified,
|
|
71
|
+
contradicted: report.contradicted,
|
|
72
|
+
ambiguous: report.ambiguous,
|
|
73
|
+
topRules: report.stack.slice(0, 5).map((s) => ({ rule: s.rule, marginal: s.marginal })),
|
|
74
|
+
warnings: report.warnings,
|
|
75
|
+
note: "lowerBoundLossPct is a FLOOR, not an estimate: the shadow matcher's own recall is unknown, so the true loss is at least this large. Call explain_misses and show the user real pairs before acting on it.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const SIDE_SCHEMA = {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: {
|
|
81
|
+
source: { type: "string", description: "Name of a source from shadowjoin.config.json. Not a connection string." },
|
|
82
|
+
table: { type: "string", description: "Table name (database sources only). Plain identifier; arbitrary SQL is rejected." },
|
|
83
|
+
column: { type: "string", description: "Column holding the join key." },
|
|
84
|
+
idColumn: { type: "string", description: "Column holding a stable row id." },
|
|
85
|
+
truthColumn: { type: "string", description: "Column holding ground truth. Supply on BOTH sides to get verified precision." },
|
|
86
|
+
},
|
|
87
|
+
required: ["source", "column"],
|
|
88
|
+
};
|
|
89
|
+
const TOOLS = [
|
|
90
|
+
{
|
|
91
|
+
name: "scan_join",
|
|
92
|
+
description: "Measure how many matches an exact join is silently dropping. Returns a compact summary including a FLOOR on lost matches and the rules worth adopting. Read-only. Call this before changing matching logic, and again after, to prove the change helped.",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
left: SIDE_SCHEMA,
|
|
97
|
+
right: SIDE_SCHEMA,
|
|
98
|
+
rules: { type: "array", items: { type: "string" }, description: `Rule ids to enable. Default all: ${ALL_RULE_IDS.join(", ")}` },
|
|
99
|
+
limit: { type: "number", description: "Max rows per side. Default 100000." },
|
|
100
|
+
},
|
|
101
|
+
required: ["left", "right"],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "explain_misses",
|
|
106
|
+
description: "Return concrete example pairs from a previous scan, with the real key values and the rule that recovered each. Use this to show a human actual evidence instead of a percentage. May return key values, so prefer redact:true when the data is sensitive.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
runId: { type: "string" },
|
|
111
|
+
rule: { type: "string", description: "Only pairs attributed to this rule." },
|
|
112
|
+
limit: { type: "number", description: "Default 20." },
|
|
113
|
+
},
|
|
114
|
+
required: ["runId"],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "suggest_normalization",
|
|
119
|
+
description: "Turn a scan into a concrete fix: the rules worth adopting in order, each as a Postgres expression and a JavaScript function ready to paste into the matching pipeline.",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: { runId: { type: "string" }, column: { type: "string", description: "Column name to build the SQL around. Default 'key'." } },
|
|
123
|
+
required: ["runId"],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "compare_runs",
|
|
128
|
+
description: "Compare two scans to prove whether a change to matching logic actually helped. Reports the change in recovered matches, reachable match rate and precision. Use after editing matching logic — never claim an improvement without this.",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: { baselineRunId: { type: "string" }, candidateRunId: { type: "string" } },
|
|
132
|
+
required: ["baselineRunId", "candidateRunId"],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
const server = new Server({ name: "shadowjoin", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
137
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
138
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
139
|
+
const { name } = request.params;
|
|
140
|
+
const args = (request.params.arguments ?? {});
|
|
141
|
+
const ok = (payload) => ({ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] });
|
|
142
|
+
try {
|
|
143
|
+
if (name === "scan_join") {
|
|
144
|
+
const config = await loadConfig();
|
|
145
|
+
const limit = typeof args.limit === "number" ? args.limit : 100_000;
|
|
146
|
+
const [left, right] = await Promise.all([
|
|
147
|
+
loadSide(args.left, config, limit),
|
|
148
|
+
loadSide(args.right, config, limit),
|
|
149
|
+
]);
|
|
150
|
+
const report = scan(left, right, {
|
|
151
|
+
rules: Array.isArray(args.rules) && args.rules.length ? args.rules : ALL_RULE_IDS,
|
|
152
|
+
maxSamplePairs: 50,
|
|
153
|
+
});
|
|
154
|
+
const runId = randomUUID().slice(0, 8);
|
|
155
|
+
runs.set(runId, { report, left: args.left, right: args.right });
|
|
156
|
+
return ok(summarize(runId, report));
|
|
157
|
+
}
|
|
158
|
+
if (name === "explain_misses") {
|
|
159
|
+
const run = runs.get(String(args.runId));
|
|
160
|
+
if (!run)
|
|
161
|
+
throw new Error(`Unknown runId "${args.runId}". Call scan_join first.`);
|
|
162
|
+
const limit = typeof args.limit === "number" ? args.limit : 20;
|
|
163
|
+
const pairs = run.report.samplePairs
|
|
164
|
+
.filter((p) => !args.rule || p.rule === args.rule)
|
|
165
|
+
.slice(0, limit);
|
|
166
|
+
return ok({
|
|
167
|
+
runId: args.runId,
|
|
168
|
+
pairs,
|
|
169
|
+
legend: {
|
|
170
|
+
verified: "ground truth confirms this pair",
|
|
171
|
+
contradicted: "ground truth says this pair is WRONG — the matcher over-merged",
|
|
172
|
+
ambiguous: "several right-hand rows collapsed onto one key; needs a tie-breaker, not a rule",
|
|
173
|
+
unverifiable: "no ground truth available for this pair",
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (name === "suggest_normalization") {
|
|
178
|
+
const run = runs.get(String(args.runId));
|
|
179
|
+
if (!run)
|
|
180
|
+
throw new Error(`Unknown runId "${args.runId}". Call scan_join first.`);
|
|
181
|
+
const column = typeof args.column === "string" ? args.column : "key";
|
|
182
|
+
const chosen = run.report.stack.map((s) => s.rule);
|
|
183
|
+
const steps = run.report.stack.map((step, i) => {
|
|
184
|
+
const upto = rulesByIds(chosen.slice(0, i + 1));
|
|
185
|
+
return { ...step, sql: toSql(column, upto) };
|
|
186
|
+
});
|
|
187
|
+
return ok({
|
|
188
|
+
runId: args.runId,
|
|
189
|
+
recommended: chosen,
|
|
190
|
+
steps,
|
|
191
|
+
fullSql: toSql(column, rulesByIds(chosen)),
|
|
192
|
+
javascript: "import { normalize, rulesByIds } from 'shadowjoin';\n" +
|
|
193
|
+
`const rules = rulesByIds(${JSON.stringify(chosen)});\n` +
|
|
194
|
+
"const key = (value) => normalize(value, rules);",
|
|
195
|
+
caution: "Adopting a rule widens matching and can merge records that should stay separate. Re-run scan_join afterwards and compare_runs to confirm precision did not fall.",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (name === "compare_runs") {
|
|
199
|
+
const a = runs.get(String(args.baselineRunId));
|
|
200
|
+
const b = runs.get(String(args.candidateRunId));
|
|
201
|
+
if (!a || !b)
|
|
202
|
+
throw new Error("Both runIds must come from scan_join calls in this session.");
|
|
203
|
+
const delta = (x, y) => Number((y - x).toFixed(2));
|
|
204
|
+
return ok({
|
|
205
|
+
baseline: summarize(String(args.baselineRunId), a.report),
|
|
206
|
+
candidate: summarize(String(args.candidateRunId), b.report),
|
|
207
|
+
delta: {
|
|
208
|
+
exactMatches: delta(a.report.exactMatches, b.report.exactMatches),
|
|
209
|
+
recovered: delta(a.report.recovered, b.report.recovered),
|
|
210
|
+
reachableMatchRate: delta(a.report.reachableMatchRate, b.report.reachableMatchRate),
|
|
211
|
+
lowerBoundLossPct: delta(a.report.lowerBoundLossPct, b.report.lowerBoundLossPct),
|
|
212
|
+
precision: a.report.precision !== null && b.report.precision !== null ? delta(a.report.precision, b.report.precision) : null,
|
|
213
|
+
contradicted: delta(a.report.contradicted, b.report.contradicted),
|
|
214
|
+
},
|
|
215
|
+
verdict: b.report.exactMatchRate > a.report.exactMatchRate
|
|
216
|
+
? "The exact join matches more rows than before."
|
|
217
|
+
: "The exact join does not match more rows than before. If you changed the matching logic, it did not take effect.",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`Unknown tool "${name}"`);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
return {
|
|
224
|
+
isError: true,
|
|
225
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Rule } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Applied to every candidate key before any optional rule. These are the
|
|
4
|
+
* transforms that are safe on essentially any text: they never merge values
|
|
5
|
+
* that a human would read as different entities.
|
|
6
|
+
*/
|
|
7
|
+
export declare const BASE_RULES: Rule[];
|
|
8
|
+
/**
|
|
9
|
+
* Each of these is measured INDEPENDENTLY on top of BASE_RULES, so the number
|
|
10
|
+
* next to a rule is what that rule alone buys you — not an artifact of the
|
|
11
|
+
* order rules happen to run in.
|
|
12
|
+
*/
|
|
13
|
+
export declare const OPTIONAL_RULES: Rule[];
|
|
14
|
+
export declare const ALL_RULE_IDS: string[];
|
|
15
|
+
export declare function rulesByIds(ids: string[]): Rule[];
|
|
16
|
+
/** Apply BASE_RULES, then the supplied rules, in order. */
|
|
17
|
+
export declare function normalize(value: string, rules?: Rule[]): string;
|
|
18
|
+
/** Compose the enabled rules into one Postgres expression over a column. */
|
|
19
|
+
export declare function toSql(column: string, rules?: Rule[]): string;
|