Initial commit: Converge portfolio rationalization app

Next.js app for mapping a customer's competitive tool landscape onto our
portfolio and building the consolidation business case: assessment
workbench, customer report and Word export, product catalog, competitive
knowledge base, dossier import, shareable landscape questionnaires with a
review inbox, tool-footprint and business-unit value analysis, and an
implementation timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:48:42 -04:00
commit d5c77543e8
64 changed files with 30861 additions and 0 deletions

94
scripts/seed-kb.mjs Normal file
View File

@@ -0,0 +1,94 @@
/**
* Builds data/knowledge-base.json from the catalog's `replaces` lists.
*
* Each competitor string named by a product becomes one entry mapped to every
* product that claims to displace it. Scores start at neutral defaults and are
* refined automatically as real assessments are saved.
*
* Re-running is safe: existing entries keep their edits, observations and pins,
* and only gain newly-discovered product mappings.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const CATALOG = "data/catalog.json";
const KB = "data/knowledge-base.json";
const MULTIWORD_VENDORS = [
"Red Hat", "Palo Alto", "Check Point", "Micro Focus", "Trend Micro", "Arctic Wolf", "Red Canary",
"Ping Identity", "One Identity", "Salt Security", "Noname Security", "Traceable AI", "Aqua Security",
"Airlock Digital", "Abnormal Security", "Menlo Security", "Recorded Future", "Scale Computing",
"Wind River", "Software AG", "Cato Networks", "Zero Networks", "Digital Guardian", "Snow Software",
"Cardinal Commerce", "Duo Security", "Crunchy Data", "Morpheus Data", "Sumo Logic", "Prisma Cloud",
"Amazon Web Services", "Google Cloud", "Data Theorem",
];
const splitVendor = (label) => {
const clean = label.replace(/\s+/g, " ").trim();
for (const v of MULTIWORD_VENDORS) {
if (clean.toLowerCase().startsWith(v.toLowerCase() + " ")) return { vendor: v, product: clean.slice(v.length).trim() };
if (clean.toLowerCase() === v.toLowerCase()) return { vendor: v, product: "" };
}
const parts = clean.split(" ");
return parts.length === 1 ? { vendor: clean, product: "" } : { vendor: parts[0], product: parts.slice(1).join(" ") };
};
const NON_COMPETITIVE = [
/\(status quo\)/i, /\(internal alternative\)/i, /status quo/i,
/\bDIY\b/i, /\bscripts?\b/i, /spreadsheets/i, /\bmanual\b/i,
/^custom /i, /^native /i, /^internal /i, /^self-managed/i, /^unsupported/i,
/^physical /i, /^consulting/i, /^fragmented/i, /^serialized/i, /^shared /i,
/^point[- ]tool/i, /^point IAM/i, /^no\b/i, /^none\b/i, /^password-only/i,
/^display statements/i, /^unencrypted/i, /^green[- ]screen/i, /^cron\b/i,
/rewrite|redevelopment/i, /^migration to /i, /^rehost/i, /^low-code platforms/i,
/^multiple /i, /^various /i, /^hand-written/i, /^ISPF /i, /^unsupported community/i,
];
const isCompetitive = (label) => !NON_COMPETITIVE.some((re) => re.test(label));
const kbId = (label) =>
label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72) || "entry";
const catalog = JSON.parse(readFileSync(CATALOG, "utf8"));
const existing = existsSync(KB) ? JSON.parse(readFileSync(KB, "utf8")) : { version: 1, entries: [] };
const byId = new Map(existing.entries.map((e) => [e.id, e]));
const now = new Date().toISOString();
let created = 0, enriched = 0;
for (const p of catalog.products) {
for (const raw of p.replaces ?? []) {
const label = raw.replace(/\s+/g, " ").trim();
if (!label) continue;
const id = kbId(label);
let e = byId.get(id);
if (!e) {
const { vendor, product } = splitVendor(label);
e = {
id, label, vendor, product, aliases: [], domain: p.domain,
productIds: [], capability: 4, switchEase: 3, timeToRoiMonths: 12,
savingsPct: 50, migrationCost: 0, unitLabel: "", risks: "", notes: "",
competitive: isCompetitive(label),
pinned: false, origin: "catalog", observations: [],
createdAt: now, updatedAt: now,
};
byId.set(id, e);
created++;
}
// Only add mappings; never strip a mapping a human curated.
if (!e.productIds.includes(p.id)) {
e.productIds.push(p.id);
if (e.origin === "catalog") enriched++;
}
if (!e.domain) e.domain = p.domain;
}
}
const entries = [...byId.values()].sort((a, b) => a.label.localeCompare(b.label));
writeFileSync(KB, JSON.stringify({ version: 1, updatedAt: now, entries }, null, 2) + "\n");
// Backfill the flag for entries written before it existed.
for (const e of entries) if (e.competitive === undefined) e.competitive = isCompetitive(e.label);
const mapped = entries.filter((e) => e.productIds.length > 0).length;
const rivals = entries.filter((e) => e.competitive).length;
console.log(`knowledge base: ${entries.length} entries (${created} new), ${mapped} mapped, ${rivals} true competitors, ${enriched} mappings added`);