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
JavaScript
// 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;