Skip to content
Visibility internal Owner _ Approver _ Created _ Updated _

taxonomy_qc.ts



FieldValue
TypeTypeScript
SourceProduct/Projects/Taxonomy/Scripts/taxonomy_qc.ts
ParentProduct
GitHubProduct/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();