UNPKG

nadial-logic

Version:

Core logic for NadiaL, enabling multi-round fulfillment processes, directory scanning, and contextual verification for research and project analysis.

409 lines (365 loc) 13.4 kB
// index.js // Fulfillment-through-family — tiny, dependency-free Node.js library // Programmatic API for your high-speed scanner (based on worker_threads). // // ✅ Usage // const { run, validateConfig } = require('@leumas/fulfillment'); // hypothetical package name // const summary = await run('D:/Leumas/tools/leumas-fufillment/adapters/1.json', { workers: 8 }); // console.log(summary.totalMatches, summary.totalOk); // // // Or pass a config object directly: // const summary2 = await run({ // main: 'D:/Leumas', // scanFor: ['adapters','helpers'], // siblings: ['docs'], // parents: ['README.md'], // grandparents: [], // nth: [{ up:0, mustInclude:['index.js'], type:'file' }, { up:2, mustInclude:['server.js'], type:'file' }], // type: 'any', // ignore: ['node_modules', '.git', 'dist', 'build'], // }, { workers: 4, onChunk: (partial) => console.log('chunk done:', partial.chunkRoot) }); // // Exports: // - run(configOrPath, options?) -> Promise<Summary> // - validateConfig(config) -> string[] // // Options: // - workers?: number // number of worker threads (default: #cpus) // - noWorkers?: boolean // force single-threaded // - onChunk?: (partial, idx, total) => void // progress callback // - signal?: AbortSignal // optional abort controller // // The returned Summary matches the CLI script’s JSON shape. const fs = require('fs'); const path = require('path'); const os = require('os'); const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); const VERSION = '1.0.0'; const DEFAULT_IGNORES = ['node_modules', '.git', 'dist', 'build', '.cache', '.next', 'out', 'tmp', 'temp']; /** @typedef {'any'|'dir'|'file'} NodeKind */ /** * @typedef {Object} NthRule * @property {number} up * @property {string[]} mustInclude * @property {NodeKind} [type] */ /** * @typedef {Object} Config * @property {string} main * @property {string[]} scanFor * @property {string[]} [siblings] * @property {string[]} [parents] * @property {string[]} [grandparents] * @property {NthRule[]} [nth] * @property {NodeKind} [type] // default search kind at each level * @property {string[]} [ignore] */ /** * @typedef {Object} Options * @property {number} [workers] * @property {boolean} [noWorkers] * @property {(partial: any, index: number, total: number) => void} [onChunk] // progress callback * @property {AbortSignal} [signal] // optional abort controller */ /* ----------------------------- Shared Helpers ----------------------------- */ function safeList(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; } } function hasChild(dir, name, type = 'any') { const entries = safeList(dir); for (const e of entries) { if (e.name !== name) continue; if (type === 'any') return true; if (type === 'dir' && e.isDirectory()) return true; if (type === 'file' && e.isFile()) return true; } return false; } function ancestorPath(start, up) { let cur = start; for (let i = 0; i < up; i++) { const parent = path.dirname(cur); if (!parent || parent === cur) return null; cur = parent; } return cur; } function evaluateAtLevel(foundDir, up, required = [], type = 'any') { const target = up === 0 ? foundDir : ancestorPath(foundDir, up); if (!target) { return { level: up, ancestorPath: null, present: [], missing: [...required], ok: required.length === 0, error: 'Ancestor not available' }; } const present = []; const missing = []; for (const name of required) { (hasChild(target, name, type) ? present : missing).push(name); } return { level: up, ancestorPath: target, present, missing, ok: missing.length === 0 }; } /* ------------------------------ Core Scanner ------------------------------ */ function crawlAndCheck(chunkRoot, cfg) { const { scanFor, ignore, defaultType, siblings, parents, grandparents, nth } = cfg; const ignoreSet = new Set(ignore || []); const results = []; let totalDirsScanned = 0; // Non-recursive stack DFS const stack = [chunkRoot]; while (stack.length) { const dir = stack.pop(); const base = path.basename(dir); if (ignoreSet.has(base)) continue; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } totalDirsScanned++; // Traverse subdirectories for (const e of entries) { if (e.isDirectory()) { const nb = e.name; if (!ignoreSet.has(nb)) stack.push(path.join(dir, nb)); } } // Match? if (scanFor.includes(base)) { const parent = ancestorPath(dir, 1); const grandparent = ancestorPath(dir, 2); const checks = { siblings: evaluateAtLevel(dir, 1, siblings, defaultType), parents: evaluateAtLevel(dir, 2, parents, defaultType), grandparents: evaluateAtLevel(dir, 3, grandparents, defaultType), nth: [] }; for (const spec of nth) { const up = Number.isInteger(spec.up) ? spec.up : 0; const must = Array.isArray(spec.mustInclude) ? spec.mustInclude : []; const t = spec.type || defaultType; checks.nth.push(evaluateAtLevel(dir, up, must, t)); } const ok = checks.siblings.ok && checks.parents.ok && checks.grandparents.ok && checks.nth.every(c => c.ok); results.push({ matchName: base, matchPath: dir, parentPath: parent, grandparentPath: grandparent, checks, ok }); } } return { chunkRoot, totalDirsScanned, results }; } /* ---------------------------- Worker Entrypoint --------------------------- */ if (!isMainThread && workerData && workerData.__fulfillmentWorker) { try { const { chunkRoot, cfg } = workerData; const out = crawlAndCheck(chunkRoot, cfg); parentPort.postMessage({ ok: true, data: out }); } catch (e) { parentPort.postMessage({ ok: false, error: e && e.message ? e.message : String(e) }); } finally { process.exit(0); } } /* --------------------------------- API ----------------------------------- */ /** * Validate a config object. Returns an array of error strings (empty if valid). * @param {Config} m * @returns {string[]} */ function validateConfig(m) { const errs = []; if (!m || typeof m !== 'object') return ['Config must be an object']; if (!m.main || typeof m.main !== 'string') errs.push('"main" must be a string path'); if (!Array.isArray(m.scanFor) || !m.scanFor.length) errs.push('"scanFor" must be a non-empty array'); const vt = t => ['any', 'dir', 'file'].includes(t); if (m.type && !vt(m.type)) errs.push('"type" must be any|dir|file'); if (m.nth && !Array.isArray(m.nth)) errs.push('"nth" must be an array'); else if (Array.isArray(m.nth)) { m.nth.forEach((n, i) => { if (!Number.isFinite(n.up) || n.up < 0) errs.push(`nth[${i}].up must be >= 0`); if (!Array.isArray(n.mustInclude)) errs.push(`nth[${i}].mustInclude must be array`); if (n.type && !vt(n.type)) errs.push(`nth[${i}].type must be any|dir|file`); }); } return errs; } /** * Normalize a config object (fill defaults; shallow clone arrays). * @param {Config} m * @returns {Required<Config>} */ function normalizeConfig(m) { return { main: m.main, scanFor: Array.isArray(m.scanFor) ? m.scanFor.slice() : [], siblings: Array.isArray(m.siblings) ? m.siblings.slice() : [], parents: Array.isArray(m.parents) ? m.parents.slice() : [], grandparents: Array.isArray(m.grandparents) ? m.grandparents.slice() : [], nth: Array.isArray(m.nth) ? m.nth.map(n => ({ up: +n.up || 0, mustInclude: Array.isArray(n.mustInclude) ? n.mustInclude.slice() : [], type: n.type || m.type || 'any' })) : [], type: m.type || 'any', ignore: Array.isArray(m.ignore) && m.ignore.length ? m.ignore.slice() : DEFAULT_IGNORES.slice() }; } /** * Core runner (single-thread or worker pool) — programmatic API. * Accepts either a config object or a JSON file path. * @param {Config|string} configOrPath * @param {Options} [options] * @returns {Promise<any>} Summary JSON (same as CLI) */ async function run(configOrPath, options = {}) { const cfg = typeof configOrPath === 'string' ? JSON.parse(fs.readFileSync(configOrPath, 'utf8')) : configOrPath; const errs = validateConfig(cfg); if (errs.length) { const e = new Error('Invalid fulfillment config: ' + errs.join('; ')); e.errors = errs; throw e; } const norm = normalizeConfig(cfg); const absMain = path.resolve(norm.main); if (!fs.existsSync(absMain) || !fs.statSync(absMain).isDirectory()) { throw new Error(`"main" is not a directory: ${absMain}`); } const workers = Number.isFinite(options.workers) && options.workers >= 1 ? Math.floor(options.workers) : os.cpus().length; const noWorkers = !!options.noWorkers; const onChunk = typeof options.onChunk === 'function' ? options.onChunk : null; const workerCfg = { scanFor: norm.scanFor, ignore: norm.ignore, defaultType: norm.type || 'any', siblings: norm.siblings, parents: norm.parents, grandparents: norm.grandparents, nth: norm.nth }; // Split work into chunks: root + each top-level directory const topEntries = safeList(absMain); const chunks = [absMain, ...topEntries.filter(e => e.isDirectory() && !workerCfg.ignore.includes(e.name)).map(e => path.join(absMain, e.name))]; // Abort support if (options.signal && options.signal.aborted) throw new Error('Aborted'); const checkAbort = () => { if (options.signal && options.signal.aborted) throw new Error('Aborted'); }; // Fast path — single-thread if (noWorkers || chunks.length <= 1 || workers <= 1) { const partials = []; for (let i = 0; i < chunks.length; i++) { checkAbort(); const p = crawlAndCheck(chunks[i], workerCfg); partials.push(p); if (onChunk) onChunk(p, i, chunks.length); } return emitSummary(partials, { cfg: norm, absMain, ignore: workerCfg.ignore, scanFor: workerCfg.scanFor }); } // Worker pool const poolSize = Math.max(1, Math.min(workers, chunks.length)); const partials = []; let inFlight = 0; let idx = 0; await new Promise((resolve, reject) => { const spawnNext = () => { checkAbort(); while (inFlight < poolSize && idx < chunks.length) { const chunkRoot = chunks[idx++]; inFlight++; const w = new Worker(__filename, { workerData: { __fulfillmentWorker: true, chunkRoot, cfg: workerCfg } }); w.once('message', (msg) => { const data = msg && msg.ok ? msg.data : { chunkRoot, totalDirsScanned: 0, results: [], error: (msg && msg.error) || 'Worker error' }; partials.push(data); if (onChunk) onChunk(data, partials.length - 1, chunks.length); }); w.once('error', (err) => { partials.push({ chunkRoot, totalDirsScanned: 0, results: [], error: err.message }); }); w.once('exit', () => { inFlight--; if (idx < chunks.length) spawnNext(); else if (inFlight === 0) resolve(); }); } }; spawnNext(); }); return emitSummary(partials, { cfg: norm, absMain, ignore: workerCfg.ignore, scanFor: workerCfg.scanFor }); } function emitSummary(partials, ctx) { // Merge/dedupe by matchPath const seen = new Set(); const results = []; let totalDirsScanned = 0; for (const p of partials) { totalDirsScanned += (p.totalDirsScanned || 0); for (const r of (p.results || [])) { if (seen.has(r.matchPath)) continue; seen.add(r.matchPath); results.push(r); } } return { version: VERSION, configUsed: ctx.cfg, scannedRoot: ctx.absMain, ignore: ctx.ignore, scanFor: ctx.scanFor, totalDirsScanned, totalMatches: results.length, totalOk: results.filter(r => r.ok).length, totalNotOk: results.filter(r => !r.ok).length, results }; } /* ------------------------------ CLI (optional) --------------------------- */ // Allow running directly: node index.js <path/to/config.json> [--workers=8|--no-workers] if (require.main === module) { (async () => { try { const argv = process.argv.slice(2); const cfgPath = argv.find(a => !a.startsWith('--')); if (!cfgPath) { console.error('Usage: node index.js <config.json> [--workers=N | --no-workers]'); process.exit(1); } let workers; let noWorkers = false; for (const a of argv) { if (a.startsWith('--workers=')) { const n = parseInt(a.split('=')[1], 10); if (Number.isFinite(n) && n >= 1) workers = n; } else if (a === '--no-workers') { noWorkers = true; } } const summary = await run(cfgPath, { workers, noWorkers }); // single JSON blob to STDOUT process.stdout.write(JSON.stringify(summary, null, 2)); } catch (e) { console.error('[ERROR]', e && e.message ? e.message : String(e)); process.exit(1); } })(); } /* -------------------------------- Exports -------------------------------- */ module.exports = { run, validateConfig, DEFAULT_IGNORES, version: VERSION, }; module.exports.default = run;