OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / validator.js

505 lines · javascript ref:
import { readFileSync, statSync } from 'fs'
import { join, resolve } from 'path'

/**
 * Validate spec claims against the code tree.
 * Returns a validation report with pass/fail/warn per claim.
 */

export function validate (tree, specs) {
  const report = {
    system: null,
    modules: {},
    docFreshness: null,
    summary: { pass: 0, fail: 0, warn: 0, total: 0 }
  }

  // Validate system spec (pass module specs for cross-level checks)
  if (specs.system) {
    report.system = validateSystem(tree, specs.system, specs)
    tally(report.summary, report.system.checks)
  }

  // Validate each module spec
  for (const [name, spec] of Object.entries(specs.modules)) {
    const mod = findModule(tree, spec)
    report.modules[name] = validateModule(tree, mod, spec)
    tally(report.summary, report.modules[name].checks)
  }

  // Validate documentation freshness (from doc cruft detection)
  if (tree.docCruft) {
    report.docFreshness = validateDocFreshness(tree.docCruft)
    tally(report.summary, report.docFreshness.checks)
  }

  return report
}

// --- System-level validation ---

function validateSystem (tree, systemSpec, specs) {
  const result = { checks: [] }

  // Check: each documented module exists in the code tree
  for (const docMod of systemSpec.modules) {
    const found = tree.modules[docMod.name]
    const locationMatch = found && found.path && docMod.location.includes(found.path)

    result.checks.push({
      level: 'system',
      canonicity: 'code-canonical',
      claim: 'Module "' + docMod.name + '" exists at ' + docMod.location,
      status: found ? (locationMatch ? 'pass' : 'warn') : 'fail',
      expected: docMod.location,
      actual: found ? found.path : null,
      detail: !found ? 'Module not found in scan' :
        (!locationMatch ? 'Location mismatch: spec says ' + docMod.location + ', found at ' + found.path : null)
    })
  }

  // Check: modules in code that aren't documented
  const documentedNames = new Set(systemSpec.modules.map(m => m.name))
  for (const name of tree.system.modules) {
    if (!documentedNames.has(name)) {
      result.checks.push({
        level: 'system',
        claim: 'Module "' + name + '" is documented in SYSTEM.md',
        status: 'warn',
        detail: 'Module exists in code but not documented in system spec'
      })
    }
  }

  // --- Cross-level: orchestrator integration ---
  // Find the orchestrator (pear-git-v3 or the "Applications" module)
  // Find the orchestrator — the module whose purpose STARTS with "Orchestrator" or "Mounts all"
  const orchestrators = systemSpec.modules.filter(m =>
    m.purpose && (m.purpose.startsWith('Orchestrator') || m.purpose.startsWith('Mounts all'))
  )

  for (const orch of orchestrators) {
    const orchMod = tree.modules[orch.name]
    if (!orchMod) continue

    const orchDeps = new Set(Object.keys(orchMod.dependencies))

    // Every module in the system spec should be either:
    // 1. A dependency of the orchestrator
    // 2. A dependency of a dependency (transitive)
    // 3. Explicitly marked as "not yet integrated" or "external"
    for (const docMod of systemSpec.modules) {
      if (docMod.name === orch.name) continue
      if (docMod.location === 'external' || docMod.location.includes('npm dep')) continue

      const isDirectDep = orchDeps.has(docMod.name)

      // Check transitive: is it a dep of any of the orchestrator's deps? (2 levels deep)
      let isTransitive = false
      let transitiveVia = null
      if (!isDirectDep) {
        for (const depName of orchDeps) {
          const depMod = tree.modules[depName]
          if (!depMod) continue
          if (depMod.dependencies[docMod.name]) {
            isTransitive = true
            transitiveVia = depName
            break
          }
          // 2nd level
          for (const dep2Name of Object.keys(depMod.dependencies)) {
            const dep2Mod = tree.modules[dep2Name]
            if (dep2Mod && dep2Mod.dependencies[docMod.name]) {
              isTransitive = true
              transitiveVia = depName + ' -> ' + dep2Name
              break
            }
          }
          if (isTransitive) break
        }
      }

      if (!isDirectDep && !isTransitive) {
        result.checks.push({
          level: 'system',
          claim: orch.name + ' integrates "' + docMod.name + '"',
          status: 'fail',
          detail: 'SYSTEM.md lists "' + docMod.name + '" as part of the system but ' + orch.name + ' does not depend on it (direct or transitive). Purpose: ' + (docMod.purpose || '').slice(0, 100)
        })
      } else {
        result.checks.push({
          level: 'system',
          claim: orch.name + ' integrates "' + docMod.name + '"',
          status: 'pass',
          detail: isDirectDep ? 'Direct dependency' : 'Transitive via ' + transitiveVia
        })
      }
    }
  }

  // --- Cross-level: dependency bidirectional consistency ---
  // If module A's spec says "depends on B", and B's spec says "depended on by A",
  // check that both match the actual code
  for (const docMod of systemSpec.modules) {
    const mod = tree.modules[docMod.name]
    if (!mod) continue

    // Check if module has any local deps that aren't in the system spec
    for (const dep of Object.keys(mod.dependencies)) {
      const depInSystem = systemSpec.modules.find(m => m.name === dep)
      const depInCode = tree.modules[dep]
      if (depInCode && !depInSystem) {
        result.checks.push({
          level: 'system',
          claim: '"' + docMod.name + '" dependency "' + dep + '" is in system spec',
          status: 'warn',
          detail: docMod.name + ' depends on ' + dep + ' (found in code) but ' + dep + ' is not documented in SYSTEM.md'
        })
      }
    }
  }

  // --- Cross-level: module spec dependency consistency ---
  // Check that each module spec's "Dependencies" and "What depends on this"
  // sections match actual code
  if (specs && specs.modules) {
    for (const [name, modSpec] of Object.entries(specs.modules)) {
      const mod = findModuleBySpec(tree, modSpec)
      if (!mod) continue

      // Spec says "X depends on this" — verify X actually depends on this module
      for (const dependent of modSpec.dependents) {
        const depMod = tree.modules[dependent]
        if (!depMod) {
          result.checks.push({
            level: 'system',
            claim: '"' + dependent + '" depends on "' + name + '" (per ' + name + ' spec)',
            status: 'warn',
            detail: 'Module spec for ' + name + ' claims ' + dependent + ' depends on it, but ' + dependent + ' not found in scan'
          })
          continue
        }
        const actuallyDepends = depMod.dependencies[name] !== undefined
        result.checks.push({
          level: 'system',
          claim: '"' + dependent + '" depends on "' + name + '"',
          status: actuallyDepends ? 'pass' : 'fail',
          detail: actuallyDepends ? null : name + ' spec says ' + dependent + ' depends on it, but ' + dependent + '/package.json does not list ' + name + ' as a dependency'
        })
      }
    }
  }

  return result
}

function findModuleBySpec (tree, spec) {
  if (tree.modules[spec.name]) return tree.modules[spec.name]
  if (spec.location) {
    for (const mod of Object.values(tree.modules)) {
      if (spec.location.includes(mod.path)) return mod
    }
  }
  return null
}

// --- Module-level validation ---

function validateModule (tree, mod, spec) {
  const result = {
    specFile: spec.filename,
    checks: []
  }

  if (!mod) {
    result.checks.push({
      level: 'module',
      canonicity: 'code-canonical',
      claim: 'Module "' + spec.name + '" exists',
      status: 'fail',
      detail: 'Spec references module at ' + spec.location + ' but it was not found in the scan'
    })
    return result
  }

  // --- LOC checks ---
  for (const [filename, claimedLOC] of Object.entries(spec.fileLOC)) {
    // Find the file in the module
    const filePath = findFileInModule(mod, filename)
    if (!filePath) {
      result.checks.push({
        level: 'file',
        canonicity: 'code-canonical',
        claim: 'File "' + filename + '" exists',
        status: 'fail',
        detail: 'Spec claims ' + filename + ' has ' + claimedLOC + ' LOC but file not found'
      })
      continue
    }

    const file = mod.files[filePath]
    if (!file) continue

    // Count actual LOC by finding the source
    const absPath = resolve(tree.root, mod.path, filePath)
    let actualLOC = null
    try {
      const source = readFileSync(absPath, 'utf-8')
      actualLOC = source.split('\n').length
    } catch {}

    if (actualLOC !== null) {
      const drift = Math.abs(actualLOC - claimedLOC)
      const driftPct = (drift / claimedLOC * 100).toFixed(0)
      result.checks.push({
        level: 'file',
        canonicity: 'code-canonical',
        claim: filename + ' has ' + claimedLOC + ' LOC',
        status: drift === 0 ? 'pass' : drift < 20 ? 'warn' : 'fail',
        expected: claimedLOC,
        actual: actualLOC,
        detail: drift > 0 ? 'LOC drifted by ' + drift + ' lines (' + driftPct + '%). Spec says ' + claimedLOC + ', actual is ' + actualLOC : null
      })
    }
  }

  // --- Source verified staleness ---
  if (spec.sourceVerified) {
    const verifiedDate = new Date(spec.sourceVerified)
    let newestMtime = 0
    for (const filePath of Object.keys(mod.files)) {
      try {
        const absPath = resolve(tree.root, mod.path, filePath)
        const stat = statSync(absPath)
        if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs
      } catch {}
    }
    if (newestMtime > 0) {
      const codeDate = new Date(newestMtime)
      const stale = codeDate > verifiedDate
      result.checks.push({
        level: 'module',
        claim: 'Source verified on ' + spec.sourceVerified,
        status: stale ? 'warn' : 'pass',
        expected: spec.sourceVerified,
        actual: codeDate.toISOString().split('T')[0],
        detail: stale ? 'Code modified on ' + codeDate.toISOString().split('T')[0] + ', after spec verified on ' + spec.sourceVerified : null
      })
    }
  }

  // --- Dependency checks ---
  for (const dep of spec.dependencies) {
    const hasDep = mod.dependencies[dep] !== undefined
    result.checks.push({
      level: 'module',
      canonicity: 'code-canonical',
      claim: 'Depends on ' + dep,
      status: hasDep ? 'pass' : 'fail',
      detail: !hasDep ? 'Spec claims dependency on ' + dep + ' but not found in package.json' : null
    })
  }

  // Check reverse: deps in package.json not documented
  // Deprecated modules skip this — the module is being removed, undocumented
  // deps aren't worth flagging.
  if (!spec.deprecated) {
    for (const dep of Object.keys(mod.dependencies)) {
      // Only check local/known deps
      if (!tree.modules[dep]) continue
      if (!spec.dependencies.includes(dep)) {
        result.checks.push({
          level: 'module',
          claim: 'Dependency "' + dep + '" is documented',
          status: 'warn',
          detail: 'package.json depends on ' + dep + ' but spec does not mention it'
        })
      }
    }
  }

  // --- Export checks ---
  for (const claimedExport of spec.claimedExports) {
    const found = mod.publicAPI.includes(claimedExport)
    result.checks.push({
      level: 'module',
      canonicity: 'code-canonical',
      claim: 'Exports "' + claimedExport + '"',
      status: found ? 'pass' : 'fail',
      detail: !found ? 'Spec claims export "' + claimedExport + '" but not found in public API: [' + mod.publicAPI.join(', ') + ']' : null
    })
  }

  // Undocumented exports — skip for deprecated modules. A module being
  // phased out shouldn't generate warnings for things its spec no longer
  // bothers documenting.
  if (!spec.deprecated) {
    for (const exp of mod.publicAPI) {
      if (!spec.claimedExports.includes(exp)) {
        result.checks.push({
          level: 'module',
          claim: 'Export "' + exp + '" is documented',
          status: 'warn',
          detail: 'Module exports "' + exp + '" but spec does not document it'
        })
      }
    }
  }

  // --- Method checks ---
  const allMethods = getAllMethods(mod)
  for (const method of spec.claimedMethods) {
    const found = allMethods.has(method)
    result.checks.push({
      level: 'method',
      canonicity: 'code-canonical',
      claim: 'Method "' + method + '" exists',
      status: found ? 'pass' : 'fail',
      detail: !found ? 'Spec documents method "' + method + '" but not found in any class' : null
    })
  }

  // Undocumented methods (only for primary class). Skip for deprecated
  // modules — they're being removed, no value in flagging missing docs.
  if (!spec.deprecated) {
    const primaryClass = findPrimaryClass(mod, spec)
    if (primaryClass && spec.claimedMethods.length > 0) {
      for (const m of primaryClass.methods) {
        if (m.kind === 'field') continue
        if (m.name.startsWith('_')) continue // skip private
        if (!spec.claimedMethods.includes(m.name) && m.name !== 'ready' && m.name !== 'close' && m.name !== 'sync') {
          result.checks.push({
            level: 'method',
            canonicity: 'code-canonical',
            claim: 'Method "' + m.name + '" is documented',
            status: 'warn',
            detail: 'Class has public method "' + m.name + '(' + m.params.join(', ') + ')" but spec does not document it'
          })
        }
      }
    }
  }

  // --- Getter checks ---
  for (const getter of spec.claimedGetters) {
    const found = allGetters(mod).has(getter)
    result.checks.push({
      level: 'method',
      canonicity: 'code-canonical',
      claim: 'Getter "' + getter + '" exists',
      status: found ? 'pass' : 'fail',
      detail: !found ? 'Spec documents getter "' + getter + '" but not found' : null
    })
  }

  return result
}

// --- Documentation freshness validation ---

function validateDocFreshness (docCruft) {
  const result = { checks: [] }

  for (const doc of docCruft.docs) {
    // Skip docs with no code references (pure prose)
    if (doc.references.total === 0) continue

    const missingRatio = doc.references.missing / doc.references.total
    let status = 'pass'
    if (missingRatio > 0.3) status = 'fail'
    else if (missingRatio > 0.1) status = 'warn'

    result.checks.push({
      level: 'doc',
      canonicity: 'narrative',
      claim: doc.path + ' references are current',
      status,
      detail: doc.references.missing + ' of ' + doc.references.total + ' code references missing. Last modified ' + doc.lastModified + ' (staleness ' + doc.staleness.score + ')',
      docPath: doc.path
    })
  }

  return result
}

// --- Helpers ---

function findModule (tree, spec) {
  // Try by name first
  if (tree.modules[spec.name]) return tree.modules[spec.name]

  // Try by location path
  if (spec.location) {
    for (const mod of Object.values(tree.modules)) {
      if (spec.location.includes(mod.path) || mod.path.includes(spec.location)) {
        return mod
      }
    }
  }

  return null
}

function findFileInModule (mod, filename) {
  // Exact match
  if (mod.files[filename]) return filename

  // Try with lib/ prefix
  if (mod.files['lib/' + filename]) return 'lib/' + filename

  // Search by basename
  for (const path of Object.keys(mod.files)) {
    if (path.endsWith('/' + filename) || path === filename) return path
  }

  return null
}

function getAllMethods (mod) {
  const methods = new Set()
  for (const file of Object.values(mod.files)) {
    if (file.classes) {
      for (const cls of file.classes) {
        for (const m of cls.methods) methods.add(m.name)
      }
    }
    if (file.functions) {
      for (const fn of file.functions) methods.add(fn.name)
    }
  }
  return methods
}

function allGetters (mod) {
  const getters = new Set()
  for (const file of Object.values(mod.files)) {
    if (file.classes) {
      for (const cls of file.classes) {
        if (cls.getters) for (const g of cls.getters) getters.add(g.name)
      }
    }
  }
  return getters
}

function findPrimaryClass (mod, spec) {
  // Find the main exported class
  for (const file of Object.values(mod.files)) {
    if (file.classes) {
      for (const cls of file.classes) {
        if (spec.claimedExports.includes(cls.name)) return cls
      }
    }
  }
  return null
}

function tally (summary, checks) {
  for (const c of checks) {
    summary.total++
    if (c.status === 'pass') summary.pass++
    else if (c.status === 'fail') summary.fail++
    else if (c.status === 'warn') summary.warn++
  }
}