feat(gate): reviewer_schema_linking tool with per-column curation + deterministic sync

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 23:25:00 +02:00
committed by Marco Pancotti
co-authored by Claude Opus 4.8
parent 559f52e26e
commit ad788278dd
3 changed files with 235 additions and 0 deletions
@@ -0,0 +1,122 @@
// L1.5 roundtrip for the reviewer_schema_linking tool: drives the registered tool's
// execute() end-to-end against a fake Pi runtime, stubbing the `tht` shell (execFileSync)
// and ctx.ui.input, then asserts the recorded decision-add / sync-schema-linking argv.
//
// HARNESS ADAPTATIONS (no existing test invokes a tool's execute() end-to-end):
// 1. tht-gate.js uses ESM `import { execFileSync } from "node:child_process"`. Under
// CommonJS `require(esm)` the named binding tracks the module namespace, so patching
// `child_process.execFileSync` BEFORE requiring tht-gate.js is observed by tht().
// 2. tht()'s loadEnvFromDotenv calls `require("node:fs")`; that `require` is undefined
// in the ESM module scope when loaded via require(esm) (in production Pi loads the
// extension as CommonJS, where require is native). We install a globalThis.require
// shim so loadEnvFromDotenv works. cwd points at a missing dir so the .env read
// fails gracefully inside its own try/catch.
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
// Adaptation #2: shim require() for the ESM-loaded-via-require module scope.
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
const CATALOG = {
table: "dim_patient",
description: "Anagrafica",
columns: [
{ name: "cod_paz", description: "Codice", type: "bigint", pk: true },
{ name: "nome", description: "Nome", type: "text", pk: false },
],
};
test("reviewer_schema_linking records table/column decisions and syncs schema_linking", async () => {
const calls = [];
// Adaptation #1: stub the shell BEFORE requiring tht-gate.js.
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
if (args[0] === "schema" && args[1] === "columns" && args[2] === "dim_patient")
return JSON.stringify(CATALOG);
return ""; // decision add, session sync-schema-linking, ...
};
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd"; // .env read fails gracefully
gate.default(pi);
// Reflect the descriptor id back and return the reviewer response so emitAndWait
// accepts it (its resp.id === descriptor.id invariant). The descriptor id is a
// runtime-generated `u${Date.now()}` — capture it, do not hardcode.
let capturedDescriptor = null;
ctx.ui.input = async (title) => {
capturedDescriptor = JSON.parse(title);
return JSON.stringify({
id: capturedDescriptor.id,
kind: "schema-linking",
tables: [{ id: "t-pat", enacted: true, columns: ["cod_paz"] }],
});
};
const tool = tools.get("reviewer_schema_linking");
assert.ok(tool, "reviewer_schema_linking must be registered");
const result = await tool.def.execute(
"call-1",
{
session: "s1",
title: "Schema linking",
tables: [
{
id: "t-pat",
name: "dim_patient",
kind: "promote",
suggested_columns: ["cod_paz", "nome"],
},
],
},
null,
null,
ctx,
);
// The emitted descriptor carried the enriched catalog columns.
assert.equal(capturedDescriptor.widget, "schema-linking");
assert.equal(capturedDescriptor.tables[0].columns.length, 2);
// cod_paz was selected -> promoted; nome was suggested but deselected -> excluded.
assert.ok(
calls.some((c) => /decision add .*--type table_promoted --subject dim_patient\b/.test(c)),
`expected table_promoted dim_patient in: ${JSON.stringify(calls)}`,
);
assert.ok(
calls.some((c) => /decision add .*--type column_promoted --subject dim_patient\.cod_paz\b/.test(c)),
`expected column_promoted dim_patient.cod_paz in: ${JSON.stringify(calls)}`,
);
assert.ok(
calls.some((c) => /decision add .*--type column_excluded --subject dim_patient\.nome\b/.test(c)),
`expected column_excluded dim_patient.nome in: ${JSON.stringify(calls)}`,
);
assert.ok(
calls.some((c) => /^session sync-schema-linking s1$/.test(c)),
`expected session sync-schema-linking s1 in: ${JSON.stringify(calls)}`,
);
// sync runs AFTER the decisions are recorded.
const syncIdx = calls.findIndex((c) => c.startsWith("session sync-schema-linking"));
const lastDecisionIdx = calls.map((c) => c.startsWith("decision add")).lastIndexOf(true);
assert.ok(syncIdx > lastDecisionIdx, "sync must run after all decision adds");
assert.match(result.content[0].text, /Schema linking registrato/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
@@ -40,6 +40,12 @@ test("prepareReviewerArguments leaves an object artifact and parses stringified
assert.equal(out.options[0].id, "a");
});
test("prepareReviewerArguments parses stringified tables array", () => {
const out = prepareReviewerArguments({ tables: JSON.stringify([{ id: "t1", name: "x" }]) });
assert.ok(Array.isArray(out.tables));
assert.equal(out.tables[0].id, "t1");
});
test("GATE_CODE_FILES blocks writes to gate extensions, not session artifacts", () => {
assert.ok(GATE_CODE_FILES.test("harness/.pi/extensions/tht-gate.js"));
assert.ok(GATE_CODE_FILES.test(".pi/extensions/reserved-labels.mjs"));