diff --git a/.gitignore b/.gitignore index 59b46e8..0364928 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist/ .DS_Store docs/superpowers/ package-lock.json -tests/fixtures/ +tests/fixtures/**/.codesight/ +tests/fixtures/**/CODESIGHT.md diff --git a/package.json b/package.json index 990120a..565e9ba 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,17 @@ "bin": { "codesight": "dist/index.js" }, + "exports": { + ".": "./dist/index.js", + "./plugins/terraform": "./dist/plugins/terraform/index.js", + "./dist/*": "./dist/*" + }, "type": "module", "scripts": { "build": "tsc", + "prepare": "tsc", "dev": "tsx src/index.ts", - "test": "pnpm build && tsx --test tests/detectors.test.ts tests/wiki.test.ts tests/monorepo.test.ts", + "test": "pnpm build && tsx --test tests/*.test.ts", "prepublishOnly": "pnpm build" }, "keywords": [ diff --git a/src/core.ts b/src/core.ts index 9a8da24..3dfe1ef 100644 --- a/src/core.ts +++ b/src/core.ts @@ -93,6 +93,7 @@ export async function scan( } // Step 3b: Run plugin detectors + const customSections: { name: string; content: string }[] = []; if (userConfig.plugins) { for (const plugin of userConfig.plugins) { if (plugin.detector) { @@ -102,6 +103,7 @@ export async function scan( if (pluginResult.schemas) schemas.push(...pluginResult.schemas); if (pluginResult.components) components.push(...pluginResult.components); if (pluginResult.middleware) middleware.push(...pluginResult.middleware); + if (pluginResult.customSections) customSections.push(...pluginResult.customSections); } catch (err: any) { if (!quiet) console.warn(`\n Warning: plugin "${plugin.name}" failed: ${err.message}`); } @@ -159,6 +161,7 @@ export async function scan( events: events.length > 0 ? events : undefined, testCoverage: testCoverage.testFiles.length > 0 ? testCoverage : undefined, crudGroups: crudGroups.length > 0 ? crudGroups : undefined, + customSections: customSections.length > 0 ? customSections : undefined, }; const outputContent = await writeOutput(tempResult, outputDir); diff --git a/src/formatter.ts b/src/formatter.ts index 365d388..1aa8f82 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -69,6 +69,18 @@ export async function writeOutput( await writeFile(join(outputDir, "coverage.md"), content); } + // Plugin-contributed custom sections + if (result.customSections) { + const reserved = new Set(["routes", "schema", "components", "libs", "config", "middleware", "graph", "events", "coverage", "codesight"]); + for (const cs of result.customSections) { + // Sanitise name to safe basename: lowercase alphanumeric, hyphens, underscores + const safeName = cs.name.replace(/[^a-z0-9_-]/gi, "").toLowerCase(); + if (!safeName || reserved.has(safeName)) continue; + sections.push({ name: safeName, content: cs.content }); + await writeFile(join(outputDir, `${safeName}.md`), cs.content); + } + } + const combined = formatCombined(result, sections); await writeFile(join(outputDir, "CODESIGHT.md"), combined); diff --git a/src/generators/ai-config.ts b/src/generators/ai-config.ts index 678d04e..b604b25 100644 --- a/src/generators/ai-config.ts +++ b/src/generators/ai-config.ts @@ -66,6 +66,16 @@ function generateContext(result: ScanResult): string { lines.push(""); } + // Plugin-contributed sections + if (result.customSections) { + for (const cs of result.customSections) { + const safeName = cs.name.replace(/[^a-z0-9_-]/gi, "").toLowerCase(); + if (!safeName) continue; + lines.push(`See .codesight/${safeName}.md for additional ${safeName} context.`); + } + lines.push(""); + } + // Wiki reference if it exists lines.push("Read .codesight/wiki/index.md for orientation (WHERE things live). Then read actual source files before implementing. Wiki articles are navigation aids, not implementation guides."); lines.push("Read .codesight/CODESIGHT.md for the complete AI context map including all routes, schema, components, libraries, config, middleware, and dependency graph."); diff --git a/src/plugins/cicd/circleci.ts b/src/plugins/cicd/circleci.ts new file mode 100644 index 0000000..4801266 --- /dev/null +++ b/src/plugins/cicd/circleci.ts @@ -0,0 +1,228 @@ +import type { CICDPipeline, CICDTrigger, CICDJob } from "./types.js"; + +/** + * Extract CircleCI pipelines from a parsed config.yml. + * + * CircleCI has a two-level structure: + * - `jobs:` defines job bodies (executor, steps) + * - `workflows:` composes jobs with dependencies, contexts, and filters + * + * Each workflow becomes a CICDPipeline. + */ +export function extractCircleCIWorkflows( + parsed: any, + relPath: string, + rawContent: string, +): CICDPipeline[] { + if (!parsed || typeof parsed !== "object") return []; + + const jobDefs = parsed.jobs || {}; + const workflows = parsed.workflows || {}; + const orbs = parsed.orbs ? Object.keys(parsed.orbs) : []; + const parameters = parsed.parameters || {}; + + const pipelines: CICDPipeline[] = []; + + for (const [name, wf] of Object.entries(workflows)) { + if (name === "version") continue; // CircleCI sometimes puts version in workflows + if (!wf || typeof wf !== "object") continue; + + const wfObj = wf as Record; + const jobRefs: any[] = Array.isArray(wfObj.jobs) ? wfObj.jobs : []; + + const jobs = extractJobs(jobRefs, jobDefs); + const triggers = extractTriggers(jobRefs, parameters, wfObj); + const environments = collectEnvironments(jobRefs); + const secrets = extractSecrets(rawContent); + + const pipeline: CICDPipeline = { + file: relPath, + system: "circleci", + name, + triggers, + jobs, + }; + + if (environments.length > 0) pipeline.environments = environments; + if (secrets.length > 0) pipeline.secrets = secrets; + if (orbs.length > 0) pipeline.envVars = orbs.map(o => `orb:${o}`); + + pipelines.push(pipeline); + } + + return pipelines; +} + +function extractJobs( + jobRefs: any[], + jobDefs: Record, +): CICDJob[] { + return jobRefs.map(ref => { + const [jobName, jobConfig] = parseJobRef(ref); + const jobDef = jobDefs[jobName] || {}; + + const steps: any[] = Array.isArray(jobDef.steps) ? jobDef.steps : []; + + const result: CICDJob = { + name: (jobConfig?.name as string) || jobName, + stepCount: steps.length, + }; + + // Runner from job definition + if (Array.isArray(jobDef.docker) && jobDef.docker[0]?.image) { + result.runner = String(jobDef.docker[0].image); + } else if (jobDef.machine) { + result.runner = typeof jobDef.machine === "string" + ? jobDef.machine + : jobDef.machine?.image || "machine"; + } else if (jobDef.macos) { + result.runner = `macos:${jobDef.macos.xcode || "latest"}`; + } else if (jobDef.resource_class) { + result.runner = String(jobDef.resource_class); + } + + // Dependencies from workflow config + if (jobConfig?.requires) { + result.needs = asArray(jobConfig.requires); + } + + // Context as environment + if (jobConfig?.context) { + const contexts = asArray(jobConfig.context); + if (contexts.length > 0) result.environment = contexts.join(", "); + } + + // Detect approval jobs + if (jobConfig?.type === "approval") { + result.stepCount = 0; + result.runner = "approval-gate"; + } + + // Collect orb commands and special steps as actions + const actions: string[] = []; + for (const step of steps) { + if (typeof step === "string" && step !== "checkout") { + actions.push(step); + } else if (step && typeof step === "object") { + const stepKeys = Object.keys(step); + for (const k of stepKeys) { + if (k !== "run" && k !== "checkout" && k !== "when" && k !== "unless") { + actions.push(k); + } + } + } + } + if (actions.length > 0) result.actions = actions; + + return result; + }); +} + +function extractTriggers( + jobRefs: any[], + parameters: Record, + workflow: Record, +): CICDTrigger[] { + const triggers: CICDTrigger[] = []; + const seenEvents = new Set(); + + // Check for parameter-based triggers (manual/conditional) + const paramInputs: string[] = []; + for (const [pName, pDef] of Object.entries(parameters)) { + if (pDef && typeof pDef === "object" && pDef.type === "boolean") { + paramInputs.push(pName); + } + } + if (paramInputs.length > 0) { + triggers.push({ event: "parameter", inputs: paramInputs }); + seenEvents.add("parameter"); + } + + // Extract triggers from job filters + for (const ref of jobRefs) { + const [, jobConfig] = parseJobRef(ref); + if (!jobConfig?.filters) continue; + + const filters = jobConfig.filters; + if (filters.branches) { + if (!seenEvents.has("push")) { + const trigger: CICDTrigger = { event: "push" }; + if (filters.branches.only) trigger.branches = asArray(filters.branches.only); + triggers.push(trigger); + seenEvents.add("push"); + } + } + if (filters.tags) { + if (!seenEvents.has("tag")) { + const trigger: CICDTrigger = { event: "tag" }; + if (filters.tags.only) trigger.tags = asArray(filters.tags.only); + triggers.push(trigger); + seenEvents.add("tag"); + } + } + } + + // Default: if no explicit triggers found, it's a push trigger + if (triggers.length === 0) { + triggers.push({ event: "push" }); + } + + // Check for scheduled triggers + if (workflow.triggers) { + const wfTriggers = Array.isArray(workflow.triggers) + ? workflow.triggers + : [workflow.triggers]; + for (const t of wfTriggers) { + if (t?.schedule?.cron) { + triggers.push({ event: "schedule", schedule: t.schedule.cron }); + } + } + } + + return triggers; +} + +function collectEnvironments(jobRefs: any[]): string[] { + const envs = new Set(); + for (const ref of jobRefs) { + const [, jobConfig] = parseJobRef(ref); + if (jobConfig?.context) { + for (const ctx of asArray(jobConfig.context)) { + envs.add(ctx); + } + } + } + return [...envs]; +} + +function extractSecrets(rawContent: string): string[] { + // CircleCI doesn't have explicit secret references like GHA, + // but we can look for environment variable patterns + const secrets = new Set(); + const patterns = [ + /\$(\w+_(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|ARN))\b/g, + /\$\{(\w+_(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|ARN))\}/g, + ]; + for (const pattern of patterns) { + let m: RegExpExecArray | null; + while ((m = pattern.exec(rawContent)) !== null) { + secrets.add(m[1]); + } + } + return [...secrets].sort(); +} + +function parseJobRef(ref: any): [string, Record | null] { + if (typeof ref === "string") return [ref, null]; + if (ref && typeof ref === "object") { + const keys = Object.keys(ref); + if (keys.length > 0) return [keys[0], ref[keys[0]] || {}]; + } + return ["unknown", null]; +} + +function asArray(val: any): string[] { + if (Array.isArray(val)) return val.map(String); + if (typeof val === "string") return [val]; + return []; +} diff --git a/src/plugins/cicd/formatter.ts b/src/plugins/cicd/formatter.ts new file mode 100644 index 0000000..48a8ab0 --- /dev/null +++ b/src/plugins/cicd/formatter.ts @@ -0,0 +1,113 @@ +import type { CICDPipeline } from "./types.js"; + +const MAX_DISPLAYED_ACTIONS = 8; + +/** + * Format CI/CD pipeline data into markdown for the custom section output. + */ +export function formatCICD(pipelines: CICDPipeline[]): string { + const lines: string[] = []; + lines.push("# CI/CD Pipelines", ""); + + const bySystem = new Map(); + for (const p of pipelines) { + if (!bySystem.has(p.system)) bySystem.set(p.system, []); + bySystem.get(p.system)!.push(p); + } + + for (const [system, items] of bySystem) { + const label = systemLabel(system); + lines.push(`## ${label} (${items.length} workflow${items.length > 1 ? "s" : ""})`, ""); + + const reusable = items.filter(p => p.isReusable); + const regular = items.filter(p => !p.isReusable); + + // Summary table for non-reusable workflows + if (regular.length > 0) { + lines.push("| Workflow | Triggers | Jobs | Deploy | Environments |"); + lines.push("|---|---|---|---|---|"); + for (const p of regular) { + const name = escapeTableCell(p.name); + const triggers = escapeTableCell(p.triggers.map(t => t.event).join(", ") || "\u2014"); + const jobCount = String(p.jobs.length); + const deploys = escapeTableCell(uniqueNonEmpty(p.jobs.map(j => j.deployTarget)).join(", ") || "\u2014"); + const envs = escapeTableCell(p.environments?.join(", ") || "\u2014"); + lines.push(`| ${name} | ${triggers} | ${jobCount} | ${deploys} | ${envs} |`); + } + lines.push(""); + } + + // Job detail for multi-job pipelines + for (const p of regular.filter(p => p.jobs.length > 1)) { + lines.push(`### ${p.name}`, ""); + lines.push(`> \`${p.file}\``, ""); + + if (p.concurrencyGroup) { + lines.push(`> Concurrency: \`${p.concurrencyGroup}\``, ""); + } + + for (const j of p.jobs) { + const needs = j.needs?.length ? ` (needs: ${j.needs.join(", ")})` : ""; + const runner = j.runner ? ` on \`${j.runner}\`` : ""; + const steps = j.runner === "approval-gate" ? "approval gate" : `${j.stepCount} steps`; + const deploy = j.deployTarget ? ` \u2192 **${j.deployTarget}**` : ""; + lines.push(`- **${j.name}**${runner} \u2014 ${steps}${needs}${deploy}`); + if (j.actions?.length) { + for (const a of j.actions.slice(0, MAX_DISPLAYED_ACTIONS)) { + lines.push(` - \`${a}\``); + } + if (j.actions.length > MAX_DISPLAYED_ACTIONS) { + lines.push(` - _...and ${j.actions.length - MAX_DISPLAYED_ACTIONS} more_`); + } + } + } + lines.push(""); + } + + // Reusable workflows + if (reusable.length > 0) { + lines.push("### Reusable Workflows", ""); + for (const p of reusable) { + const jobNames = p.jobs.map(j => j.name).join(", "); + lines.push(`- \`${p.file}\` \u2014 ${p.name} (${jobNames})`); + } + lines.push(""); + } + + // Secrets summary + const allSecrets = [...new Set(items.flatMap(p => p.secrets || []))].sort(); + if (allSecrets.length > 0) { + lines.push("### Secrets", ""); + for (const s of allSecrets) { + lines.push(`- \`${s}\``); + } + lines.push(""); + } + } + + // Footer + lines.push("---"); + const files = [...new Set(pipelines.map(p => p.file))].sort(); + lines.push(`_Source: ${files.join(", ")}_`); + lines.push("_Generated by codesight-cicd-plugin_"); + lines.push(""); + + return lines.join("\n"); +} + +function systemLabel(system: string): string { + switch (system) { + case "github-actions": return "GitHub Actions"; + case "circleci": return "CircleCI"; + default: return system; + } +} + +function uniqueNonEmpty(arr: (string | undefined)[]): string[] { + return [...new Set(arr.filter(Boolean) as string[])]; +} + +/** Escape pipe characters so they don't break markdown table cells. */ +function escapeTableCell(s: string): string { + return s.replace(/\|/g, "\\|"); +} diff --git a/src/plugins/cicd/github-actions.ts b/src/plugins/cicd/github-actions.ts new file mode 100644 index 0000000..bae6d93 --- /dev/null +++ b/src/plugins/cicd/github-actions.ts @@ -0,0 +1,190 @@ +import { basename, extname } from "node:path"; +import type { CICDPipeline, CICDTrigger, CICDJob } from "./types.js"; + +/** + * Extract GitHub Actions workflow pipelines from a parsed YAML object. + */ +export function extractGitHubActionsWorkflow( + parsed: any, + relPath: string, + rawContent: string, +): CICDPipeline | null { + if (!parsed || typeof parsed !== "object") return null; + + const name = parsed.name || basename(relPath, extname(relPath)).replace(/-/g, " "); + const triggers = extractTriggers(parsed.on || parsed.true); // YAML parses bare `on:` as `true:` sometimes + const jobs = extractJobs(parsed.jobs); + const reusableWorkflows = collectReusableWorkflowRefs(parsed.jobs); + const isReusable = triggers.some(t => t.event === "workflow_call"); + const secrets = extractSecrets(rawContent); + const envVars = parsed.env ? Object.keys(parsed.env) : undefined; + + const environments = [ + ...new Set(jobs.map(j => j.environment).filter(Boolean) as string[]), + ]; + + const concurrencyGroup = typeof parsed.concurrency === "string" + ? parsed.concurrency + : parsed.concurrency?.group || undefined; + + const pipeline: CICDPipeline = { + file: relPath, + system: "github-actions", + name: String(name), + triggers, + jobs, + }; + + if (reusableWorkflows.length > 0) pipeline.reusableWorkflows = reusableWorkflows; + if (isReusable) pipeline.isReusable = true; + if (environments.length > 0) pipeline.environments = environments; + if (secrets && secrets.length > 0) pipeline.secrets = secrets; + if (envVars && envVars.length > 0) pipeline.envVars = envVars; + if (concurrencyGroup) pipeline.concurrencyGroup = String(concurrencyGroup); + + return pipeline; +} + +function extractTriggers(on: any): CICDTrigger[] { + if (!on) return []; + if (typeof on === "string") return [{ event: on }]; + if (Array.isArray(on)) return on.map(e => ({ event: String(e) })); + if (typeof on === "object") { + return Object.entries(on).map(([event, config]: [string, any]) => { + const trigger: CICDTrigger = { event }; + if (typeof config === "string") { + trigger.schedule = config; + } else if (Array.isArray(config) && config[0]?.cron) { + // schedule is an array of cron objects + trigger.schedule = config[0].cron; + } else if (config && typeof config === "object") { + if (config.branches) trigger.branches = asArray(config.branches); + if (config.paths) trigger.paths = asArray(config.paths); + if (config.inputs) trigger.inputs = Object.keys(config.inputs); + } + return trigger; + }); + } + return []; +} + +function extractJobs(jobs: any): CICDJob[] { + if (!jobs || typeof jobs !== "object") return []; + + return Object.entries(jobs).map(([name, job]: [string, any]) => { + if (!job || typeof job !== "object") { + return { name, stepCount: 0 }; + } + + const steps: any[] = Array.isArray(job.steps) ? job.steps : []; + const actions = steps + .filter((s: any) => s && s.uses) + .map((s: any) => String(s.uses)); + + const result: CICDJob = { + name, + stepCount: steps.length, + }; + + // Runner + if (job["runs-on"]) { + result.runner = stringifyRunner(job["runs-on"]); + } + + // Job is a reusable workflow call (uses: at job level, not step level) + if (job.uses && typeof job.uses === "string") { + result.actions = [job.uses]; + result.stepCount = 1; + } else if (actions.length > 0) { + result.actions = actions; + } + + // Dependencies + if (job.needs) result.needs = asArray(job.needs); + + // Environment + if (job.environment) { + result.environment = typeof job.environment === "string" + ? job.environment + : job.environment?.name; + } + + // Services + if (job.services && typeof job.services === "object") { + result.services = Object.keys(job.services); + } + + // Matrix + if (job.strategy?.matrix && typeof job.strategy.matrix === "object") { + result.matrix = Object.keys(job.strategy.matrix) + .filter(k => k !== "include" && k !== "exclude"); + } + + // Deploy target + result.deployTarget = inferDeployTarget( + result.actions || [], + steps, + ); + + return result; + }); +} + +function collectReusableWorkflowRefs(jobs: any): string[] { + if (!jobs || typeof jobs !== "object") return []; + const refs: string[] = []; + for (const job of Object.values(jobs) as any[]) { + if (job?.uses && typeof job.uses === "string" && job.uses.startsWith("./")) { + refs.push(job.uses); + } + if (Array.isArray(job?.steps)) { + for (const step of job.steps) { + if (step?.uses && typeof step.uses === "string" && step.uses.startsWith("./")) { + refs.push(step.uses); + } + } + } + } + return [...new Set(refs)]; +} + +function extractSecrets(rawContent: string): string[] { + const pattern = /\$\{\{\s*secrets\.(\w+)\s*\}\}/g; + const secrets = new Set(); + let m: RegExpExecArray | null; + while ((m = pattern.exec(rawContent)) !== null) { + secrets.add(m[1]); + } + return [...secrets].sort(); +} + +function inferDeployTarget(actions: string[], steps: any[]): string | undefined { + const allText = [ + ...actions, + ...steps.map((s: any) => String(s?.run || "")), + ].join(" ").toLowerCase(); + + if (allText.includes("ecs") || allText.includes("amazon-ecs")) return "ecs"; + if (allText.includes("lambda") && allText.includes("deploy")) return "lambda"; + if (allText.includes("s3") && (allText.includes("deploy") || allText.includes("sync"))) return "s3"; + if (allText.includes("vercel")) return "vercel"; + if (allText.includes("fly deploy") || allText.includes("flyctl")) return "fly"; + if (allText.includes("netlify")) return "netlify"; + if (allText.includes("heroku")) return "heroku"; + if (allText.includes("wrangler") || allText.includes("cloudflare")) return "cloudflare"; + if (allText.includes("gcloud") || allText.includes("cloud run")) return "gcp"; + if (allText.includes("azure") && allText.includes("deploy")) return "azure"; + if (allText.includes("docker push") || allText.includes("ecr-login") || allText.includes("amazon-ecr")) return "container-registry"; + return undefined; +} + +function asArray(val: any): string[] { + if (Array.isArray(val)) return val.map(String); + if (typeof val === "string") return [val]; + return []; +} + +function stringifyRunner(val: any): string { + if (Array.isArray(val)) return `[${val.join(", ")}]`; + return String(val); +} diff --git a/src/plugins/cicd/index.ts b/src/plugins/cicd/index.ts new file mode 100644 index 0000000..e8e2040 --- /dev/null +++ b/src/plugins/cicd/index.ts @@ -0,0 +1,109 @@ +import { readFile, readdir } from "node:fs/promises"; +import { relative, join } from "node:path"; +import type { CodesightPlugin, ProjectInfo } from "../../types.js"; +import type { CICDPipeline } from "./types.js"; +import { parseYAML } from "./yaml-parser.js"; +import { extractGitHubActionsWorkflow } from "./github-actions.js"; +import { extractCircleCIWorkflows } from "./circleci.js"; +import { formatCICD } from "./formatter.js"; + +export type { CICDPipeline, CICDTrigger, CICDJob, CICDSystem } from "./types.js"; + +export interface CICDPluginConfig { + /** CI systems to scan. Default: all supported systems. */ + systems?: ("github-actions" | "circleci")[]; +} + +/** + * Create a CI/CD pipeline detection plugin for codesight. + * + * Scans GitHub Actions workflow files and CircleCI config files, + * extracts pipeline structure (triggers, jobs, secrets, deploy targets), + * and produces a cicd.md section. + * + * CI/CD configs live in dotfile directories (.github/, .circleci/) which + * codesight's collectFiles() skips. This plugin discovers them independently + * from project.root, similar to how the Terraform plugin discovers .tf files. + * + * @example + * // Detect all supported CI systems + * createCICDPlugin() + * + * @example + * // Only scan GitHub Actions + * createCICDPlugin({ systems: ["github-actions"] }) + */ +export function createCICDPlugin(config: CICDPluginConfig = {}): CodesightPlugin { + const systems = new Set(config.systems || ["github-actions", "circleci"]); + + return { + name: "cicd", + + detector: async (_files: string[], project: ProjectInfo) => { + const pipelines: CICDPipeline[] = []; + + // GitHub Actions — discover from .github/workflows/ directly + if (systems.has("github-actions")) { + const ghFiles = await collectGitHubActionsFiles(project.root); + for (const file of ghFiles) { + const content = await readFileSafe(file); + if (!content) continue; + try { + const parsed = parseYAML(content); + const relPath = relative(project.root, file).replace(/\\/g, "/"); + const pipeline = extractGitHubActionsWorkflow(parsed, relPath, content); + if (pipeline) pipelines.push(pipeline); + } catch { + // Skip unparseable files + } + } + } + + // CircleCI — discover from .circleci/ directly (.yml and .yaml) + if (systems.has("circleci")) { + for (const ext of ["config.yml", "config.yaml"]) { + const circleFile = join(project.root, ".circleci", ext); + const content = await readFileSafe(circleFile); + if (content) { + try { + const parsed = parseYAML(content); + const relPath = relative(project.root, circleFile).replace(/\\/g, "/"); + const extracted = extractCircleCIWorkflows(parsed, relPath, content); + pipelines.push(...extracted); + } catch { + // Skip unparseable files + } + break; // Only one config file per project + } + } + } + + if (pipelines.length === 0) return {}; + + return { + customSections: [{ name: "cicd", content: formatCICD(pipelines) }], + }; + }, + }; +} + +/** Collect .yml/.yaml files from .github/workflows/ */ +async function collectGitHubActionsFiles(root: string): Promise { + const workflowsDir = join(root, ".github", "workflows"); + try { + const entries = await readdir(workflowsDir, { withFileTypes: true }); + return entries + .filter(e => e.isFile() && /\.ya?ml$/.test(e.name)) + .map(e => join(workflowsDir, e.name)); + } catch { + return []; + } +} + +async function readFileSafe(path: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch { + return ""; + } +} diff --git a/src/plugins/cicd/types.ts b/src/plugins/cicd/types.ts new file mode 100644 index 0000000..ed85559 --- /dev/null +++ b/src/plugins/cicd/types.ts @@ -0,0 +1,36 @@ +export type CICDSystem = "github-actions" | "circleci"; + +export interface CICDTrigger { + event: string; + branches?: string[]; + tags?: string[]; + paths?: string[]; + inputs?: string[]; + schedule?: string; +} + +export interface CICDJob { + name: string; + runner?: string; + needs?: string[]; + stepCount: number; + actions?: string[]; + services?: string[]; + matrix?: string[]; + deployTarget?: string; + environment?: string; +} + +export interface CICDPipeline { + file: string; + system: CICDSystem; + name: string; + triggers: CICDTrigger[]; + jobs: CICDJob[]; + reusableWorkflows?: string[]; + isReusable?: boolean; + environments?: string[]; + secrets?: string[]; + envVars?: string[]; + concurrencyGroup?: string; +} diff --git a/src/plugins/cicd/yaml-parser.ts b/src/plugins/cicd/yaml-parser.ts new file mode 100644 index 0000000..afd085b --- /dev/null +++ b/src/plugins/cicd/yaml-parser.ts @@ -0,0 +1,378 @@ +/** + * Purpose-built YAML parser for CI/CD config files (GitHub Actions, CircleCI). + * + * Handles the subset of YAML used in CI configs: + * - Block mappings (nested to ~7 levels) + * - Block sequences of scalars and of mappings (steps, jobs) + * - Mixed scalar/mapping sequences (CircleCI workflow jobs) + * - Literal block scalars (|) for multi-line shell commands + * - Flow sequences [a, b, c] + * - Plain, single-quoted, double-quoted scalars + * - Comments (full-line and inline) + * - ${{ }} and << >> expressions as opaque strings + * + * Does NOT handle: anchors/aliases, tags, flow mappings {}, merge keys, multi-document. + */ + +export function parseYAML(text: string): any { + const lines = text.split("\n"); + let start = 0; + // Skip leading document marker + if (lines[start]?.trim() === "---") start++; + start = skipEmpty(lines, start); + if (start >= lines.length) return {}; + const { value } = parseBlock(lines, start, indentOf(lines[start])); + return value; +} + +function parseBlock( + lines: string[], + startIdx: number, + baseIndent: number, +): { value: any; nextIdx: number } { + const idx = skipEmpty(lines, startIdx); + if (idx >= lines.length) return { value: {}, nextIdx: idx }; + const trimmed = lines[idx].trimStart(); + if (trimmed.startsWith("- ") || trimmed === "-") { + return parseSequence(lines, idx, baseIndent); + } + return parseMapping(lines, idx, baseIndent); +} + +function parseMapping( + lines: string[], + startIdx: number, + baseIndent: number, +): { value: Record; nextIdx: number } { + const obj: Record = Object.create(null); + let i = startIdx; + + while (i < lines.length) { + const ci = skipEmpty(lines, i); + if (ci >= lines.length) { i = ci; break; } + + const line = lines[ci]; + const indent = indentOf(line); + if (indent < baseIndent) { i = ci; break; } + if (indent > baseIndent) { i = ci + 1; continue; } + + const trimmed = line.trimStart(); + // A dash at this indent means we've entered a sequence — bail + if (trimmed.startsWith("- ")) { i = ci; break; } + + const colonIdx = findKeyColon(trimmed); + if (colonIdx === -1) { i = ci + 1; continue; } + + const key = trimmed.slice(0, colonIdx).trim(); + const rawValue = trimmed.slice(colonIdx + 1).trim(); + + if (rawValue === "|" || rawValue === "|-" || rawValue === "|+" || + rawValue === ">" || rawValue === ">-" || rawValue === ">+") { + const fold = rawValue.startsWith(">"); + const { value, nextIdx } = consumeBlockScalar(lines, ci + 1, indent); + obj[key] = fold ? value.replace(/\n/g, " ").trim() : value; + i = nextIdx; + } else if (rawValue) { + obj[key] = parseInlineValue(rawValue); + i = ci + 1; + } else { + // Empty value — check for nested content + const nextContent = skipEmpty(lines, ci + 1); + if (nextContent < lines.length && indentOf(lines[nextContent]) > baseIndent) { + const nested = parseBlock(lines, nextContent, indentOf(lines[nextContent])); + obj[key] = nested.value; + i = nested.nextIdx; + } else { + obj[key] = null; + i = ci + 1; + } + } + } + + return { value: obj, nextIdx: i }; +} + +function parseSequence( + lines: string[], + startIdx: number, + baseIndent: number, +): { value: any[]; nextIdx: number } { + const arr: any[] = []; + let i = startIdx; + + while (i < lines.length) { + const ci = skipEmpty(lines, i); + if (ci >= lines.length) { i = ci; break; } + + const line = lines[ci]; + const indent = indentOf(line); + if (indent < baseIndent) { i = ci; break; } + if (indent > baseIndent) { i = ci + 1; continue; } + + const trimmed = line.trimStart(); + if (!trimmed.startsWith("- ") && trimmed !== "-") { i = ci; break; } + + // Content after "- " + const afterDash = trimmed === "-" ? "" : trimmed.slice(2); + const itemIndent = indent + 2; // sibling keys align here + + if (!afterDash.trim()) { + // Bare dash with nested content below + const nextContent = skipEmpty(lines, ci + 1); + if (nextContent < lines.length && indentOf(lines[nextContent]) >= itemIndent) { + const nested = parseBlock(lines, nextContent, indentOf(lines[nextContent])); + arr.push(nested.value); + i = nested.nextIdx; + } else { + arr.push(null); + i = ci + 1; + } + } else { + const colonIdx = findKeyColon(afterDash); + if (colonIdx !== -1) { + // Dash item starts an object: "- key: value" + const key = afterDash.slice(0, colonIdx).trim(); + const rawVal = afterDash.slice(colonIdx + 1).trim(); + + const itemObj: Record = {}; + + // Parse the inline key's value + if (rawVal === "|" || rawVal === "|-" || rawVal === "|+" || + rawVal === ">" || rawVal === ">-" || rawVal === ">+") { + const fold = rawVal.startsWith(">"); + const { value, nextIdx } = consumeBlockScalar(lines, ci + 1, indent); + itemObj[key] = fold ? value.replace(/\n/g, " ").trim() : value; + i = nextIdx; + } else if (rawVal) { + itemObj[key] = parseInlineValue(rawVal); + i = ci + 1; + } else { + // Empty inline value — might have nested content + const nextContent = skipEmpty(lines, ci + 1); + if (nextContent < lines.length && indentOf(lines[nextContent]) >= itemIndent) { + const nested = parseBlock(lines, nextContent, indentOf(lines[nextContent])); + itemObj[key] = nested.value; + i = nested.nextIdx; + } else { + itemObj[key] = null; + i = ci + 1; + } + } + + // Gather sibling keys at itemIndent + while (i < lines.length) { + const si = skipEmpty(lines, i); + if (si >= lines.length) { i = si; break; } + const sLine = lines[si]; + const sIndent = indentOf(sLine); + if (sIndent < itemIndent) break; + if (sIndent > itemIndent) { i = si + 1; continue; } + + const sTrimmed = sLine.trimStart(); + if (sTrimmed.startsWith("- ")) break; // next sequence item + + const sColon = findKeyColon(sTrimmed); + if (sColon === -1) { i = si + 1; continue; } + + const sKey = sTrimmed.slice(0, sColon).trim(); + const sRaw = sTrimmed.slice(sColon + 1).trim(); + + if (sRaw === "|" || sRaw === "|-" || sRaw === "|+" || + sRaw === ">" || sRaw === ">-" || sRaw === ">+") { + const fold = sRaw.startsWith(">"); + const { value, nextIdx } = consumeBlockScalar(lines, si + 1, sIndent); + itemObj[sKey] = fold ? value.replace(/\n/g, " ").trim() : value; + i = nextIdx; + } else if (sRaw) { + itemObj[sKey] = parseInlineValue(sRaw); + i = si + 1; + } else { + const nextContent = skipEmpty(lines, si + 1); + if (nextContent < lines.length && indentOf(lines[nextContent]) > sIndent) { + const nested = parseBlock(lines, nextContent, indentOf(lines[nextContent])); + itemObj[sKey] = nested.value; + i = nested.nextIdx; + } else { + itemObj[sKey] = null; + i = si + 1; + } + } + } + + arr.push(itemObj); + } else { + // Plain scalar item + arr.push(parseScalar(afterDash)); + i = ci + 1; + } + } + } + + return { value: arr, nextIdx: i }; +} + +function consumeBlockScalar( + lines: string[], + startIdx: number, + parentIndent: number, +): { value: string; nextIdx: number } { + const collected: string[] = []; + let i = startIdx; + let scalarIndent = -1; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + // Blank lines within block scalars are preserved + if (!trimmed) { + if (scalarIndent !== -1) collected.push(""); + i++; + continue; + } + + const indent = indentOf(line); + if (indent <= parentIndent) break; + + if (scalarIndent === -1) scalarIndent = indent; + if (indent < scalarIndent) break; + + collected.push(line.slice(scalarIndent)); + i++; + } + + // Trim trailing empty lines + while (collected.length > 0 && collected[collected.length - 1] === "") { + collected.pop(); + } + + return { value: collected.join("\n"), nextIdx: i }; +} + +function parseInlineValue(raw: string): any { + if (raw.startsWith("[")) return parseFlowSequence(raw); + return parseScalar(raw); +} + +export function parseFlowSequence(s: string): any[] { + // Find matching ] + let depth = 0; + let end = -1; + let inQuote: string | null = null; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (inQuote) { + if (c === inQuote && s[i - 1] !== "\\") inQuote = null; + continue; + } + if (c === '"' || c === "'") { inQuote = c; continue; } + if (c === "[") depth++; + if (c === "]") { depth--; if (depth === 0) { end = i; break; } } + } + if (end === -1) end = s.length; + const inner = s.slice(1, end).trim(); + if (!inner) return []; + + // Split on top-level commas respecting quotes and nested brackets + const items: string[] = []; + let current = ""; + let bracketDepth = 0; + inQuote = null; + for (let i = 0; i < inner.length; i++) { + const c = inner[i]; + if (inQuote) { + current += c; + if (c === inQuote && inner[i - 1] !== "\\") inQuote = null; + continue; + } + if (c === '"' || c === "'") { inQuote = c; current += c; continue; } + if (c === "[") { bracketDepth++; current += c; continue; } + if (c === "]") { bracketDepth--; current += c; continue; } + if (c === "," && bracketDepth === 0) { items.push(current.trim()); current = ""; continue; } + current += c; + } + if (current.trim()) items.push(current.trim()); + + return items.map(parseScalar); +} + +function parseScalar(s: string): any { + s = stripComment(s).trim(); + if (!s) return null; + if (s === "true" || s === "True" || s === "TRUE") return true; + if (s === "false" || s === "False" || s === "FALSE") return false; + if (s === "null" || s === "~") return null; + // Quoted strings + if ((s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + // Numbers — only if the entire string is numeric and not a leading-zero integer (version-like) + const isNumeric = /^-?\d+(\.\d+)?$/.test(s); + const isLeadingZeroInteger = /^-?0\d+$/.test(s); + if (isNumeric && !isLeadingZeroInteger) { + const n = Number(s); + if (!isNaN(n)) return n; + } + return s; +} + +function stripComment(s: string): string { + let inQuote: string | null = null; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (inQuote) { + // Handle escaped quotes in double-quoted strings + if (c === "\\" && inQuote === '"') { i++; continue; } + if (c === inQuote) inQuote = null; + continue; + } + if (c === '"' || c === "'") { inQuote = c; continue; } + // GitHub Actions expressions ${{ }} can contain # — skip them + if (c === "$" && s[i + 1] === "{" && s[i + 2] === "{") { + const closeIdx = s.indexOf("}}", i + 3); + if (closeIdx !== -1) { i = closeIdx + 1; continue; } + } + if (c === "#" && (i === 0 || s[i - 1] === " " || s[i - 1] === "\t")) { + return s.slice(0, i).trimEnd(); + } + } + return s; +} + +/** Find the first colon that's a key separator (not inside quotes or expressions). */ +function findKeyColon(s: string): number { + let inQuote: string | null = null; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (inQuote) { + if (c === "\\" && inQuote === '"') { i++; continue; } + if (c === inQuote) inQuote = null; + continue; + } + if (c === '"' || c === "'") { inQuote = c; continue; } + if (c === ":" && (i + 1 >= s.length || s[i + 1] === " " || s[i + 1] === "\n")) { + return i; + } + } + return -1; +} + +function indentOf(line: string): number { + let n = 0; + for (let i = 0; i < line.length; i++) { + if (line[i] === " ") n++; + else break; + } + return n; +} + +function skipEmpty(lines: string[], startIdx: number): number { + let i = startIdx; + while (i < lines.length) { + const trimmed = lines[i].trim(); + if (trimmed && !trimmed.startsWith("#")) return i; + i++; + } + return i; +} diff --git a/src/plugins/githooks/formatter.ts b/src/plugins/githooks/formatter.ts new file mode 100644 index 0000000..2315552 --- /dev/null +++ b/src/plugins/githooks/formatter.ts @@ -0,0 +1,38 @@ +import type { GitHook } from "./types.js"; + +const LIFECYCLE_ORDER = [ + "pre-commit", "prepare-commit-msg", "commit-msg", "post-commit", + "pre-rebase", "post-checkout", "post-merge", "pre-push", "post-rewrite", +]; + +const TOOL_LABEL: Record = { + lefthook: "lefthook", + husky: "husky", + raw: "raw git hook", +}; + +export function formatGitHooks(hooks: GitHook[]): string { + const lines: string[] = []; + lines.push("# Git Hooks", ""); + lines.push("> **Note for agents:** These hooks fire automatically on git operations and will block the operation if they fail.", ""); + + const sorted = [...hooks].sort((a, b) => { + const ai = LIFECYCLE_ORDER.indexOf(a.lifecycle); + const bi = LIFECYCLE_ORDER.indexOf(b.lifecycle); + if (ai === -1 && bi === -1) return a.lifecycle.localeCompare(b.lifecycle); + return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi); + }); + + for (const hook of sorted) { + lines.push(`## \`${hook.lifecycle}\` — ${TOOL_LABEL[hook.tool] ?? hook.tool}`, ""); + for (const cmd of hook.commands) { + lines.push(`- **${cmd.name}**: \`${cmd.run}\``); + } + lines.push(""); + } + + const sources = [...new Set(hooks.map(h => h.source))].sort(); + lines.push(`_Source: ${sources.join(", ")}_`, ""); + + return lines.join("\n"); +} diff --git a/src/plugins/githooks/husky.ts b/src/plugins/githooks/husky.ts new file mode 100644 index 0000000..ff70c3e --- /dev/null +++ b/src/plugins/githooks/husky.ts @@ -0,0 +1,34 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { GitHook } from "./types.js"; + +const HOOK_NAMES = new Set([ + "pre-commit", "commit-msg", "prepare-commit-msg", "post-commit", + "pre-push", "post-merge", "post-checkout", "pre-rebase", "post-rewrite", +]); + +export async function parseHusky(root: string): Promise { + const hooks: GitHook[] = []; + try { + const entries = await readdir(join(root, ".husky"), { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !HOOK_NAMES.has(entry.name)) continue; + const content = await readFile(join(root, ".husky", entry.name), "utf-8"); + const commands = extractShellCommands(content); + if (commands.length > 0) { + hooks.push({ lifecycle: entry.name, tool: "husky", commands, source: `.husky/${entry.name}` }); + } + } + } catch { + // .husky dir doesn't exist + } + return hooks; +} + +function extractShellCommands(content: string) { + return content + .split("\n") + .map(l => l.trim()) + .filter(l => l && !l.startsWith("#") && !/^#!/.test(l) && !l.startsWith(". ")) + .map(run => ({ name: run.split(/\s+/)[0], run })); +} diff --git a/src/plugins/githooks/index.ts b/src/plugins/githooks/index.ts new file mode 100644 index 0000000..a8ff3cc --- /dev/null +++ b/src/plugins/githooks/index.ts @@ -0,0 +1,35 @@ +import type { CodesightPlugin, ProjectInfo } from "../../types.js"; +import { parseLefthook } from "./lefthook.js"; +import { parseHusky } from "./husky.js"; +import { parseRawHooks } from "./raw.js"; +import { formatGitHooks } from "./formatter.js"; + +export type { GitHook, GitHookCommand, HookTool } from "./types.js"; + +export function createGitHooksPlugin(): CodesightPlugin { + return { + name: "githooks", + detector: async (_files: string[], project: ProjectInfo) => { + const [lefthookHooks, huskyHooks, rawHooks] = await Promise.all([ + parseLefthook(project.root), + parseHusky(project.root), + parseRawHooks(project.root), + ]); + + // If a managed tool (lefthook/husky) handles a lifecycle, suppress the + // raw hook for it — managed tools install raw hooks that just delegate. + const managedLifecycles = new Set([ + ...lefthookHooks.map(h => h.lifecycle), + ...huskyHooks.map(h => h.lifecycle), + ]); + const filteredRaw = rawHooks.filter(h => !managedLifecycles.has(h.lifecycle)); + + const allHooks = [...lefthookHooks, ...huskyHooks, ...filteredRaw]; + if (allHooks.length === 0) return {}; + + return { + customSections: [{ name: "githooks", content: formatGitHooks(allHooks) }], + }; + }, + }; +} diff --git a/src/plugins/githooks/lefthook.ts b/src/plugins/githooks/lefthook.ts new file mode 100644 index 0000000..51beedc --- /dev/null +++ b/src/plugins/githooks/lefthook.ts @@ -0,0 +1,95 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { GitHook, GitHookCommand } from "./types.js"; + +const HOOK_NAMES = new Set([ + "pre-commit", "commit-msg", "prepare-commit-msg", "post-commit", + "pre-push", "post-merge", "post-checkout", "pre-rebase", "post-rewrite", +]); + +export async function parseLefthook(root: string): Promise { + for (const name of ["lefthook.yml", "lefthook.yaml", "lefthook.json"]) { + try { + const content = await readFile(join(root, name), "utf-8"); + return name.endsWith(".json") + ? parseJson(JSON.parse(content), name) + : parseYaml(content, name); + } catch { + // file doesn't exist, try next + } + } + return []; +} + +// Minimal line-by-line parser for lefthook's YAML structure. +// Handles the common pattern: lifecycle > commands > name > run. +function parseYaml(content: string, source: string): GitHook[] { + const commandsByLifecycle = new Map(); + let currentLifecycle: string | null = null; + let inCommands = false; + let currentCommand: string | null = null; + + for (const raw of content.split("\n")) { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const indent = raw.search(/\S/); + + if (indent === 0 && trimmed.endsWith(":")) { + const key = trimmed.slice(0, -1); + currentLifecycle = HOOK_NAMES.has(key) ? key : null; + inCommands = false; + currentCommand = null; + if (currentLifecycle && !commandsByLifecycle.has(key)) { + commandsByLifecycle.set(key, []); + } + continue; + } + + if (!currentLifecycle) continue; + + if (indent === 2 && (trimmed === "commands:" || trimmed === "scripts:")) { + inCommands = true; + continue; + } + + if (inCommands && indent === 4 && trimmed.endsWith(":") && !trimmed.startsWith("run:")) { + currentCommand = trimmed.slice(0, -1); + continue; + } + + if (inCommands && currentCommand && indent === 6 && trimmed.startsWith("run:")) { + const run = trimmed.slice(4).trim().replace(/^['"]|['"]$/g, ""); + commandsByLifecycle.get(currentLifecycle)!.push({ name: currentCommand, run }); + continue; + } + + // run: directly under hook (no commands block) + if (!inCommands && indent === 2 && trimmed.startsWith("run:")) { + const run = trimmed.slice(4).trim().replace(/^['"]|['"]$/g, ""); + commandsByLifecycle.get(currentLifecycle)!.push({ name: currentLifecycle, run }); + } + } + + return [...commandsByLifecycle.entries()] + .filter(([, cmds]) => cmds.length > 0) + .map(([lifecycle, commands]) => ({ lifecycle, tool: "lefthook", commands, source })); +} + +function parseJson(obj: Record, source: string): GitHook[] { + const hooks: GitHook[] = []; + for (const lifecycle of HOOK_NAMES) { + const hook = obj[lifecycle] as Record | undefined; + if (!hook) continue; + const commands: GitHookCommand[] = []; + const block = hook.commands ?? hook.scripts; + if (block && typeof block === "object") { + for (const [name, cmd] of Object.entries(block as Record)) { + const c = cmd as Record; + if (typeof c?.run === "string") commands.push({ name, run: c.run }); + } + } + if (commands.length > 0) hooks.push({ lifecycle, tool: "lefthook", commands, source }); + } + return hooks; +} diff --git a/src/plugins/githooks/raw.ts b/src/plugins/githooks/raw.ts new file mode 100644 index 0000000..5974371 --- /dev/null +++ b/src/plugins/githooks/raw.ts @@ -0,0 +1,39 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { GitHook } from "./types.js"; + +const HOOK_NAMES = new Set([ + "pre-commit", "commit-msg", "prepare-commit-msg", "post-commit", + "pre-push", "post-merge", "post-checkout", "pre-rebase", "post-rewrite", +]); + +export async function parseRawHooks(root: string): Promise { + const hooks: GitHook[] = []; + const hooksDir = join(root, ".git", "hooks"); + try { + const entries = await readdir(hooksDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || entry.name.endsWith(".sample") || !HOOK_NAMES.has(entry.name)) continue; + const fullPath = join(hooksDir, entry.name); + // Skip non-executable files + const s = await stat(fullPath).catch(() => null); + if (!s || !(s.mode & 0o111)) continue; + const content = await readFile(fullPath, "utf-8").catch(() => ""); + const commands = extractShellCommands(content); + if (commands.length > 0) { + hooks.push({ lifecycle: entry.name, tool: "raw", commands, source: `.git/hooks/${entry.name}` }); + } + } + } catch { + // .git/hooks doesn't exist + } + return hooks; +} + +function extractShellCommands(content: string) { + return content + .split("\n") + .map(l => l.trim()) + .filter(l => l && !l.startsWith("#") && !/^#!/.test(l)) + .map(run => ({ name: run.split(/\s+/)[0], run })); +} diff --git a/src/plugins/githooks/types.ts b/src/plugins/githooks/types.ts new file mode 100644 index 0000000..e0300e8 --- /dev/null +++ b/src/plugins/githooks/types.ts @@ -0,0 +1,13 @@ +export type HookTool = "lefthook" | "husky" | "raw"; + +export interface GitHookCommand { + name: string; + run: string; +} + +export interface GitHook { + lifecycle: string; + tool: HookTool; + commands: GitHookCommand[]; + source: string; +} diff --git a/src/plugins/skills/formatter.ts b/src/plugins/skills/formatter.ts new file mode 100644 index 0000000..8b41ec5 --- /dev/null +++ b/src/plugins/skills/formatter.ts @@ -0,0 +1,17 @@ +import type { Skill } from "./index.js"; + +export function formatSkills(skills: Skill[]): string { + const lines: string[] = []; + lines.push("# Claude Skills", ""); + lines.push("Project-local slash commands available to Claude Code agents:", ""); + + for (const skill of [...skills].sort((a, b) => a.name.localeCompare(b.name))) { + const desc = skill.description ? ` — ${skill.description}` : ""; + lines.push(`- \`/${skill.name}\`${desc}`); + } + + const dirs = [...new Set(skills.map(s => s.path.split("/").slice(0, -1).join("/")))].sort(); + lines.push("", `_Source: ${dirs.join(", ")}_`, ""); + + return lines.join("\n"); +} diff --git a/src/plugins/skills/index.ts b/src/plugins/skills/index.ts new file mode 100644 index 0000000..b8ce2b3 --- /dev/null +++ b/src/plugins/skills/index.ts @@ -0,0 +1,70 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join, basename, extname, relative } from "node:path"; +import type { CodesightPlugin, ProjectInfo } from "../../types.js"; +import { formatSkills } from "./formatter.js"; + +export interface Skill { + name: string; + description: string; + path: string; +} + +const SKILL_DIRS = [".claude/commands", ".claude/skills"]; + +export function createSkillsPlugin(): CodesightPlugin { + return { + name: "skills", + detector: async (_files: string[], project: ProjectInfo) => { + const skills: Skill[] = []; + + for (const dir of SKILL_DIRS) { + const found = await readSkillsDir(join(project.root, dir), project.root); + skills.push(...found); + } + + if (skills.length === 0) return {}; + + return { + customSections: [{ name: "skills", content: formatSkills(skills) }], + }; + }, + }; +} + +async function readSkillsDir(dir: string, root: string): Promise { + const skills: Skill[] = []; + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !/\.(md|txt)$/.test(entry.name)) continue; + const fullPath = join(dir, entry.name); + const content = await readFile(fullPath, "utf-8"); + skills.push({ + name: basename(entry.name, extname(entry.name)), + description: extractDescription(content), + path: relative(root, fullPath).replace(/\\/g, "/"), + }); + } + } catch { + // dir doesn't exist — not an error + } + return skills; +} + +function extractDescription(content: string): string { + // Prefer frontmatter description: field + const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (fmMatch) { + const descMatch = fmMatch[1].match(/^description:\s*(.+)$/m); + if (descMatch) return descMatch[1].trim(); + } + + // Fall back to first non-empty line after stripping frontmatter and headings + const body = fmMatch ? content.slice(fmMatch[0].length) : content; + for (const line of body.split("\n")) { + const trimmed = line.trim().replace(/^#+\s*/, ""); + if (trimmed) return trimmed; + } + + return ""; +} diff --git a/src/plugins/terraform/extractor.ts b/src/plugins/terraform/extractor.ts new file mode 100644 index 0000000..99b06a9 --- /dev/null +++ b/src/plugins/terraform/extractor.ts @@ -0,0 +1,455 @@ +import { basename } from "node:path"; +import { normaliseServiceName } from "./service-matcher.js"; +import { parseTfvars } from "./hcl-parser.js"; +import { readFileSafe } from "./file-collector.js"; +import type { + HclBlock, + ServiceInfrastructure, + ServiceComponent, + DnsConfig, + EnvVar, + SecretRef, + ObservabilityConfig, + EnvironmentOverrides, + TerraformPluginConfig, +} from "./types.js"; + +/** + * Extract structured infrastructure context from matched HCL blocks. + */ +export function extractServiceInfrastructure( + matchedBlocks: HclBlock[], + allBlocks: HclBlock[], + config: TerraformPluginConfig, +): ServiceInfrastructure { + const serviceName = config.serviceName ?? "unknown"; + const sourceFiles = [...new Set(matchedBlocks.map((b) => b.file))]; + + const components = extractComponents(matchedBlocks); + const envVars = extractEnvVars(matchedBlocks); + const secrets = extractSecrets(matchedBlocks); + const dns = extractDns(matchedBlocks); + const dependencies = extractDependencies(matchedBlocks, allBlocks); + const iamPermissions = extractIamPermissions(matchedBlocks); + const observability = extractObservability(matchedBlocks, allBlocks, serviceName); + + return { + serviceName, + sourceFiles, + components, + dns, + envVars, + secrets, + dependencies, + iamPermissions, + observability, + environments: {}, + }; +} + +/** + * Parse .tfvars files and extract per-environment overrides for this service. + */ +export async function extractEnvironments( + tfvarsFiles: string[], + serviceName: string, +): Promise> { + const environments: Record = {}; + const normalised = normaliseServiceName(serviceName); + + // TODO: consider Promise.all for parallel file reads in large infra repos + for (const file of tfvarsFiles) { + const envName = basename(file, ".tfvars"); + const content = await readFileSafe(file); + if (!content) continue; + + const vars = parseTfvars(content); + const matched: Record = {}; + let enabled: boolean | undefined; + + for (const [key, value] of Object.entries(vars)) { + const normalisedKey = normaliseServiceName(key); + + // Check enable flag + if (normalisedKey.startsWith(`enable_${normalised}`)) { + enabled = value === "true"; + matched[key] = value; + continue; + } + + // Check if variable name contains the service name + if (normalisedKey.includes(normalised)) { + matched[key] = value; + } + } + + if (Object.keys(matched).length > 0 || enabled !== undefined) { + environments[envName] = { enabled, variables: matched }; + } + } + + return environments; +} + +// ─── Component Extraction ─── + +function extractComponents(blocks: HclBlock[]): ServiceComponent[] { + const components: ServiceComponent[] = []; + + for (const block of blocks) { + if (block.blockType !== "module" && block.blockType !== "resource") continue; + // Skip variable/output blocks — they're metadata not components + if (block.blockType === "resource" && !isComputeResource(block.resourceType)) continue; + + // Skip non-compute modules (alarms, s3, logging, etc.) + if (block.blockType === "module" && !isComputeModule(block)) continue; + + const moduleSource = block.attributes["source"]; + const deploymentType = inferDeploymentType(moduleSource, block.resourceType); + const isPublicFacing = detectPublicFacing(block); + + components.push({ + label: block.label, + deploymentType, + moduleSource, + compute: { + cpu: block.attributes["cpu"], + memory: block.attributes["memory"], + desiredCount: block.attributes["desired_count"], + }, + healthCheck: extractHealthCheck(block), + enableFlag: extractEnableFlag(block), + isPublicFacing, + }); + } + + return components; +} + +function isComputeResource(resourceType: string): boolean { + return [ + "aws_ecs_service", + "aws_ecs_task_definition", + "aws_lambda_function", + "aws_instance", + "aws_autoscaling_group", + ].includes(resourceType); +} + +function isComputeModule(block: HclBlock): boolean { + const source = block.attributes["source"] ?? ""; + // Positive: compute modules + if (source.includes("ecs-service") || source.includes("ecs-worker") || source.includes("lambda")) return true; + // Negative: known non-compute modules + if (source.includes("alarm") || source.includes("logging") || source.includes("s3") || + source.includes("autoscaling") || source.includes("kms") || source.includes("secret") || + source.includes("iam") || source.includes("deploy")) return false; + // Heuristic: modules with image attribute are likely compute + if (block.attributes["image"] || block.attributes["container_image"]) return true; + // Default: include if it has cpu/memory (likely compute) + if (block.attributes["cpu"] || block.attributes["memory"]) return true; + return false; +} + +function inferDeploymentType(moduleSource: string | undefined, resourceType: string): string { + if (moduleSource) { + if (moduleSource.includes("ecs-service")) return "ecs-fargate"; + if (moduleSource.includes("ecs-worker")) return "ecs-worker"; + if (moduleSource.includes("ecs-service-internal")) return "ecs-internal"; + if (moduleSource.includes("lambda")) return "lambda"; + if (moduleSource.includes("ec2") || moduleSource.includes("instance")) return "ec2"; + } + if (resourceType.includes("lambda")) return "lambda"; + if (resourceType.includes("ecs")) return "ecs-fargate"; + if (resourceType.includes("instance")) return "ec2"; + return "unknown"; +} + +function detectPublicFacing(block: HclBlock): boolean { + const attrs = block.attributes; + if (attrs["alb_listener_arn"] || attrs["https_listener_arn"]) return true; + if (attrs["host_headers"]) return true; + + // Check if any attribute references an ALB + for (const val of Object.values(attrs)) { + if (typeof val === "string" && val.includes("module.alb")) return true; + } + + // Internal service modules are not public-facing + const source = attrs["source"] ?? ""; + if (source.includes("ecs-worker") || source.includes("ecs-service-internal")) return false; + + return false; +} + +function extractHealthCheck(block: HclBlock): string | undefined { + if (block.attributes["health_check_path"]) return block.attributes["health_check_path"]; + + const healthCheck = block.nestedBlocks["health_check"]; + if (healthCheck?.[0]?.attributes["path"]) return healthCheck[0].attributes["path"]; + + return undefined; +} + +function extractEnableFlag(block: HclBlock): string | undefined { + const count = block.attributes["count"]; + if (!count) return undefined; + + const match = count.match(/var\.(\w+)/); + return match ? `var.${match[1]}` : undefined; +} + +// ─── Environment Variables ─── + +function extractEnvVars(blocks: HclBlock[]): EnvVar[] { + const envVars: EnvVar[] = []; + const seen = new Set(); + + for (const block of blocks) { + const entries = block.nestedBlocks["environment_variables"] ?? block.nestedBlocks["environment"]; + if (!entries) continue; + + for (const entry of entries) { + const name = entry.attributes["name"]; + const value = entry.attributes["value"] ?? entry.attributes["valueFrom"] ?? ""; + if (!name || seen.has(name)) continue; + seen.add(name); + + envVars.push({ + name, + value, + source: classifyValueSource(value), + }); + } + } + + return envVars; +} + +function classifyValueSource(value: string): "literal" | "variable" | "reference" { + if (value.startsWith("var.")) return "variable"; + if (value.includes("module.") || value.includes("aws_") || value.includes("data.")) return "reference"; + if (value.includes("${")) { + // Interpolated string — check if it references vars or resources + if (value.includes("${var.")) return "variable"; + if (value.includes("${module.") || value.includes("${data.") || value.includes("${aws_")) return "reference"; + } + return "literal"; +} + +// ─── Secrets ─── + +function extractSecrets(blocks: HclBlock[]): SecretRef[] { + const secrets: SecretRef[] = []; + const seen = new Set(); + + for (const block of blocks) { + const entries = block.nestedBlocks["secrets"]; + if (!entries) continue; + + for (const entry of entries) { + const name = entry.attributes["name"]; + const arn = entry.attributes["valueFrom"] ?? entry.attributes["value_from"] ?? ""; + if (!name || seen.has(name)) continue; + seen.add(name); + + secrets.push({ name, arnPattern: arn }); + } + } + + return secrets; +} + +// ─── DNS ─── + +function extractDns(blocks: HclBlock[]): DnsConfig { + const hostnames: string[] = []; + let isPublicFacing = false; + + for (const block of blocks) { + // host_headers attribute (used in ECS service modules for ALB routing) + const hostHeaders = block.attributes["host_headers"]; + if (hostHeaders) { + // Parse list: ["host1", "host2"] or extract from ternary expressions + const matches = hostHeaders.match(/"([^"]+)"/g); + if (matches) { + for (const m of matches) { + const hostname = m.replace(/"/g, ""); + // Only include values that look like hostnames (contain a dot or interpolation) + if (hostname.includes(".") || hostname.includes("${")) { + hostnames.push(hostname); + } + } + } + isPublicFacing = true; + } + + // Route53 record + if (block.blockType === "resource" && block.resourceType === "aws_route53_record") { + const name = block.attributes["name"]; + if (name && (name.includes(".") || name.includes("${"))) { + hostnames.push(name); + } + isPublicFacing = true; + } + + // ALB references + if (block.attributes["alb_listener_arn"] || block.attributes["https_listener_arn"]) { + isPublicFacing = true; + } + } + + return { hostnames: [...new Set(hostnames)], isPublicFacing }; +} + +// ─── Dependencies ─── + +function extractDependencies(matchedBlocks: HclBlock[], allBlocks: HclBlock[]): string[] { + const deps = new Set(); + + for (const block of matchedBlocks) { + // Scan all attribute values for module/resource references + for (const [key, value] of Object.entries(block.attributes)) { + if (key === "source" || key === "count") continue; + + // module.xxx references + const moduleRefs = value.match(/module\.(\w+)/g); + if (moduleRefs) { + for (const ref of moduleRefs) { + const moduleName = ref.replace("module.", ""); + const desc = describeModuleDep(moduleName, allBlocks); + deps.add(desc); + } + } + + // data.xxx references + const dataRefs = value.match(/data\.(\w+)\.(\w+)/g); + if (dataRefs) { + for (const ref of dataRefs) { + deps.add(ref); + } + } + + // Service Connect references + if (key.includes("service_connect") && value.includes("true")) { + deps.add("ECS Service Connect"); + } + } + + // Service Connect discovery name → dependency + if (block.attributes["service_connect_discovery_name"]) { + const svcName = block.attributes["service_connect_discovery_name"]; + deps.add(`Service Connect: ${svcName}`); + } + } + + return [...deps]; +} + +function describeModuleDep(moduleName: string, allBlocks: HclBlock[]): string { + // Try to find the module definition to get its source for a friendlier name + const moduleBlock = allBlocks.find( + (b) => b.blockType === "module" && b.label === moduleName, + ); + + if (moduleBlock?.attributes["source"]) { + const source = moduleBlock.attributes["source"]; + if (source.includes("rds") || source.includes("database")) return `RDS (module.${moduleName})`; + if (source.includes("redis") || source.includes("elasticache")) return `Redis (module.${moduleName})`; + if (source.includes("s3")) return `S3 (module.${moduleName})`; + if (source.includes("sqs")) return `SQS (module.${moduleName})`; + if (source.includes("sns")) return `SNS (module.${moduleName})`; + if (source.includes("vpc")) return `VPC (module.${moduleName})`; + if (source.includes("alb")) return `ALB (module.${moduleName})`; + if (source.includes("efs")) return `EFS (module.${moduleName})`; + if (source.includes("kms")) return `KMS (module.${moduleName})`; + if (source.includes("dynamodb")) return `DynamoDB (module.${moduleName})`; + } + + return `module.${moduleName}`; +} + +// ─── IAM Permissions ─── + +function extractIamPermissions(blocks: HclBlock[]): string[] { + const permissions: string[] = []; + + for (const block of blocks) { + // task_policy_arns attribute + const policyArns = block.attributes["task_policy_arns"]; + if (policyArns) { + const refs = policyArns.match(/[\w.-]+/g); + if (refs) { + for (const ref of refs) { + if (ref.includes("policy") || ref.includes("arn")) { + permissions.push(ref); + } + } + } + } + + // Inline IAM policy statements + const statements = block.nestedBlocks["statement"]; + if (statements) { + for (const stmt of statements) { + const actions = stmt.attributes["actions"] ?? stmt.attributes["action"]; + const resources = stmt.attributes["resources"] ?? stmt.attributes["resource"]; + if (actions) { + const effect = stmt.attributes["effect"]; + const prefix = effect ? `${effect}: ` : ""; + permissions.push(`${prefix}${actions}${resources ? ` on ${resources}` : ""}`); + } + } + } + } + + return [...new Set(permissions)]; +} + +// ─── Observability ─── + +function extractObservability( + matchedBlocks: HclBlock[], + allBlocks: HclBlock[], + serviceName: string, +): ObservabilityConfig { + let logGroup: string | undefined; + const alarms: string[] = []; + const normalisedService = normaliseServiceName(serviceName); + + // Look for log_group attribute in matched blocks + for (const block of matchedBlocks) { + if (block.attributes["log_group"]) { + logGroup = block.attributes["log_group"]; + } + } + + // Search all blocks for alarm modules referencing this service + for (const block of allBlocks) { + if (block.blockType !== "module") continue; + const source = block.attributes["source"] ?? ""; + if (!source.includes("alarm")) continue; + + // Check if this alarm references our service + const allValues = Object.values(block.attributes).join(" "); + if (allValues.includes(normalisedService) || allValues.includes(normalisedService.replace(/_/g, "-"))) { + const alarmName = block.label; + const threshold = block.attributes["threshold"] ?? ""; + const metric = block.attributes["metric_name"] ?? ""; + const desc = [alarmName, metric, threshold ? `threshold: ${threshold}` : ""].filter(Boolean).join(" — "); + alarms.push(desc); + } + } + + // Look for CloudWatch log group resources matching the service + for (const block of allBlocks) { + if (block.blockType === "resource" && block.resourceType === "aws_cloudwatch_log_group") { + const name = block.attributes["name"] ?? ""; + if (name.includes(normalisedService) || name.includes(normalisedService.replace(/_/g, "-"))) { + logGroup = name; + } + } + } + + return { logGroup, alarms }; +} diff --git a/src/plugins/terraform/file-collector.ts b/src/plugins/terraform/file-collector.ts new file mode 100644 index 0000000..4c12915 --- /dev/null +++ b/src/plugins/terraform/file-collector.ts @@ -0,0 +1,93 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, resolve, extname } from "node:path"; +import type { TerraformPluginConfig } from "./types.js"; + +const SKIP_DIRS = new Set([".terraform", ".git", "node_modules", ".terragrunt-cache"]); + +export interface CollectedFiles { + tfFiles: string[]; + tfvarsFiles: string[]; + basePath: string; +} + +/** + * Collect .tf and .tfvars files from the best-matching infrastructure location. + * Tries: explicit config path → in-project subdirs (terraform/, infra/, etc.) → project root. + */ +export async function collectTfFiles( + projectRoot: string, + config: TerraformPluginConfig, +): Promise { + // 1. Explicit infraPath from config + if (config.infraPath) { + const resolved = resolve(projectRoot, config.infraPath); + const files = await scanDirForTf(resolved); + if (files.tfFiles.length > 0) return files; + } + + // 2. In-project directories (common conventions) + for (const subdir of ["terraform", "infra", "infrastructure", "deploy", "iac"]) { + const candidate = join(projectRoot, subdir); + const files = await scanDirForTf(candidate); + if (files.tfFiles.length > 0) return files; + } + + // 3. .tf files at project root + const rootFiles = await scanDirForTf(projectRoot, 1); + if (rootFiles.tfFiles.length > 0) return rootFiles; + + return { tfFiles: [], tfvarsFiles: [], basePath: projectRoot }; +} + +/** + * Read a file's contents, returning empty string on failure. + */ +export async function readFileSafe(path: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch { + return ""; + } +} + +async function scanDirForTf(dir: string, maxDepth = 5): Promise { + const tfFiles: string[] = []; + const tfvarsFiles: string[] = []; + + try { + await stat(dir); + } catch { + return { tfFiles, tfvarsFiles, basePath: dir }; + } + + async function walk(current: string, depth: number): Promise { + if (depth > maxDepth) return; + + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = join(current, entry.name); + + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) { + await walk(fullPath, depth + 1); + } + } else if (entry.isFile()) { + const ext = extname(entry.name); + if (ext === ".tf") { + tfFiles.push(fullPath); + } else if (ext === ".tfvars") { + tfvarsFiles.push(fullPath); + } + } + } + } + + await walk(dir, 0); + return { tfFiles, tfvarsFiles, basePath: dir }; +} diff --git a/src/plugins/terraform/formatter.ts b/src/plugins/terraform/formatter.ts new file mode 100644 index 0000000..bad2d78 --- /dev/null +++ b/src/plugins/terraform/formatter.ts @@ -0,0 +1,157 @@ +import type { ServiceInfrastructure } from "./types.js"; + +/** + * Generate infrastructure.md content from extracted service infrastructure. + */ +export function formatInfrastructure(infra: ServiceInfrastructure): string { + const lines: string[] = []; + + lines.push(`# Infrastructure — ${infra.serviceName}`, ""); + + // Summary block + const summaryParts = buildSummary(infra); + if (summaryParts.length > 0) { + for (const part of summaryParts) { + lines.push(`> ${part}`); + } + lines.push(""); + } + + // Components + if (infra.components.length > 0) { + lines.push("## Components", ""); + for (const comp of infra.components) { + const typeParts = [comp.deploymentType]; + if (comp.isPublicFacing) typeParts.push("public"); + if (comp.compute?.cpu) typeParts.push(`${comp.compute.cpu} CPU`); + if (comp.compute?.memory) typeParts.push(`${comp.compute.memory} MB`); + lines.push(`- **${comp.label}** — ${typeParts.join(", ")}`); + } + lines.push(""); + } + + // Environment Variables + if (infra.envVars.length > 0) { + lines.push("## Environment Variables", ""); + lines.push("| Name | Value | Source |"); + lines.push("|------|-------|--------|"); + for (const ev of infra.envVars) { + lines.push(`| \`${ev.name}\` | \`${escapeTable(ev.value)}\` | ${ev.source} |`); + } + lines.push(""); + } + + // Secrets + if (infra.secrets.length > 0) { + lines.push("## Secrets (SSM Parameter Store)", ""); + lines.push("| Name | SSM Path |"); + lines.push("|------|----------|"); + for (const sec of infra.secrets) { + lines.push(`| \`${sec.name}\` | \`${escapeTable(sec.arnPattern)}\` |`); + } + lines.push(""); + } + + // DNS + if (infra.dns.hostnames.length > 0) { + lines.push("## DNS", ""); + lines.push(`**Public-facing:** ${infra.dns.isPublicFacing ? "Yes" : "No"}`, ""); + for (const hostname of infra.dns.hostnames) { + lines.push(`- \`${hostname}\``); + } + lines.push(""); + } + + // Dependencies + if (infra.dependencies.length > 0) { + lines.push("## Dependencies", ""); + for (const dep of infra.dependencies) { + lines.push(`- ${dep}`); + } + lines.push(""); + } + + // IAM Permissions + if (infra.iamPermissions.length > 0) { + lines.push("## IAM Permissions", ""); + for (const perm of infra.iamPermissions) { + lines.push(`- \`${perm}\``); + } + lines.push(""); + } + + // Observability + if (infra.observability.logGroup || infra.observability.alarms.length > 0) { + lines.push("## Observability", ""); + if (infra.observability.logGroup) { + lines.push(`- **Log group:** \`${infra.observability.logGroup}\``); + } + for (const alarm of infra.observability.alarms) { + lines.push(`- **Alarm:** ${alarm}`); + } + lines.push(""); + } + + // Environments + const envNames = Object.keys(infra.environments); + if (envNames.length > 0) { + lines.push("## Environments", ""); + for (const envName of envNames) { + const env = infra.environments[envName]; + lines.push(`### ${envName}`, ""); + + if (env.enabled !== undefined) { + lines.push(`**Enabled:** ${env.enabled ? "Yes" : "No"}`, ""); + } + + const vars = Object.entries(env.variables); + if (vars.length > 0) { + lines.push("| Variable | Value |"); + lines.push("|----------|-------|"); + for (const [key, value] of vars) { + lines.push(`| \`${key}\` | \`${escapeTable(value)}\` |`); + } + lines.push(""); + } + } + } + + // Footer + lines.push("---"); + lines.push(`_Source: ${infra.sourceFiles.join(", ")}_`); + lines.push("_Generated by codesight-terraform-plugin_"); + lines.push(""); + + return lines.join("\n"); +} + +function buildSummary(infra: ServiceInfrastructure): string[] { + const parts: string[] = []; + + if (infra.components.length > 0) { + const primary = infra.components[0]; + const runtimeParts = [primary.deploymentType]; + if (primary.moduleSource) runtimeParts.push(`\`${primary.moduleSource}\``); + if (primary.compute?.cpu && primary.compute?.memory) { + runtimeParts.push(`${primary.compute.cpu} CPU / ${primary.compute.memory} MB`); + } + parts.push(`**Runtime:** ${runtimeParts.join(" | ")}`); + + if (primary.enableFlag) { + parts.push(`**Enable flag:** \`${primary.enableFlag}\``); + } + } + + if (infra.dns.isPublicFacing) { + const host = infra.dns.hostnames[0]; + parts.push(`**Public-facing:** Yes${host ? ` — \`${host}\`` : ""}`); + } else { + parts.push("**Public-facing:** No (internal service)"); + } + + return parts; +} + +function escapeTable(value: string): string { + return value.replace(/\|/g, "\\|").replace(/\n/g, " "); +} diff --git a/src/plugins/terraform/hcl-parser.ts b/src/plugins/terraform/hcl-parser.ts new file mode 100644 index 0000000..e804cbb --- /dev/null +++ b/src/plugins/terraform/hcl-parser.ts @@ -0,0 +1,485 @@ +import type { HclBlock, NestedBlock } from "./types.js"; + +/** + * Parse all top-level HCL blocks from a .tf file. + * Uses regex + brace-counting — zero dependencies. + */ +export function parseHclFile(content: string, filePath: string): HclBlock[] { + const cleaned = stripComments(content); + return parseTopLevelBlocks(cleaned, filePath); +} + +/** + * Parse a .tfvars file into simple key=value pairs. + * tfvars files are flat: `key = value` per line, no blocks. + * NOTE: multiline values (lists, maps, heredocs) are silently truncated to the first line. + * This is sufficient for scalar overrides (enable flags, counts, tags) but won't capture + * complex tfvars structures. Extend if needed. + */ +export function parseTfvars(content: string): Record { + const result: Record = {}; + const cleaned = stripComments(content); + + for (const line of cleaned.split("\n")) { + const match = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/); + if (!match) continue; + result[match[1]] = stripQuotes(match[2].trim()); + } + + return result; +} + +// ─── Comment Stripping ─── + +/** + * Strip HCL comments while preserving string contents. + * Handles #, //, and block comments. + */ +export function stripComments(content: string): string { + const result: string[] = []; + let i = 0; + let inBlockComment = false; + let inString = false; + let heredocDelimiter: string | null = null; + + while (i < content.length) { + // Inside heredoc: pass through verbatim until closing delimiter + if (heredocDelimiter !== null) { + const lineEnd = content.indexOf("\n", i); + const line = lineEnd === -1 ? content.slice(i) : content.slice(i, lineEnd); + if (line.trim() === heredocDelimiter) { + heredocDelimiter = null; + } + result.push(lineEnd === -1 ? line : line + "\n"); + i = lineEnd === -1 ? content.length : lineEnd + 1; + continue; + } + + // Inside block comment: scan for */ + if (inBlockComment) { + if (content[i] === "*" && content[i + 1] === "/") { + inBlockComment = false; + i += 2; + continue; + } + // Preserve newlines for line count + if (content[i] === "\n") result.push("\n"); + i++; + continue; + } + + const ch = content[i]; + + // Track string state to avoid stripping inside strings + if (ch === '"' && !inString) { + inString = true; + result.push(ch); + i++; + continue; + } + if (inString) { + if (ch === "\\" && i + 1 < content.length) { + result.push(ch, content[i + 1]); + i += 2; + continue; + } + if (ch === '"') inString = false; + result.push(ch); + i++; + continue; + } + + // Heredoc start: < 0) { + // Heredoc handling: scan for closing delimiter on its own line + if (heredocDelimiter !== null) { + const lineEnd = content.indexOf("\n", i); + const line = lineEnd === -1 ? content.slice(i) : content.slice(i, lineEnd); + if (line.trim() === heredocDelimiter) { + heredocDelimiter = null; + } + i = lineEnd === -1 ? content.length : lineEnd + 1; + continue; + } + + const ch = content[i]; + + // String handling + if (ch === '"' && !inString) { + inString = true; + i++; + continue; + } + if (inString) { + if (ch === "\\" && i + 1 < content.length) { + i += 2; // skip escaped char + continue; + } + if (ch === '"') { + inString = false; + } + i++; + continue; + } + + // Heredoc detection: <; + nestedBlocks: Record; +} + +function parseBlockBody(body: string): ParsedBody { + const attributes: Record = {}; + const nestedBlocks: Record = {}; + + let i = 0; + const lines = body.split("\n"); + + while (i < lines.length) { + const line = lines[i].trim(); + + // Skip empty lines + if (!line) { + i++; + continue; + } + + // Check for nested block: `identifier {` + const nestedBlockMatch = line.match(/^(\w+)\s*\{$/); + if (nestedBlockMatch) { + const blockName = nestedBlockMatch[1]; + // Find matching close brace by brace-counting in remaining lines + const remaining = lines.slice(i).join("\n"); + const braceStart = remaining.indexOf("{") + 1; + const blockBody = extractBraceBlock(remaining, braceStart); + + if (blockBody !== null) { + const nested = parseBlockBody(blockBody); + if (!nestedBlocks[blockName]) nestedBlocks[blockName] = []; + nestedBlocks[blockName].push({ attributes: nested.attributes }); + + // Skip past the block + const blockLines = (remaining.slice(0, braceStart + blockBody.length + 1)).split("\n").length; + i += blockLines; + continue; + } + } + + // Check for dynamic block: `dynamic "name" {` + const dynamicMatch = line.match(/^dynamic\s+"(\w+)"\s*\{$/); + if (dynamicMatch) { + const blockName = dynamicMatch[1]; + const remaining = lines.slice(i).join("\n"); + const braceStart = remaining.indexOf("{") + 1; + const blockBody = extractBraceBlock(remaining, braceStart); + + if (blockBody !== null) { + if (!nestedBlocks[blockName]) nestedBlocks[blockName] = []; + nestedBlocks[blockName].push({ attributes: { _dynamic: "true" } }); + const blockLines = (remaining.slice(0, braceStart + blockBody.length + 1)).split("\n").length; + i += blockLines; + continue; + } + } + + // Check for attribute with list-of-maps: `key = [` + const listStartMatch = line.match(/^(\w+)\s*=\s*\[$/); + if (listStartMatch) { + const key = listStartMatch[1]; + const remaining = lines.slice(i).join("\n"); + const bracketStart = remaining.indexOf("[") + 1; + const listBody = extractBracketBlock(remaining, bracketStart); + + if (listBody !== null) { + const entries = parseListOfMaps(listBody); + if (entries.length > 0) { + if (!nestedBlocks[key]) nestedBlocks[key] = []; + for (const entry of entries) { + nestedBlocks[key].push({ attributes: entry }); + } + } else { + // Store as raw attribute + attributes[key] = `[${listBody.trim()}]`; + } + const listLines = (remaining.slice(0, bracketStart + listBody.length + 1)).split("\n").length; + i += listLines; + continue; + } + } + + // Check for simple attribute: `key = value` + const attrMatch = line.match(/^(\w[\w-]*)\s*=\s*(.+)$/); + if (attrMatch) { + const key = attrMatch[1]; + let value = attrMatch[2].trim(); + + // Multi-line value: string continuation or heredoc + if (value.startsWith("<<")) { + const heredocMatch = value.match(/^<<-?\s*(\w+)/); + if (heredocMatch) { + const delimiter = heredocMatch[1]; + const heredocLines: string[] = []; + i++; + while (i < lines.length && lines[i].trim() !== delimiter) { + heredocLines.push(lines[i]); + i++; + } + value = heredocLines.join("\n"); + } + } else if (value === "{") { + // Inline block as attribute value + const remaining = lines.slice(i).join("\n"); + const eqPos = remaining.indexOf("="); + const bracePos = remaining.indexOf("{", eqPos); + const blockBody = extractBraceBlock(remaining, bracePos + 1); + if (blockBody !== null) { + value = `{${blockBody.trim()}}`; + const blockLines = (remaining.slice(0, bracePos + 1 + blockBody.length + 1)).split("\n").length; + i += blockLines; + continue; + } + } else if (value === "[") { + // Multi-line list — string-aware bracket counting + const listLines: string[] = [value]; + i++; + let bracketDepth = 1; + while (i < lines.length && bracketDepth > 0) { + listLines.push(lines[i]); + let inStr = false; + for (let ci = 0; ci < lines[i].length; ci++) { + const ch = lines[i][ci]; + if (ch === '"' && !inStr) { inStr = true; continue; } + if (inStr) { + if (ch === "\\" && ci + 1 < lines[i].length) { ci++; continue; } + if (ch === '"') inStr = false; + continue; + } + if (ch === "[") bracketDepth++; + if (ch === "]") bracketDepth--; + } + i++; + } + value = listLines.join("\n"); + attributes[key] = stripQuotes(value); + continue; + } + + attributes[key] = stripQuotes(value); + i++; + continue; + } + + i++; + } + + return { attributes, nestedBlocks }; +} + +// ─── List-of-Maps Parser ─── + +/** + * Parse `[{ name = "X", value = "Y" }, { name = "A", value = "B" }]` + * Returns array of key-value records. + */ +function parseListOfMaps(listBody: string): Record[] { + const entries: Record[] = []; + let i = 0; + + while (i < listBody.length) { + // Find next opening brace + const bracePos = listBody.indexOf("{", i); + if (bracePos === -1) break; + + const body = extractBraceBlock(listBody, bracePos + 1); + if (body === null) break; + + const record: Record = {}; + // Parse comma/newline-separated key = value pairs + for (const part of body.split(/[,\n]/)) { + const kv = part.trim().match(/^(\w+)\s*=\s*(.+?)\s*$/); + if (kv) { + record[kv[1]] = stripQuotes(kv[2].trim()); + } + } + if (Object.keys(record).length > 0) { + entries.push(record); + } + + i = bracePos + 1 + body.length + 1; + } + + return entries; +} + +// ─── Bracket Block Extraction ─── + +function extractBracketBlock(content: string, startAfterOpenBracket: number): string | null { + let depth = 1; + let i = startAfterOpenBracket; + let inString = false; + + while (i < content.length && depth > 0) { + const ch = content[i]; + + if (ch === '"' && !inString) { + inString = true; + i++; + continue; + } + if (inString) { + if (ch === "\\" && i + 1 < content.length) { + i += 2; + continue; + } + if (ch === '"') inString = false; + i++; + continue; + } + + if (ch === "[") depth++; + else if (ch === "]") depth--; + i++; + } + + if (depth !== 0) return null; + return content.slice(startAfterOpenBracket, i - 1); +} + +// ─── Helpers ─── + +function stripQuotes(value: string): string { + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1); + } + return value; +} diff --git a/src/plugins/terraform/index.ts b/src/plugins/terraform/index.ts new file mode 100644 index 0000000..db1cf93 --- /dev/null +++ b/src/plugins/terraform/index.ts @@ -0,0 +1,82 @@ +import { relative } from "node:path"; +import type { CodesightPlugin, ProjectInfo } from "../../types.js"; +import type { TerraformPluginConfig, HclBlock } from "./types.js"; +import { parseHclFile } from "./hcl-parser.js"; +import { collectTfFiles, readFileSafe } from "./file-collector.js"; +import { matchServiceBlocks } from "./service-matcher.js"; +import { extractServiceInfrastructure, extractEnvironments } from "./extractor.js"; +import { formatInfrastructure } from "./formatter.js"; + +export type { TerraformPluginConfig } from "./types.js"; + +/** + * Create a Terraform infrastructure plugin for codesight. + * + * Scans .tf files co-located in the project (terraform/, infra/, etc.) and generates + * an infrastructure section with deployment context for AI agents. + * Use infraPath to point at a separate infrastructure repository. + * + * @example + * // Auto-discover co-located terraform + * createTerraformPlugin() + * + * @example + * // Explicit separate infra repo + * createTerraformPlugin({ + * infraPath: '../infrastructure', + * serviceName: 'my-service', + * }) + */ +export function createTerraformPlugin(config: TerraformPluginConfig = {}): CodesightPlugin { + return { + name: "terraform", + + detector: async (files: string[], project: ProjectInfo) => { + const serviceName = config.serviceName ?? project.name; + + // Collect .tf files from project or external path + const collected = await collectTfFiles(project.root, config); + if (collected.tfFiles.length === 0) return {}; + + // Parse all .tf files into HCL blocks + // TODO: consider Promise.all for parallel file reads in large infra repos + const allBlocks: HclBlock[] = []; + for (const tfFile of collected.tfFiles) { + const content = await readFileSafe(tfFile); + if (!content) continue; + const relPath = relative(collected.basePath, tfFile); + const blocks = parseHclFile(content, relPath); + allBlocks.push(...blocks); + } + + if (allBlocks.length === 0) return {}; + + // Match blocks to this service + const matched = matchServiceBlocks( + serviceName, + allBlocks, + { ...config, serviceName }, + ); + if (matched.length === 0) return {}; + + // Extract structured infrastructure data + const infra = extractServiceInfrastructure( + matched, + allBlocks, + { ...config, serviceName }, + ); + + // Extract per-environment overrides from .tfvars + if (config.scanEnvironments !== false && collected.tfvarsFiles.length > 0) { + infra.environments = await extractEnvironments( + collected.tfvarsFiles, + serviceName, + ); + } + + return { + customSections: [{ name: "infrastructure", content: formatInfrastructure(infra) }], + }; + }, + }; +} diff --git a/src/plugins/terraform/service-matcher.ts b/src/plugins/terraform/service-matcher.ts new file mode 100644 index 0000000..69e0c50 --- /dev/null +++ b/src/plugins/terraform/service-matcher.ts @@ -0,0 +1,116 @@ +import { basename } from "node:path"; +import type { HclBlock, TerraformPluginConfig } from "./types.js"; + +export interface ScoredBlock { + block: HclBlock; + score: number; +} + +/** + * Find all HCL blocks belonging to a given service. + * Uses a multi-signal scoring algorithm: file name, label prefix/exact, + * image URI, enable flags, and user-configured aliases. + */ +export function matchServiceBlocks( + projectName: string, + blocks: HclBlock[], + config: TerraformPluginConfig, +): HclBlock[] { + const serviceName = config.serviceName ?? projectName; + const normalised = normaliseServiceName(serviceName); + + if (!normalised) return []; + + const scored: ScoredBlock[] = []; + + for (const block of blocks) { + const score = scoreBlock(normalised, block, config); + if (score >= 2) { + scored.push({ block, score }); + } + } + + // Sort by score descending + scored.sort((a, b) => b.score - a.score); + return scored.map((s) => s.block); +} + +function scoreBlock( + normalisedService: string, + block: HclBlock, + config: TerraformPluginConfig, +): number { + let score = 0; + const normalisedLabel = normaliseServiceName(block.label); + + // File name contains service name (+2) + const fileName = normaliseServiceName(basename(block.file, ".tf").replace(".variables", "")); + if (fileName && fileName.includes(normalisedService)) { + score += 2; + } + + // Block label starts with service name (+3) + if (normalisedLabel.startsWith(normalisedService)) { + score += 3; + } + + // Block label exact match (+5, replaces prefix) + if (normalisedLabel === normalisedService) { + score += 2; // +5 total with prefix + } + + // Image URI contains service name (+4) + const imageAttr = block.attributes["image"] ?? block.attributes["container_image"]; + if (imageAttr) { + const kebabName = normalisedService.replace(/_/g, "-"); + if (imageAttr.includes(kebabName) || imageAttr.includes(normalisedService)) { + score += 4; + } + } + + // Enable flag match (+3) + if (block.blockType === "variable") { + const enablePrefix = `enable_${normalisedService}`; + if (normalisedLabel === enablePrefix || normalisedLabel.startsWith(enablePrefix)) { + score += 3; + } + } + + // count = var.enable_xxx pattern in non-variable blocks (+2) + const countAttr = block.attributes["count"]; + if (countAttr) { + const enableRef = `var.enable_${normalisedService}`; + if (countAttr.includes(enableRef)) { + score += 2; + } + } + + // Service aliases match (+2) + for (const alias of config.serviceAliases ?? []) { + const normalisedAlias = normaliseServiceName(alias); + if (normalisedAlias && normalisedLabel.includes(normalisedAlias)) { + score += 2; + } + } + + return score; +} + +/** + * Normalise a service name for comparison. + * "query-service" → "query_service" + * "QueryService" → "query_service" + * "query-service-app" → "query_service_app" + */ +export function normaliseServiceName(name: string): string { + return name + // Insert underscore before uppercase letters (camelCase → camel_Case) + .replace(/([a-z])([A-Z])/g, "$1_$2") + .toLowerCase() + // Replace hyphens, dots, spaces with underscores + .replace(/[^a-z0-9]/g, "_") + // Collapse multiple underscores + .replace(/_+/g, "_") + // Trim leading/trailing underscores + .replace(/^_|_$/g, ""); +} diff --git a/src/plugins/terraform/types.ts b/src/plugins/terraform/types.ts new file mode 100644 index 0000000..99bbb4a --- /dev/null +++ b/src/plugins/terraform/types.ts @@ -0,0 +1,82 @@ +/** User-facing configuration for the Terraform infrastructure plugin */ +export interface TerraformPluginConfig { + /** Path to a separate infrastructure repo — absolute or relative to project root. + * Default: auto-discovers ./terraform, ./infra, ./infrastructure, ./deploy, ./iac */ + infraPath?: string; + /** Override service name matching (default: project.name from package.json etc.) */ + serviceName?: string; + /** Additional name patterns to match against resource labels */ + serviceAliases?: string[]; + /** Scan environments/*.tfvars for per-env overrides (default: true) */ + scanEnvironments?: boolean; +} + +/** A parsed HCL top-level block */ +export interface HclBlock { + /** "resource" | "module" | "data" | "variable" | "output" | "locals" | "provider" */ + blockType: string; + /** For resource/data: the resource type (e.g. "aws_ecs_service"). For module/variable/output: same as label. */ + resourceType: string; + /** The resource/module name label */ + label: string; + /** Source .tf file (relative path) */ + file: string; + /** Top-level key=value attributes (values kept as raw strings, not evaluated) */ + attributes: Record; + /** Named nested blocks, e.g. { "tags": [{ key: "Name", value: "..." }] } */ + nestedBlocks: Record; +} + +export interface NestedBlock { + attributes: Record; +} + +/** Extracted infrastructure context for one service */ +export interface ServiceInfrastructure { + serviceName: string; + sourceFiles: string[]; + components: ServiceComponent[]; + dns: DnsConfig; + envVars: EnvVar[]; + secrets: SecretRef[]; + dependencies: string[]; + iamPermissions: string[]; + observability: ObservabilityConfig; + environments: Record; +} + +export interface ServiceComponent { + label: string; + deploymentType: string; + moduleSource?: string; + compute?: { cpu?: string; memory?: string; desiredCount?: string }; + healthCheck?: string; + enableFlag?: string; + isPublicFacing: boolean; +} + +export interface DnsConfig { + hostnames: string[]; + isPublicFacing: boolean; +} + +export interface EnvVar { + name: string; + value: string; + source: "literal" | "variable" | "reference"; +} + +export interface SecretRef { + name: string; + arnPattern: string; +} + +export interface ObservabilityConfig { + logGroup?: string; + alarms: string[]; +} + +export interface EnvironmentOverrides { + enabled?: boolean; + variables: Record; +} diff --git a/src/types.ts b/src/types.ts index 36cb681..66aa23e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -244,6 +244,8 @@ export interface PluginDetectorResult { components?: ComponentInfo[]; /** Additional middleware to merge */ middleware?: MiddlewareInfo[]; + /** Custom markdown sections rendered into CODESIGHT.md and written as individual .md files */ + customSections?: { name: string; content: string }[]; } export interface EventInfo { @@ -280,6 +282,8 @@ export interface ScanResult { events?: EventInfo[]; testCoverage?: TestCoverage; crudGroups?: CrudGroup[]; + /** Plugin-contributed custom sections (rendered into CODESIGHT.md alongside built-in sections) */ + customSections?: { name: string; content: string }[]; } export interface TokenStats { diff --git a/tests/fixtures/config-app/.env.example b/tests/fixtures/config-app/.env.example deleted file mode 100644 index 7d4c260..0000000 --- a/tests/fixtures/config-app/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -DATABASE_URL= -JWT_SECRET= -PORT=3000 \ No newline at end of file diff --git a/tests/fixtures/config-app/package.json b/tests/fixtures/config-app/package.json deleted file mode 100644 index 357e8e2..0000000 --- a/tests/fixtures/config-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test"} \ No newline at end of file diff --git a/tests/fixtures/config-app/src/config.ts b/tests/fixtures/config-app/src/config.ts deleted file mode 100644 index b77bec6..0000000 --- a/tests/fixtures/config-app/src/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -const db = process.env.DATABASE_URL; -const port = process.env.PORT || 3000; \ No newline at end of file diff --git a/tests/fixtures/django-app/requirements.txt b/tests/fixtures/django-app/requirements.txt deleted file mode 100644 index d3e4ba5..0000000 --- a/tests/fixtures/django-app/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -django diff --git a/tests/fixtures/django-app/urls.py b/tests/fixtures/django-app/urls.py deleted file mode 100644 index e0aa979..0000000 --- a/tests/fixtures/django-app/urls.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.urls import path -urlpatterns = [ - path("api/users/", views.UserList.as_view()), - path("api/users//", views.UserDetail.as_view()), -] \ No newline at end of file diff --git a/tests/fixtures/drizzle-schema/package.json b/tests/fixtures/drizzle-schema/package.json deleted file mode 100644 index 2ab8dea..0000000 --- a/tests/fixtures/drizzle-schema/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"drizzle-orm":"^0.30.0"}} \ No newline at end of file diff --git a/tests/fixtures/drizzle-schema/src/schema.ts b/tests/fixtures/drizzle-schema/src/schema.ts deleted file mode 100644 index ee270d4..0000000 --- a/tests/fixtures/drizzle-schema/src/schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { pgTable, text, uuid, timestamp, boolean } from "drizzle-orm/pg-core"; -export const users = pgTable("users", { - id: uuid("id").primaryKey().defaultRandom(), - email: text("email").notNull().unique(), - name: text("name").notNull(), - active: boolean("active").default(true), -}); -export const posts = pgTable("posts", { - id: uuid("id").primaryKey().defaultRandom(), - title: text("title").notNull(), - userId: uuid("user_id").references(() => users.id), -}); \ No newline at end of file diff --git a/tests/fixtures/elysia-app/package.json b/tests/fixtures/elysia-app/package.json deleted file mode 100644 index dd7bf2e..0000000 --- a/tests/fixtures/elysia-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"elysia":"^1.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/elysia-app/src/index.ts b/tests/fixtures/elysia-app/src/index.ts deleted file mode 100644 index c3335fc..0000000 --- a/tests/fixtures/elysia-app/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Elysia } from "elysia"; -const app = new Elysia() - .get("/api/health", () => "ok") - .post("/api/items", () => ({ created: true })); \ No newline at end of file diff --git a/tests/fixtures/elysia-detect/package.json b/tests/fixtures/elysia-detect/package.json deleted file mode 100644 index dd7bf2e..0000000 --- a/tests/fixtures/elysia-detect/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"elysia":"^1.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/express-app/package.json b/tests/fixtures/express-app/package.json deleted file mode 100644 index 749d35e..0000000 --- a/tests/fixtures/express-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"express":"^4.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/express-app/src/routes.ts b/tests/fixtures/express-app/src/routes.ts deleted file mode 100644 index ef853b4..0000000 --- a/tests/fixtures/express-app/src/routes.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Router } from "express"; -const router = Router(); -router.get("/users", (req, res) => res.json([])); -router.post("/users", (req, res) => res.json({})); -router.delete("/users/:id", (req, res) => res.json({})); -export default router; \ No newline at end of file diff --git a/tests/fixtures/fastapi-app/main.py b/tests/fixtures/fastapi-app/main.py deleted file mode 100644 index 87fecc8..0000000 --- a/tests/fixtures/fastapi-app/main.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import FastAPI -app = FastAPI() -@app.get("/users") -def get_users(): - return [] -@app.post("/users") -def create_user(): - return {} \ No newline at end of file diff --git a/tests/fixtures/fastapi-app/requirements.txt b/tests/fixtures/fastapi-app/requirements.txt deleted file mode 100644 index 97dc7cd..0000000 --- a/tests/fixtures/fastapi-app/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -fastapi -uvicorn diff --git a/tests/fixtures/fastify-app/package.json b/tests/fixtures/fastify-app/package.json deleted file mode 100644 index 9de5a5c..0000000 --- a/tests/fixtures/fastify-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"fastify":"^4.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/fastify-app/src/server.ts b/tests/fixtures/fastify-app/src/server.ts deleted file mode 100644 index b4d96d8..0000000 --- a/tests/fixtures/fastify-app/src/server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import fastify from "fastify"; -const app = fastify(); -app.get("/health", async () => ({ status: "ok" })); -app.post("/items", async (req) => ({ created: true })); -export default app; \ No newline at end of file diff --git a/tests/fixtures/graph-app/package.json b/tests/fixtures/graph-app/package.json deleted file mode 100644 index aff692f..0000000 --- a/tests/fixtures/graph-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"hono":"^4.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/graph-app/src/auth.ts b/tests/fixtures/graph-app/src/auth.ts deleted file mode 100644 index e1b5ffb..0000000 --- a/tests/fixtures/graph-app/src/auth.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { db } from "./db.js"; -export const auth = {}; \ No newline at end of file diff --git a/tests/fixtures/graph-app/src/db.ts b/tests/fixtures/graph-app/src/db.ts deleted file mode 100644 index 04cb237..0000000 --- a/tests/fixtures/graph-app/src/db.ts +++ /dev/null @@ -1 +0,0 @@ -export const db = {}; \ No newline at end of file diff --git a/tests/fixtures/graph-app/src/middleware.ts b/tests/fixtures/graph-app/src/middleware.ts deleted file mode 100644 index 81996f7..0000000 --- a/tests/fixtures/graph-app/src/middleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { auth } from "./auth.js"; -import { db } from "./db.js"; -export const mw = {}; \ No newline at end of file diff --git a/tests/fixtures/graph-app/src/routes.ts b/tests/fixtures/graph-app/src/routes.ts deleted file mode 100644 index 2cf61c9..0000000 --- a/tests/fixtures/graph-app/src/routes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { db } from "./db.js"; -import { auth } from "./auth.js"; -export const routes = {}; \ No newline at end of file diff --git a/tests/fixtures/hono-app/package.json b/tests/fixtures/hono-app/package.json deleted file mode 100644 index aff692f..0000000 --- a/tests/fixtures/hono-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"hono":"^4.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/hono-app/src/index.ts b/tests/fixtures/hono-app/src/index.ts deleted file mode 100644 index 9a2b542..0000000 --- a/tests/fixtures/hono-app/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Hono } from "hono"; -const app = new Hono(); -app.get("/api/users", (c) => c.json([])); -app.post("/api/users", (c) => c.json({})); -app.get("/api/users/:id", (c) => c.json({})); -export default app; \ No newline at end of file diff --git a/tests/fixtures/js-imports/package.json b/tests/fixtures/js-imports/package.json deleted file mode 100644 index 357e8e2..0000000 --- a/tests/fixtures/js-imports/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test"} \ No newline at end of file diff --git a/tests/fixtures/js-imports/src/main.ts b/tests/fixtures/js-imports/src/main.ts deleted file mode 100644 index 33086b3..0000000 --- a/tests/fixtures/js-imports/src/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./utils.js"; -console.log(helper); \ No newline at end of file diff --git a/tests/fixtures/js-imports/src/utils.ts b/tests/fixtures/js-imports/src/utils.ts deleted file mode 100644 index 94ec3e1..0000000 --- a/tests/fixtures/js-imports/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => {}; \ No newline at end of file diff --git a/tests/fixtures/middleware-app/package.json b/tests/fixtures/middleware-app/package.json deleted file mode 100644 index 749d35e..0000000 --- a/tests/fixtures/middleware-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"express":"^4.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/middleware-app/src/middleware/auth.ts b/tests/fixtures/middleware-app/src/middleware/auth.ts deleted file mode 100644 index 700ee2c..0000000 --- a/tests/fixtures/middleware-app/src/middleware/auth.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function authMiddleware(req, res, next) { - const token = req.headers.authorization; - if (!token) return res.status(401).json({ error: "unauthorized" }); - next(); -} \ No newline at end of file diff --git a/tests/fixtures/middleware-app/src/middleware/rate-limit.ts b/tests/fixtures/middleware-app/src/middleware/rate-limit.ts deleted file mode 100644 index 0874422..0000000 --- a/tests/fixtures/middleware-app/src/middleware/rate-limit.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function rateLimiter(req, res, next) { - // rate limiting logic - next(); -} \ No newline at end of file diff --git a/tests/fixtures/monorepo-detect/package.json b/tests/fixtures/monorepo-detect/package.json deleted file mode 100644 index 950e109..0000000 --- a/tests/fixtures/monorepo-detect/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","workspaces":["packages/*"]} \ No newline at end of file diff --git a/tests/fixtures/monorepo-detect/packages/api/package.json b/tests/fixtures/monorepo-detect/packages/api/package.json deleted file mode 100644 index 018378c..0000000 --- a/tests/fixtures/monorepo-detect/packages/api/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"@test/api","dependencies":{"hono":"^4.0.0","drizzle-orm":"^0.30.0"}} \ No newline at end of file diff --git a/tests/fixtures/monorepo-detect/packages/web/package.json b/tests/fixtures/monorepo-detect/packages/web/package.json deleted file mode 100644 index 7224562..0000000 --- a/tests/fixtures/monorepo-detect/packages/web/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"@test/web","dependencies":{"react":"^18.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/nestjs-app/package.json b/tests/fixtures/nestjs-app/package.json deleted file mode 100644 index 7672715..0000000 --- a/tests/fixtures/nestjs-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/nestjs-app/src/users.controller.ts b/tests/fixtures/nestjs-app/src/users.controller.ts deleted file mode 100644 index e3cd6dc..0000000 --- a/tests/fixtures/nestjs-app/src/users.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Param } from '@nestjs/common'; -@Controller('users') -export class UsersController { - @Get() - findAll() { return []; } - @Get(':id') - findOne(@Param('id') id: string) { return {}; } - @Post() - create() { return {}; } - @Delete(':id') - remove(@Param('id') id: string) { return {}; } -} \ No newline at end of file diff --git a/tests/fixtures/nestjs-detect/package.json b/tests/fixtures/nestjs-detect/package.json deleted file mode 100644 index 7672715..0000000 --- a/tests/fixtures/nestjs-detect/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/next-app/package.json b/tests/fixtures/next-app/package.json deleted file mode 100644 index afd7a24..0000000 --- a/tests/fixtures/next-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"next":"^14.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/next-app/src/app/api/users/route.ts b/tests/fixtures/next-app/src/app/api/users/route.ts deleted file mode 100644 index 0590ffa..0000000 --- a/tests/fixtures/next-app/src/app/api/users/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -export async function GET() { - return Response.json([]); -} -export async function POST(request: Request) { - return Response.json({}); -} \ No newline at end of file diff --git a/tests/fixtures/nuxt-app/package.json b/tests/fixtures/nuxt-app/package.json deleted file mode 100644 index 8ed5126..0000000 --- a/tests/fixtures/nuxt-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"nuxt":"^3.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/nuxt-app/server/api/users.get.ts b/tests/fixtures/nuxt-app/server/api/users.get.ts deleted file mode 100644 index 355792e..0000000 --- a/tests/fixtures/nuxt-app/server/api/users.get.ts +++ /dev/null @@ -1 +0,0 @@ -export default defineEventHandler(() => []); \ No newline at end of file diff --git a/tests/fixtures/nuxt-app/server/api/users.post.ts b/tests/fixtures/nuxt-app/server/api/users.post.ts deleted file mode 100644 index 3a5224b..0000000 --- a/tests/fixtures/nuxt-app/server/api/users.post.ts +++ /dev/null @@ -1 +0,0 @@ -export default defineEventHandler(() => ({})); \ No newline at end of file diff --git a/tests/fixtures/nuxt-app/server/api/users/[id].get.ts b/tests/fixtures/nuxt-app/server/api/users/[id].get.ts deleted file mode 100644 index 3a5224b..0000000 --- a/tests/fixtures/nuxt-app/server/api/users/[id].get.ts +++ /dev/null @@ -1 +0,0 @@ -export default defineEventHandler(() => ({})); \ No newline at end of file diff --git a/tests/fixtures/nuxt-detect/package.json b/tests/fixtures/nuxt-detect/package.json deleted file mode 100644 index 8ed5126..0000000 --- a/tests/fixtures/nuxt-detect/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"nuxt":"^3.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/prisma-schema/package.json b/tests/fixtures/prisma-schema/package.json deleted file mode 100644 index dca7c60..0000000 --- a/tests/fixtures/prisma-schema/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"prisma":"^5.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/prisma-schema/prisma/schema.prisma b/tests/fixtures/prisma-schema/prisma/schema.prisma deleted file mode 100644 index 00ab729..0000000 --- a/tests/fixtures/prisma-schema/prisma/schema.prisma +++ /dev/null @@ -1,12 +0,0 @@ -model User { - id String @id @default(cuid()) - email String @unique - name String - posts Post[] -} -model Post { - id String @id @default(cuid()) - title String - userId String - user User @relation(fields: [userId], references: [id]) -} \ No newline at end of file diff --git a/tests/fixtures/raw-http-app/package.json b/tests/fixtures/raw-http-app/package.json deleted file mode 100644 index 357e8e2..0000000 --- a/tests/fixtures/raw-http-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test"} \ No newline at end of file diff --git a/tests/fixtures/raw-http-app/src/server.ts b/tests/fixtures/raw-http-app/src/server.ts deleted file mode 100644 index 57e0722..0000000 --- a/tests/fixtures/raw-http-app/src/server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createServer } from "http"; -const server = createServer((req, res) => { - const url = new URL(req.url!, "http://localhost").pathname; - if (url === "/health") { res.end("ok"); return; } - if (url === "/api/users" && req.method === "GET") { res.end("[]"); return; } - if (url === "/api/users" && req.method === "POST") { res.end("{}"); return; } -}); \ No newline at end of file diff --git a/tests/fixtures/react-app/package.json b/tests/fixtures/react-app/package.json deleted file mode 100644 index c8cacbf..0000000 --- a/tests/fixtures/react-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"react":"^18.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/react-app/src/Button.tsx b/tests/fixtures/react-app/src/Button.tsx deleted file mode 100644 index ca0191c..0000000 --- a/tests/fixtures/react-app/src/Button.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Button({ label, onClick, disabled }: { label: string; onClick: () => void; disabled?: boolean }) { - return ; -} \ No newline at end of file diff --git a/tests/fixtures/react-app/src/Card.tsx b/tests/fixtures/react-app/src/Card.tsx deleted file mode 100644 index 3238d79..0000000 --- a/tests/fixtures/react-app/src/Card.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const Card = ({ title, children }: { title: string; children: React.ReactNode }) => { - return

{title}

{children}
; -}; \ No newline at end of file diff --git a/tests/fixtures/react-app/src/ProjectCard.tsx b/tests/fixtures/react-app/src/ProjectCard.tsx deleted file mode 100644 index 8f1567f..0000000 --- a/tests/fixtures/react-app/src/ProjectCard.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const ProjectCard = ({ title, description }: { title: string; description: string }) => { - return

{title}

{description}

; -}; \ No newline at end of file diff --git a/tests/fixtures/react-app/src/UserProfile.tsx b/tests/fixtures/react-app/src/UserProfile.tsx deleted file mode 100644 index e4e45bf..0000000 --- a/tests/fixtures/react-app/src/UserProfile.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function UserProfile({ name, email, avatar }: { name: string; email: string; avatar?: string }) { - return
{name} - {email}
; -} \ No newline at end of file diff --git a/tests/fixtures/remix-app/app/routes/users.tsx b/tests/fixtures/remix-app/app/routes/users.tsx deleted file mode 100644 index 510aae2..0000000 --- a/tests/fixtures/remix-app/app/routes/users.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export async function loader({ request }) { - return json([]); -} -export async function action({ request }) { - return json({}); -} \ No newline at end of file diff --git a/tests/fixtures/remix-app/package.json b/tests/fixtures/remix-app/package.json deleted file mode 100644 index 44f75e3..0000000 --- a/tests/fixtures/remix-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"@remix-run/node":"^2.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/remix-detect/package.json b/tests/fixtures/remix-detect/package.json deleted file mode 100644 index 44f75e3..0000000 --- a/tests/fixtures/remix-detect/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"@remix-run/node":"^2.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/sveltekit-app/package.json b/tests/fixtures/sveltekit-app/package.json deleted file mode 100644 index f48cdce..0000000 --- a/tests/fixtures/sveltekit-app/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"@sveltejs/kit":"^2.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/sveltekit-app/src/routes/api/users/+server.ts b/tests/fixtures/sveltekit-app/src/routes/api/users/+server.ts deleted file mode 100644 index 834b1fe..0000000 --- a/tests/fixtures/sveltekit-app/src/routes/api/users/+server.ts +++ /dev/null @@ -1,6 +0,0 @@ -export async function GET() { - return new Response(JSON.stringify([]), { headers: { 'content-type': 'application/json' } }); -} -export async function POST({ request }) { - return new Response(JSON.stringify({})); -} \ No newline at end of file diff --git a/tests/fixtures/sveltekit-detect/package.json b/tests/fixtures/sveltekit-detect/package.json deleted file mode 100644 index f48cdce..0000000 --- a/tests/fixtures/sveltekit-detect/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"@sveltejs/kit":"^2.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/terraform/edge-cases/complex.tf b/tests/fixtures/terraform/edge-cases/complex.tf new file mode 100644 index 0000000..ca2f29f --- /dev/null +++ b/tests/fixtures/terraform/edge-cases/complex.tf @@ -0,0 +1,109 @@ +# This file tests edge cases for the HCL parser + +// C-style comment +variable "basic_var" { + type = string + default = "hello" +} + +/* Block comment + spanning multiple lines + with { braces } inside */ +resource "aws_ecs_task_definition" "with_heredoc" { + family = "test" + + container_definitions = < []), - create: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ input }) => ({})), - getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => ({})), -}); \ No newline at end of file diff --git a/tests/fixtures/trpc-detect/package.json b/tests/fixtures/trpc-detect/package.json deleted file mode 100644 index 1a434da..0000000 --- a/tests/fixtures/trpc-detect/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test","dependencies":{"@trpc/server":"^10.0.0"}} \ No newline at end of file diff --git a/tests/githooks-plugin.test.ts b/tests/githooks-plugin.test.ts new file mode 100644 index 0000000..cda3436 --- /dev/null +++ b/tests/githooks-plugin.test.ts @@ -0,0 +1,155 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, writeFile, rm, chmod } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +async function setup(files: Record, executablePaths: string[] = []): Promise { + const root = join(tmpdir(), `codesight-githooks-test-${Date.now()}`); + for (const [rel, content] of Object.entries(files)) { + const full = join(root, rel); + await mkdir(join(full, ".."), { recursive: true }); + await writeFile(full, content); + } + for (const rel of executablePaths) { + await chmod(join(root, rel), 0o755); + } + return root; +} + +async function cleanup(root: string) { + await rm(root, { recursive: true, force: true }); +} + +const fakeProject = (root: string) => ({ root, frameworks: [], language: "typescript", orms: [], isMonorepo: false, workspaces: [], repoType: "single" as const }); + +describe("Git Hooks Plugin", async () => { + const { createGitHooksPlugin } = await import("../dist/plugins/githooks/index.js"); + + it("parses lefthook.yml with commands block", async () => { + const root = await setup({ + "lefthook.yml": `pre-commit:\n commands:\n lint:\n run: pnpm lint\n typecheck:\n run: pnpm typecheck\npre-push:\n commands:\n test:\n run: pnpm test\n`, + }); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + assert.ok(result.customSections?.length === 1); + const content = result.customSections![0].content; + assert.ok(content.includes("pre-commit"), "should include pre-commit"); + assert.ok(content.includes("pnpm lint"), "should include lint command"); + assert.ok(content.includes("pnpm typecheck"), "should include typecheck command"); + assert.ok(content.includes("pre-push"), "should include pre-push"); + assert.ok(content.includes("pnpm test"), "should include test command"); + } finally { + await cleanup(root); + } + }); + + it("parses lefthook.json", async () => { + const root = await setup({ + "lefthook.json": JSON.stringify({ + "pre-commit": { commands: { lint: { run: "pnpm lint" } } }, + }), + }); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + const content = result.customSections![0].content; + assert.ok(content.includes("pnpm lint")); + } finally { + await cleanup(root); + } + }); + + it("parses husky hooks", async () => { + const root = await setup({ + ".husky/pre-commit": `#!/bin/sh\npnpm lint\npnpm typecheck\n`, + }); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + const content = result.customSections![0].content; + assert.ok(content.includes("pre-commit")); + assert.ok(content.includes("pnpm lint")); + assert.ok(content.includes("husky")); + } finally { + await cleanup(root); + } + }); + + it("parses raw executable git hooks", async () => { + const root = await setup( + { ".git/hooks/pre-commit": `#!/bin/sh\nnpm test\n` }, + [".git/hooks/pre-commit"], + ); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + const content = result.customSections![0].content; + assert.ok(content.includes("pre-commit")); + assert.ok(content.includes("npm test")); + } finally { + await cleanup(root); + } + }); + + it("suppresses raw hook when lefthook manages the same lifecycle", async () => { + const root = await setup( + { + "lefthook.yml": `pre-commit:\n commands:\n lint:\n run: pnpm lint\n`, + // Simulates lefthook-installed raw hook + ".git/hooks/pre-commit": `#!/bin/sh\nlefthook run pre-commit\n`, + }, + [".git/hooks/pre-commit"], + ); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + const content = result.customSections![0].content; + assert.ok(content.includes("lefthook"), "should show lefthook section"); + const rawOccurrences = (content.match(/raw git hook/g) || []).length; + assert.equal(rawOccurrences, 0, "should suppress raw hook when lefthook manages the lifecycle"); + } finally { + await cleanup(root); + } + }); + + it("ignores .sample files in .git/hooks", async () => { + const root = await setup( + { ".git/hooks/pre-commit.sample": `#!/bin/sh\nnpm test\n` }, + [".git/hooks/pre-commit.sample"], + ); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + assert.deepEqual(result, {}); + } finally { + await cleanup(root); + } + }); + + it("returns empty when no hooks found", async () => { + const root = await setup({ "package.json": `{"name":"test"}` }); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + assert.deepEqual(result, {}); + } finally { + await cleanup(root); + } + }); + + it("output includes agent warning note", async () => { + const root = await setup({ + "lefthook.yml": `pre-commit:\n commands:\n lint:\n run: pnpm lint\n`, + }); + try { + const plugin = createGitHooksPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + const content = result.customSections![0].content; + assert.ok(content.includes("agents"), "should include note for agents"); + } finally { + await cleanup(root); + } + }); +}); diff --git a/tests/plugins/cicd.test.ts b/tests/plugins/cicd.test.ts new file mode 100644 index 0000000..4b05bf1 --- /dev/null +++ b/tests/plugins/cicd.test.ts @@ -0,0 +1,499 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { writeFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const FIXTURE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures"); + +async function writeFixture(subdir: string, files: Record) { + const dir = join(FIXTURE_ROOT, subdir); + await mkdir(dir, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + const filePath = join(dir, name); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + return dir; +} + +// =================== YAML PARSER TESTS =================== + +describe("YAML Parser", async () => { + const { parseYAML } = await import("../../dist/plugins/cicd/yaml-parser.js"); + + it("parses block mappings", () => { + const result = parseYAML("name: test\nversion: 2.1"); + assert.equal(result.name, "test"); + }); + + it("parses block sequences of scalars", () => { + const result = parseYAML("items:\n - a\n - b\n - c"); + assert.deepEqual(result.items, ["a", "b", "c"]); + }); + + it("parses array-of-objects (steps pattern)", () => { + const yaml = [ + "steps:", + " - name: Checkout", + " uses: actions/checkout@v4", + " - name: Setup", + " uses: actions/setup-node@v4", + ].join("\n"); + const result = parseYAML(yaml); + assert.equal(result.steps.length, 2); + assert.equal(result.steps[0].name, "Checkout"); + assert.equal(result.steps[0].uses, "actions/checkout@v4"); + assert.equal(result.steps[1].name, "Setup"); + }); + + it("parses block scalars (|)", () => { + const yaml = [ + "script: |", + " echo hello", + " echo world", + "other: val", + ].join("\n"); + const result = parseYAML(yaml); + assert.ok(result.script.includes("echo hello")); + assert.ok(result.script.includes("echo world")); + assert.equal(result.other, "val"); + }); + + it("parses flow sequences", () => { + const result = parseYAML("needs: [build, test, lint]"); + assert.deepEqual(result.needs, ["build", "test", "lint"]); + }); + + it("handles inline comments", () => { + const result = parseYAML("key: value # this is a comment"); + assert.equal(result.key, "value"); + }); + + it("handles quoted strings with colons", () => { + const result = parseYAML('name: "Deploy: staging"'); + assert.equal(result.name, "Deploy: staging"); + }); + + it("handles empty mapping values with nested content", () => { + const yaml = [ + "on:", + " push:", + " branches:", + " - main", + ].join("\n"); + const result = parseYAML(yaml); + assert.deepEqual(result.on.push.branches, ["main"]); + }); + + it("handles mixed scalar and mapping dash items", () => { + const yaml = [ + "jobs:", + " - test", + " - deploy:", + " requires:", + " - test", + ].join("\n"); + const result = parseYAML(yaml); + assert.equal(result.jobs[0], "test"); + assert.ok(typeof result.jobs[1] === "object"); + assert.deepEqual(result.jobs[1].deploy.requires, ["test"]); + }); + + it("preserves expressions as opaque strings", () => { + const result = parseYAML("ref: ${{ inputs.ref }}"); + assert.equal(result.ref, "${{ inputs.ref }}"); + }); + + it("handles document marker ---", () => { + const yaml = "---\nname: test\nversion: 1"; + const result = parseYAML(yaml); + assert.equal(result.name, "test"); + }); + + it("parses single-quoted strings", () => { + const result = parseYAML("version: '3.12'"); + assert.equal(result.version, "3.12"); + }); + + it("parses boolean values", () => { + const result = parseYAML("required: true\noptional: false"); + assert.equal(result.required, true); + assert.equal(result.optional, false); + }); + + it("handles real-world GHA workflow structure", () => { + const yaml = [ + "name: CI", + "on:", + " push:", + " branches: [main]", + " pull_request:", + " branches: [main]", + " paths:", + " - 'src/**'", + "jobs:", + " build:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + " - name: Install", + " run: npm ci", + " - name: Test", + " run: npm test", + ].join("\n"); + const result = parseYAML(yaml); + assert.equal(result.name, "CI"); + assert.deepEqual(result.on.push.branches, ["main"]); + assert.deepEqual(result.on.pull_request.paths, ["src/**"]); + assert.equal(result.jobs.build["runs-on"], "ubuntu-latest"); + assert.equal(result.jobs.build.steps.length, 3); + assert.equal(result.jobs.build.steps[0].uses, "actions/checkout@v4"); + }); + + it("handles flow sequence as runner", () => { + const yaml = "runs-on: [self-hosted, staging-deploy]"; + const result = parseYAML(yaml); + assert.deepEqual(result["runs-on"], ["self-hosted", "staging-deploy"]); + }); +}); + +// =================== GITHUB ACTIONS TESTS =================== + +describe("GitHub Actions Detection", async () => { + const { parseYAML } = await import("../../dist/plugins/cicd/yaml-parser.js"); + const { extractGitHubActionsWorkflow } = await import("../../dist/plugins/cicd/github-actions.js"); + + it("extracts basic workflow", () => { + const yaml = [ + "name: CI", + "on: [push, pull_request]", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + " - run: npm test", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipeline = extractGitHubActionsWorkflow(parsed, ".github/workflows/ci.yml", yaml); + + assert.ok(pipeline); + assert.equal(pipeline.name, "CI"); + assert.equal(pipeline.system, "github-actions"); + assert.ok(pipeline.triggers.some(t => t.event === "push")); + assert.ok(pipeline.triggers.some(t => t.event === "pull_request")); + assert.equal(pipeline.jobs.length, 1); + assert.equal(pipeline.jobs[0].name, "test"); + assert.equal(pipeline.jobs[0].runner, "ubuntu-latest"); + assert.ok(pipeline.jobs[0].actions?.includes("actions/checkout@v4")); + }); + + it("detects reusable workflows", () => { + const yaml = [ + "name: Deploy", + "on:", + " workflow_dispatch:", + " inputs:", + " ref:", + " type: string", + "jobs:", + " staging:", + " uses: ./.github/workflows/_shared-deploy.yml", + " with:", + " environment: staging", + " production:", + " needs: staging", + " uses: ./.github/workflows/_shared-deploy.yml", + " with:", + " environment: production", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipeline = extractGitHubActionsWorkflow(parsed, ".github/workflows/deploy.yml", yaml); + + assert.ok(pipeline); + assert.ok(pipeline.reusableWorkflows?.includes("./.github/workflows/_shared-deploy.yml")); + assert.equal(pipeline.jobs.length, 2); + assert.ok(pipeline.jobs[1].needs?.includes("staging")); + }); + + it("detects workflow_call as reusable", () => { + const yaml = [ + "name: Shared Deploy", + "on:", + " workflow_call:", + " inputs:", + " environment:", + " type: string", + "jobs:", + " deploy:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipeline = extractGitHubActionsWorkflow(parsed, ".github/workflows/_shared-deploy.yml", yaml); + + assert.ok(pipeline); + assert.equal(pipeline.isReusable, true); + }); + + it("extracts secrets from expressions", () => { + const yaml = [ + "name: CI", + "on: [push]", + "jobs:", + " build:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo ${{ secrets.AWS_ACCESS_KEY_ID }}", + " - run: echo ${{ secrets.DEPLOY_TOKEN }}", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipeline = extractGitHubActionsWorkflow(parsed, ".github/workflows/ci.yml", yaml); + + assert.ok(pipeline); + assert.ok(pipeline.secrets?.includes("AWS_ACCESS_KEY_ID")); + assert.ok(pipeline.secrets?.includes("DEPLOY_TOKEN")); + }); + + it("infers ECS deploy target", () => { + const yaml = [ + "name: Deploy", + "on: [push]", + "jobs:", + " deploy:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + " - uses: aws-actions/amazon-ecs-deploy-task-definition@v1", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipeline = extractGitHubActionsWorkflow(parsed, ".github/workflows/deploy.yml", yaml); + + assert.ok(pipeline); + assert.equal(pipeline.jobs[0].deployTarget, "ecs"); + }); + + it("extracts workflow-level env vars", () => { + const yaml = [ + "name: CI", + "on: [push]", + "env:", + " AWS_REGION: eu-central-1", + " NODE_ENV: test", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo test", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipeline = extractGitHubActionsWorkflow(parsed, ".github/workflows/ci.yml", yaml); + + assert.ok(pipeline); + assert.ok(pipeline.envVars?.includes("AWS_REGION")); + assert.ok(pipeline.envVars?.includes("NODE_ENV")); + }); +}); + +// =================== CIRCLECI TESTS =================== + +describe("CircleCI Detection", async () => { + const { parseYAML } = await import("../../dist/plugins/cicd/yaml-parser.js"); + const { extractCircleCIWorkflows } = await import("../../dist/plugins/cicd/circleci.js"); + + it("extracts basic workflow", () => { + const yaml = [ + "version: 2.1", + "jobs:", + " test:", + " docker:", + " - image: cimg/node:18.0", + " steps:", + " - checkout", + " - run: npm test", + " deploy:", + " docker:", + " - image: cimg/node:18.0", + " steps:", + " - checkout", + " - run: npm run deploy", + "workflows:", + " build-and-deploy:", + " jobs:", + " - test", + " - deploy:", + " requires:", + " - test", + " context:", + " - production", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipelines = extractCircleCIWorkflows(parsed, ".circleci/config.yml", yaml); + + assert.equal(pipelines.length, 1); + const wf = pipelines[0]; + assert.equal(wf.name, "build-and-deploy"); + assert.equal(wf.system, "circleci"); + assert.equal(wf.jobs.length, 2); + assert.ok(wf.jobs.some(j => j.name === "test")); + assert.ok(wf.jobs.some(j => j.name === "deploy" && j.needs?.includes("test"))); + assert.ok(wf.environments?.includes("production")); + }); + + it("detects orbs as env vars", () => { + const yaml = [ + "version: 2.1", + "orbs:", + " aws-cli: circleci/aws-cli@5.1", + " python: circleci/python@2", + "jobs:", + " test:", + " docker:", + " - image: cimg/python:3.14", + " steps:", + " - checkout", + "workflows:", + " ci:", + " jobs:", + " - test", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipelines = extractCircleCIWorkflows(parsed, ".circleci/config.yml", yaml); + + assert.ok(pipelines[0].envVars?.includes("orb:aws-cli")); + assert.ok(pipelines[0].envVars?.includes("orb:python")); + }); + + it("extracts docker image as runner", () => { + const yaml = [ + "version: 2.1", + "jobs:", + " test:", + " docker:", + " - image: cimg/python:3.14", + " steps:", + " - checkout", + "workflows:", + " ci:", + " jobs:", + " - test", + ].join("\n"); + const parsed = parseYAML(yaml); + const pipelines = extractCircleCIWorkflows(parsed, ".circleci/config.yml", yaml); + + assert.equal(pipelines[0].jobs[0].runner, "cimg/python:3.14"); + }); +}); + +// =================== PLUGIN INTEGRATION TESTS =================== + +describe("CI/CD Plugin Integration", async () => { + const { collectFiles, detectProject } = await import("../../dist/scanner.js"); + const { createCICDPlugin } = await import("../../dist/plugins/cicd/index.js"); + + it("produces customSection for GitHub Actions project", async () => { + const dir = await writeFixture("cicd-github-actions", { + "package.json": JSON.stringify({ name: "test-gha" }), + ".github/workflows/ci.yml": [ + "name: CI", + "on:", + " push:", + " branches: [main]", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + " - run: npm test", + ].join("\n"), + }); + const project = await detectProject(dir); + const files = await collectFiles(dir); + const plugin = createCICDPlugin(); + const result = await plugin.detector!(files, project); + + assert.ok(result.customSections); + assert.equal(result.customSections.length, 1); + assert.equal(result.customSections[0].name, "cicd"); + assert.ok(result.customSections[0].content.includes("CI")); + assert.ok(result.customSections[0].content.includes("GitHub Actions")); + }); + + it("produces customSection for CircleCI project", async () => { + const dir = await writeFixture("cicd-circleci", { + "package.json": JSON.stringify({ name: "test-cci" }), + ".circleci/config.yml": [ + "version: 2.1", + "jobs:", + " test:", + " docker:", + " - image: cimg/node:18.0", + " steps:", + " - checkout", + "workflows:", + " ci:", + " jobs:", + " - test", + ].join("\n"), + }); + const project = await detectProject(dir); + const files = await collectFiles(dir); + const plugin = createCICDPlugin(); + const result = await plugin.detector!(files, project); + + assert.ok(result.customSections); + assert.equal(result.customSections[0].name, "cicd"); + assert.ok(result.customSections[0].content.includes("CircleCI")); + }); + + it("returns empty for projects without CI/CD", async () => { + const dir = await writeFixture("cicd-none", { + "package.json": JSON.stringify({ name: "test-none" }), + "src/index.ts": "console.log('hello');", + }); + const project = await detectProject(dir); + const files = await collectFiles(dir); + const plugin = createCICDPlugin(); + const result = await plugin.detector!(files, project); + + assert.equal(result.customSections, undefined); + }); + + it("respects systems filter", async () => { + const dir = await writeFixture("cicd-filter", { + "package.json": JSON.stringify({ name: "test-filter" }), + ".github/workflows/ci.yml": [ + "name: CI", + "on: [push]", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo test", + ].join("\n"), + ".circleci/config.yml": [ + "version: 2.1", + "jobs:", + " test:", + " docker:", + " - image: node:18", + " steps:", + " - checkout", + "workflows:", + " ci:", + " jobs:", + " - test", + ].join("\n"), + }); + const project = await detectProject(dir); + const files = await collectFiles(dir); + + // Only GitHub Actions + const ghPlugin = createCICDPlugin({ systems: ["github-actions"] }); + const ghResult = await ghPlugin.detector!(files, project); + assert.ok(ghResult.customSections?.[0].content.includes("GitHub Actions")); + assert.ok(!ghResult.customSections?.[0].content.includes("CircleCI")); + }); +}); diff --git a/tests/skills-plugin.test.ts b/tests/skills-plugin.test.ts new file mode 100644 index 0000000..fb51461 --- /dev/null +++ b/tests/skills-plugin.test.ts @@ -0,0 +1,98 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +async function setup(files: Record): Promise { + const root = join(tmpdir(), `codesight-skills-test-${Date.now()}`); + for (const [rel, content] of Object.entries(files)) { + const full = join(root, rel); + await mkdir(join(full, ".."), { recursive: true }); + await writeFile(full, content); + } + return root; +} + +async function cleanup(root: string) { + await rm(root, { recursive: true, force: true }); +} + +const fakeProject = (root: string) => ({ root, frameworks: [], language: "typescript", orms: [], isMonorepo: false, workspaces: [], repoType: "single" as const }); + +describe("Skills Plugin", async () => { + const { createSkillsPlugin } = await import("../dist/plugins/skills/index.js"); + + it("detects skills in .claude/commands", async () => { + const root = await setup({ + ".claude/commands/review.md": `---\ndescription: Pre-landing PR review\n---\nReview the current PR.`, + ".claude/commands/ship.md": `---\ndescription: Ship workflow\n---\nMerge and deploy.`, + }); + try { + const plugin = createSkillsPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + assert.ok(result.customSections?.length === 1); + const content = result.customSections![0].content; + assert.ok(content.includes("/review"), "should include /review"); + assert.ok(content.includes("Pre-landing PR review"), "should include description"); + assert.ok(content.includes("/ship"), "should include /ship"); + } finally { + await cleanup(root); + } + }); + + it("detects skills in .claude/skills", async () => { + const root = await setup({ + ".claude/skills/investigate.md": `---\ndescription: Systematic debugging\n---\nInvestigate the issue.`, + }); + try { + const plugin = createSkillsPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + assert.ok(result.customSections?.length === 1); + assert.ok(result.customSections![0].content.includes("/investigate")); + } finally { + await cleanup(root); + } + }); + + it("falls back to first line when no frontmatter description", async () => { + const root = await setup({ + ".claude/commands/health.md": `# Health check\n\nRuns the health dashboard.`, + }); + try { + const plugin = createSkillsPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + const content = result.customSections![0].content; + assert.ok(content.includes("Health check"), "should fall back to heading text"); + } finally { + await cleanup(root); + } + }); + + it("returns empty when no skill directories exist", async () => { + const root = await setup({ "src/index.ts": "export {}" }); + try { + const plugin = createSkillsPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + assert.deepEqual(result, {}); + } finally { + await cleanup(root); + } + }); + + it("merges skills from both directories", async () => { + const root = await setup({ + ".claude/commands/review.md": `---\ndescription: Review\n---`, + ".claude/skills/investigate.md": `---\ndescription: Investigate\n---`, + }); + try { + const plugin = createSkillsPlugin(); + const result = await plugin.detector!([], fakeProject(root)); + const content = result.customSections![0].content; + assert.ok(content.includes("/review")); + assert.ok(content.includes("/investigate")); + } finally { + await cleanup(root); + } + }); +}); diff --git a/tests/terraform-plugin.test.ts b/tests/terraform-plugin.test.ts new file mode 100644 index 0000000..65765ba --- /dev/null +++ b/tests/terraform-plugin.test.ts @@ -0,0 +1,401 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const FIXTURES = join(import.meta.dirname, "fixtures", "terraform"); + +// Import from dist — matches codesight's test convention +const { parseHclFile, parseTfvars, stripComments, extractBraceBlock } = await import( + "../dist/plugins/terraform/hcl-parser.js" +); +const { matchServiceBlocks, normaliseServiceName } = await import( + "../dist/plugins/terraform/service-matcher.js" +); +const { extractServiceInfrastructure, extractEnvironments } = await import( + "../dist/plugins/terraform/extractor.js" +); +const { formatInfrastructure } = await import("../dist/plugins/terraform/formatter.js"); +const { collectTfFiles } = await import("../dist/plugins/terraform/file-collector.js"); + +// ─── HCL Parser Tests ─── + +describe("HCL Parser", () => { + describe("stripComments", () => { + it("strips # comments", () => { + const result = stripComments('name = "test" # this is a comment\n'); + assert.ok(!result.includes("this is a comment")); + assert.ok(result.includes('name = "test"')); + }); + + it("strips // comments", () => { + const result = stripComments('name = "test" // c-style comment\n'); + assert.ok(!result.includes("c-style comment")); + }); + + it("strips /* */ block comments", () => { + const result = stripComments('before /* this_is_removed\ncomment */ after'); + assert.ok(!result.includes("this_is_removed"), "Block comment content should be stripped"); + assert.ok(result.includes("before")); + assert.ok(result.includes("after")); + }); + + it("preserves # inside strings", () => { + const result = stripComments('default = "color is #ff0000"\n'); + assert.ok(result.includes("#ff0000")); + }); + + it("preserves // inside strings", () => { + const result = stripComments('default = "https://example.com"\n'); + assert.ok(result.includes("https://example.com")); + }); + }); + + describe("extractBraceBlock", () => { + it("extracts simple block", () => { + const content = '{ name = "test" }'; + const result = extractBraceBlock(content, 1); + assert.ok(result !== null); + assert.ok(result.includes('name = "test"')); + }); + + it("handles nested braces", () => { + const content = '{ outer { inner = true } }'; + const result = extractBraceBlock(content, 1); + assert.ok(result !== null); + assert.ok(result.includes("inner = true")); + }); + + it("handles braces inside strings", () => { + const content = '{ name = "value with { braces }" }'; + const result = extractBraceBlock(content, 1); + assert.ok(result !== null); + assert.ok(result.includes("{ braces }")); + }); + + it("returns null for unmatched braces", () => { + const content = "{ name = true"; + const result = extractBraceBlock(content, 1); + assert.equal(result, null); + }); + }); + + describe("parseHclFile", () => { + it("parses simple-ecs-service fixture", async () => { + const content = await readFile(join(FIXTURES, "simple-ecs-service", "app-service.tf"), "utf-8"); + const blocks = parseHclFile(content, "app-service.tf"); + + // Should find: 2 variables, 2 modules, 1 resource + const variables = blocks.filter((b: any) => b.blockType === "variable"); + const modules = blocks.filter((b: any) => b.blockType === "module"); + const resources = blocks.filter((b: any) => b.blockType === "resource"); + + assert.equal(variables.length, 2, `Expected 2 variables, got ${variables.length}`); + assert.equal(modules.length, 2, `Expected 2 modules, got ${modules.length}`); + assert.equal(resources.length, 1, `Expected 1 resource, got ${resources.length}`); + + // Check module attributes + const appModule = modules.find((m: any) => m.label === "app_service"); + assert.ok(appModule, "Should find app_service module"); + assert.equal(appModule.attributes.cpu, "512"); + assert.equal(appModule.attributes.memory, "1024"); + assert.equal(appModule.attributes.health_check_path, "/health"); + assert.equal(appModule.attributes.source, "./modules/compute/ecs-service"); + + // Check environment_variables nested block + const envVars = appModule.nestedBlocks.environment_variables; + assert.ok(envVars, "Should have environment_variables"); + assert.ok(envVars.length >= 5, `Expected at least 5 env vars, got ${envVars.length}`); + + const svcName = envVars.find((e: any) => e.attributes.name === "SERVICE_NAME"); + assert.ok(svcName, "Should find SERVICE_NAME env var"); + assert.equal(svcName.attributes.value, "app-service"); + + // Check secrets nested block + const secrets = appModule.nestedBlocks.secrets; + assert.ok(secrets, "Should have secrets"); + assert.equal(secrets.length, 3, `Expected 3 secrets, got ${secrets.length}`); + }); + + it("parses edge cases fixture", async () => { + const content = await readFile(join(FIXTURES, "edge-cases", "complex.tf"), "utf-8"); + const blocks = parseHclFile(content, "complex.tf"); + + // Should handle comments, heredocs, jsonencode, dynamic blocks, etc. + assert.ok(blocks.length >= 7, `Expected at least 7 blocks, got ${blocks.length}`); + + // Find the heredoc resource + const heredocBlock = blocks.find( + (b: any) => b.blockType === "resource" && b.label === "with_heredoc", + ); + assert.ok(heredocBlock, "Should find heredoc resource"); + assert.ok( + heredocBlock.attributes.container_definitions, + "Should have container_definitions attribute", + ); + + // String with hash should be preserved + const trickyString = blocks.find((b: any) => b.label === "tricky_string"); + assert.ok(trickyString, "Should find tricky_string variable"); + assert.ok( + trickyString.attributes.default.includes("#ff0000"), + "Hash in string should be preserved", + ); + + // Nested blocks (ingress/egress) + const sgBlock = blocks.find((b: any) => b.label === "nested_blocks"); + assert.ok(sgBlock, "Should find nested_blocks security group"); + assert.ok(sgBlock.nestedBlocks.ingress, "Should have ingress blocks"); + assert.equal(sgBlock.nestedBlocks.ingress.length, 2, "Should have 2 ingress blocks"); + assert.ok(sgBlock.nestedBlocks.egress, "Should have egress blocks"); + }); + }); + + describe("parseTfvars", () => { + it("parses key=value pairs", async () => { + const content = await readFile( + join(FIXTURES, "simple-ecs-service", "environments", "staging.tfvars"), + "utf-8", + ); + const vars = parseTfvars(content); + + assert.equal(vars.enable_app_service, "true"); + assert.equal(vars.app_service_desired_count, "1"); + assert.equal(vars.app_service_cpu, "256"); + assert.equal(vars.environment, "staging"); + }); + }); +}); + +// ─── Service Matcher Tests ─── + +describe("Service Matcher", () => { + describe("normaliseServiceName", () => { + it("converts kebab-case to snake_case", () => { + assert.equal(normaliseServiceName("query-service"), "query_service"); + }); + + it("converts camelCase to snake_case", () => { + assert.equal(normaliseServiceName("QueryService"), "query_service"); + }); + + it("handles dots and spaces", () => { + assert.equal(normaliseServiceName("my.app service"), "my_app_service"); + }); + + it("collapses multiple separators", () => { + assert.equal(normaliseServiceName("my--service__app"), "my_service_app"); + }); + }); + + describe("matchServiceBlocks", () => { + it("matches blocks by label prefix", async () => { + const content = await readFile(join(FIXTURES, "simple-ecs-service", "app-service.tf"), "utf-8"); + const blocks = parseHclFile(content, "app-service.tf"); + const matched = matchServiceBlocks("app-service", blocks, {}); + + // Should match app_service, app_service_worker, enable_app_service, and route53 + assert.ok(matched.length >= 2, `Expected at least 2 matched blocks, got ${matched.length}`); + + const labels = matched.map((b: any) => b.label); + assert.ok(labels.includes("app_service"), "Should match app_service module"); + }); + + it("isolates correct service in multi-service fixture", async () => { + const files = ["billing.tf", "auth.tf", "notifications.tf"]; + const allBlocks: any[] = []; + for (const f of files) { + const content = await readFile(join(FIXTURES, "multi-service", f), "utf-8"); + allBlocks.push(...parseHclFile(content, f)); + } + + const matched = matchServiceBlocks("billing", allBlocks, { serviceName: "billing" }); + + // Should only match billing-related blocks + for (const block of matched) { + const label = (block as any).label.toLowerCase(); + assert.ok( + label.includes("billing") || label.includes("enable_billing"), + `Unexpected match: ${label}`, + ); + } + assert.ok(matched.length >= 1, "Should find at least the billing module"); + }); + + it("returns empty for non-existent service", async () => { + const content = await readFile(join(FIXTURES, "simple-ecs-service", "app-service.tf"), "utf-8"); + const blocks = parseHclFile(content, "app-service.tf"); + const matched = matchServiceBlocks("nonexistent-service", blocks, {}); + + assert.equal(matched.length, 0, "Should not match any blocks"); + }); + + it("matches with serviceAliases", async () => { + const files = ["billing.tf", "auth.tf", "notifications.tf"]; + const allBlocks: any[] = []; + for (const f of files) { + const content = await readFile(join(FIXTURES, "multi-service", f), "utf-8"); + allBlocks.push(...parseHclFile(content, f)); + } + + const matched = matchServiceBlocks("payment", allBlocks, { + serviceName: "payment", + serviceAliases: ["billing"], + }); + + assert.ok(matched.length >= 1, "Should match via alias"); + }); + }); +}); + +// ─── Extractor Tests ─── + +describe("Extractor", () => { + it("extracts env vars and secrets", async () => { + const content = await readFile(join(FIXTURES, "simple-ecs-service", "app-service.tf"), "utf-8"); + const blocks = parseHclFile(content, "app-service.tf"); + const matched = matchServiceBlocks("app-service", blocks, { serviceName: "app-service" }); + + const infra = extractServiceInfrastructure(matched, blocks, { serviceName: "app-service" }); + + // Environment variables + assert.ok(infra.envVars.length >= 5, `Expected at least 5 env vars, got ${infra.envVars.length}`); + const svcName = infra.envVars.find((e: any) => e.name === "SERVICE_NAME"); + assert.ok(svcName, "Should find SERVICE_NAME"); + assert.equal(svcName.source, "literal"); + + const envVar = infra.envVars.find((e: any) => e.name === "ENVIRONMENT"); + assert.ok(envVar, "Should find ENVIRONMENT"); + assert.equal(envVar.source, "variable"); + + // Secrets + assert.ok(infra.secrets.length >= 3, `Expected at least 3 secrets, got ${infra.secrets.length}`); + const dbUrl = infra.secrets.find((s: any) => s.name === "DATABASE_URL"); + assert.ok(dbUrl, "Should find DATABASE_URL secret"); + assert.ok(dbUrl.arnPattern.includes("ssm"), "Should contain SSM ARN"); + }); + + it("detects DNS and public-facing status", async () => { + const content = await readFile(join(FIXTURES, "simple-ecs-service", "app-service.tf"), "utf-8"); + const blocks = parseHclFile(content, "app-service.tf"); + const matched = matchServiceBlocks("app-service", blocks, { serviceName: "app-service" }); + + const infra = extractServiceInfrastructure(matched, blocks, { serviceName: "app-service" }); + + assert.equal(infra.dns.isPublicFacing, true, "Should be public-facing"); + assert.ok(infra.dns.hostnames.length > 0, "Should have hostnames"); + }); + + it("extracts components with deployment type", async () => { + const content = await readFile(join(FIXTURES, "simple-ecs-service", "app-service.tf"), "utf-8"); + const blocks = parseHclFile(content, "app-service.tf"); + const matched = matchServiceBlocks("app-service", blocks, { serviceName: "app-service" }); + + const infra = extractServiceInfrastructure(matched, blocks, { serviceName: "app-service" }); + + assert.ok(infra.components.length >= 2, `Expected at least 2 components, got ${infra.components.length}`); + + const api = infra.components.find((c: any) => c.label === "app_service"); + assert.ok(api, "Should find app_service component"); + assert.equal(api.deploymentType, "ecs-fargate"); + assert.equal(api.compute?.cpu, "512"); + assert.equal(api.healthCheck, "/health"); + + const worker = infra.components.find((c: any) => c.label === "app_service_worker"); + assert.ok(worker, "Should find app_service_worker component"); + assert.equal(worker.deploymentType, "ecs-worker"); + }); + + it("extracts per-environment overrides from tfvars", async () => { + const tfvarsFiles = [ + join(FIXTURES, "simple-ecs-service", "environments", "staging.tfvars"), + join(FIXTURES, "simple-ecs-service", "environments", "production.tfvars"), + ]; + + const envs = await extractEnvironments(tfvarsFiles, "app-service"); + + assert.ok(envs.staging, "Should have staging environment"); + assert.ok(envs.production, "Should have production environment"); + assert.equal(envs.staging.enabled, true); + assert.equal(envs.staging.variables.app_service_cpu, "256"); + assert.equal(envs.production.variables.app_service_cpu, "1024"); + assert.equal(envs.production.variables.app_service_desired_count, "3"); + }); +}); + +// ─── File Collector Tests ─── + +describe("File Collector", () => { + it("discovers in-project terraform directory", async () => { + const result = await collectTfFiles(join(FIXTURES, "in-project"), {}); + assert.ok(result.tfFiles.length > 0, "Should find .tf files in terraform/ subdir"); + assert.ok( + result.tfFiles.some((f: string) => f.endsWith("main.tf")), + "Should find main.tf", + ); + }); + + it("collects from explicit infraPath", async () => { + const result = await collectTfFiles("/tmp", { + infraPath: join(FIXTURES, "simple-ecs-service"), + }); + assert.ok(result.tfFiles.length > 0, "Should find .tf files at explicit path"); + assert.ok(result.tfvarsFiles.length > 0, "Should find .tfvars files"); + }); + + it("returns empty for non-existent path", async () => { + const result = await collectTfFiles("/tmp/nonexistent-12345", {}); + assert.equal(result.tfFiles.length, 0); + assert.equal(result.tfvarsFiles.length, 0); + }); +}); + +// ─── Formatter Tests ─── + +describe("Formatter", () => { + it("generates markdown with all sections", async () => { + const content = await readFile(join(FIXTURES, "simple-ecs-service", "app-service.tf"), "utf-8"); + const blocks = parseHclFile(content, "app-service.tf"); + const matched = matchServiceBlocks("app-service", blocks, { serviceName: "app-service" }); + const infra = extractServiceInfrastructure(matched, blocks, { serviceName: "app-service" }); + + const tfvarsFiles = [ + join(FIXTURES, "simple-ecs-service", "environments", "staging.tfvars"), + join(FIXTURES, "simple-ecs-service", "environments", "production.tfvars"), + ]; + infra.environments = await extractEnvironments(tfvarsFiles, "app-service"); + + const md = formatInfrastructure(infra); + + assert.ok(md.includes("# Infrastructure — app-service"), "Should have title"); + assert.ok(md.includes("## Components"), "Should have Components section"); + assert.ok(md.includes("## Environment Variables"), "Should have Env Vars section"); + assert.ok(md.includes("## Secrets"), "Should have Secrets section"); + assert.ok(md.includes("SERVICE_NAME"), "Should list SERVICE_NAME"); + assert.ok(md.includes("DATABASE_URL"), "Should list DATABASE_URL secret"); + assert.ok(md.includes("## Environments"), "Should have Environments section"); + assert.ok(md.includes("### staging"), "Should have staging env"); + assert.ok(md.includes("### production"), "Should have production env"); + assert.ok(md.includes("codesight-terraform-plugin"), "Should have attribution"); + }); + + it("omits empty sections", () => { + const md = formatInfrastructure({ + serviceName: "empty-service", + sourceFiles: ["empty.tf"], + components: [], + dns: { hostnames: [], isPublicFacing: false }, + envVars: [], + secrets: [], + dependencies: [], + iamPermissions: [], + observability: { alarms: [] }, + environments: {}, + }); + + assert.ok(md.includes("# Infrastructure — empty-service"), "Should have title"); + assert.ok(!md.includes("## Environment Variables"), "Should not have empty env vars section"); + assert.ok(!md.includes("## Secrets"), "Should not have empty secrets section"); + assert.ok(!md.includes("## Dependencies"), "Should not have empty deps section"); + }); +});