OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / doc-parser.js

301 lines · javascript ref:
import { readFileSync, existsSync, readdirSync, statSync } from 'fs'
import { resolve, join, basename, relative } from 'path'

/**
 * Parse spec files and SYSTEM.md to extract documented claims.
 * Returns claims linked to module names that the validator can check
 * against the code tree.
 */

export function parseSpecs (specDir) {
  const result = {
    system: null,
    modules: {}
  }

  if (!existsSync(specDir)) return result

  // Parse SYSTEM.md
  const systemPath = join(specDir, 'SYSTEM.md')
  if (existsSync(systemPath)) {
    result.system = parseSystemSpec(readFileSync(systemPath, 'utf-8'))
  }

  // Parse module specs
  const modulesDir = join(specDir, 'modules')
  if (existsSync(modulesDir) && statSync(modulesDir).isDirectory()) {
    for (const file of readdirSync(modulesDir)) {
      if (!file.endsWith('.md')) continue
      const content = readFileSync(join(modulesDir, file), 'utf-8')
      const spec = parseModuleSpec(content, file)
      if (spec.name) {
        result.modules[spec.name] = spec
      }
    }
  }

  return result
}

// --- System spec parser ---

function parseSystemSpec (content) {
  const spec = {
    lastVerified: null,
    modules: [], // { name, location, purpose, category }
    categories: {}, // category -> [module names]
    builtItems: [],
    missingItems: []
  }

  // Last verified date
  const verifiedMatch = content.match(/\*\*Last verified:\*\*\s*(\d{4}-\d{2}-\d{2})/)
  if (verifiedMatch) spec.lastVerified = verifiedMatch[1]

  // Module inventory from markdown tables, grouped by category headers
  // Categories are ### headings above the tables
  const sections = content.split(/^### /m)
  let currentCategory = 'Uncategorized'

  for (const section of sections) {
    const categoryMatch = section.match(/^([^\n]+)/)
    if (categoryMatch && !section.startsWith('#')) {
      currentCategory = categoryMatch[1].trim().replace(/\s*\(.*\)/, '')
    }

    const tablePattern = /\|\s*\*\*(\w[\w-]*)\*\*\s*\|\s*`([^`]*)`\s*\|\s*([^|]+)\|/g
    let match
    while ((match = tablePattern.exec(section)) !== null) {
      const name = match[1]
      spec.modules.push({
        name,
        location: match[2].trim(),
        purpose: match[3].trim(),
        category: currentCategory
      })
      if (!spec.categories[currentCategory]) spec.categories[currentCategory] = []
      spec.categories[currentCategory].push(name)
    }
  }

  // Built items (checkmarks)
  const builtPattern = /- ✅\s*(.+)/g
  let builtMatch
  while ((builtMatch = builtPattern.exec(content)) !== null) {
    spec.builtItems.push(builtMatch[1].trim())
  }

  // Missing items (x marks)
  const missingPattern = /- ❌\s*(.+)/g
  let missingMatch
  while ((missingMatch = missingPattern.exec(content)) !== null) {
    spec.missingItems.push(missingMatch[1].trim())
  }

  return spec
}

// --- Module spec parser ---

function parseModuleSpec (content, filename) {
  const spec = {
    name: null,
    filename,
    location: null,
    testCount: null,
    sourceVerified: null,
    fileLOC: {}, // filename -> claimed LOC
    description: null,
    notDescription: [],
    dependencies: [],
    dependents: [],
    claimedMethods: [],
    claimedGetters: [],
    claimedExports: [],
    claimedConstructorParams: [],
    roleInSystem: null,
    deprecated: false,
    replacedBy: null
  }

  // Module name from title. Accepts both "--" (double-hyphen) and "—"
  // (em-dash) as separators — both are common in hand-written specs and
  // markdown renderers often auto-convert one to the other.
  //   "# hyper-orgs -- Module Spec"
  //   "# hyperident — Module Spec"
  const titleMatch = content.match(/^#\s+(\S+)\s+(?:--|—|–)\s+/m)
  if (titleMatch) spec.name = titleMatch[1]

  // Location
  const locMatch = content.match(/\*\*Location:\*\*\s*`([^`]+)`/)
  if (locMatch) spec.location = locMatch[1].replace(/\/$/, '')

  // Deprecation status. Module spec opt-in header:
  //   **Status:** deprecated
  //   **Replaced by:** IdentityRegistry
  // Deprecated modules skip undocumented-export/method warnings but
  // still fail existence checks (claimed file missing → fail).
  const statusMatch = content.match(/\*\*Status:\*\*\s*([^\n]+)/i)
  if (statusMatch) {
    const statusLine = statusMatch[1].trim().toLowerCase()
    if (statusLine.startsWith('deprecated')) spec.deprecated = true
  }
  const replacedMatch = content.match(/\*\*Replaced by:\*\*\s*`?([^`\n]+?)`?\s*$/im)
  if (replacedMatch) spec.replacedBy = replacedMatch[1].trim()

  // Test count
  const testMatch = content.match(/\*\*Tests:\*\*\s*(\d+)\s*pass/)
  if (testMatch) spec.testCount = parseInt(testMatch[1], 10)

  // Source verified date + LOC claims
  const verifiedMatch = content.match(/\*\*Source verified:\*\*\s*(\d{4}-\d{2}-\d{2})\s*\(([^)]+)\)/)
  if (verifiedMatch) {
    spec.sourceVerified = verifiedMatch[1]
    // Parse "hyper-orgs.js 637 LOC, schema.js 515 LOC"
    const locParts = verifiedMatch[2].split(',')
    for (const part of locParts) {
      const locPartMatch = part.trim().match(/(\S+)\s+(\d+)\s+LOC/)
      if (locPartMatch) {
        spec.fileLOC[locPartMatch[1]] = parseInt(locPartMatch[2], 10)
      }
    }
  }

  // Description (## What it is)
  const whatMatch = content.match(/## What it is\n\n([\s\S]*?)(?=\n## |\n---|\n$)/)
  if (whatMatch) spec.description = whatMatch[1].trim()

  // Not description (## What it is NOT)
  const notSection = content.match(/## What it is NOT\n\n([\s\S]*?)(?=\n## |\n---|\n$)/)
  if (notSection) {
    const lines = notSection[1].split('\n').filter(l => l.startsWith('- '))
    spec.notDescription = lines.map(l => l.replace(/^- /, '').trim())
  }

  // Dependencies section
  const depSection = content.match(/## Dependencies\n\n([\s\S]*?)(?=\n## |\n$)/)
  if (depSection) {
    const lines = depSection[1].split('\n').filter(l => l.startsWith('- '))
    for (const line of lines) {
      const depMatch = line.match(/`([^`]+)`/)
      if (depMatch) spec.dependencies.push(depMatch[1])
    }
  }

  // Dependents section
  const deptsSection = content.match(/## What depends on this\n\n([\s\S]*?)(?=\n## |\n$)/)
  if (deptsSection) {
    const lines = deptsSection[1].split('\n').filter(l => l.startsWith('- '))
    for (const line of lines) {
      const deptMatch = line.match(/\*\*([^*]+)\*\*/)
      if (deptMatch) spec.dependents.push(deptMatch[1])
    }
  }

  // Role in pear-git
  const roleSection = content.match(/## Role in pear-git\n\n([\s\S]*?)(?=\n## |\n$)/)
  if (roleSection) spec.roleInSystem = roleSection[1].trim()

  // Extract method names from API code block
  const apiBlock = content.match(/## API[^\n]*\n\n```js\n([\s\S]*?)```/)
  if (apiBlock) {
    const code = apiBlock[1]

    // Method calls: await orgs.methodName( or orgs.methodName(
    const methodPattern = /(?:await\s+)?\w+\.(\w+)\s*\(/g
    let m
    while ((m = methodPattern.exec(code)) !== null) {
      const name = m[1]
      if (!spec.claimedMethods.includes(name) && name !== 'ready' && name !== 'close') {
        spec.claimedMethods.push(name)
      }
    }

    // Properties: orgs.key / keyHex / ...
    const propPattern = /\w+\.(\w+)\s*[/\n]/g
    while ((m = propPattern.exec(code)) !== null) {
      if (!spec.claimedGetters.includes(m[1])) {
        spec.claimedGetters.push(m[1])
      }
    }

    // Constructor: new ClassName(param1, { ... })
    const ctorMatch = code.match(/new\s+(\w+)\s*\(([^)]*)\)/)
    if (ctorMatch) {
      spec.claimedExports.push(ctorMatch[1])
      // Extract param names
      const params = ctorMatch[2].split(',').map(p => p.trim().replace(/[{}\s].*/g, '')).filter(Boolean)
      spec.claimedConstructorParams = params
    }

    // Import: import { X } from 'module'
    const importMatch = code.match(/import\s*\{([^}]+)\}\s*from/)
    if (importMatch) {
      const names = importMatch[1].split(',').map(n => n.trim())
      for (const n of names) {
        if (!spec.claimedExports.includes(n)) spec.claimedExports.push(n)
      }
    }
  }

  return spec
}

/**
 * Scan a directory for all doc sources — specs, READMEs, JSDoc.
 * Returns a unified doc inventory.
 */
export function findAllDocs (rootDir) {
  const docs = {
    specs: [],     // spec/modules/*.md
    system: null,  // spec/SYSTEM.md
    readmes: [],   // */README.md
    specDir: null
  }

  // Look for spec directory in common locations
  const specLocations = [
    join(rootDir, 'spec'),
    join(rootDir, 'docs', 'spec')
  ]

  // Also check each module for its own spec dir
  try {
    for (const entry of readdirSync(rootDir)) {
      const specPath = join(rootDir, entry, 'spec')
      if (existsSync(specPath) && statSync(specPath).isDirectory()) {
        specLocations.push(specPath)
      }
    }
  } catch {}

  for (const specDir of specLocations) {
    if (!existsSync(specDir)) continue
    docs.specDir = specDir

    const systemPath = join(specDir, 'SYSTEM.md')
    if (existsSync(systemPath)) docs.system = systemPath

    const modulesDir = join(specDir, 'modules')
    if (existsSync(modulesDir)) {
      try {
        for (const f of readdirSync(modulesDir)) {
          if (f.endsWith('.md')) docs.specs.push(join(modulesDir, f))
        }
      } catch {}
    }
  }

  // Find READMEs
  try {
    for (const entry of readdirSync(rootDir)) {
      const readmePath = join(rootDir, entry, 'README.md')
      if (existsSync(readmePath)) {
        docs.readmes.push({ module: entry, path: readmePath })
      }
    }
  } catch {}

  return docs
}