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