@qiwi/tech-radar
Version:
Fully automated tech-radar generator
250 lines (227 loc) • 8.02 kB
JavaScript
import { globby } from 'globby'
import path from 'node:path'
import { asArray } from '../util.js'
import { parseCsvRadar } from './csv.js'
import { parseJsonRadar } from './json.js'
import { validate } from './validator.js'
import { parseYamlRadar } from './yaml.js'
export { parseCsvRadar } from './csv.js'
export { parseJsonRadar } from './json.js'
export { parseYamlRadar } from './yaml.js'
/**
* Parse radarDocument
* @param filePath
* @returns {Promise<{data: any[], meta: {}, quadrantAliases?: {}}>} radarDocument
*/
export const parse = async (filePath) => {
try {
const reader = getReader(path.extname(filePath))
const document = await reader(filePath)
const radar = normalizeEntries(document)
return validate(radar)
} catch (err) {
console.error('filePath:', filePath, err)
return {}
}
}
const READERS = {
'.csv': parseCsvRadar,
'.json': parseJsonRadar,
'.yml': parseYamlRadar,
'.yaml': parseYamlRadar,
}
/**
* selection of the reading function depending on the extension
* @param ext
* @returns {(function(*=): {data: any[], meta: {}})}
*/
export const getReader = (ext) => {
const reader = READERS[ext]
if (!reader) throw new Error(`Unsupported format: ${ext}`)
return reader
}
/**
* Returns absolute files paths by glob pattern
* @param {string|string[]} pattern - glob pattern
* @param cwd - cwd
* @returns {Promise<string[]>}
*/
export const getSources = async (pattern, cwd) =>
globby([pattern], {
onlyFiles: true,
absolute: true,
cwd,
})
export const normalizeQuadrantAliases = (aliases) =>
Object.entries(aliases || {}).reduce((m, [k, v]) => {
if (/^q[1-4]$/.test(k)) {
asArray(v).forEach((_v) => {
m[_v] = k
})
} else {
m[k] = v
}
return m
}, {})
export const normalizeQuadrantTitles = (titles) => ({
q1: 'Q1',
q2: 'Q2',
q3: 'Q3',
q4: 'Q4',
...titles,
})
const LEGACY_RING_IDS = ['adopt', 'trial', 'assess', 'hold']
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s)
/** True iff the parsed radar has 4 sectors AND 4 rings whose ids match the
* legacy adopt/trial/assess/hold ordering. Used to decide whether to
* emit the legacy `quadrant*` view alongside the canonical sectors/rings
* arrays — only then can the zalando renderer consume the doc. */
const canBe4x4 = (sectors, rings) =>
sectors.length === 4 &&
rings.length === 4 &&
rings.every((r, i) => r.id === LEGACY_RING_IDS[i])
/** Flatten `{ alias: 'sN' }` map → array of aliases for a given sector. */
const aliasesFor = (sid, aliasMap) =>
Object.entries(aliasMap || {})
.filter(([, v]) => v === sid)
.map(([k]) => k)
/** Build the canonical `sectors` array. Inputs (in priority order):
* 1. doc.sectors — Flex array form
* 2. doc.sectorTitles + sectorAliases — Flex CSV form
* 3. doc.quadrantTitles + quadrantAliases — legacy 4x4
* Output items are `{ id: 'sN', title, aliases: [...] }`, ordered by id. */
const computeSectors = (doc) => {
if (Array.isArray(doc.sectors) && doc.sectors.length) {
return doc.sectors.map((s, i) => ({
id: s.id || `s${i + 1}`,
title: s.title,
aliases: s.aliases || [],
}))
}
if (doc.sectorTitles && Object.keys(doc.sectorTitles).length) {
return Object.keys(doc.sectorTitles)
.toSorted()
.map((id) => ({
id,
title: doc.sectorTitles[id],
aliases: aliasesFor(id, doc.sectorAliases),
}))
}
// Legacy: derive s1..s4 from quadrantTitles + quadrantAliases.
const qTitles = normalizeQuadrantTitles(doc.quadrantTitles || {})
const qAliases = normalizeQuadrantAliases(doc.quadrantAliases || {})
return ['q1', 'q2', 'q3', 'q4'].map((qid, i) => ({
id: `s${i + 1}`,
title: qTitles[qid],
aliases: aliasesFor(qid, qAliases),
}))
}
/** Build the canonical `rings` array. Inputs:
* 1. doc.rings — Flex array form
* 2. doc.ringTitles — Flex CSV form
* 3. Auto-derive from entries: if all entry rings match the legacy
* adopt/trial/assess/hold names → use the legacy order; otherwise
* first-seen order with `r1..rN` ids. */
const computeRings = (doc) => {
if (Array.isArray(doc.rings) && doc.rings.length) {
return doc.rings.map((r, i) => ({ id: r.id || `r${i + 1}`, title: r.title }))
}
if (doc.ringTitles && Object.keys(doc.ringTitles).length) {
return Object.keys(doc.ringTitles)
.toSorted()
.map((id) => ({ id, title: doc.ringTitles[id] }))
}
const seen = []
for (const e of doc.data) {
const n = String(e.ring || '').toLowerCase()
if (n && !seen.includes(n)) seen.push(n)
}
const allLegacy = seen.length > 0 && seen.every((n) => LEGACY_RING_IDS.includes(n))
if (allLegacy) {
return LEGACY_RING_IDS.map((id) => ({ id, title: cap(id) }))
}
return seen.map((name, i) => ({ id: `r${i + 1}`, title: cap(name) }))
}
/** Resolve a raw sector reference (sN, qN, alias, title) → canonical sN id. */
const resolveSectorId = (raw, sectors) => {
const norm = String(raw || '').toLowerCase()
// Direct sN id
const direct = sectors.find((s) => s.id === norm)
if (direct) return direct.id
// Legacy quadrant id qN → sN by position
if (/^q[1-8]$/.test(norm)) {
const sid = `s${norm.slice(1)}`
if (sectors.find((s) => s.id === sid)) return sid
}
// Alias (case-insensitive)
const viaAlias = sectors.find((s) =>
(s.aliases || []).some((a) => a.toLowerCase() === norm),
)
if (viaAlias) return viaAlias.id
// Title (case-insensitive)
const byTitle = sectors.find((s) => String(s.title).toLowerCase() === norm)
if (byTitle) return byTitle.id
return norm
}
/** Resolve a raw ring reference (rN, name, title) → canonical rN id. */
const resolveRingId = (raw, rings) => {
const norm = String(raw || '').toLowerCase()
const direct = rings.find((r) => r.id === norm)
if (direct) return direct.id
const byTitle = rings.find((r) => String(r.title).toLowerCase() === norm)
if (byTitle) return byTitle.id
return norm
}
/**
* Build the unified internal model:
* - doc.sectors / doc.rings — canonical arrays (always)
* - doc.data[*].sector / .ring — resolved to canonical ids
* - doc.quadrant{Titles,Aliases} + entry.quadrant — ONLY when the radar
* is structurally 4x4 with the legacy adopt/trial/assess/hold rings;
* gives zalando its expected view, lets aurora share the same doc.
*/
export const normalizeEntries = (doc) => {
const sectors = computeSectors(doc)
const rings = computeRings(doc)
const legacyCompat = canBe4x4(sectors, rings)
doc.data.forEach((entry) => {
const rawSector = entry.sector ?? entry.quadrant
entry.sector = resolveSectorId(rawSector, sectors)
entry.ring = resolveRingId(entry.ring, rings)
entry.moved = +entry.moved || 0
})
doc.data.sort((a, b) => (a.name > b.name ? 1 : -1))
doc.sectors = sectors
doc.rings = rings
if (legacyCompat) {
// Mirror sectors → q1..q4 for the legacy renderer + 4x4 validator.
doc.quadrantTitles = Object.fromEntries(
sectors.map((s, i) => [`q${i + 1}`, s.title]),
)
doc.quadrantAliases = sectors.reduce((m, s, i) => {
const qid = `q${i + 1}`
;(s.aliases || []).forEach((a) => {
m[String(a).toLowerCase()] = qid
})
return m
}, {})
doc.data.forEach((entry) => {
entry.quadrant = entry.sector.replace(/^s/, 'q')
entry.quadrantTitle = doc.quadrantTitles[entry.quadrant]
})
} else {
// Non-4x4 radar — strip the legacy view so the 4x4 schema rejects it
// and the dispatch routes the doc strictly through aurora.
delete doc.quadrantTitles
delete doc.quadrantAliases
}
// CSV scaffolding fields aren't part of either output schema.
delete doc.sectorTitles
delete doc.sectorAliases
delete doc.ringTitles
return doc
}
export const getQuadrant = (quadrant, quadrantAliases) => {
const lowQuadrant = quadrant.toLowerCase()
return quadrantAliases[lowQuadrant] || lowQuadrant
}