refactor(workflow): contract shared core (#33)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// enrich.js -- PURE merge/enrichment for the v2 gate payloads (WS2).
|
||||
//
|
||||
// Catalog lookups are INJECTED as `getColumns` params (for testability): a function
|
||||
// (tableName) => { description, columns: [{ name, description, ... }] } | null.
|
||||
// A catalog miss (null) yields empty descriptions, never an error (the hard-fail
|
||||
// stays only in reviewer_schema_linking).
|
||||
//
|
||||
// No dependency on tht-gate.js: importable and testable in isolation.
|
||||
|
||||
"use strict";
|
||||
|
||||
// Memoize a raw catalog lookup so repeated table hits do a single I/O call. The
|
||||
// gate wraps its `tht schema columns <t> --json` (try/catch -> null on miss) and
|
||||
// passes it here; enrich itself stays pure.
|
||||
function memoizeGetColumns(rawGetColumns) {
|
||||
const cache = new Map();
|
||||
return function getColumns(tableName) {
|
||||
if (cache.has(tableName)) return cache.get(tableName);
|
||||
const v = rawGetColumns(tableName);
|
||||
cache.set(tableName, v);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
|
||||
function columnDescription(cat, colName) {
|
||||
if (!cat || !Array.isArray(cat.columns)) return "";
|
||||
const col = cat.columns.find((c) => c.name === colName);
|
||||
return (col && col.description) || "";
|
||||
}
|
||||
|
||||
// Contract A. Fills index (1-based), tables[].description and filters[].description
|
||||
// via getColumns. For filters, `column: "tab.col"` resolves to the column's catalog
|
||||
// description. Returns a NEW object; model-owned fields pass through unchanged.
|
||||
function enrichCtePlanV2(data, getColumns) {
|
||||
const ctes = (data.ctes || []).map((cte, i) => {
|
||||
const out = { ...cte, index: i + 1 };
|
||||
if (Array.isArray(cte.tables)) {
|
||||
out.tables = cte.tables.map((t) => {
|
||||
const cat = getColumns(t.name);
|
||||
return { ...t, description: (cat && cat.description) || "" };
|
||||
});
|
||||
}
|
||||
if (Array.isArray(cte.filters)) {
|
||||
out.filters = cte.filters.map((f) => {
|
||||
const out2 = { ...f };
|
||||
if (typeof f.column === "string" && f.column.includes(".")) {
|
||||
const idx = f.column.indexOf(".");
|
||||
const tableName = f.column.slice(0, idx);
|
||||
const colName = f.column.slice(idx + 1);
|
||||
out2.description = columnDescription(getColumns(tableName), colName);
|
||||
} else {
|
||||
out2.description = "";
|
||||
}
|
||||
return out2;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return { ...data, ctes };
|
||||
}
|
||||
|
||||
// Contract B. Fuses the model's thin data with `tht cte info` output.
|
||||
// name/index/total/sql <- cteInfo
|
||||
// purpose/rationale/depends_on <- cteInfo.doc (fallback: thinData)
|
||||
// status/execution_ms/row_sample/warnings/sql_hash/preview <- cteInfo.last_test
|
||||
// note <- thinData
|
||||
// preview = { columns: last_test.columns, rows: last_test.preview_rows }.
|
||||
function buildCteResultV2(thinData, cteInfo) {
|
||||
const thin = thinData || {};
|
||||
const doc = cteInfo.doc || {};
|
||||
const lt = cteInfo.last_test || {};
|
||||
const pick = (docVal, thinVal) => (docVal !== undefined ? docVal : thinVal);
|
||||
return {
|
||||
schema_version: 2,
|
||||
name: cteInfo.name,
|
||||
index: cteInfo.index,
|
||||
total: cteInfo.total,
|
||||
purpose: pick(doc.purpose, thin.purpose),
|
||||
rationale: pick(doc.rationale, thin.rationale),
|
||||
depends_on: pick(doc.depends_on, thin.depends_on),
|
||||
sql: cteInfo.sql,
|
||||
status: lt.status,
|
||||
execution_ms: lt.execution_ms,
|
||||
row_sample: lt.row_sample,
|
||||
warnings: lt.warnings || [],
|
||||
sql_hash: lt.sql_hash,
|
||||
columns: (lt.columns || []).map((name) => ({ name, description: "" })),
|
||||
preview: { columns: lt.columns || [], rows: lt.preview_rows || [] },
|
||||
note: thin.note,
|
||||
};
|
||||
}
|
||||
|
||||
// Contract C. Fills `phase` from phaseMeta and the `description` fields in
|
||||
// tables[]/tables[].columns[]/sections[].items[] via getColumns. phaseMeta is
|
||||
// { id, num, name }. Returns a NEW object.
|
||||
function enrichPhaseSummaryV2(data, phaseMeta, getColumns) {
|
||||
const out = { ...data, phase: phaseMeta };
|
||||
if (Array.isArray(data.tables)) {
|
||||
out.tables = data.tables.map((t) => {
|
||||
const cat = getColumns(t.name);
|
||||
const enriched = { ...t, description: (cat && cat.description) || "" };
|
||||
if (Array.isArray(t.columns)) {
|
||||
enriched.columns = t.columns.map((c) => ({
|
||||
...c,
|
||||
description: columnDescription(cat, c.name),
|
||||
}));
|
||||
}
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(data.sections)) {
|
||||
out.sections = data.sections.map((s) => {
|
||||
if (!Array.isArray(s.items)) return { ...s };
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((it) => {
|
||||
// An item's description resolves against its declared table+column.
|
||||
let desc = "";
|
||||
if (typeof it.table === "string" && typeof it.column === "string") {
|
||||
desc = columnDescription(getColumns(it.table), it.column);
|
||||
} else if (typeof it.table === "string") {
|
||||
const cat = getColumns(it.table);
|
||||
desc = (cat && cat.description) || "";
|
||||
}
|
||||
return { ...it, description: desc };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Fills columns[].description of a built cte_result payload by matching each column
|
||||
// name against the catalog of the plan's tables (`planTables`: string[]). First
|
||||
// table whose catalog carries the column wins; unresolved -> empty description.
|
||||
// Mutates a copy of payload.columns; returns the payload.
|
||||
function enrichCteResultColumns(payload, planTables, getColumns) {
|
||||
if (!Array.isArray(payload.columns)) return payload;
|
||||
const tables = Array.isArray(planTables) ? planTables : [];
|
||||
const columns = payload.columns.map((col) => {
|
||||
let desc = "";
|
||||
for (const t of tables) {
|
||||
const d = columnDescription(getColumns(t), col.name);
|
||||
if (d) {
|
||||
desc = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...col, description: desc };
|
||||
});
|
||||
return { ...payload, columns };
|
||||
}
|
||||
|
||||
// Contract C bis. Appends a deterministic "decisions of this phase" section built
|
||||
// from the session ledger, so the model's recap can stay thin (Discipline 6 exactness
|
||||
// comes from persisted state, not model prose). `decisions` is session-show's ledger
|
||||
// dump; `emits` is the phase's substantive decision-type list from workflow.yaml.
|
||||
// No matching decisions -> data returned unchanged. Pure: returns a NEW object.
|
||||
function appendLedgerSection(data, decisions, emits) {
|
||||
const emitSet = new Set(emits || []);
|
||||
const rows = (decisions || []).filter((d) => d && emitSet.has(d.type));
|
||||
if (rows.length === 0) return data;
|
||||
const section = {
|
||||
title: "Decisioni registrate in questa fase (dal ledger)",
|
||||
items: rows.map((d) => ({
|
||||
label: `${d.type}: ${d.subject}`,
|
||||
value: d.detail || "",
|
||||
kind: "decision",
|
||||
rationale: d.rationale || "",
|
||||
})),
|
||||
};
|
||||
return { ...data, sections: [...(data.sections || []), section] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
memoizeGetColumns,
|
||||
enrichCtePlanV2,
|
||||
buildCteResultV2,
|
||||
enrichCteResultColumns,
|
||||
enrichPhaseSummaryV2,
|
||||
appendLedgerSection,
|
||||
};
|
||||
Reference in New Issue
Block a user