Visibility internal Owner _ Approver _ Created _ Updated _
taxonomy_qc.ts
| Field | Value |
|---|---|
| Type | TypeScript |
| Source | Product/Projects/Taxonomy/Scripts/taxonomy_qc.ts |
| Parent | Product |
| GitHub | Product/Projects/Taxonomy/Scripts/taxonomy_qc.ts |
This page is auto-generated. Edit the source to change the content.
#!/usr/bin/env ts-node
/**
* uvilo-taxonomy-qc.ts
*
* Quality-control checker for Uvilo Taxonomy CSV files.
* Validates Taxonomy.csv and TaxonomyMap.csv against schema and conventions.
*
* Usage:
* npx ts-node uvilo-taxonomy-qc.ts <taxonomy.csv> [--map <taxonomymap.csv>] [--domain <domainId>]
*/
import * as fs from 'fs';
import * as path from 'path';
// ── Schema constants ──────────────────────────────────────────────────────────
const VALID_DOMAINS = [
'body', 'mind', 'home', 'intimacy', 'connection', 'community',
'school', 'work', 'business', 'money', 'parenting', 'caregiving',
'play', 'meaning', 'growth', 'legacy', 'purpose', 'general',
];
const VALID_TYPES = ['issue', 'aspiration', 'practice'];
const MAX_NAME_LENGTH = 50;
// ── Types ─────────────────────────────────────────────────────────────────────
interface TaxonomyRow {
line: number;
domain: string;
type: string;
id: string;
name: string;
description: string;
tags: string[];
rawTags: string;
}
interface MapRow {
line: number;
id: string;
fromId: string;
toId: string;
weight: string;
note: string;
}
interface Finding {
level: 'ERROR' | 'WARN' | 'INFO';
file: string;
line?: number;
id?: string;
message: string;
}
// ── Parsing ───────────────────────────────────────────────────────────────────
function parseTaxonomyCsv(filePath: string): TaxonomyRow[] {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').filter((l) => l.trim().length > 0);
if (lines.length < 2) return [];
const header = lines[0].split(';').map((h) => h.trim());
const expected = ['domain', 'type', 'id', 'name', 'description', 'tags'];
if (header.join(',') !== expected.join(',')) {
console.error(`ERROR: Unexpected header. Got: ${header.join(';')}`);
process.exit(1);
}
return lines.slice(1).map((line, idx) => {
const parts = line.split(';');
const rawTags = (parts[5] || '').trim();
const tags = rawTags
.replace(/^\{/, '')
.replace(/\}$/, '')
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
return {
line: idx + 2,
domain: (parts[0] || '').trim(),
type: (parts[1] || '').trim(),
id: (parts[2] || '').trim(),
name: (parts[3] || '').trim(),
description: (parts[4] || '').trim(),
tags,
rawTags,
};
});
}
function parseMapCsv(filePath: string): MapRow[] {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').filter((l) => l.trim().length > 0);
if (lines.length < 2) return [];
const header = lines[0].split(',').map((h) => h.trim());
const expected = ['id', 'fromId', 'toId', 'weight', 'note'];
if (header.join(',') !== expected.join(',')) {
console.error(`ERROR: Unexpected map header. Got: ${header.join(',')}`);
process.exit(1);
}
return lines.slice(1).map((line, idx) => {
const parts = line.split(',');
return {
line: idx + 2,
id: (parts[0] || '').trim(),
fromId: (parts[1] || '').trim(),
toId: (parts[2] || '').trim(),
weight: (parts[3] || '').trim(),
note: (parts[4] || '').trim(),
};
});
}
// ── Validation ────────────────────────────────────────────────────────────────
function validateTaxonomy(rows: TaxonomyRow[], domainFilter?: string): Finding[] {
const findings: Finding[] = [];
const ids = new Set<string>();
const filtered = domainFilter ? rows.filter((r) => r.domain === domainFilter) : rows;
// Check all rows for ID uniqueness (across entire file, not just filtered)
for (const row of rows) {
if (ids.has(row.id)) {
findings.push({ level: 'ERROR', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `Duplicate ID: "${row.id}"` });
}
ids.add(row.id);
}
for (const row of filtered) {
// Domain validation
if (!VALID_DOMAINS.includes(row.domain)) {
findings.push({ level: 'ERROR', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `Invalid domain: "${row.domain}"` });
}
// Type validation
if (!VALID_TYPES.includes(row.type)) {
findings.push({ level: 'ERROR', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `Invalid type: "${row.type}"` });
}
// ID format
if (row.id !== row.id.toLowerCase() || /[^a-z0-9_]/.test(row.id)) {
findings.push({ level: 'ERROR', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `ID must be lowercase snake_case: "${row.id}"` });
}
// Name length
if (row.name.length === 0) {
findings.push({ level: 'ERROR', file: 'Taxonomy.csv', line: row.line, id: row.id, message: 'Name is empty' });
} else if (row.name.length > MAX_NAME_LENGTH) {
findings.push({ level: 'ERROR', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `Name exceeds ${MAX_NAME_LENGTH} chars (${row.name.length})` });
}
// Description
if (row.description.length === 0) {
findings.push({ level: 'WARN', file: 'Taxonomy.csv', line: row.line, id: row.id, message: 'Description is empty' });
}
// Tags format
if (!row.rawTags.startsWith('{') || !row.rawTags.endsWith('}')) {
findings.push({ level: 'ERROR', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `Tags must be wrapped in {}: "${row.rawTags}"` });
}
if (row.tags.length < 3) {
findings.push({ level: 'WARN', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `Few tags (${row.tags.length}), recommend 5-7` });
}
// Trailing spaces
if (row.name !== row.name.trim() || row.description !== row.description.trim()) {
findings.push({ level: 'WARN', file: 'Taxonomy.csv', line: row.line, id: row.id, message: 'Trailing/leading spaces in name or description' });
}
}
// Domain grouping check
if (!domainFilter) {
let lastDomain = '';
let lastType = '';
const typeOrder: Record<string, number> = { issue: 0, aspiration: 1, practice: 2 };
for (const row of rows) {
if (row.domain !== lastDomain) {
lastDomain = row.domain;
lastType = '';
}
if (lastType && typeOrder[row.type] < typeOrder[lastType]) {
findings.push({ level: 'WARN', file: 'Taxonomy.csv', line: row.line, id: row.id, message: `Type ordering: "${row.type}" after "${lastType}" — expected issues → aspirations → practices` });
}
lastType = row.type;
}
}
return findings;
}
function validateMap(mapRows: MapRow[], taxRows: TaxonomyRow[], domainFilter?: string): Finding[] {
const findings: Finding[] = [];
const taxIds = new Map<string, TaxonomyRow>();
for (const r of taxRows) taxIds.set(r.id, r);
const mapIds = new Set<string>();
const fromCounts = new Map<string, number>();
for (const row of mapRows) {
// Duplicate map ID
if (mapIds.has(row.id)) {
findings.push({ level: 'ERROR', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `Duplicate map ID` });
}
mapIds.add(row.id);
// Map ID format
if (!row.id.startsWith('tam_')) {
findings.push({ level: 'WARN', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `Map ID should start with "tam_"` });
}
const fromItem = taxIds.get(row.fromId);
const toItem = taxIds.get(row.toId);
// Reference validity
if (!fromItem) {
findings.push({ level: 'ERROR', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `fromId "${row.fromId}" not found in taxonomy` });
}
if (!toItem) {
findings.push({ level: 'ERROR', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `toId "${row.toId}" not found in taxonomy` });
}
if (fromItem && toItem) {
// Domain filter
if (domainFilter && fromItem.domain !== domainFilter) continue;
// Direction check: from should be issue/aspiration, to should be practice
if (fromItem.type === 'practice') {
findings.push({ level: 'ERROR', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `fromId "${row.fromId}" is a practice — should be issue or aspiration` });
}
if (toItem.type !== 'practice') {
findings.push({ level: 'ERROR', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `toId "${row.toId}" is "${toItem.type}" — should be practice` });
}
// Cross-domain mapping
if (fromItem.domain !== toItem.domain) {
findings.push({ level: 'WARN', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `Cross-domain mapping: "${row.fromId}" (${fromItem.domain}) → "${row.toId}" (${toItem.domain})` });
}
fromCounts.set(row.fromId, (fromCounts.get(row.fromId) || 0) + 1);
}
// Weight validation
const w = parseInt(row.weight, 10);
if (isNaN(w) || w < 0 || w > 100) {
findings.push({ level: 'WARN', file: 'TaxonomyMap.csv', line: row.line, id: row.id, message: `Weight "${row.weight}" — expected 0-100` });
}
}
// Check for issues/aspirations with no mapped practices
const relevantRows = domainFilter
? taxRows.filter((r) => r.domain === domainFilter)
: taxRows;
for (const row of relevantRows) {
if (row.type === 'issue' || row.type === 'aspiration') {
const count = fromCounts.get(row.id) || 0;
if (count === 0) {
findings.push({ level: 'WARN', file: 'TaxonomyMap.csv', id: row.id, message: `${row.type} "${row.id}" has no mapped practices` });
} else if (count === 1) {
findings.push({ level: 'INFO', file: 'TaxonomyMap.csv', id: row.id, message: `${row.type} "${row.id}" has only 1 mapped practice (recommend 2-5)` });
}
}
}
// Check for orphan practices (not mapped from anything)
const allToIds = new Set(mapRows.map((r) => r.toId));
for (const row of relevantRows) {
if (row.type === 'practice' && !allToIds.has(row.id)) {
findings.push({ level: 'WARN', file: 'TaxonomyMap.csv', id: row.id, message: `Practice "${row.id}" is not mapped from any issue/aspiration` });
}
}
return findings;
}
// ── Summary ───────────────────────────────────────────────────────────────────
function printSummary(rows: TaxonomyRow[], domainFilter?: string): void {
const filtered = domainFilter ? rows.filter((r) => r.domain === domainFilter) : rows;
const domains = [...new Set(filtered.map((r) => r.domain))];
console.log('\n── Coverage Summary ──────────────────────────');
console.log(`${'Domain'.padEnd(14)} ${'Issues'.padStart(7)} ${'Aspirations'.padStart(12)} ${'Practices'.padStart(10)} ${'Total'.padStart(6)}`);
console.log('─'.repeat(52));
for (const d of domains) {
const dr = filtered.filter((r) => r.domain === d);
const issues = dr.filter((r) => r.type === 'issue').length;
const aspirations = dr.filter((r) => r.type === 'aspiration').length;
const practices = dr.filter((r) => r.type === 'practice').length;
console.log(`${d.padEnd(14)} ${String(issues).padStart(7)} ${String(aspirations).padStart(12)} ${String(practices).padStart(10)} ${String(dr.length).padStart(6)}`);
}
const totalI = filtered.filter((r) => r.type === 'issue').length;
const totalA = filtered.filter((r) => r.type === 'aspiration').length;
const totalP = filtered.filter((r) => r.type === 'practice').length;
console.log('─'.repeat(52));
console.log(`${'TOTAL'.padEnd(14)} ${String(totalI).padStart(7)} ${String(totalA).padStart(12)} ${String(totalP).padStart(10)} ${String(filtered.length).padStart(6)}`);
}
// ── Main ──────────────────────────────────────────────────────────────────────
function main(): void {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help')) {
console.log('Usage: uvilo-taxonomy-qc.ts <taxonomy.csv> [--map <map.csv>] [--domain <id>]');
process.exit(0);
}
const taxonomyPath = args[0];
let mapPath: string | undefined;
let domainFilter: string | undefined;
for (let i = 1; i < args.length; i++) {
if (args[i] === '--map' && args[i + 1]) { mapPath = args[++i]; }
if (args[i] === '--domain' && args[i + 1]) { domainFilter = args[++i]; }
}
if (!fs.existsSync(taxonomyPath)) {
console.error(`File not found: ${taxonomyPath}`);
process.exit(1);
}
console.log(`\n🔍 Uvilo Taxonomy QC — ${path.basename(taxonomyPath)}`);
if (domainFilter) console.log(` Filtering domain: ${domainFilter}`);
const taxRows = parseTaxonomyCsv(taxonomyPath);
console.log(` Loaded ${taxRows.length} taxonomy items`);
let allFindings: Finding[] = validateTaxonomy(taxRows, domainFilter);
if (mapPath) {
if (!fs.existsSync(mapPath)) {
console.error(`Map file not found: ${mapPath}`);
process.exit(1);
}
const mapRows = parseMapCsv(mapPath);
console.log(` Loaded ${mapRows.length} map entries`);
allFindings = allFindings.concat(validateMap(mapRows, taxRows, domainFilter));
}
printSummary(taxRows, domainFilter);
// Print findings
const errors = allFindings.filter((f) => f.level === 'ERROR');
const warns = allFindings.filter((f) => f.level === 'WARN');
const infos = allFindings.filter((f) => f.level === 'INFO');
if (allFindings.length > 0) {
console.log(`\n── Findings ──────────────────────────────────`);
for (const f of errors) {
console.log(` ❌ ERROR [${f.file}${f.line ? `:${f.line}` : ''}] ${f.id ? `(${f.id}) ` : ''}${f.message}`);
}
for (const f of warns) {
console.log(` ⚠️ WARN [${f.file}${f.line ? `:${f.line}` : ''}] ${f.id ? `(${f.id}) ` : ''}${f.message}`);
}
for (const f of infos) {
console.log(` ℹ️ INFO [${f.file}${f.line ? `:${f.line}` : ''}] ${f.id ? `(${f.id}) ` : ''}${f.message}`);
}
}
console.log(`\n── Result: ${errors.length} errors, ${warns.length} warnings, ${infos.length} info ──`);
process.exit(errors.length > 0 ? 1 : 0);
}
main();