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
}