OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / classify-project.js

144 lines · javascript ref:
// Shared project classifier.
// Single source of truth for mapping a working directory (+ optional file list)
// to a project name. Used by loop-detector.js (at loop extraction time) and
// loop-briefing.js (at SessionStart).
//
// Strategy, in order:
//   1. Hardcoded substring match — preserves backwards compatibility with
//      historical classifications AND handles names that don't match a
//      directory basename (e.g., "nvidia-rtx" when the path contains "nvidia").
//   2. Walk parent directories from cwd looking for .git. Use that directory's
//      basename. If the basename is too generic (e.g., "repos", "src"), walk
//      up one more level.
//   3. Fall back to 'unknown'.
//
// Before this file existed, classifyProject was duplicated in loop-detector.js
// and loop-briefing.js with a hardcoded ~11-project allowlist, which left
// ~16% of loops and ~11% of rules in the 'unknown' bucket — including all
// work done on hypercore-kb, opencog-kb, hyper-deploy, and several other
// directories that didn't happen to be on the list.

const fs = require('fs')
const path = require('path')

// Generic directory names that are never useful project identifiers on
// their own. If the git root directory has one of these as its basename,
// walk up one more level to get the real project name.
const GENERIC_BASENAMES = new Set([
  'repos', 'src', 'code', 'build', 'dist', 'lib', 'project', 'projects',
  'work', 'workspace', 'tmp', 'temp', 'test', 'tests', 'app', 'apps'
])

// Hardcoded substring matches. Preserves backwards compatibility with
// historical loops.json / learned-rules.json entries. These must match
// the set of names used in those files.
function matchHardcoded (haystack) {
  const lower = haystack.toLowerCase()
  // Order matters: more specific before more general.
  if (lower.includes('dc-terrain') || lower.includes('dc_terrain')) return 'dc-terrain'
  if (lower.includes('hyper-tps')) return 'hyper-tps'
  if (lower.includes('hypernote')) return 'hypernote'
  if (lower.includes('pear-git')) return 'pear-git'
  if (lower.includes('blender-bridge') || lower.includes('blender_bridge')) return 'blender-bridge'
  if (lower.includes('proc-nodes') || lower.includes('proc_nodes')) return 'proc-nodes'
  if (lower.includes('hyper-overlay')) return 'hyper-overlay'
  if (lower.includes('hyper-social')) return 'hyper-social'
  if (lower.includes('nvidia') || lower.includes('rtx')) return 'nvidia-rtx'
  if (lower.includes('nubis') || lower.includes('clouds')) return 'nubis'
  if (lower.includes('godot')) return 'godot'
  return null
}

// Walk parent directories from startDir, return the first one containing
// a .git entry (file or directory), or null if none found before root.
function findGitRoot (startDir) {
  if (!startDir) return null
  let dir
  try {
    dir = path.resolve(startDir)
  } catch {
    return null
  }
  while (dir && dir !== path.dirname(dir)) {
    try {
      if (fs.existsSync(path.join(dir, '.git'))) return dir
    } catch {}
    dir = path.dirname(dir)
  }
  return null
}

// Derive a project name from a git root directory.
// If the basename is generic (like "repos"), walk up one more level.
function nameFromGitRoot (gitRoot) {
  if (!gitRoot) return null
  const name = path.basename(gitRoot)
  if (!GENERIC_BASENAMES.has(name)) return name
  const parentDir = path.dirname(gitRoot)
  if (!parentDir || parentDir === path.dirname(parentDir)) return name
  const parentName = path.basename(parentDir)
  return (parentName && !GENERIC_BASENAMES.has(parentName)) ? parentName : name
}

// Main entry point. Returns a project name string, or 'unknown' as the
// explicit fallback. Never returns null or undefined.
function classifyProject (cwd, files = []) {
  // Strategy 1: hardcoded substring match (backwards compat + specific names)
  const haystack = (cwd || '') + ' ' + ((files || []).join(' '))
  const hardcoded = matchHardcoded(haystack)
  if (hardcoded) return hardcoded

  // Strategy 2: walk parents to find .git, use that dir's basename
  const gitRoot = findGitRoot(cwd)
  if (gitRoot) {
    const name = nameFromGitRoot(gitRoot)
    if (name) return name
  }

  return 'unknown'
}

module.exports = {
  classifyProject,
  matchHardcoded,
  findGitRoot,
  nameFromGitRoot,
  GENERIC_BASENAMES
}

// ─── Self-test ──────────────────────────────────────────────────────────
// Run `node classify-project.js` to verify classification against known cwds.
if (require.main === module) {
  const cases = [
    // Previously bucketed 'unknown' — should now classify properly
    { cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-deploy', expect: 'hyper-deploy' },
    { cwd: '/storage/projects/code/hypercore-kb/opencog-kb/repos', expect: 'opencog-kb' },
    { cwd: '/storage/projects/code/hypercore-kb', expect: 'hypercore-kb' },
    // knowledge-ratchet is its own git repo nested inside hypercore-kb
    { cwd: '/storage/projects/code/hypercore-kb/knowledge-ratchet', expect: 'knowledge-ratchet' },
    // Previously classified via hardcoded list — must still match
    { cwd: '/storage/projects/code/godot/dc-terrain/src', expect: 'dc-terrain' },
    { cwd: '/home/user/hyper-tps/game', expect: 'hyper-tps' },
    { cwd: '/any/path/with/pear-git/in/it', expect: 'pear-git' },
    { cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hypernote', expect: 'hypernote' },
    { cwd: '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-overlay', expect: 'hyper-overlay' },
    // Edge cases
    { cwd: '', expect: 'unknown' },
    { cwd: '/tmp', expect: 'unknown' },
    { cwd: null, expect: 'unknown' }
  ]

  let passed = 0
  let failed = 0
  for (const { cwd, expect } of cases) {
    const got = classifyProject(cwd)
    const ok = got === expect
    if (ok) passed++
    else failed++
    const mark = ok ? '✓' : '✗'
    console.log(`${mark} classifyProject(${JSON.stringify(cwd)}) => ${JSON.stringify(got)} ${ok ? '' : `(expected ${JSON.stringify(expect)})`}`)
  }
  console.log(`\n${passed}/${passed + failed} passed`)
  if (failed > 0) process.exit(1)
}