OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / config.js

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

/**
 * Codetree per-project configuration. Loads `.codetree.json` from the
 * project root. CLI flags override config fields individually.
 *
 * All fields are optional. Defaults are safe (no auto-crawling, no
 * assumed externals). A project opts into richer behavior by shipping
 * a `.codetree.json` at its root.
 *
 * Schema documented in `docs/DOC-DEDUP-LAYER.md`.
 */

export const DEFAULT_CONFIG = {
  scanRoot: '.',

  // Symbols known to exist outside the scanned tree. doc-cruft resolves
  // these as 'external' instead of 'missing'.
  externalSymbols: [],

  // Filesystem paths known to exist outside the scanned tree. Same
  // treatment: 'external' not 'missing'.
  externalPaths: [],

  // Package names (from node_modules or similar). When an import statement
  // references one of these, it's not expected to resolve locally.
  externalPackages: [],

  // Cross-filesystem dependency crawling.
  // false (default): only scan scanRoot.
  // true: follow package.json `file:../foo` deps, then walk up 2 ancestors
  //       for bare-name matches against sibling directories.
  autoCrawl: false,

  // Explicit additional scan roots. Merged with autoCrawl results.
  siblingDirs: [],

  // Where to find specs relative to scanRoot.
  specDir: 'spec',

  // Glob patterns for markdown discovery in doc-cruft.
  docGlobs: ['docs/**/*.md', 'spec/**/*.md', '*.md'],

  // Regex patterns matching placeholder / example paths that should
  // NOT be classified as real filesystem references.
  placeholderPatterns: [
    '^~/',                    // ~/foo — user home shorthand
    '^/path/to/',             // /path/to/foo — canonical placeholder
    '^<[^>]+>$',              // <placeholder> — template token
    '\\$\\{[^}]+\\}',         // ${var} — variable interpolation
    '<[a-zA-Z_][a-zA-Z0-9_-]*>',  // <name> inside a path, e.g. /etc/<name>
    '^\\[.+\\]\\(.+\\)$'      // accidental markdown link syntax
  ],

  // Module names to treat as deprecated. Validator skips
  // undocumented-exports and undocumented-methods warnings for these.
  // Existence checks still run.
  deprecatedModules: [],

  // Thresholds for `codetree docs --check` gate.
  thresholds: {
    maxMissingRatio: 0.1,
    maxStalenessScore: 0.3,
    maxValidatorFailures: 0
  },

  // Minimum confidence for `codetree docs --apply`.
  // Values: 'high' (default), 'medium', 'low'.
  suggestConfidenceFloor: 'high'
}

/**
 * Load `.codetree.json` from `rootDir`, merged with defaults. Missing
 * file returns defaults. Bad JSON logs a warning and returns defaults —
 * never throws.
 *
 * @param {string} rootDir  - Project root path
 * @returns {object} merged config
 */
export function loadConfig (rootDir) {
  const path = join(rootDir, '.codetree.json')
  if (!existsSync(path)) return cloneDefaults()

  let user
  try {
    user = JSON.parse(readFileSync(path, 'utf-8'))
  } catch (err) {
    // eslint-disable-next-line no-console
    console.warn('[codetree] failed to load ' + path + ': ' + err.message)
    return cloneDefaults()
  }

  return mergeConfig(DEFAULT_CONFIG, user)
}

/**
 * Apply CLI flag overrides on top of a loaded config. CLI > config > default.
 *
 * @param {object} config  - Loaded config
 * @param {object} cli     - CLI flag values (only fields present override)
 * @returns {object} new config
 */
export function applyCliOverrides (config, cli) {
  const out = mergeConfig(config, {})
  if (cli.autoCrawl !== undefined) out.autoCrawl = cli.autoCrawl
  if (Array.isArray(cli.siblingDirs) && cli.siblingDirs.length) {
    out.siblingDirs = dedupe([...out.siblingDirs, ...cli.siblingDirs])
  }
  if (cli.suggestConfidence) out.suggestConfidenceFloor = cli.suggestConfidence
  if (cli.threshold !== undefined) {
    out.thresholds = { ...out.thresholds, maxStalenessScore: cli.threshold }
  }
  return out
}

/**
 * Check if a reference (symbol name, path, or package) is a known external.
 *
 * @param {string} ref  - The reference to check
 * @param {object} config - Loaded config
 * @returns {boolean}
 */
export function isExternalRef (ref, config) {
  if (!ref) return false
  if (config.externalSymbols.includes(ref)) return true
  if (config.externalPackages.includes(ref)) return true
  for (const p of config.externalPaths) {
    if (ref === p || ref.startsWith(p + '/')) return true
  }
  return false
}

/**
 * Check if a reference string matches any placeholder pattern.
 *
 * @param {string} ref  - The reference to test
 * @param {object} config - Loaded config
 * @returns {boolean}
 */
export function isPlaceholder (ref, config) {
  if (!ref) return false
  for (const pattern of config.placeholderPatterns) {
    try {
      if (new RegExp(pattern).test(ref)) return true
    } catch {
      // skip malformed patterns rather than crash
    }
  }
  return false
}

// --- internals ---

function cloneDefaults () {
  return mergeConfig(DEFAULT_CONFIG, {})
}

function mergeConfig (base, user) {
  const out = {}
  for (const key of Object.keys(base)) {
    const baseVal = base[key]
    const userVal = user[key]

    if (key === 'thresholds') {
      out.thresholds = { ...baseVal, ...(userVal || {}) }
    } else if (Array.isArray(baseVal)) {
      out[key] = Array.isArray(userVal) ? dedupe(userVal.slice()) : baseVal.slice()
    } else if (userVal !== undefined) {
      out[key] = userVal
    } else {
      out[key] = baseVal
    }
  }
  // Pass through user-only keys we don't know about (forward-compat)
  for (const key of Object.keys(user)) {
    if (!(key in base)) out[key] = user[key]
  }
  return out
}

function dedupe (arr) {
  return Array.from(new Set(arr))
}

/**
 * Expose default config for tests that want to compare against it.
 */
export function getDefaults () {
  return cloneDefaults()
}

/**
 * Resolve a config's siblingDirs to absolute paths, relative to rootDir.
 *
 * @param {object} config
 * @param {string} rootDir
 * @returns {string[]}
 */
export function resolvedSiblingDirs (config, rootDir) {
  return (config.siblingDirs || []).map(s => resolve(rootDir, s))
}