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>
95 lines
4.3 KiB
JavaScript
95 lines
4.3 KiB
JavaScript
/**
|
|
* 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`);
|