mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
65ba0b3922
* Add Java Modernization Studio canvas extension A canvas extension that drives the GitHub Copilot App Modernization for Java workflow from an interactive dashboard: environment readiness checks, repo assessment, prioritized plan/progress, validation gates (CVE scan, test generation), and one-click predefined-task runs — all grounded in the repo's real artifacts (.appmod/assessment.json, plan.md, progress.md). Includes a node:test suite (109 tests) for the grounding logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Secure cockpit loopback server with a per-instance token Address Copilot review feedback on the Java Modernization Studio canvas: - Mint a per-instance secret in createInstanceServer, embed it in the iframe URL, and validate it on every loopback request (/, /state, /events, /action). Other local processes can no longer read repo state or dispatch agent actions just by guessing the random port. Mirrors the existing diagram-viewer token pattern; the client echoes the token from its boot payload. The guard is a no-op when no token is set, so direct makeHandler unit tests are unaffected. - Handle async request-handler rejections in createServer with a .catch that returns 500 and logs, instead of leaking an unhandled rejection that could destabilize the extension process. - Tests: token guard 403s unauthenticated /, /state, /events and /action and allows valid-token requests; the end-to-end test asserts the tokenized URL and a tokenless 403. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI: add preview screenshot and clear codespell hits Add the required assets/preview.png screenshot for the canvas-extension validator, and resolve two codespell findings in the cockpit: rename the planSim helper's `nd` variable to `notDone` and reword a catalog comment to avoid the `invokable`/`invocable` flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden cockpit loopback server per Copilot review Address the second-round Copilot review comments on the loopback server: - broadcast(): drop an SSE client whose write() throws instead of keeping it in the Set, so a uncleanly-disconnected client can't cause repeated exceptions or leak dead entries on every subsequent broadcast. - POST /action: treat a malformed JSON body or a missing/invalid "kind" as a 400 client error (with an application/json body) instead of a 200 carrying { ok:false, error:"Unknown action: undefined" }. - Handler catch: set Content-Type: application/json on the 500 error response so it is consistent with the other JSON routes. Adds four tests covering dead-client eviction, the two 400 paths, and the JSON-typed 500. Full suite: 115 passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Add canvas.json gallery metadata for Java Modernization Studio Aaron requested a canvas.json so the awesome-copilot website/gallery can list this extension. Generated via `npm run website:data` (writePerExtensionCanvasManifests), which derives id/name/description/ version/keywords/screenshots from package.json + the createCanvas source + assets, and carries the author through. Output committed verbatim so a CI regeneration produces no diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Add author tag to package.json Declare the author in the package manifest (object form, matching the author already carried in canvas.json so it includes the profile URL). The published gallery sources author from canvas.json; this adds the conventional npm author tag to package.json for completeness. Verified `npm run website:data` still emits the author on the extensions record and leaves canvas.json byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> --------- Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
572 lines
22 KiB
JavaScript
572 lines
22 KiB
JavaScript
// 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 = /<!--\s*appmod-cockpit\b/i;
|
||
function hasMarker(md) {
|
||
return typeof md === "string" && APPMOD_MARKER_RE.test(md.slice(0, 2000));
|
||
}
|
||
|
||
// The assessment.json is written by an LLM step, so its shape can drift: severities
|
||
// may be lowercase or word-form, `files` may be a bare string, findings may be
|
||
// missing, and the top level might not even be an object. Normalize defensively so
|
||
// the renderer always gets P0–P3 severities and array `files`, and nothing is
|
||
// silently dropped or used to build an invalid CSS class.
|
||
const SEVERITY_ALIASES = {
|
||
p0: "P0", p1: "P1", p2: "P2", p3: "P3",
|
||
critical: "P0", blocker: "P0", severe: "P0",
|
||
high: "P1", major: "P1",
|
||
medium: "P2", moderate: "P2", warning: "P2", warn: "P2",
|
||
low: "P3", minor: "P3", info: "P3", informational: "P3", note: "P3",
|
||
};
|
||
function normalizeSeverity(sev) {
|
||
if (typeof sev !== "string") return "P3";
|
||
return SEVERITY_ALIASES[sev.trim().toLowerCase()] || "P3";
|
||
}
|
||
function normalizeFinding(f, idx) {
|
||
if (!f || typeof f !== "object" || Array.isArray(f)) return null;
|
||
let files = [];
|
||
if (typeof f.files === "string") files = f.files ? [f.files] : [];
|
||
else if (Array.isArray(f.files)) files = f.files.filter((x) => typeof x === "string");
|
||
const title =
|
||
(typeof f.title === "string" && f.title.trim()) ||
|
||
(typeof f.id === "string" && f.id.trim()) ||
|
||
"Untitled finding";
|
||
const out = {
|
||
id: typeof f.id === "string" && f.id ? f.id : "finding-" + (idx + 1),
|
||
severity: normalizeSeverity(f.severity),
|
||
title,
|
||
detail: typeof f.detail === "string" ? f.detail : "",
|
||
files,
|
||
};
|
||
if (f.action && typeof f.action === "object" && !Array.isArray(f.action)) out.action = f.action;
|
||
return out;
|
||
}
|
||
function normalizeReport(raw) {
|
||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||
const findingsIn = Array.isArray(raw.findings) ? raw.findings : [];
|
||
const findings = [];
|
||
findingsIn.forEach((f, i) => {
|
||
const n = normalizeFinding(f, i);
|
||
if (n) findings.push(n);
|
||
});
|
||
const out = { ...raw, findings };
|
||
if (raw.strengths != null && !Array.isArray(raw.strengths)) {
|
||
out.strengths = typeof raw.strengths === "string" && raw.strengths ? [raw.strengths] : [];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function stripMd(s) {
|
||
return String(s)
|
||
.replace(/[*_`~]+/g, "")
|
||
.replace(/^\s*#+\s*/, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
// Infer a status from inline markers / emojis in a line of text.
|
||
function statusFromText(text) {
|
||
const t = text.toLowerCase();
|
||
if (/[✅✔]|\bdone\b|\bcompleted?\b|\bpassed?\b/.test(t)) return "done";
|
||
if (/[❌✖]|\bfailed?\b|\berror\b|\bblocked\b/.test(t)) return "failed";
|
||
if (/[⏳🔄]|in progress|in-progress|ongoing|running|wip/.test(t)) return "in_progress";
|
||
return null;
|
||
}
|
||
|
||
// Parse GitHub-style task list items: "- [ ] ...", "- [x] ...", "- [/] ...".
|
||
// Each item also carries the nearest preceding heading as its `section`.
|
||
function parseChecklist(md) {
|
||
const items = [];
|
||
let section = null;
|
||
for (const line of md.split(/\r?\n/)) {
|
||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||
if (h) { section = stripMd(h[1]); continue; }
|
||
const m = line.match(/^\s*[-*]\s+\[([ xX/~\-])\]\s+(.*\S)\s*$/);
|
||
if (!m) continue;
|
||
const mark = m[1].toLowerCase();
|
||
let status = "pending";
|
||
if (mark === "x") status = "done";
|
||
else if (mark === "/" || mark === "~" || mark === "-") status = "in_progress";
|
||
const inline = statusFromText(m[2]);
|
||
if (inline) status = inline;
|
||
items.push({ title: stripMd(m[2]), status, section });
|
||
}
|
||
return items;
|
||
}
|
||
|
||
// Fallbacks when there are no checkbox items: numbered steps, then H2/H3 headings.
|
||
function parseLooseSteps(md) {
|
||
const numbered = [];
|
||
let section = null;
|
||
for (const line of md.split(/\r?\n/)) {
|
||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||
if (h) { section = stripMd(h[1]); continue; }
|
||
const m = line.match(/^\s*\d+[.)]\s+(.*\S)\s*$/);
|
||
if (m) numbered.push({ title: stripMd(m[1]), status: statusFromText(m[1]) || "pending", section });
|
||
}
|
||
if (numbered.length) return numbered;
|
||
|
||
const headings = [];
|
||
for (const line of md.split(/\r?\n/)) {
|
||
const m = line.match(/^\s*#{2,4}\s+(.*\S)\s*$/);
|
||
if (m) headings.push({ title: stripMd(m[1]), status: statusFromText(m[1]) || "pending", section: null });
|
||
}
|
||
return headings;
|
||
}
|
||
|
||
function parseSteps(md) {
|
||
if (!md) return [];
|
||
const checklist = parseChecklist(md);
|
||
return checklist.length ? checklist : parseLooseSteps(md);
|
||
}
|
||
|
||
// Return the steps under the first heading matching `headingRe`, up to the next
|
||
// heading, or null if there is no such section. Used to scope the validation
|
||
// gates to an explicit "Validation gates" section so unrelated checklist items
|
||
// that merely mention "build"/"vulnerable"/etc. don't hijack a gate's status.
|
||
function sectionSteps(md, headingRe) {
|
||
if (!md) return null;
|
||
let inSection = false;
|
||
const buf = [];
|
||
for (const line of md.split(/\r?\n/)) {
|
||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||
if (h) {
|
||
if (inSection) break; // next heading closes the section
|
||
if (headingRe.test(h[1])) inSection = true;
|
||
continue;
|
||
}
|
||
if (inSection) buf.push(line);
|
||
}
|
||
if (!inSection) return null;
|
||
const steps = parseSteps(buf.join("\n"));
|
||
return steps.length ? steps : null;
|
||
}
|
||
|
||
function percentDone(steps) {
|
||
if (!steps.length) return 0;
|
||
let score = 0;
|
||
for (const s of steps) {
|
||
if (s.status === "done") score += 1;
|
||
else if (s.status === "in_progress") score += 0.5;
|
||
}
|
||
return Math.round((score / steps.length) * 100);
|
||
}
|
||
|
||
// Rank a phase/section heading into the canonical modernization order so the UI
|
||
// can recommend (and gently gate) the sequence: assessment -> P0 -> P1 -> P2 ->
|
||
// P3 -> validation. Unknown sections return null (never gated).
|
||
function phaseRankFromName(name) {
|
||
if (!name) return null;
|
||
const n = String(name).toLowerCase();
|
||
if (/assessment/.test(n)) return 0;
|
||
const m = n.match(/\bp([0-3])\b/);
|
||
if (m) return 1 + Number(m[1]);
|
||
if (/validation|gates|sign-?off/.test(n)) return 5;
|
||
return null;
|
||
}
|
||
|
||
// Summarize phase ordering from the checklist: which phase is the earliest one
|
||
// with unfinished work (the "active" phase the user should be in), plus a
|
||
// per-phase done/total roll-up. Steps in phases ranked after `activeRank` are
|
||
// "ahead of sequence" and the UI flags them so users don't jump the gun.
|
||
function computeOrdering(steps) {
|
||
const phases = [];
|
||
const byKey = {};
|
||
for (const st of steps) {
|
||
const key = st.section || "Steps";
|
||
if (!byKey[key]) {
|
||
byKey[key] = { name: key, rank: st.rank == null ? null : st.rank, total: 0, done: 0 };
|
||
phases.push(byKey[key]);
|
||
}
|
||
byKey[key].total++;
|
||
if (st.status === "done") byKey[key].done++;
|
||
}
|
||
let activeRank = null;
|
||
let activePhase = null;
|
||
for (const st of steps) {
|
||
if (st.status !== "done" && st.rank != null) {
|
||
if (activeRank == null || st.rank < activeRank) {
|
||
activeRank = st.rank;
|
||
activePhase = st.section;
|
||
}
|
||
}
|
||
}
|
||
return { activeRank, activePhase, phases };
|
||
}
|
||
|
||
// Derive the five validation gates from progress/summary step titles.
|
||
function deriveGates(steps, summaryText) {
|
||
const patterns = {
|
||
build: /\bbuild\b|compil/i,
|
||
tests: /unit test|\btests?\b|junit/i,
|
||
cve: /\bcve\b|vulnerab|security scan/i,
|
||
consistency: /consistenc/i,
|
||
completeness: /completenes/i,
|
||
};
|
||
const gates = {};
|
||
for (const g of VALIDATION_GATES) {
|
||
let status = "not_run";
|
||
for (const s of steps) {
|
||
if (patterns[g.key].test(s.title)) {
|
||
status = s.status; // done | in_progress | failed | pending
|
||
break;
|
||
}
|
||
}
|
||
if (status === "not_run" && summaryText) {
|
||
const line = summaryText
|
||
.split(/\r?\n/)
|
||
.find((l) => patterns[g.key].test(l));
|
||
if (line) status = statusFromText(line) || "in_progress";
|
||
}
|
||
gates[g.key] = status;
|
||
}
|
||
return gates;
|
||
}
|
||
|
||
async function discoverSkills(repoPath) {
|
||
const skillsDir = join(repoPath, ".github", "skills");
|
||
const out = [];
|
||
let entries;
|
||
try {
|
||
entries = await readdir(skillsDir, { withFileTypes: true });
|
||
} catch {
|
||
return out;
|
||
}
|
||
for (const e of entries) {
|
||
if (!e.isDirectory()) continue;
|
||
const md = await readText(join(skillsDir, e.name, "SKILL.md"));
|
||
let name = e.name;
|
||
let description = "";
|
||
if (md) {
|
||
const nameM = md.match(/(?:^|\n)#+\s*(.+)|name:\s*(.+)/i);
|
||
if (nameM) name = stripMd(nameM[1] || nameM[2] || e.name);
|
||
const descM = md.match(/description:\s*(.+)/i);
|
||
if (descM) description = stripMd(descM[1]);
|
||
}
|
||
out.push({ folder: e.name, name, description, hasSkillMd: !!md });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function detectJavaVersion(pom, gradle) {
|
||
const text = `${pom || ""}\n${gradle || ""}`;
|
||
const grab = (re) => {
|
||
const m = text.match(re);
|
||
return m ? m[1].replace(/^1\./, "") : null;
|
||
};
|
||
return (
|
||
grab(/<maven\.compiler\.release>\s*([\d.]+)\s*</) ||
|
||
grab(/<maven\.compiler\.source>\s*([\d.]+)\s*</) ||
|
||
grab(/<java\.version>\s*([\d.]+)\s*</) ||
|
||
grab(/<source>\s*([\d.]+)\s*</) ||
|
||
grab(/<release>\s*([\d.]+)\s*</) ||
|
||
grab(/sourceCompatibility\s*=?\s*['"]?(?:JavaVersion\.VERSION_)?([\d.]+)/) ||
|
||
grab(/languageVersion[^)]*?(\d+)/) ||
|
||
null
|
||
);
|
||
}
|
||
|
||
// Match a dependency signature against build text. Plain substring matching makes
|
||
// short alpha tokens dangerous ("ant" used to hit "important/constant", and a bare
|
||
// token could land inside an unrelated word). We instead require the signature to
|
||
// begin a *token* — i.e. be preceded by a word boundary — which rejects mid-word
|
||
// matches while still allowing versioned/suffixed coordinates (ojdbc -> "ojdbc8",
|
||
// hikari -> "hikaricp", s3 -> "...-s3"). Signatures that intentionally start with a
|
||
// separator (e.g. ".jks") are matched as plain substrings.
|
||
function signatureMatches(text, sig) {
|
||
const s = sig.toLowerCase();
|
||
if (!s) return false;
|
||
const alnum = (c) => (c >= "a" && c <= "z") || (c >= "0" && c <= "9");
|
||
if (!alnum(s[0])) return text.includes(s);
|
||
let from = 0;
|
||
for (;;) {
|
||
const i = text.indexOf(s, from);
|
||
if (i < 0) return false;
|
||
const before = i === 0 ? "" : text[i - 1];
|
||
if (before === "" || !alnum(before)) return true; // signature begins a token
|
||
from = i + 1;
|
||
}
|
||
}
|
||
|
||
function detectDependencies(buildText) {
|
||
const text = (buildText || "").toLowerCase();
|
||
const keys = [];
|
||
for (const [key, sigs] of Object.entries(DEP_SIGNATURES)) {
|
||
if (sigs.some((s) => signatureMatches(text, s))) keys.push(key);
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
const DOCKERFILE_RE = /^(dockerfile|containerfile)(\.[\w.-]+)?$/i;
|
||
|
||
async function dirHasDockerfile(dir) {
|
||
try {
|
||
const entries = await readdir(dir, { withFileTypes: true });
|
||
return entries.some((e) => (e.isFile() || e.isSymbolicLink()) && DOCKERFILE_RE.test(e.name));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function findDockerfile(repoPath) {
|
||
// Case-insensitive, and also accept Containerfile (Podman) / Dockerfile.<tag>.
|
||
if (await dirHasDockerfile(repoPath)) return true;
|
||
try {
|
||
const entries = await readdir(repoPath, { withFileTypes: true });
|
||
for (const e of entries) {
|
||
if (e.isDirectory() && !e.name.startsWith(".")) {
|
||
if (await dirHasDockerfile(join(repoPath, e.name))) return true;
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Directories we never descend into when unioning module build files.
|
||
const SKIP_DIRS = new Set(["node_modules", "target", "build", "dist", "out", "bin", ".git"]);
|
||
|
||
async function buildAssessment(repoPath) {
|
||
const rootPom = await readText(join(repoPath, "pom.xml"));
|
||
let rootGradle = await readText(join(repoPath, "build.gradle"));
|
||
if (!rootGradle) rootGradle = await readText(join(repoPath, "build.gradle.kts"));
|
||
const antBuild = await readText(join(repoPath, "build.xml"));
|
||
|
||
// Union one level of modules so multi-module Maven, Gradle subprojects, and
|
||
// monorepos report the real Java version / dependencies — not just whatever
|
||
// the (often dependency-free) aggregator root pom declares.
|
||
const childPoms = [];
|
||
const childGradles = [];
|
||
try {
|
||
const entries = await readdir(repoPath, { withFileTypes: true });
|
||
for (const e of entries) {
|
||
if (!e.isDirectory() || e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
|
||
const cp = await readText(join(repoPath, e.name, "pom.xml"));
|
||
if (cp) childPoms.push(cp);
|
||
let cg = await readText(join(repoPath, e.name, "build.gradle"));
|
||
if (!cg) cg = await readText(join(repoPath, e.name, "build.gradle.kts"));
|
||
if (cg) childGradles.push(cg);
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
|
||
const allPom = [rootPom, ...childPoms].filter(Boolean).join("\n");
|
||
const allGradle = [rootGradle, ...childGradles].filter(Boolean).join("\n");
|
||
const buildText = `${allPom}\n${allGradle}\n${antBuild || ""}`;
|
||
const hasMaven = !!(rootPom || childPoms.length);
|
||
const hasGradle = !!(rootGradle || childGradles.length);
|
||
const buildTool = hasMaven ? "Maven" : hasGradle ? "Gradle" : antBuild ? "Ant" : null;
|
||
const springBoot = /spring-boot/i.test(buildText);
|
||
const springVersion = (() => {
|
||
const m = buildText.match(/spring-boot[^>]*?<version>\s*([\d.]+[\w.-]*)\s*</i);
|
||
return m ? m[1] : null;
|
||
})();
|
||
|
||
const detectedKeys = detectDependencies(buildText);
|
||
// Ant is identified by its build file, not a content substring (avoids the
|
||
// old "ant" false positives), so flag it from the resolved build tool.
|
||
if (buildTool === "Ant" && !detectedKeys.includes("ant")) detectedKeys.push("ant");
|
||
|
||
return {
|
||
buildTool,
|
||
javaVersion: detectJavaVersion(allPom, allGradle),
|
||
springBoot,
|
||
springVersion,
|
||
hasDockerfile: await findDockerfile(repoPath),
|
||
hasMavenWrapper:
|
||
(await exists(join(repoPath, "mvnw"))) || (await exists(join(repoPath, "mvnw.cmd"))),
|
||
hasGradleWrapper:
|
||
(await exists(join(repoPath, "gradlew"))) || (await exists(join(repoPath, "gradlew.bat"))),
|
||
detectedKeys,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Scan a repo and produce the full cockpit state snapshot.
|
||
* @param {string} repoPath absolute path to the repo working directory
|
||
* @param {{ includeGit?: boolean }} [opts]
|
||
*/
|
||
export async function scanRepo(repoPath, opts = {}) {
|
||
const includeGit = opts.includeGit !== false;
|
||
if (!repoPath || !(await isDirectory(repoPath))) {
|
||
return { ok: false, repoPath: repoPath || null, error: "repo path not available" };
|
||
}
|
||
|
||
// Modernization docs. Prefer the namespaced .appmod/ copies; the root-level
|
||
// files (plan.md / progress.md / summary.md) use very common filenames, so we
|
||
// trust them as workflow state only when the provenance gate below passes.
|
||
const [apPlan, apProgress, apSummary, rootPlan, rootProgress, rootSummary] = await Promise.all([
|
||
readText(join(repoPath, ".appmod", "plan.md")),
|
||
readText(join(repoPath, ".appmod", "progress.md")),
|
||
readText(join(repoPath, ".appmod", "summary.md")),
|
||
readText(join(repoPath, "plan.md")),
|
||
readText(join(repoPath, "progress.md")),
|
||
readText(join(repoPath, "summary.md")),
|
||
]);
|
||
|
||
// Structured assessment report (written by the assessment step). Optional,
|
||
// and the strongest provenance signal that this repo is running the workflow.
|
||
let report = null;
|
||
const reportRaw = await readText(join(repoPath, ".appmod", "assessment.json"));
|
||
if (reportRaw) {
|
||
try {
|
||
report = normalizeReport(JSON.parse(reportRaw));
|
||
} catch {
|
||
report = null;
|
||
}
|
||
}
|
||
|
||
// Provenance gate. A .appmod/ artifact (assessment.json or a namespaced doc)
|
||
// means the modernization workflow is active in this repo, so the root docs
|
||
// are ours. Otherwise a root doc is trusted only if it carries the explicit
|
||
// <!-- appmod-cockpit --> 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,
|
||
};
|
||
}
|