// scan.mjs — Grounds the cockpit in the real repository. Reads App Modernization // artifacts (plan.md / progress.md / summary.md), discovers custom skills under // .github/skills/, extracts assessment facts from Maven/Gradle build files, and // reports git branch state. Everything degrades gracefully when files are absent. import { readFile, readdir, stat, open } from "node:fs/promises"; import { join } from "node:path"; import { execFile } from "node:child_process"; import { catalogWithRelevance, VALIDATION_GATES } from "./catalog.mjs"; // Dependency signatures: substring(s) searched in build files -> catalog `detect` key. const DEP_SIGNATURES = { rabbitmq: ["rabbitmq", "spring-rabbit", "amqp-client", "spring-amqp", "starter-amqp", "amqp"], activemq: ["activemq"], jms: ["javax.jms", "jakarta.jms", "spring-jms"], awsS3: ["aws-java-sdk-s3", "s3", "software.amazon.awssdk"], awsSqs: ["aws-java-sdk-sqs", "sqs"], awsSecrets: ["secretsmanager"], javamail: ["javax.mail", "jakarta.mail", "com.sun.mail", "spring-boot-starter-mail"], ldap: ["spring-ldap", "spring-security-ldap", "unboundid-ldapsdk", "ldaptive"], cache: ["infinispan", "swarmcache", "memcached", "spymemcached", "ehcache"], oracle: ["ojdbc", "oracle"], db2: ["db2jcc", "com.ibm.db2"], sybase: ["jconn", "sybase"], informix: ["informix"], jdbc: ["jdbc", "hikari", "mysql", "postgresql", "mssql-jdbc", "sqlserver"], keystore: ["keystore", ".jks", "javax.net.ssl"], crypto: ["javax.crypto", "java.security", "bouncycastle", "bcprov"], filelog: ["fileappender", "rollingfileappender", "logback", "log4j"], }; // Cap how much of any single file we read. Build files and modernization docs are // tiny; an unbounded read on a pathological repo (a giant generated XML, a vendored // blob) would waste memory for no parsing benefit. Truncating is safe — every // parser here scans for early markers/coordinates. const MAX_READ_BYTES = 2 * 1024 * 1024; function execGit(args, cwd) { return new Promise((resolve) => { execFile("git", args, { cwd, timeout: 4000 }, (err, stdout) => { if (err) resolve(null); else resolve(String(stdout).trim()); }); }); } async function readText(path) { try { const st = await stat(path); if (!st.isFile()) return null; if (st.size > MAX_READ_BYTES) { const fh = await open(path, "r"); try { const buf = Buffer.alloc(MAX_READ_BYTES); const { bytesRead } = await fh.read(buf, 0, MAX_READ_BYTES, 0); return buf.toString("utf8", 0, bytesRead); } finally { await fh.close(); } } return await readFile(path, "utf8"); } catch { return null; } } async function exists(path) { try { await stat(path); return true; } catch { return false; } } async function isDirectory(path) { try { return (await stat(path)).isDirectory(); } catch { return false; } } // Explicit provenance marker the cockpit writes at the top of artifacts it owns, // so a root plan/progress/summary self-identifies as modernization state even // when there is no .appmod/ directory yet. const APPMOD_MARKER_RE = / marker. This stops an unrelated summary.md from // flipping a foreign repo to "completed", or a stray plan.md from injecting // fake steps into the progress/ordering/autopilot machinery. const hasAppmodArtifact = reportRaw != null || apPlan != null || apProgress != null || apSummary != null; const trusted = (root) => hasAppmodArtifact || hasMarker(root); const planMd = apPlan || (trusted(rootPlan) ? rootPlan : null); const progressMd = apProgress || (trusted(rootProgress) ? rootProgress : null); const summaryMd = apSummary || (trusted(rootSummary) ? rootSummary : null); const assessment = await buildAssessment(repoPath); const skills = await discoverSkills(repoPath); const planSteps = parseSteps(planMd); const progressSteps = parseSteps(progressMd); // Tag each step with its canonical phase rank for ordering guidance. for (const arr of [planSteps, progressSteps]) { for (const st of arr) st.rank = phaseRankFromName(st.section); } // Progress is the source of truth for status; fall back to plan steps. const steps = progressSteps.length ? progressSteps : planSteps; const percent = percentDone(steps); const ordering = computeOrdering(steps); // Prefer an explicit "Validation"/"gates" section for gate status; fall back // to scanning all steps when no such section exists. const gateSteps = sectionSteps(progressMd, /validation|gates/i) || sectionSteps(summaryMd, /validation|gates/i) || steps; const gates = deriveGates(gateSteps, summaryMd); let status = "not_started"; if (summaryMd) status = "completed"; else if (planMd || progressMd) status = "in_progress"; let git = null; if (includeGit) { const branch = await execGit(["rev-parse", "--abbrev-ref", "HEAD"], repoPath); const dirty = await execGit(["status", "--porcelain"], repoPath); git = { branch, isMigrationBranch: !!branch && /moderniz|migrat|upgrade|appmod/i.test(branch), dirty: dirty === null ? null : dirty.length > 0, changedFiles: dirty ? dirty.split(/\r?\n/).filter(Boolean).length : 0, }; } return { ok: true, repoPath, scannedAt: new Date().toISOString(), status, percent, assessment, tasks: catalogWithRelevance(assessment.detectedKeys), skills, git, plan: { exists: !!planMd, steps: planSteps }, progress: { exists: !!progressMd, steps: progressSteps }, summary: { exists: !!summaryMd, markdown: summaryMd || "" }, report, ordering, gates, }; }