// 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 --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, };