From 46ac032a0e0adc12de282c4f4a1f5bcd0c4e8351 Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Mon, 13 Apr 2026 18:17:59 +0100 Subject: [PATCH 1/9] feat: terraform infrastructure plugin + customSections plugin API (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add customSections to plugin API for first-class plugin output Plugins could previously only return routes, schemas, components, and middleware — they had no way to contribute new types of content to CODESIGHT.md. This meant plugin-generated insights were invisible to agents unless they knew to look for a separate file. Add customSections to PluginDetectorResult and ScanResult so plugins can return arbitrary markdown sections that get rendered into CODESIGHT.md alongside built-in sections, written as individual .md files, and referenced in AI config files (CLAUDE.md, .cursorrules, etc). Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add terraform infrastructure plugin (AWS-focused) Add a plugin that scans Terraform/HCL files and generates infrastructure.md with deployment context for AI agents — where a service runs, what env vars and SSM secrets it receives, whether it's public-facing, what it depends on, and per-environment overrides. Supports two modes: in-project (terraform/ subdir alongside code) and external path (separate infrastructure repo, default ../infrastructure). Uses regex + brace-counting for zero-dependency HCL parsing, following the same approach as the Go extractor. The HCL parser and service matcher are provider-agnostic, but the infrastructure extractor currently targets AWS patterns (ECS, SSM Parameter Store, ALB, Route53, CloudWatch). Azure and GCP extraction would require additional provider-specific logic in the extractor — the parser and matcher layers would not need changes. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review comments - formatter.ts: sanitise customSections names to safe basenames and reject collisions with built-in section names - hcl-parser.ts: skip comment stripping inside heredoc bodies to avoid corrupting literal content - extractor.ts: fix IAM statement extraction to read action/actions instead of falling back to effect - package.json: add dist/* wildcard export to preserve deep imports, update test script to run all test files Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review findings — reserved name bypass, name mismatch, regex safety - Fix reserved name "CODESIGHT" casing mismatch in formatter.ts (was uppercase, safeName is always lowercased so the guard never matched) - Sanitize section names in ai-config.ts to match filenames written by formatter.ts - Move BLOCK_HEADER regex inside function body to prevent shared mutable state under concurrent use - Add string-aware bracket counting in multi-line list parser - Remove dead TF_EXTENSIONS constant - Add informational TODOs for parallel file reads, parseTfvars multiline limitation, and custom section name collision Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- package.json | 7 +- src/formatter.ts | 12 + src/generators/ai-config.ts | 10 + src/index.ts | 172 +++++++ src/plugins/terraform/extractor.ts | 455 ++++++++++++++++ src/plugins/terraform/file-collector.ts | 101 ++++ src/plugins/terraform/formatter.ts | 157 ++++++ src/plugins/terraform/hcl-parser.ts | 485 ++++++++++++++++++ src/plugins/terraform/index.ts | 82 +++ src/plugins/terraform/service-matcher.ts | 116 +++++ src/plugins/terraform/types.ts | 82 +++ src/types.ts | 4 + .../fixtures/terraform/edge-cases/complex.tf | 109 ++++ .../terraform/in-project/src/index.ts | 2 + .../terraform/in-project/terraform/main.tf | 17 + .../fixtures/terraform/multi-service/auth.tf | 21 + .../terraform/multi-service/billing.tf | 26 + .../terraform/multi-service/notifications.tf | 17 + .../simple-ecs-service/app-service.tf | 85 +++ .../environments/production.tfvars | 5 + .../environments/staging.tfvars | 5 + tests/terraform-plugin.test.ts | 401 +++++++++++++++ 22 files changed, 2370 insertions(+), 1 deletion(-) create mode 100644 src/plugins/terraform/extractor.ts create mode 100644 src/plugins/terraform/file-collector.ts create mode 100644 src/plugins/terraform/formatter.ts create mode 100644 src/plugins/terraform/hcl-parser.ts create mode 100644 src/plugins/terraform/index.ts create mode 100644 src/plugins/terraform/service-matcher.ts create mode 100644 src/plugins/terraform/types.ts create mode 100644 tests/fixtures/terraform/edge-cases/complex.tf create mode 100644 tests/fixtures/terraform/in-project/src/index.ts create mode 100644 tests/fixtures/terraform/in-project/terraform/main.tf create mode 100644 tests/fixtures/terraform/multi-service/auth.tf create mode 100644 tests/fixtures/terraform/multi-service/billing.tf create mode 100644 tests/fixtures/terraform/multi-service/notifications.tf create mode 100644 tests/fixtures/terraform/simple-ecs-service/app-service.tf create mode 100644 tests/fixtures/terraform/simple-ecs-service/environments/production.tfvars create mode 100644 tests/fixtures/terraform/simple-ecs-service/environments/staging.tfvars create mode 100644 tests/terraform-plugin.test.ts diff --git a/package.json b/package.json index 990120a..969de67 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,16 @@ "bin": { "codesight": "dist/index.js" }, + "exports": { + ".": "./dist/index.js", + "./plugins/terraform": "./dist/plugins/terraform/index.js", + "./dist/*": "./dist/*" + }, "type": "module", "scripts": { "build": "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/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/index.ts b/src/index.ts index bc5d2e2..b0fecd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,178 @@ async function fileExists(path: string): Promise { } } +async function scan(root: string, outputDirName: string, maxDepth: number, userConfig: CodesightConfig = {}): Promise { + const outputDir = join(root, outputDirName); + + console.log(`\n ${BRAND} v${VERSION}`); + console.log(` Scanning: ${root}\n`); + + const startTime = Date.now(); + + // Step 1: Detect project + process.stdout.write(" Detecting project..."); + const project = await detectProject(root); + console.log( + ` ${project.frameworks.length > 0 ? project.frameworks.join(", ") : "generic"} | ${project.orms.length > 0 ? project.orms.join(", ") : "no ORM"} | ${project.language}` + ); + + if (project.isMonorepo) { + console.log(` Monorepo: ${project.workspaces.map((w) => w.name).join(", ")}`); + } + + // Step 2: Collect files — merge .codesightignore + config ignorePatterns + process.stdout.write(" Collecting files..."); + const ignoreFromFile = await readCodesightIgnore(root); + const allIgnorePatterns = [...(userConfig.ignorePatterns ?? []), ...ignoreFromFile]; + const files = await collectFiles(root, maxDepth, allIgnorePatterns); + console.log(` ${files.length} files`); + + // Step 3: Run all detectors in parallel (respecting disableDetectors config) + process.stdout.write(" Analyzing..."); + + const disabled = new Set(userConfig.disableDetectors || []); + + const [rawHttpRoutes, schemas, components, libs, configResult, middleware, graph, + graphqlRoutes, grpcRoutes, wsRoutes, events, openapi] = + await Promise.all([ + disabled.has("routes") ? Promise.resolve([]) : detectRoutes(files, project, userConfig), + disabled.has("schema") ? Promise.resolve([]) : detectSchemas(files, project), + disabled.has("components") ? Promise.resolve([]) : detectComponents(files, project), + disabled.has("libs") ? Promise.resolve([]) : detectLibs(files, project), + disabled.has("config") ? Promise.resolve({ envVars: [], configFiles: [], dependencies: {}, devDependencies: {} }) : detectConfig(files, project), + disabled.has("middleware") ? Promise.resolve([]) : detectMiddleware(files, project), + disabled.has("graph") ? Promise.resolve({ edges: [], hotFiles: [] }) : detectDependencyGraph(files, project), + disabled.has("graphql") ? Promise.resolve([]) : detectGraphQLRoutes(files, project), + disabled.has("graphql") ? Promise.resolve([]) : detectGRPCRoutes(files, project), + disabled.has("graphql") ? Promise.resolve([]) : detectWebSocketRoutes(files, project), + disabled.has("events") ? Promise.resolve([]) : detectEvents(files, project), + detectOpenAPISpec(root, project), + ]); + + // Merge OpenAPI routes and schemas if spec found + const rawRoutes = [...rawHttpRoutes, ...graphqlRoutes, ...grpcRoutes, ...wsRoutes]; + if (openapi.routes.length > 0) { + // Only use OpenAPI routes if we got very few from code detection + if (rawRoutes.length === 0) { + rawRoutes.push(...openapi.routes); + } + // Add any OpenAPI schemas not already detected + const existingModelNames = new Set(schemas.map((m) => m.name.toLowerCase())); + for (const m of openapi.schemas) { + if (!existingModelNames.has(m.name.toLowerCase())) schemas.push(m); + } + } + + // Step 3b: Run plugin detectors + // NOTE: if two plugins emit sections with the same name, the last writer wins on disk + // but both appear in CODESIGHT.md. Guard with unique names per plugin for now. + const customSections: { name: string; content: string }[] = []; + if (userConfig.plugins) { + for (const plugin of userConfig.plugins) { + if (plugin.detector) { + try { + const pluginResult = await plugin.detector(files, project); + if (pluginResult.routes) rawRoutes.push(...pluginResult.routes); + 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) { + console.warn(`\n Warning: plugin "${plugin.name}" failed: ${err.message}`); + } + } + } + } + + // Step 4: Enrich routes with contract info + const routes = await enrichRouteContracts(rawRoutes, project); + + // Step 4b: Test coverage detection + const testCoverage = await detectTestCoverage(files, routes, schemas, root); + + // Step 4c: Compute CRUD groups + const crudGroups = computeCrudGroups(routes); + + // Report AST vs regex detection + const astRoutes = routes.filter((r) => r.confidence === "ast").length; + const astSchemas = schemas.filter((s) => s.confidence === "ast").length; + const astComponents = components.filter((c) => c.confidence === "ast").length; + const totalAST = astRoutes + astSchemas + astComponents; + + const specialCounts: string[] = []; + const gqlCount = routes.filter((r) => ["QUERY", "MUTATION", "SUBSCRIPTION"].includes(r.method)).length; + const grpcCount = routes.filter((r) => r.method === "RPC").length; + const wsCount = routes.filter((r) => r.method === "WS" || r.method === "WS-ROOM").length; + if (gqlCount > 0) specialCounts.push(`${gqlCount} graphql`); + if (grpcCount > 0) specialCounts.push(`${grpcCount} rpc`); + if (wsCount > 0) specialCounts.push(`${wsCount} ws`); + if (events.length > 0) specialCounts.push(`${events.length} events`); + + const specialStr = specialCounts.length > 0 ? `, ${specialCounts.join(", ")}` : ""; + if (totalAST > 0) { + console.log(` done (AST: ${astRoutes} routes, ${astSchemas} models, ${astComponents} components${specialStr})`); + } else if (specialCounts.length > 0) { + console.log(` done (${specialCounts.join(", ")})`); + } else { + console.log(" done"); + } + + // Step 5: Write output + process.stdout.write(" Writing output..."); + + // Temporary result without token stats to generate output + const tempResult: ScanResult = { + project, + routes, + schemas, + components, + libs, + config: configResult, + middleware, + graph, + tokenStats: { outputTokens: 0, estimatedExplorationTokens: 0, saved: 0, fileCount: files.length }, + 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); + + // Step 6: Calculate real token stats + const tokenStats = calculateTokenStats(tempResult, outputContent, files.length); + const result: ScanResult = { ...tempResult, tokenStats }; + + // Re-write with accurate token stats + await writeOutput(result, outputDir); + + console.log(` ${outputDirName}/`); + + const elapsed = Date.now() - startTime; + + // Stats + console.log(` + Results: + Routes: ${routes.length} + Models: ${schemas.length} + Components: ${components.length} + Libraries: ${libs.length} + Env vars: ${configResult.envVars.length} + Middleware: ${middleware.length} + Import links: ${graph.edges.length} + Hot files: ${graph.hotFiles.length} + + Tokens: + Output size: ~${tokenStats.outputTokens.toLocaleString()} tokens + Exploration cost: ~${tokenStats.estimatedExplorationTokens.toLocaleString()} tokens + Saved: ~${tokenStats.saved.toLocaleString()} tokens per conversation + + Done in ${elapsed}ms +`); + + return result; +} + async function installGitHook(root: string, outputDirName: string) { const hooksDir = join(root, ".git", "hooks"); const hookPath = join(hooksDir, "pre-commit"); 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..4241c5a --- /dev/null +++ b/src/plugins/terraform/file-collector.ts @@ -0,0 +1,101 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, dirname, 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 → sibling repos → 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. Sibling infrastructure repo + const parent = dirname(projectRoot); + for (const sibling of ["infrastructure", "infra", "terraform", "deploy"]) { + const candidate = join(parent, sibling); + const files = await scanDirForTf(candidate); + if (files.tfFiles.length > 0) return files; + } + + // 4. .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..ce26eec --- /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 — either co-located in the project or in a separate + * infrastructure repo — and generates an infrastructure section with + * deployment context for AI agents. + * + * @example + * // Auto-discover infrastructure + * createTerraformPlugin() + * + * @example + * // Explicit centralised infra repo + * createTerraformPlugin({ + * infraPath: '../infrastructure', + * serviceName: 'query-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..f8efffa --- /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 infra repo — absolute or relative to project root. + * Default: auto-discovers ../infrastructure, ./terraform, ./infra, ./deploy */ + 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/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 = < { + 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"); + }); +}); From 80207b605cf38d4d1a1a9c402b65c8c3ec84ca1a Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Tue, 14 Apr 2026 11:10:19 +0100 Subject: [PATCH 2/9] fix: enable direct install from GitHub via prepare script Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 969de67..565e9ba 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "type": "module", "scripts": { "build": "tsc", + "prepare": "tsc", "dev": "tsx src/index.ts", "test": "pnpm build && tsx --test tests/*.test.ts", "prepublishOnly": "pnpm build" From 9f8c577b1f3af3f54778d2638b7396081cbd5507 Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Tue, 14 Apr 2026 08:47:04 +0100 Subject: [PATCH 3/9] feat: CI/CD pipeline detection plugin (GitHub Actions + CircleCI) Add a new plugin under src/plugins/cicd/ that scans CI/CD configuration files and produces a cicd.md section via the customSections plugin API. Follows the same architecture as the existing Terraform plugin. Supports GitHub Actions (.github/workflows/*.yml) and CircleCI (.circleci/config.yml) with extraction of triggers, jobs, secrets, deploy targets, reusable workflows, environments, and concurrency groups. Includes a purpose-built YAML parser for the CI/CD config subset (array-of-objects, block scalars, flow sequences) since the existing parseMinimalYAML cannot handle these constructs. 28 tests covering YAML parser, GHA extraction, CircleCI extraction, and full plugin integration. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugins/cicd/circleci.ts | 226 ++++++++ src/plugins/cicd/formatter.ts | 105 ++++ src/plugins/cicd/github-actions.ts | 190 +++++++ src/plugins/cicd/index.ts | 106 ++++ src/plugins/cicd/types.ts | 35 ++ src/plugins/cicd/yaml-parser.ts | 370 +++++++++++++ .../cicd-circleci/.circleci/config.yml | 11 + tests/fixtures/cicd-circleci/package.json | 1 + .../fixtures/cicd-filter/.circleci/config.yml | 11 + .../cicd-filter/.github/workflows/ci.yml | 7 + tests/fixtures/cicd-filter/package.json | 1 + .../.github/workflows/ci.yml | 10 + .../fixtures/cicd-github-actions/package.json | 1 + tests/fixtures/cicd-none/package.json | 1 + tests/fixtures/cicd-none/src/index.ts | 1 + tests/plugins/cicd.test.ts | 499 ++++++++++++++++++ 16 files changed, 1575 insertions(+) create mode 100644 src/plugins/cicd/circleci.ts create mode 100644 src/plugins/cicd/formatter.ts create mode 100644 src/plugins/cicd/github-actions.ts create mode 100644 src/plugins/cicd/index.ts create mode 100644 src/plugins/cicd/types.ts create mode 100644 src/plugins/cicd/yaml-parser.ts create mode 100644 tests/fixtures/cicd-circleci/.circleci/config.yml create mode 100644 tests/fixtures/cicd-circleci/package.json create mode 100644 tests/fixtures/cicd-filter/.circleci/config.yml create mode 100644 tests/fixtures/cicd-filter/.github/workflows/ci.yml create mode 100644 tests/fixtures/cicd-filter/package.json create mode 100644 tests/fixtures/cicd-github-actions/.github/workflows/ci.yml create mode 100644 tests/fixtures/cicd-github-actions/package.json create mode 100644 tests/fixtures/cicd-none/package.json create mode 100644 tests/fixtures/cicd-none/src/index.ts create mode 100644 tests/plugins/cicd.test.ts diff --git a/src/plugins/cicd/circleci.ts b/src/plugins/cicd/circleci.ts new file mode 100644 index 0000000..fd6c301 --- /dev/null +++ b/src/plugins/cicd/circleci.ts @@ -0,0 +1,226 @@ +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) + for (const [pName, pDef] of Object.entries(parameters)) { + if (pDef && typeof pDef === "object" && pDef.type === "boolean") { + if (!seenEvents.has("parameter")) { + triggers.push({ event: "parameter", inputs: [pName] }); + 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.branches = 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..3994b55 --- /dev/null +++ b/src/plugins/cicd/formatter.ts @@ -0,0 +1,105 @@ +import type { CICDPipeline } from "./types.js"; + +/** + * 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 triggers = p.triggers.map(t => t.event).join(", ") || "\u2014"; + const jobCount = String(p.jobs.length); + const deploys = uniqueNonEmpty(p.jobs.map(j => j.deployTarget)).join(", ") || "\u2014"; + const envs = p.environments?.join(", ") || "\u2014"; + lines.push(`| ${p.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.stepCount > 0 ? `${j.stepCount} steps` : "approval gate"; + 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, 8)) { // Cap at 8 to avoid noise + lines.push(` - \`${a}\``); + } + if (j.actions.length > 8) { + lines.push(` - _...and ${j.actions.length - 8} 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[])]; +} diff --git a/src/plugins/cicd/github-actions.ts b/src/plugins/cicd/github-actions.ts new file mode 100644 index 0000000..9c404c1 --- /dev/null +++ b/src/plugins/cicd/github-actions.ts @@ -0,0 +1,190 @@ +import { basename } 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, ".yml").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 (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); + if (typeof config === "string") trigger.schedule = config; + // schedule is an array of cron objects + if (Array.isArray(config) && config[0]?.cron) { + trigger.schedule = config[0].cron; + } + } + 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..16f5083 --- /dev/null +++ b/src/plugins/cicd/index.ts @@ -0,0 +1,106 @@ +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 + if (systems.has("circleci")) { + const circleFile = join(project.root, ".circleci", "config.yml"); + 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 + } + } + } + + 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..68c6971 --- /dev/null +++ b/src/plugins/cicd/types.ts @@ -0,0 +1,35 @@ +export type CICDSystem = "github-actions" | "circleci"; + +export interface CICDTrigger { + event: string; + branches?: 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..5a3b45b --- /dev/null +++ b/src/plugins/cicd/yaml-parser.ts @@ -0,0 +1,370 @@ +/** + * 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 = {}; + 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 commas respecting quotes + const items: string[] = []; + let current = ""; + 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 === ",") { 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 version-like pattern + if (/^-?\d+(\.\d+)?$/.test(s) && !s.startsWith("0") || s === "0") { + 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) { + 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) 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/tests/fixtures/cicd-circleci/.circleci/config.yml b/tests/fixtures/cicd-circleci/.circleci/config.yml new file mode 100644 index 0000000..0163303 --- /dev/null +++ b/tests/fixtures/cicd-circleci/.circleci/config.yml @@ -0,0 +1,11 @@ +version: 2.1 +jobs: + test: + docker: + - image: cimg/node:18.0 + steps: + - checkout +workflows: + ci: + jobs: + - test \ No newline at end of file diff --git a/tests/fixtures/cicd-circleci/package.json b/tests/fixtures/cicd-circleci/package.json new file mode 100644 index 0000000..f69b929 --- /dev/null +++ b/tests/fixtures/cicd-circleci/package.json @@ -0,0 +1 @@ +{"name":"test-cci"} \ No newline at end of file diff --git a/tests/fixtures/cicd-filter/.circleci/config.yml b/tests/fixtures/cicd-filter/.circleci/config.yml new file mode 100644 index 0000000..47aef26 --- /dev/null +++ b/tests/fixtures/cicd-filter/.circleci/config.yml @@ -0,0 +1,11 @@ +version: 2.1 +jobs: + test: + docker: + - image: node:18 + steps: + - checkout +workflows: + ci: + jobs: + - test \ No newline at end of file diff --git a/tests/fixtures/cicd-filter/.github/workflows/ci.yml b/tests/fixtures/cicd-filter/.github/workflows/ci.yml new file mode 100644 index 0000000..81405fc --- /dev/null +++ b/tests/fixtures/cicd-filter/.github/workflows/ci.yml @@ -0,0 +1,7 @@ +name: CI +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo test \ No newline at end of file diff --git a/tests/fixtures/cicd-filter/package.json b/tests/fixtures/cicd-filter/package.json new file mode 100644 index 0000000..4af659f --- /dev/null +++ b/tests/fixtures/cicd-filter/package.json @@ -0,0 +1 @@ +{"name":"test-filter"} \ No newline at end of file diff --git a/tests/fixtures/cicd-github-actions/.github/workflows/ci.yml b/tests/fixtures/cicd-github-actions/.github/workflows/ci.yml new file mode 100644 index 0000000..fe39f90 --- /dev/null +++ b/tests/fixtures/cicd-github-actions/.github/workflows/ci.yml @@ -0,0 +1,10 @@ +name: CI +on: + push: + branches: [main] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm test \ No newline at end of file diff --git a/tests/fixtures/cicd-github-actions/package.json b/tests/fixtures/cicd-github-actions/package.json new file mode 100644 index 0000000..869a033 --- /dev/null +++ b/tests/fixtures/cicd-github-actions/package.json @@ -0,0 +1 @@ +{"name":"test-gha"} \ No newline at end of file diff --git a/tests/fixtures/cicd-none/package.json b/tests/fixtures/cicd-none/package.json new file mode 100644 index 0000000..931ce19 --- /dev/null +++ b/tests/fixtures/cicd-none/package.json @@ -0,0 +1 @@ +{"name":"test-none"} \ No newline at end of file diff --git a/tests/fixtures/cicd-none/src/index.ts b/tests/fixtures/cicd-none/src/index.ts new file mode 100644 index 0000000..ea17b22 --- /dev/null +++ b/tests/fixtures/cicd-none/src/index.ts @@ -0,0 +1 @@ +console.log('hello'); \ No newline at end of file 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")); + }); +}); From 1f1f46f489b8c57531868c7bc1d08296431862e1 Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Tue, 14 Apr 2026 13:51:16 +0100 Subject: [PATCH 4/9] fix: address PR review comments on CI/CD plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - github-actions.ts: use extname() to strip both .yml and .yaml from fallback names - github-actions.ts: fix dead code — move string/array config checks outside object branch so schedule triggers are extracted - formatter.ts: use runner === "approval-gate" instead of stepCount === 0 to identify approval gates - yaml-parser.ts: fix operator precedence in numeric detection so 0.1 parses as number - yaml-parser.ts: handle escaped quotes in double-quoted strings in stripComment() Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugins/cicd/formatter.ts | 2 +- src/plugins/cicd/github-actions.ts | 16 ++++++++-------- src/plugins/cicd/yaml-parser.ts | 8 ++++++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/plugins/cicd/formatter.ts b/src/plugins/cicd/formatter.ts index 3994b55..0419d63 100644 --- a/src/plugins/cicd/formatter.ts +++ b/src/plugins/cicd/formatter.ts @@ -46,7 +46,7 @@ export function formatCICD(pipelines: CICDPipeline[]): string { 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.stepCount > 0 ? `${j.stepCount} steps` : "approval gate"; + 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) { diff --git a/src/plugins/cicd/github-actions.ts b/src/plugins/cicd/github-actions.ts index 9c404c1..bae6d93 100644 --- a/src/plugins/cicd/github-actions.ts +++ b/src/plugins/cicd/github-actions.ts @@ -1,4 +1,4 @@ -import { basename } from "node:path"; +import { basename, extname } from "node:path"; import type { CICDPipeline, CICDTrigger, CICDJob } from "./types.js"; /** @@ -11,7 +11,7 @@ export function extractGitHubActionsWorkflow( ): CICDPipeline | null { if (!parsed || typeof parsed !== "object") return null; - const name = parsed.name || basename(relPath, ".yml").replace(/-/g, " "); + 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); @@ -52,15 +52,15 @@ function extractTriggers(on: any): CICDTrigger[] { if (typeof on === "object") { return Object.entries(on).map(([event, config]: [string, any]) => { const trigger: CICDTrigger = { event }; - if (config && typeof config === "object") { + 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); - if (typeof config === "string") trigger.schedule = config; - // schedule is an array of cron objects - if (Array.isArray(config) && config[0]?.cron) { - trigger.schedule = config[0].cron; - } } return trigger; }); diff --git a/src/plugins/cicd/yaml-parser.ts b/src/plugins/cicd/yaml-parser.ts index 5a3b45b..e9901a2 100644 --- a/src/plugins/cicd/yaml-parser.ts +++ b/src/plugins/cicd/yaml-parser.ts @@ -304,8 +304,10 @@ function parseScalar(s: string): any { (s.startsWith("'") && s.endsWith("'"))) { return s.slice(1, -1); } - // Numbers — only if the entire string is numeric and not a version-like pattern - if (/^-?\d+(\.\d+)?$/.test(s) && !s.startsWith("0") || s === "0") { + // 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; } @@ -317,6 +319,8 @@ function stripComment(s: string): string { 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; } From a3f508e38095ac6f96fac9651291c271eb125a48 Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Wed, 15 Apr 2026 08:48:06 +0100 Subject: [PATCH 5/9] fix: address review findings in CI/CD plugin - Fix CircleCI extractTriggers dropping boolean parameters after the first - Add markdown table cell escaping to prevent pipe character corruption - Fix YAML number parsing for negative zero-prefixed strings like -01 - Add nested bracket depth tracking in parseFlowSequence - Support config.yaml in addition to config.yml for CircleCI - Add tags field to CICDTrigger type, fix tag filter using wrong field - Use Object.create(null) for parsed YAML mappings - Handle escaped quotes in findKeyColon Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plugins/cicd/circleci.ts | 12 +++++++----- src/plugins/cicd/formatter.ts | 22 +++++++++++++++------- src/plugins/cicd/index.ts | 25 ++++++++++++++----------- src/plugins/cicd/types.ts | 1 + src/plugins/cicd/yaml-parser.ts | 12 ++++++++---- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/plugins/cicd/circleci.ts b/src/plugins/cicd/circleci.ts index fd6c301..4801266 100644 --- a/src/plugins/cicd/circleci.ts +++ b/src/plugins/cicd/circleci.ts @@ -127,14 +127,16 @@ function extractTriggers( 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") { - if (!seenEvents.has("parameter")) { - triggers.push({ event: "parameter", inputs: [pName] }); - seenEvents.add("parameter"); - } + 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) { @@ -153,7 +155,7 @@ function extractTriggers( if (filters.tags) { if (!seenEvents.has("tag")) { const trigger: CICDTrigger = { event: "tag" }; - if (filters.tags.only) trigger.branches = asArray(filters.tags.only); + if (filters.tags.only) trigger.tags = asArray(filters.tags.only); triggers.push(trigger); seenEvents.add("tag"); } diff --git a/src/plugins/cicd/formatter.ts b/src/plugins/cicd/formatter.ts index 0419d63..48a8ab0 100644 --- a/src/plugins/cicd/formatter.ts +++ b/src/plugins/cicd/formatter.ts @@ -1,5 +1,7 @@ import type { CICDPipeline } from "./types.js"; +const MAX_DISPLAYED_ACTIONS = 8; + /** * Format CI/CD pipeline data into markdown for the custom section output. */ @@ -25,11 +27,12 @@ export function formatCICD(pipelines: CICDPipeline[]): string { lines.push("| Workflow | Triggers | Jobs | Deploy | Environments |"); lines.push("|---|---|---|---|---|"); for (const p of regular) { - const triggers = p.triggers.map(t => t.event).join(", ") || "\u2014"; + const name = escapeTableCell(p.name); + const triggers = escapeTableCell(p.triggers.map(t => t.event).join(", ") || "\u2014"); const jobCount = String(p.jobs.length); - const deploys = uniqueNonEmpty(p.jobs.map(j => j.deployTarget)).join(", ") || "\u2014"; - const envs = p.environments?.join(", ") || "\u2014"; - lines.push(`| ${p.name} | ${triggers} | ${jobCount} | ${deploys} | ${envs} |`); + 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(""); } @@ -50,11 +53,11 @@ export function formatCICD(pipelines: CICDPipeline[]): string { 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, 8)) { // Cap at 8 to avoid noise + for (const a of j.actions.slice(0, MAX_DISPLAYED_ACTIONS)) { lines.push(` - \`${a}\``); } - if (j.actions.length > 8) { - lines.push(` - _...and ${j.actions.length - 8} more_`); + if (j.actions.length > MAX_DISPLAYED_ACTIONS) { + lines.push(` - _...and ${j.actions.length - MAX_DISPLAYED_ACTIONS} more_`); } } } @@ -103,3 +106,8 @@ function systemLabel(system: string): string { 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/index.ts b/src/plugins/cicd/index.ts index 16f5083..e8e2040 100644 --- a/src/plugins/cicd/index.ts +++ b/src/plugins/cicd/index.ts @@ -59,18 +59,21 @@ export function createCICDPlugin(config: CICDPluginConfig = {}): CodesightPlugin } } - // CircleCI — discover from .circleci/ directly + // CircleCI — discover from .circleci/ directly (.yml and .yaml) if (systems.has("circleci")) { - const circleFile = join(project.root, ".circleci", "config.yml"); - 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 + 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 } } } diff --git a/src/plugins/cicd/types.ts b/src/plugins/cicd/types.ts index 68c6971..ed85559 100644 --- a/src/plugins/cicd/types.ts +++ b/src/plugins/cicd/types.ts @@ -3,6 +3,7 @@ export type CICDSystem = "github-actions" | "circleci"; export interface CICDTrigger { event: string; branches?: string[]; + tags?: string[]; paths?: string[]; inputs?: string[]; schedule?: string; diff --git a/src/plugins/cicd/yaml-parser.ts b/src/plugins/cicd/yaml-parser.ts index e9901a2..afd085b 100644 --- a/src/plugins/cicd/yaml-parser.ts +++ b/src/plugins/cicd/yaml-parser.ts @@ -44,7 +44,7 @@ function parseMapping( startIdx: number, baseIndent: number, ): { value: Record; nextIdx: number } { - const obj: Record = {}; + const obj: Record = Object.create(null); let i = startIdx; while (i < lines.length) { @@ -273,9 +273,10 @@ export function parseFlowSequence(s: string): any[] { const inner = s.slice(1, end).trim(); if (!inner) return []; - // Split on commas respecting quotes + // 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]; @@ -285,7 +286,9 @@ export function parseFlowSequence(s: string): any[] { continue; } if (c === '"' || c === "'") { inQuote = c; current += c; continue; } - if (c === ",") { items.push(current.trim()); current = ""; 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()); @@ -306,7 +309,7 @@ function parseScalar(s: string): any { } // 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); + const isLeadingZeroInteger = /^-?0\d+$/.test(s); if (isNumeric && !isLeadingZeroInteger) { const n = Number(s); if (!isNaN(n)) return n; @@ -343,6 +346,7 @@ function findKeyColon(s: string): number { 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; } From 99309cfc5a95d2a521c6d0f4a76d658103578993 Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Wed, 15 Apr 2026 09:00:45 +0100 Subject: [PATCH 6/9] chore: gitignore test fixtures generated by test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests dynamically create fixtures via writeFixture() — no need to track them. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cicd-circleci/.circleci/config.yml | 11 -- tests/fixtures/cicd-circleci/package.json | 1 - .../fixtures/cicd-filter/.circleci/config.yml | 11 -- .../cicd-filter/.github/workflows/ci.yml | 7 -- tests/fixtures/cicd-filter/package.json | 1 - .../.github/workflows/ci.yml | 10 -- .../fixtures/cicd-github-actions/package.json | 1 - tests/fixtures/cicd-none/package.json | 1 - tests/fixtures/cicd-none/src/index.ts | 1 - tests/fixtures/config-app/.env.example | 3 - tests/fixtures/config-app/package.json | 1 - tests/fixtures/config-app/src/config.ts | 2 - tests/fixtures/django-app/requirements.txt | 1 - tests/fixtures/django-app/urls.py | 5 - tests/fixtures/drizzle-schema/package.json | 1 - tests/fixtures/drizzle-schema/src/schema.ts | 12 -- tests/fixtures/elysia-app/package.json | 1 - tests/fixtures/elysia-app/src/index.ts | 4 - tests/fixtures/elysia-detect/package.json | 1 - tests/fixtures/express-app/package.json | 1 - tests/fixtures/express-app/src/routes.ts | 6 - tests/fixtures/fastapi-app/main.py | 8 -- tests/fixtures/fastapi-app/requirements.txt | 2 - tests/fixtures/fastify-app/package.json | 1 - tests/fixtures/fastify-app/src/server.ts | 5 - tests/fixtures/graph-app/package.json | 1 - tests/fixtures/graph-app/src/auth.ts | 2 - tests/fixtures/graph-app/src/db.ts | 1 - tests/fixtures/graph-app/src/middleware.ts | 3 - tests/fixtures/graph-app/src/routes.ts | 3 - tests/fixtures/hono-app/package.json | 1 - tests/fixtures/hono-app/src/index.ts | 6 - tests/fixtures/js-imports/package.json | 1 - tests/fixtures/js-imports/src/main.ts | 2 - tests/fixtures/js-imports/src/utils.ts | 1 - tests/fixtures/middleware-app/package.json | 1 - .../middleware-app/src/middleware/auth.ts | 5 - .../src/middleware/rate-limit.ts | 4 - tests/fixtures/monorepo-detect/package.json | 1 - .../monorepo-detect/packages/api/package.json | 1 - .../monorepo-detect/packages/web/package.json | 1 - tests/fixtures/nestjs-app/package.json | 1 - .../nestjs-app/src/users.controller.ts | 12 -- tests/fixtures/nestjs-detect/package.json | 1 - tests/fixtures/next-app/package.json | 1 - .../next-app/src/app/api/users/route.ts | 6 - tests/fixtures/nuxt-app/package.json | 1 - .../fixtures/nuxt-app/server/api/users.get.ts | 1 - .../nuxt-app/server/api/users.post.ts | 1 - .../nuxt-app/server/api/users/[id].get.ts | 1 - tests/fixtures/nuxt-detect/package.json | 1 - tests/fixtures/prisma-schema/package.json | 1 - .../prisma-schema/prisma/schema.prisma | 12 -- tests/fixtures/raw-http-app/package.json | 1 - tests/fixtures/raw-http-app/src/server.ts | 7 -- tests/fixtures/react-app/package.json | 1 - tests/fixtures/react-app/src/Button.tsx | 3 - tests/fixtures/react-app/src/Card.tsx | 3 - tests/fixtures/react-app/src/ProjectCard.tsx | 3 - tests/fixtures/react-app/src/UserProfile.tsx | 3 - tests/fixtures/remix-app/app/routes/users.tsx | 6 - tests/fixtures/remix-app/package.json | 1 - tests/fixtures/remix-detect/package.json | 1 - tests/fixtures/sveltekit-app/package.json | 1 - .../src/routes/api/users/+server.ts | 6 - tests/fixtures/sveltekit-detect/package.json | 1 - .../fixtures/terraform/edge-cases/complex.tf | 109 ------------------ .../terraform/in-project/src/index.ts | 2 - .../terraform/in-project/terraform/main.tf | 17 --- .../fixtures/terraform/multi-service/auth.tf | 21 ---- .../terraform/multi-service/billing.tf | 26 ----- .../terraform/multi-service/notifications.tf | 17 --- .../simple-ecs-service/app-service.tf | 85 -------------- .../environments/production.tfvars | 5 - .../environments/staging.tfvars | 5 - tests/fixtures/trpc-app/package.json | 1 - tests/fixtures/trpc-app/src/router.ts | 6 - tests/fixtures/trpc-detect/package.json | 1 - 78 files changed, 503 deletions(-) delete mode 100644 tests/fixtures/cicd-circleci/.circleci/config.yml delete mode 100644 tests/fixtures/cicd-circleci/package.json delete mode 100644 tests/fixtures/cicd-filter/.circleci/config.yml delete mode 100644 tests/fixtures/cicd-filter/.github/workflows/ci.yml delete mode 100644 tests/fixtures/cicd-filter/package.json delete mode 100644 tests/fixtures/cicd-github-actions/.github/workflows/ci.yml delete mode 100644 tests/fixtures/cicd-github-actions/package.json delete mode 100644 tests/fixtures/cicd-none/package.json delete mode 100644 tests/fixtures/cicd-none/src/index.ts delete mode 100644 tests/fixtures/config-app/.env.example delete mode 100644 tests/fixtures/config-app/package.json delete mode 100644 tests/fixtures/config-app/src/config.ts delete mode 100644 tests/fixtures/django-app/requirements.txt delete mode 100644 tests/fixtures/django-app/urls.py delete mode 100644 tests/fixtures/drizzle-schema/package.json delete mode 100644 tests/fixtures/drizzle-schema/src/schema.ts delete mode 100644 tests/fixtures/elysia-app/package.json delete mode 100644 tests/fixtures/elysia-app/src/index.ts delete mode 100644 tests/fixtures/elysia-detect/package.json delete mode 100644 tests/fixtures/express-app/package.json delete mode 100644 tests/fixtures/express-app/src/routes.ts delete mode 100644 tests/fixtures/fastapi-app/main.py delete mode 100644 tests/fixtures/fastapi-app/requirements.txt delete mode 100644 tests/fixtures/fastify-app/package.json delete mode 100644 tests/fixtures/fastify-app/src/server.ts delete mode 100644 tests/fixtures/graph-app/package.json delete mode 100644 tests/fixtures/graph-app/src/auth.ts delete mode 100644 tests/fixtures/graph-app/src/db.ts delete mode 100644 tests/fixtures/graph-app/src/middleware.ts delete mode 100644 tests/fixtures/graph-app/src/routes.ts delete mode 100644 tests/fixtures/hono-app/package.json delete mode 100644 tests/fixtures/hono-app/src/index.ts delete mode 100644 tests/fixtures/js-imports/package.json delete mode 100644 tests/fixtures/js-imports/src/main.ts delete mode 100644 tests/fixtures/js-imports/src/utils.ts delete mode 100644 tests/fixtures/middleware-app/package.json delete mode 100644 tests/fixtures/middleware-app/src/middleware/auth.ts delete mode 100644 tests/fixtures/middleware-app/src/middleware/rate-limit.ts delete mode 100644 tests/fixtures/monorepo-detect/package.json delete mode 100644 tests/fixtures/monorepo-detect/packages/api/package.json delete mode 100644 tests/fixtures/monorepo-detect/packages/web/package.json delete mode 100644 tests/fixtures/nestjs-app/package.json delete mode 100644 tests/fixtures/nestjs-app/src/users.controller.ts delete mode 100644 tests/fixtures/nestjs-detect/package.json delete mode 100644 tests/fixtures/next-app/package.json delete mode 100644 tests/fixtures/next-app/src/app/api/users/route.ts delete mode 100644 tests/fixtures/nuxt-app/package.json delete mode 100644 tests/fixtures/nuxt-app/server/api/users.get.ts delete mode 100644 tests/fixtures/nuxt-app/server/api/users.post.ts delete mode 100644 tests/fixtures/nuxt-app/server/api/users/[id].get.ts delete mode 100644 tests/fixtures/nuxt-detect/package.json delete mode 100644 tests/fixtures/prisma-schema/package.json delete mode 100644 tests/fixtures/prisma-schema/prisma/schema.prisma delete mode 100644 tests/fixtures/raw-http-app/package.json delete mode 100644 tests/fixtures/raw-http-app/src/server.ts delete mode 100644 tests/fixtures/react-app/package.json delete mode 100644 tests/fixtures/react-app/src/Button.tsx delete mode 100644 tests/fixtures/react-app/src/Card.tsx delete mode 100644 tests/fixtures/react-app/src/ProjectCard.tsx delete mode 100644 tests/fixtures/react-app/src/UserProfile.tsx delete mode 100644 tests/fixtures/remix-app/app/routes/users.tsx delete mode 100644 tests/fixtures/remix-app/package.json delete mode 100644 tests/fixtures/remix-detect/package.json delete mode 100644 tests/fixtures/sveltekit-app/package.json delete mode 100644 tests/fixtures/sveltekit-app/src/routes/api/users/+server.ts delete mode 100644 tests/fixtures/sveltekit-detect/package.json delete mode 100644 tests/fixtures/terraform/edge-cases/complex.tf delete mode 100644 tests/fixtures/terraform/in-project/src/index.ts delete mode 100644 tests/fixtures/terraform/in-project/terraform/main.tf delete mode 100644 tests/fixtures/terraform/multi-service/auth.tf delete mode 100644 tests/fixtures/terraform/multi-service/billing.tf delete mode 100644 tests/fixtures/terraform/multi-service/notifications.tf delete mode 100644 tests/fixtures/terraform/simple-ecs-service/app-service.tf delete mode 100644 tests/fixtures/terraform/simple-ecs-service/environments/production.tfvars delete mode 100644 tests/fixtures/terraform/simple-ecs-service/environments/staging.tfvars delete mode 100644 tests/fixtures/trpc-app/package.json delete mode 100644 tests/fixtures/trpc-app/src/router.ts delete mode 100644 tests/fixtures/trpc-detect/package.json diff --git a/tests/fixtures/cicd-circleci/.circleci/config.yml b/tests/fixtures/cicd-circleci/.circleci/config.yml deleted file mode 100644 index 0163303..0000000 --- a/tests/fixtures/cicd-circleci/.circleci/config.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2.1 -jobs: - test: - docker: - - image: cimg/node:18.0 - steps: - - checkout -workflows: - ci: - jobs: - - test \ No newline at end of file diff --git a/tests/fixtures/cicd-circleci/package.json b/tests/fixtures/cicd-circleci/package.json deleted file mode 100644 index f69b929..0000000 --- a/tests/fixtures/cicd-circleci/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test-cci"} \ No newline at end of file diff --git a/tests/fixtures/cicd-filter/.circleci/config.yml b/tests/fixtures/cicd-filter/.circleci/config.yml deleted file mode 100644 index 47aef26..0000000 --- a/tests/fixtures/cicd-filter/.circleci/config.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2.1 -jobs: - test: - docker: - - image: node:18 - steps: - - checkout -workflows: - ci: - jobs: - - test \ No newline at end of file diff --git a/tests/fixtures/cicd-filter/.github/workflows/ci.yml b/tests/fixtures/cicd-filter/.github/workflows/ci.yml deleted file mode 100644 index 81405fc..0000000 --- a/tests/fixtures/cicd-filter/.github/workflows/ci.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: CI -on: [push] -jobs: - test: - runs-on: ubuntu-latest - steps: - - run: echo test \ No newline at end of file diff --git a/tests/fixtures/cicd-filter/package.json b/tests/fixtures/cicd-filter/package.json deleted file mode 100644 index 4af659f..0000000 --- a/tests/fixtures/cicd-filter/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test-filter"} \ No newline at end of file diff --git a/tests/fixtures/cicd-github-actions/.github/workflows/ci.yml b/tests/fixtures/cicd-github-actions/.github/workflows/ci.yml deleted file mode 100644 index fe39f90..0000000 --- a/tests/fixtures/cicd-github-actions/.github/workflows/ci.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: CI -on: - push: - branches: [main] -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: npm test \ No newline at end of file diff --git a/tests/fixtures/cicd-github-actions/package.json b/tests/fixtures/cicd-github-actions/package.json deleted file mode 100644 index 869a033..0000000 --- a/tests/fixtures/cicd-github-actions/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test-gha"} \ No newline at end of file diff --git a/tests/fixtures/cicd-none/package.json b/tests/fixtures/cicd-none/package.json deleted file mode 100644 index 931ce19..0000000 --- a/tests/fixtures/cicd-none/package.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"test-none"} \ No newline at end of file diff --git a/tests/fixtures/cicd-none/src/index.ts b/tests/fixtures/cicd-none/src/index.ts deleted file mode 100644 index ea17b22..0000000 --- a/tests/fixtures/cicd-none/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log('hello'); \ No newline at end of file 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 deleted file mode 100644 index ca2f29f..0000000 --- a/tests/fixtures/terraform/edge-cases/complex.tf +++ /dev/null @@ -1,109 +0,0 @@ -# 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 From 7563c118d793dac17c4604d5cd93d3c51271aab9 Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Thu, 23 Apr 2026 16:51:54 +0100 Subject: [PATCH 7/9] fix: resolve post-rebase TS errors and restore terraform fixtures - Remove duplicate scan() from index.ts (now lives in core.ts since upstream refactor) - Port customSections plugin API into core.ts scan() to preserve the feature - Narrow .gitignore pattern from tests/fixtures/ to only ignore generated .codesight/ output - Restore terraform fixture files deleted by the overly-broad gitignore commit Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +- src/core.ts | 3 + src/index.ts | 172 ------------------ .../fixtures/terraform/edge-cases/complex.tf | 109 +++++++++++ .../terraform/in-project/src/index.ts | 2 + .../terraform/in-project/terraform/main.tf | 17 ++ .../fixtures/terraform/multi-service/auth.tf | 21 +++ .../terraform/multi-service/billing.tf | 26 +++ .../terraform/multi-service/notifications.tf | 17 ++ .../simple-ecs-service/app-service.tf | 85 +++++++++ .../environments/production.tfvars | 5 + .../environments/staging.tfvars | 5 + 12 files changed, 292 insertions(+), 173 deletions(-) create mode 100644 tests/fixtures/terraform/edge-cases/complex.tf create mode 100644 tests/fixtures/terraform/in-project/src/index.ts create mode 100644 tests/fixtures/terraform/in-project/terraform/main.tf create mode 100644 tests/fixtures/terraform/multi-service/auth.tf create mode 100644 tests/fixtures/terraform/multi-service/billing.tf create mode 100644 tests/fixtures/terraform/multi-service/notifications.tf create mode 100644 tests/fixtures/terraform/simple-ecs-service/app-service.tf create mode 100644 tests/fixtures/terraform/simple-ecs-service/environments/production.tfvars create mode 100644 tests/fixtures/terraform/simple-ecs-service/environments/staging.tfvars 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/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/index.ts b/src/index.ts index b0fecd5..bc5d2e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,178 +76,6 @@ async function fileExists(path: string): Promise { } } -async function scan(root: string, outputDirName: string, maxDepth: number, userConfig: CodesightConfig = {}): Promise { - const outputDir = join(root, outputDirName); - - console.log(`\n ${BRAND} v${VERSION}`); - console.log(` Scanning: ${root}\n`); - - const startTime = Date.now(); - - // Step 1: Detect project - process.stdout.write(" Detecting project..."); - const project = await detectProject(root); - console.log( - ` ${project.frameworks.length > 0 ? project.frameworks.join(", ") : "generic"} | ${project.orms.length > 0 ? project.orms.join(", ") : "no ORM"} | ${project.language}` - ); - - if (project.isMonorepo) { - console.log(` Monorepo: ${project.workspaces.map((w) => w.name).join(", ")}`); - } - - // Step 2: Collect files — merge .codesightignore + config ignorePatterns - process.stdout.write(" Collecting files..."); - const ignoreFromFile = await readCodesightIgnore(root); - const allIgnorePatterns = [...(userConfig.ignorePatterns ?? []), ...ignoreFromFile]; - const files = await collectFiles(root, maxDepth, allIgnorePatterns); - console.log(` ${files.length} files`); - - // Step 3: Run all detectors in parallel (respecting disableDetectors config) - process.stdout.write(" Analyzing..."); - - const disabled = new Set(userConfig.disableDetectors || []); - - const [rawHttpRoutes, schemas, components, libs, configResult, middleware, graph, - graphqlRoutes, grpcRoutes, wsRoutes, events, openapi] = - await Promise.all([ - disabled.has("routes") ? Promise.resolve([]) : detectRoutes(files, project, userConfig), - disabled.has("schema") ? Promise.resolve([]) : detectSchemas(files, project), - disabled.has("components") ? Promise.resolve([]) : detectComponents(files, project), - disabled.has("libs") ? Promise.resolve([]) : detectLibs(files, project), - disabled.has("config") ? Promise.resolve({ envVars: [], configFiles: [], dependencies: {}, devDependencies: {} }) : detectConfig(files, project), - disabled.has("middleware") ? Promise.resolve([]) : detectMiddleware(files, project), - disabled.has("graph") ? Promise.resolve({ edges: [], hotFiles: [] }) : detectDependencyGraph(files, project), - disabled.has("graphql") ? Promise.resolve([]) : detectGraphQLRoutes(files, project), - disabled.has("graphql") ? Promise.resolve([]) : detectGRPCRoutes(files, project), - disabled.has("graphql") ? Promise.resolve([]) : detectWebSocketRoutes(files, project), - disabled.has("events") ? Promise.resolve([]) : detectEvents(files, project), - detectOpenAPISpec(root, project), - ]); - - // Merge OpenAPI routes and schemas if spec found - const rawRoutes = [...rawHttpRoutes, ...graphqlRoutes, ...grpcRoutes, ...wsRoutes]; - if (openapi.routes.length > 0) { - // Only use OpenAPI routes if we got very few from code detection - if (rawRoutes.length === 0) { - rawRoutes.push(...openapi.routes); - } - // Add any OpenAPI schemas not already detected - const existingModelNames = new Set(schemas.map((m) => m.name.toLowerCase())); - for (const m of openapi.schemas) { - if (!existingModelNames.has(m.name.toLowerCase())) schemas.push(m); - } - } - - // Step 3b: Run plugin detectors - // NOTE: if two plugins emit sections with the same name, the last writer wins on disk - // but both appear in CODESIGHT.md. Guard with unique names per plugin for now. - const customSections: { name: string; content: string }[] = []; - if (userConfig.plugins) { - for (const plugin of userConfig.plugins) { - if (plugin.detector) { - try { - const pluginResult = await plugin.detector(files, project); - if (pluginResult.routes) rawRoutes.push(...pluginResult.routes); - 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) { - console.warn(`\n Warning: plugin "${plugin.name}" failed: ${err.message}`); - } - } - } - } - - // Step 4: Enrich routes with contract info - const routes = await enrichRouteContracts(rawRoutes, project); - - // Step 4b: Test coverage detection - const testCoverage = await detectTestCoverage(files, routes, schemas, root); - - // Step 4c: Compute CRUD groups - const crudGroups = computeCrudGroups(routes); - - // Report AST vs regex detection - const astRoutes = routes.filter((r) => r.confidence === "ast").length; - const astSchemas = schemas.filter((s) => s.confidence === "ast").length; - const astComponents = components.filter((c) => c.confidence === "ast").length; - const totalAST = astRoutes + astSchemas + astComponents; - - const specialCounts: string[] = []; - const gqlCount = routes.filter((r) => ["QUERY", "MUTATION", "SUBSCRIPTION"].includes(r.method)).length; - const grpcCount = routes.filter((r) => r.method === "RPC").length; - const wsCount = routes.filter((r) => r.method === "WS" || r.method === "WS-ROOM").length; - if (gqlCount > 0) specialCounts.push(`${gqlCount} graphql`); - if (grpcCount > 0) specialCounts.push(`${grpcCount} rpc`); - if (wsCount > 0) specialCounts.push(`${wsCount} ws`); - if (events.length > 0) specialCounts.push(`${events.length} events`); - - const specialStr = specialCounts.length > 0 ? `, ${specialCounts.join(", ")}` : ""; - if (totalAST > 0) { - console.log(` done (AST: ${astRoutes} routes, ${astSchemas} models, ${astComponents} components${specialStr})`); - } else if (specialCounts.length > 0) { - console.log(` done (${specialCounts.join(", ")})`); - } else { - console.log(" done"); - } - - // Step 5: Write output - process.stdout.write(" Writing output..."); - - // Temporary result without token stats to generate output - const tempResult: ScanResult = { - project, - routes, - schemas, - components, - libs, - config: configResult, - middleware, - graph, - tokenStats: { outputTokens: 0, estimatedExplorationTokens: 0, saved: 0, fileCount: files.length }, - 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); - - // Step 6: Calculate real token stats - const tokenStats = calculateTokenStats(tempResult, outputContent, files.length); - const result: ScanResult = { ...tempResult, tokenStats }; - - // Re-write with accurate token stats - await writeOutput(result, outputDir); - - console.log(` ${outputDirName}/`); - - const elapsed = Date.now() - startTime; - - // Stats - console.log(` - Results: - Routes: ${routes.length} - Models: ${schemas.length} - Components: ${components.length} - Libraries: ${libs.length} - Env vars: ${configResult.envVars.length} - Middleware: ${middleware.length} - Import links: ${graph.edges.length} - Hot files: ${graph.hotFiles.length} - - Tokens: - Output size: ~${tokenStats.outputTokens.toLocaleString()} tokens - Exploration cost: ~${tokenStats.estimatedExplorationTokens.toLocaleString()} tokens - Saved: ~${tokenStats.saved.toLocaleString()} tokens per conversation - - Done in ${elapsed}ms -`); - - return result; -} - async function installGitHook(root: string, outputDirName: string) { const hooksDir = join(root, ".git", "hooks"); const hookPath = join(hooksDir, "pre-commit"); 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 = < Date: Sat, 25 Apr 2026 11:47:02 +0100 Subject: [PATCH 8/9] feat: add skills and githooks plugins for agent context Skills plugin scans .claude/commands and .claude/skills for project-local slash commands, surfacing name + description so agents can discover and use available tools before reaching for generic solutions. Git hooks plugin detects lefthook, husky, and raw git hooks and surfaces which lifecycle triggers what command, with an explicit agent warning that hook failures block the operation. Managed tool hooks suppress the underlying raw hook to avoid duplication. Co-Authored-By: Claude Sonnet 4.6 --- src/plugins/githooks/formatter.ts | 38 ++++++++ src/plugins/githooks/husky.ts | 34 +++++++ src/plugins/githooks/index.ts | 35 +++++++ src/plugins/githooks/lefthook.ts | 95 ++++++++++++++++++ src/plugins/githooks/raw.ts | 39 ++++++++ src/plugins/githooks/types.ts | 13 +++ src/plugins/skills/formatter.ts | 17 ++++ src/plugins/skills/index.ts | 70 ++++++++++++++ tests/githooks-plugin.test.ts | 155 ++++++++++++++++++++++++++++++ tests/skills-plugin.test.ts | 98 +++++++++++++++++++ 10 files changed, 594 insertions(+) create mode 100644 src/plugins/githooks/formatter.ts create mode 100644 src/plugins/githooks/husky.ts create mode 100644 src/plugins/githooks/index.ts create mode 100644 src/plugins/githooks/lefthook.ts create mode 100644 src/plugins/githooks/raw.ts create mode 100644 src/plugins/githooks/types.ts create mode 100644 src/plugins/skills/formatter.ts create mode 100644 src/plugins/skills/index.ts create mode 100644 tests/githooks-plugin.test.ts create mode 100644 tests/skills-plugin.test.ts 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/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/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); + } + }); +}); From 7bc8ff3efd1053b4bf852274e5e6758ef6cef07c Mon Sep 17 00:00:00 2001 From: Neil Chambers Date: Sat, 25 Apr 2026 13:03:52 +0100 Subject: [PATCH 9/9] fix: remove sibling-repo discovery from terraform plugin The ../infrastructure sibling scan was specific to a particular monorepo layout. The general case is terraform co-located in the project; users with a separate infra repo should set infraPath explicitly. Co-Authored-By: Claude Sonnet 4.6 --- src/plugins/terraform/file-collector.ts | 14 +++----------- src/plugins/terraform/index.ts | 12 ++++++------ src/plugins/terraform/types.ts | 4 ++-- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/plugins/terraform/file-collector.ts b/src/plugins/terraform/file-collector.ts index 4241c5a..4c12915 100644 --- a/src/plugins/terraform/file-collector.ts +++ b/src/plugins/terraform/file-collector.ts @@ -1,5 +1,5 @@ import { readdir, readFile, stat } from "node:fs/promises"; -import { join, dirname, resolve, extname } from "node:path"; +import { join, resolve, extname } from "node:path"; import type { TerraformPluginConfig } from "./types.js"; const SKIP_DIRS = new Set([".terraform", ".git", "node_modules", ".terragrunt-cache"]); @@ -12,7 +12,7 @@ export interface CollectedFiles { /** * Collect .tf and .tfvars files from the best-matching infrastructure location. - * Tries: explicit config path → in-project subdirs → sibling repos → project root. + * Tries: explicit config path → in-project subdirs (terraform/, infra/, etc.) → project root. */ export async function collectTfFiles( projectRoot: string, @@ -32,15 +32,7 @@ export async function collectTfFiles( if (files.tfFiles.length > 0) return files; } - // 3. Sibling infrastructure repo - const parent = dirname(projectRoot); - for (const sibling of ["infrastructure", "infra", "terraform", "deploy"]) { - const candidate = join(parent, sibling); - const files = await scanDirForTf(candidate); - if (files.tfFiles.length > 0) return files; - } - - // 4. .tf files at project root + // 3. .tf files at project root const rootFiles = await scanDirForTf(projectRoot, 1); if (rootFiles.tfFiles.length > 0) return rootFiles; diff --git a/src/plugins/terraform/index.ts b/src/plugins/terraform/index.ts index ce26eec..db1cf93 100644 --- a/src/plugins/terraform/index.ts +++ b/src/plugins/terraform/index.ts @@ -12,19 +12,19 @@ export type { TerraformPluginConfig } from "./types.js"; /** * Create a Terraform infrastructure plugin for codesight. * - * Scans .tf files — either co-located in the project or in a separate - * infrastructure repo — and generates an infrastructure section with - * deployment context for AI agents. + * 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 infrastructure + * // Auto-discover co-located terraform * createTerraformPlugin() * * @example - * // Explicit centralised infra repo + * // Explicit separate infra repo * createTerraformPlugin({ * infraPath: '../infrastructure', - * serviceName: 'query-service', + * serviceName: 'my-service', * }) */ export function createTerraformPlugin(config: TerraformPluginConfig = {}): CodesightPlugin { diff --git a/src/plugins/terraform/types.ts b/src/plugins/terraform/types.ts index f8efffa..99bbb4a 100644 --- a/src/plugins/terraform/types.ts +++ b/src/plugins/terraform/types.ts @@ -1,7 +1,7 @@ /** User-facing configuration for the Terraform infrastructure plugin */ export interface TerraformPluginConfig { - /** Path to infra repo — absolute or relative to project root. - * Default: auto-discovers ../infrastructure, ./terraform, ./infra, ./deploy */ + /** 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;