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))
}