diff --git a/.claude/agent-memory/archgate-developer/project_test_isolation_gotchas.md b/.claude/agent-memory/archgate-developer/project_test_isolation_gotchas.md index 35da6b87..e63f43e7 100644 --- a/.claude/agent-memory/archgate-developer/project_test_isolation_gotchas.md +++ b/.claude/agent-memory/archgate-developer/project_test_isolation_gotchas.md @@ -12,3 +12,4 @@ metadata: - **`bun:sqlite` file handles persist after `db.close()` on Windows.** `rmSync` on the temp dir in `afterEach` can throw `EBUSY`. Set `PRAGMA journal_mode = DELETE` to avoid WAL/SHM files, and wrap `rmSync` in try/catch. - **macOS `/var` → `/private/var` symlink breaks temp dir path comparisons.** `mkdtempSync` returns `/var/folders/...` but `process.cwd()` after `chdir()` resolves to `/private/var/...`. Always wrap with `realpathSync`. Invisible on ubuntu-only PR CI — only surfaces in release builds. - **Don't test that well-known tools exist on PATH** (e.g. `expect(resolveCommand("bun")).toBe("bun")`) — asserts CI environment state, not logic, and fails when tools are installed via shims. Delete such tests; the null-return and `.exe`-fallback tests already cover the real logic. +- **Attach `.catch()` at promise creation, not at `await`.** When firing an async call in parallel (`const p = asyncFn()`) and awaiting later, Bun reports an unhandled rejection in the window between creation and the later `await p.catch(...)`. Fix: `const p = asyncFn().catch(() => null)` then `await p`. diff --git a/src/commands/check.ts b/src/commands/check.ts index e5eff54f..bedb1fab 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -18,6 +18,7 @@ import { logError } from "../helpers/log"; import { formatJSON, isAgentContext } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; import { getConfiguredBaseBranch } from "../helpers/project-config"; +import { detectStack } from "../helpers/stack-detect"; import { trackCheckResult } from "../helpers/telemetry"; const maxWarningsOption = new Option( @@ -60,6 +61,14 @@ export function registerCheckCommand(program: Command) { return; } + // Run stack detection in parallel with rule loading — both are fast I/O + // and independent. Stack info enriches the telemetry event at the end. + // Bounded with a timeout so pathological projects can't stall the exit. + const stackPromise = Promise.race([ + detectStack(projectRoot), + Bun.sleep(500).then(() => null), + ]).catch(() => null); + let loadResults; const loadStart = performance.now(); try { @@ -146,6 +155,9 @@ export function registerCheckCommand(program: Command) { reportConsole(result, opts.verbose ?? false, summary); } + // Await stack detection (started in parallel with rule loading above). + const stack = await stackPromise; + // Track aggregate check results (no file paths or violation content) trackCheckResult({ total_rules: summary.total, @@ -164,6 +176,9 @@ export function registerCheckCommand(program: Command) { files_scanned: filterFiles.length, load_duration_ms: loadDurationMs, check_duration_ms: Math.round(result.totalDurationMs), + languages: stack?.languages, + runtimes: stack?.runtimes, + frameworks: stack?.frameworks, }); const exitCode = getExitCode(result, summary); diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index 7f9cbead..dfb0e8bd 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -1,9 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; +import { z } from "zod"; + import { logDebug } from "./log"; +import { internalPath } from "./paths"; export interface DetectedStack { languages: string[]; @@ -11,33 +15,225 @@ export interface DetectedStack { frameworks: string[]; } +const DetectedStackSchema = z.object({ + languages: z.array(z.string()), + runtimes: z.array(z.string()), + frameworks: z.array(z.string()), +}); + +const StackCacheSchema = z.object({ + fingerprint: z.string(), + stack: DetectedStackSchema, +}); + +/** Loose schema for the subset of package.json we inspect. */ +const PackageJsonSchema = z.object({ + dependencies: z.record(z.string(), z.string()).optional(), + devDependencies: z.record(z.string(), z.string()).optional(), +}); + +/** PEP 621 pyproject.toml — only the [project].dependencies list. */ +const PyprojectSchema = z.object({ + project: z + .object({ dependencies: z.array(z.string()).optional() }) + .optional(), +}); + +/** Config file extensions commonly used by JS/TS frameworks. */ +const JS_CONFIG_EXTENSIONS = ["js", "cjs", "mjs", "ts", "mts", "cts"]; + +/** Check whether any of `.` exists in `dir`. */ +function hasConfig(dir: string, basename: string, exts: string[]): boolean { + return exts.some((ext) => existsSync(join(dir, `${basename}.${ext}`))); +} + +// --------------------------------------------------------------------------- +// Sentinel-based disk cache +// --------------------------------------------------------------------------- + +/** + * Framework config basenames, the extensions hasConfig() scans, and the + * framework name to push. Single source of truth for both detection and + * sentinel generation — adding a framework here covers both. + */ +const FRAMEWORK_CONFIGS: Array< + [name: string, basename: string, exts: string[]] +> = [ + ["nextjs", "next.config", [...JS_CONFIG_EXTENSIONS, "json"]], + ["remix", "remix.config", JS_CONFIG_EXTENSIONS], + ["vite", "vite.config", JS_CONFIG_EXTENSIONS], + ["nuxt", "nuxt.config", JS_CONFIG_EXTENSIONS], + ["astro", "astro.config", JS_CONFIG_EXTENSIONS], + ["svelte", "svelte.config", ["js", "cjs", "mjs"]], + ["tailwindcss", "tailwind.config", [...JS_CONFIG_EXTENSIONS, "json"]], +]; + +/** + * Files whose presence/absence or modification signals a stack change. + * Generated from the same constants that detectStackUncached() uses so + * sentinels and detection never drift apart. + */ +const SENTINEL_FILES: string[] = [ + // Languages + "package.json", + "tsconfig.json", + "pyproject.toml", + "requirements.txt", + "setup.py", + "go.mod", + "Cargo.toml", + "Gemfile", + ".ruby-version", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "composer.json", + "Package.swift", + "mix.exs", + "pubspec.yaml", + "global.json", + "Directory.Build.props", + "build.sbt", + "build.zig", + // Runtimes + "bun.lock", + "bunfig.toml", + "deno.json", + "deno.jsonc", + // Frameworks — config files (generated from FRAMEWORK_CONFIGS) + ...FRAMEWORK_CONFIGS.flatMap(([, base, exts]) => + exts.map((ext) => `${base}.${ext}`) + ), + // Frameworks — non-JS markers + "manage.py", + "artisan", + "bin/rails", + "config/routes.rb", +]; + +type StackCache = z.infer; + +/** + * Build a fingerprint from sentinel file stats. Two projects with identical + * sentinel states produce the same fingerprint. This is fast — ~20 stat + * syscalls (~1ms total) vs the full detection scan (~10ms). + */ +function buildFingerprint(projectRoot: string): string { + const parts: string[] = []; + for (const file of SENTINEL_FILES) { + try { + const st = statSync(join(projectRoot, file)); + parts.push(`${file}:${st.mtimeMs}`); + } catch { + parts.push(`${file}:-`); + } + } + return createHash("sha256") + .update(parts.join("|")) + .digest("hex") + .slice(0, 16); +} + +/** Stable short hash of the project root path for cache filename. */ +function projectHash(projectRoot: string): string { + return createHash("sha256").update(projectRoot).digest("hex").slice(0, 12); +} + +function getCachePath(projectRoot: string): string { + return internalPath("cache", `stack-${projectHash(projectRoot)}.json`); +} + +async function readCache(projectRoot: string): Promise { + const cachePath = getCachePath(projectRoot); + if (!existsSync(cachePath)) return null; + try { + const raw = await Bun.file(cachePath).json(); + const result = StackCacheSchema.safeParse(raw); + return result.success ? result.data : null; + } catch { + return null; + } +} + +async function writeCache( + projectRoot: string, + fingerprint: string, + stack: DetectedStack +): Promise { + const cachePath = getCachePath(projectRoot); + try { + const data: StackCache = { fingerprint, stack }; + // Bun.write() auto-creates parent directories since Bun v1.0.16 + await Bun.write(cachePath, JSON.stringify(data)); + logDebug("Stack cache written:", cachePath); + } catch { + logDebug("Failed to write stack cache (ignored)"); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + /** * Detect the project's language, runtime, and framework from files present - * in the project root. Used by the greenfield wizard to recommend packs. + * in the project root. Results are cached to disk and invalidated when any + * sentinel file (package.json, Gemfile, go.mod, etc.) is added, removed, or + * modified. Typical cached lookup: ~1ms; full detection: ~5-10ms. */ export async function detectStack(projectRoot: string): Promise { + const fingerprint = buildFingerprint(projectRoot); + + // Check disk cache + const cached = await readCache(projectRoot); + if (cached && cached.fingerprint === fingerprint) { + logDebug("Stack cache hit for", projectRoot); + return cached.stack; + } + + logDebug("Stack cache miss — running full detection"); + const stack = await detectStackUncached(projectRoot); + + // Write cache in the background — don't block the caller + writeCache(projectRoot, fingerprint, stack).catch(() => {}); + + return stack; +} + +/** + * Run the full stack detection without caching. Exported for testing and + * for callers that need a fresh read (e.g. after `archgate init` modifies + * the project). + */ +export async function detectStackUncached( + projectRoot: string +): Promise { const languages: string[] = []; const runtimes: string[] = []; const frameworks: string[] = []; const pkgJsonPath = join(projectRoot, "package.json"); const hasPkgJson = existsSync(pkgJsonPath); - let pkgJson: Record | null = null; + let pkgJson: z.infer | null = null; if (hasPkgJson) { try { - pkgJson = (await Bun.file(pkgJsonPath).json()) as Record; + const raw = await Bun.file(pkgJsonPath).json(); + const result = PackageJsonSchema.safeParse(raw); + pkgJson = result.success ? result.data : null; } catch { logDebug("Failed to parse package.json"); } } - const deps = { - ...(pkgJson?.dependencies as Record | undefined), - ...(pkgJson?.devDependencies as Record | undefined), + const deps: Record = { + ...pkgJson?.dependencies, + ...pkgJson?.devDependencies, }; // --- Languages --- + + // TypeScript / JavaScript (mutually exclusive — TS wins when both present) const hasTsConfig = existsSync(join(projectRoot, "tsconfig.json")); const hasTsDep = Boolean(deps.typescript); if (hasTsConfig || hasTsDep) { @@ -62,7 +258,54 @@ export async function detectStack(projectRoot: string): Promise { languages.push("rust"); } + if ( + existsSync(join(projectRoot, "Gemfile")) || + existsSync(join(projectRoot, ".ruby-version")) + ) { + languages.push("ruby"); + } + + if ( + existsSync(join(projectRoot, "pom.xml")) || + existsSync(join(projectRoot, "build.gradle")) || + existsSync(join(projectRoot, "build.gradle.kts")) + ) { + languages.push("java"); + } + + if (existsSync(join(projectRoot, "composer.json"))) { + languages.push("php"); + } + + if (existsSync(join(projectRoot, "Package.swift"))) { + languages.push("swift"); + } + + if (existsSync(join(projectRoot, "mix.exs"))) { + languages.push("elixir"); + } + + if (existsSync(join(projectRoot, "pubspec.yaml"))) { + languages.push("dart"); + } + + if ( + existsSync(join(projectRoot, "global.json")) || + existsSync(join(projectRoot, "Directory.Build.props")) + ) { + languages.push("csharp"); + } + + if (existsSync(join(projectRoot, "build.sbt"))) { + languages.push("scala"); + } + + if (existsSync(join(projectRoot, "build.zig"))) { + languages.push("zig"); + } + // --- Runtimes --- + if (hasPkgJson) { runtimes.push("node"); } @@ -82,45 +325,166 @@ export async function detectStack(projectRoot: string): Promise { } // --- Frameworks --- - // Next.js: next.config.* (any extension) - const nextConfigExtensions = ["js", "cjs", "mjs", "ts", "mts", "cts", "json"]; - if ( - nextConfigExtensions.some((ext) => - existsSync(join(projectRoot, `next.config.${ext}`)) - ) - ) { - frameworks.push("nextjs"); + + // JS/TS config-file-based detection (driven by FRAMEWORK_CONFIGS) + for (const [name, basename, exts] of FRAMEWORK_CONFIGS) { + if (hasConfig(projectRoot, basename, exts)) { + frameworks.push(name); + } } - // Remix: remix.config.* - const remixConfigExtensions = ["js", "cjs", "mjs", "ts"]; + // JS/TS dependency-based detection — UI frameworks & libraries + if (deps.react) frameworks.push("react"); + if (deps.vue) frameworks.push("vue"); + if (deps["solid-js"]) frameworks.push("solid"); + if (deps["@angular/core"]) frameworks.push("angular"); + if (deps["ember-source"]) frameworks.push("ember"); + if (deps["@mui/material"]) frameworks.push("mui"); + if (deps["@tanstack/react-query"] || deps["@tanstack/vue-query"]) + frameworks.push("tanstack-query"); + if (deps["@tanstack/react-router"] || deps["@tanstack/router"]) + frameworks.push("tanstack-router"); + if (deps["@tanstack/start"]) frameworks.push("tanstack-start"); + if (deps["@tanstack/react-form"] || deps["@tanstack/form"]) + frameworks.push("tanstack-form"); + if (deps["@tanstack/react-table"] || deps["@tanstack/table"]) + frameworks.push("tanstack-table"); + if (deps["@chakra-ui/react"]) frameworks.push("chakra-ui"); + if (deps["@shadcn/ui"] || deps["shadcn-ui"]) frameworks.push("shadcn"); + if (deps["@headlessui/react"] || deps["@headlessui/vue"]) + frameworks.push("headless-ui"); + if (deps["@radix-ui/react-slot"] || deps["@radix-ui/themes"]) + frameworks.push("radix"); + + // JS/TS dependency-based detection — server frameworks + if (deps.express) frameworks.push("express"); + if (deps.fastify) frameworks.push("fastify"); + if (deps.hono) frameworks.push("hono"); + if (deps.koa) frameworks.push("koa"); + if (deps.elysia) frameworks.push("elysia"); + if (deps["@nestjs/core"]) frameworks.push("nestjs"); + if (deps.gatsby) frameworks.push("gatsby"); + if (deps["@trpc/server"]) frameworks.push("trpc"); + if (deps.prisma || deps["@prisma/client"]) frameworks.push("prisma"); + if (deps.drizzle || deps["drizzle-orm"]) frameworks.push("drizzle"); + + // JS/TS dependency-based detection — testing & tooling + if (deps.jest || deps["@jest/core"]) frameworks.push("jest"); + if (deps.vitest) frameworks.push("vitest"); + if (deps.playwright || deps["@playwright/test"]) + frameworks.push("playwright"); + if (deps.cypress) frameworks.push("cypress"); + if (deps.storybook || deps["@storybook/react"]) frameworks.push("storybook"); + + // Ruby — Rails detection via conventional directory structure if ( - remixConfigExtensions.some((ext) => - existsSync(join(projectRoot, `remix.config.${ext}`)) - ) + existsSync(join(projectRoot, "bin", "rails")) || + existsSync(join(projectRoot, "config", "routes.rb")) ) { - frameworks.push("remix"); + frameworks.push("rails"); } - // Vite: vite.config.* - const viteConfigExtensions = ["js", "cjs", "mjs", "ts", "mts", "cts"]; - if ( - viteConfigExtensions.some((ext) => - existsSync(join(projectRoot, `vite.config.${ext}`)) - ) - ) { - frameworks.push("vite"); + // Python — framework detection via manage.py / pyproject.toml deps + if (existsSync(join(projectRoot, "manage.py"))) { + frameworks.push("django"); } - // Fastify, Express, Hono from package.json dependencies - if (deps.fastify) frameworks.push("fastify"); - if (deps.express) frameworks.push("express"); - if (deps.hono) frameworks.push("hono"); + // Python — FastAPI / Streamlit / Flask from pyproject.toml or requirements.txt + const pyDeps = await readPythonDeps(projectRoot); + if (pyDeps.has("fastapi")) frameworks.push("fastapi"); + if (pyDeps.has("streamlit")) frameworks.push("streamlit"); + if (pyDeps.has("flask")) frameworks.push("flask"); - // React from package.json dependencies - if (deps.react) frameworks.push("react"); + // PHP — Laravel detection via artisan + if (existsSync(join(projectRoot, "artisan"))) { + frameworks.push("laravel"); + } + + // Dart — Flutter detection via pubspec.yaml flutter dependency + if (existsSync(join(projectRoot, "pubspec.yaml"))) { + try { + const pubspec = await Bun.file(join(projectRoot, "pubspec.yaml")).text(); + if (/^\s*flutter:/mu.test(pubspec)) { + frameworks.push("flutter"); + } + } catch { + logDebug("Failed to read pubspec.yaml"); + } + } + + // Elixir — Phoenix detection via mix.exs phoenix dependency + if (existsSync(join(projectRoot, "mix.exs"))) { + try { + const mixContent = await Bun.file(join(projectRoot, "mix.exs")).text(); + if (/:phoenix\b/u.test(mixContent)) { + frameworks.push("phoenix"); + } + } catch { + logDebug("Failed to read mix.exs"); + } + } logDebug("Detected stack:", { languages, runtimes, frameworks }); return { languages, runtimes, frameworks }; } + +// --------------------------------------------------------------------------- +// Python dependency helpers +// --------------------------------------------------------------------------- + +/** Normalize a Python package name: lowercase, underscores → hyphens. */ +function normalizePyName(name: string): string { + return name.toLowerCase().replaceAll("_", "-"); +} + +/** Extract the bare package name before any version specifier or extras. */ +function extractPyPackageName(spec: string): string | null { + const match = /^([a-z0-9][a-z0-9._-]*)/iu.exec(spec.trim()); + return match ? normalizePyName(match[1]) : null; +} + +/** + * Best-effort extraction of Python dependency names from pyproject.toml + * (via Bun's built-in TOML parser) and requirements.txt. + * Returns a Set of lowercase, hyphen-normalized package names. + */ +async function readPythonDeps(projectRoot: string): Promise> { + const deps = new Set(); + + // requirements.txt — one package per line, format: `name[==version]` + const reqPath = join(projectRoot, "requirements.txt"); + if (existsSync(reqPath)) { + try { + const text = await Bun.file(reqPath).text(); + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) + continue; + const name = extractPyPackageName(trimmed); + if (name) deps.add(name); + } + } catch { + logDebug("Failed to read requirements.txt"); + } + } + + // pyproject.toml — use Bun's built-in TOML parser for reliable extraction + const pyprojectPath = join(projectRoot, "pyproject.toml"); + if (existsSync(pyprojectPath)) { + try { + const toml = await Bun.file(pyprojectPath).text(); + const result = PyprojectSchema.safeParse(Bun.TOML.parse(toml)); + if (result.success) { + for (const spec of result.data.project?.dependencies ?? []) { + const name = extractPyPackageName(spec); + if (name) deps.add(name); + } + } + } catch { + logDebug("Failed to parse pyproject.toml"); + } + } + + return deps; +} diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index 2483f2a4..6b395a5f 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -334,6 +334,12 @@ export function trackCheckResult(properties: { files_scanned?: number; load_duration_ms?: number; check_duration_ms?: number; + /** Detected project languages (e.g. "typescript", "python", "go") */ + languages?: string[]; + /** Detected project runtimes (e.g. "node", "bun", "deno") */ + runtimes?: string[]; + /** Detected project frameworks (e.g. "nextjs", "react", "express") */ + frameworks?: string[]; }): void { trackEvent("check_completed", properties); } diff --git a/tests/commands/check-action.test.ts b/tests/commands/check-action.test.ts index b38f63ff..2ea9d59b 100644 --- a/tests/commands/check-action.test.ts +++ b/tests/commands/check-action.test.ts @@ -19,6 +19,7 @@ import type { CheckResult } from "../../src/engine/runner"; import * as runnerModule from "../../src/engine/runner"; import * as exitModule from "../../src/helpers/exit"; import * as pathsModule from "../../src/helpers/paths"; +import * as stackDetectModule from "../../src/helpers/stack-detect"; import * as telemetryModule from "../../src/helpers/telemetry"; // --------------------------------------------------------------------------- @@ -82,6 +83,7 @@ describe("check action handler", () => { let reportConsoleSpy: ReturnType; let reportJSONSpy: ReturnType; let reportCISpy: ReturnType; + let detectStackSpy: ReturnType; let trackCheckResultSpy: ReturnType; let originalIsTTY: boolean | undefined; @@ -116,6 +118,11 @@ describe("check action handler", () => { reportCISpy = spyOn(reporterModule, "reportCI").mockImplementation( () => {} ); + detectStackSpy = spyOn(stackDetectModule, "detectStack").mockResolvedValue({ + languages: ["typescript"], + runtimes: ["bun"], + frameworks: [], + }); trackCheckResultSpy = spyOn( telemetryModule, "trackCheckResult" @@ -141,6 +148,7 @@ describe("check action handler", () => { reportConsoleSpy.mockRestore(); reportJSONSpy.mockRestore(); reportCISpy.mockRestore(); + detectStackSpy.mockRestore(); trackCheckResultSpy.mockRestore(); Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, @@ -403,4 +411,35 @@ describe("check action handler", () => { expect(data.used_staged).toBe(true); expect(data.used_adr_filter).toBe(true); }); + + test("telemetry includes detected project stack", async () => { + detectStackSpy.mockResolvedValue({ + languages: ["typescript", "python"], + runtimes: ["bun", "node"], + frameworks: ["react", "nextjs"], + }); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + const data = trackCheckResultSpy.mock.calls[0][0]; + expect(data.languages).toEqual(["typescript", "python"]); + expect(data.runtimes).toEqual(["bun", "node"]); + expect(data.frameworks).toEqual(["react", "nextjs"]); + }); + + test("telemetry still fires when stack detection fails", async () => { + detectStackSpy.mockRejectedValue(new Error("fs error")); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(trackCheckResultSpy).toHaveBeenCalledTimes(1); + const data = trackCheckResultSpy.mock.calls[0][0]; + expect(data.languages).toBeUndefined(); + expect(data.runtimes).toBeUndefined(); + expect(data.frameworks).toBeUndefined(); + }); }); diff --git a/tests/helpers/stack-detect-frameworks.test.ts b/tests/helpers/stack-detect-frameworks.test.ts new file mode 100644 index 00000000..39b83fb2 --- /dev/null +++ b/tests/helpers/stack-detect-frameworks.test.ts @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate + +// --------------------------------------------------------------------------- +// Framework detection and caching tests — split from stack-detect.test.ts to +// stay under the 500-line lint limit. +// --------------------------------------------------------------------------- + +import { describe, expect, test, afterEach } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { detectStack } from "../../src/helpers/stack-detect"; +import { safeRmSync } from "../test-utils"; + +describe("detectStack — frameworks", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) safeRmSync(tempDir); + }); + + // --------------------------------------------------------------------------- + // JS/TS dependency-based + // --------------------------------------------------------------------------- + + test("detects Express from package.json dependencies", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { express: "^4" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("express"); + }); + + test("detects Vue from package.json dependencies", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { vue: "^3" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("vue"); + }); + + test("detects Angular from @angular/core", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "@angular/core": "^17" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("angular"); + }); + + test("detects Solid from solid-js", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "solid-js": "^1" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("solid"); + }); + + test("detects NestJS from @nestjs/core", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "@nestjs/core": "^10" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("nestjs"); + }); + + test("detects Koa", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { koa: "^2" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("koa"); + }); + + test("detects Elysia", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { elysia: "^1" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("elysia"); + }); + + // --------------------------------------------------------------------------- + // Tailwind, MUI, TanStack + // --------------------------------------------------------------------------- + + test("detects Tailwind CSS from tailwind.config.ts", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); + writeFileSync(join(tempDir, "tailwind.config.ts"), "export default {}"); + expect((await detectStack(tempDir)).frameworks).toContain("tailwindcss"); + }); + + test("detects MUI from @mui/material", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "@mui/material": "^5" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("mui"); + }); + + test("detects TanStack Query from @tanstack/react-query", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "t", + dependencies: { "@tanstack/react-query": "^5" }, + }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("tanstack-query"); + }); + + test("detects TanStack Router", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "t", + dependencies: { "@tanstack/react-router": "^1" }, + }) + ); + expect((await detectStack(tempDir)).frameworks).toContain( + "tanstack-router" + ); + }); + + test("detects TanStack Start", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "@tanstack/start": "^1" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("tanstack-start"); + }); + + test("detects TanStack Form", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "t", + dependencies: { "@tanstack/react-form": "^0" }, + }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("tanstack-form"); + }); + + // --------------------------------------------------------------------------- + // Non-JS ecosystems + // --------------------------------------------------------------------------- + + test("detects Rails from bin/rails", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "Gemfile"), 'gem "rails"'); + mkdirSync(join(tempDir, "bin"), { recursive: true }); + writeFileSync(join(tempDir, "bin", "rails"), "#!/usr/bin/env ruby"); + const s = await detectStack(tempDir); + expect(s.languages).toContain("ruby"); + expect(s.frameworks).toContain("rails"); + }); + + test("detects Rails from config/routes.rb", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "Gemfile"), 'gem "rails"'); + mkdirSync(join(tempDir, "config"), { recursive: true }); + writeFileSync(join(tempDir, "config", "routes.rb"), "Rails.routes {}"); + expect((await detectStack(tempDir)).frameworks).toContain("rails"); + }); + + test("detects Django from manage.py", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "pyproject.toml"), "[project]\nname = 't'"); + writeFileSync(join(tempDir, "manage.py"), "#!/usr/bin/env python"); + const s = await detectStack(tempDir); + expect(s.languages).toContain("python"); + expect(s.frameworks).toContain("django"); + }); + + test("detects Laravel from artisan file", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "composer.json"), '{"name":"v/p"}'); + writeFileSync(join(tempDir, "artisan"), "#!/usr/bin/env php"); + const s = await detectStack(tempDir); + expect(s.languages).toContain("php"); + expect(s.frameworks).toContain("laravel"); + }); + + test("detects Flutter from pubspec.yaml", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "pubspec.yaml"), + "name: app\ndependencies:\n flutter:\n sdk: flutter\n" + ); + const s = await detectStack(tempDir); + expect(s.languages).toContain("dart"); + expect(s.frameworks).toContain("flutter"); + }); + + test("does not detect Flutter for plain Dart", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "pubspec.yaml"), "name: cli\ndependencies:\n"); + const s = await detectStack(tempDir); + expect(s.languages).toContain("dart"); + expect(s.frameworks).not.toContain("flutter"); + }); + + test("detects Phoenix from mix.exs", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "mix.exs"), + 'defmodule App do\n [{:phoenix, "~> 1.7"}]\nend' + ); + const s = await detectStack(tempDir); + expect(s.languages).toContain("elixir"); + expect(s.frameworks).toContain("phoenix"); + }); + + test("does not detect Phoenix for plain Elixir", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "mix.exs"), "defmodule App do\nend"); + const s = await detectStack(tempDir); + expect(s.languages).toContain("elixir"); + expect(s.frameworks).not.toContain("phoenix"); + }); + + // --------------------------------------------------------------------------- + // Python frameworks (FastAPI, Streamlit, Flask) + // --------------------------------------------------------------------------- + + test("detects FastAPI from requirements.txt", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "requirements.txt"), "fastapi>=0.100\n"); + expect((await detectStack(tempDir)).frameworks).toContain("fastapi"); + }); + + test("detects Streamlit from requirements.txt", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "requirements.txt"), "streamlit==1.30.0\n"); + expect((await detectStack(tempDir)).frameworks).toContain("streamlit"); + }); + + test("detects FastAPI from pyproject.toml", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "pyproject.toml"), + '[project]\nname = "api"\ndependencies = ["fastapi>=0.100"]\n' + ); + expect((await detectStack(tempDir)).frameworks).toContain("fastapi"); + }); + + test("detects Flask from requirements.txt", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "requirements.txt"), "flask==3.0.0\n"); + expect((await detectStack(tempDir)).frameworks).toContain("flask"); + }); + + // --------------------------------------------------------------------------- + // Testing & tooling + // --------------------------------------------------------------------------- + + test("detects Prisma from devDependencies", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", devDependencies: { prisma: "^5" } }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("prisma"); + }); + + test("detects Playwright from @playwright/test", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "t", + devDependencies: { "@playwright/test": "^1" }, + }) + ); + expect((await detectStack(tempDir)).frameworks).toContain("playwright"); + }); +}); + +// --------------------------------------------------------------------------- +// Caching +// --------------------------------------------------------------------------- + +describe("detectStack — caching", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) safeRmSync(tempDir); + }); + + test("returns cached result on second call with unchanged files", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { express: "^4" } }) + ); + + const first = await detectStack(tempDir); + await Bun.sleep(50); + const second = await detectStack(tempDir); + + expect(first).toEqual(second); + expect(second.frameworks).toContain("express"); + }); + + test("invalidates cache when a sentinel file changes", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { express: "^4" } }) + ); + + const first = await detectStack(tempDir); + await Bun.sleep(50); + + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "t", + dependencies: { express: "^4", react: "^18" }, + }) + ); + + const second = await detectStack(tempDir); + expect(first.frameworks).not.toContain("react"); + expect(second.frameworks).toContain("react"); + }); +}); diff --git a/tests/helpers/stack-detect.test.ts b/tests/helpers/stack-detect.test.ts index 5535c48c..85391a9a 100644 --- a/tests/helpers/stack-detect.test.ts +++ b/tests/helpers/stack-detect.test.ts @@ -15,6 +15,10 @@ describe("detectStack", () => { if (tempDir) safeRmSync(tempDir); }); + // --------------------------------------------------------------------------- + // Languages — JS / TS + // --------------------------------------------------------------------------- + test("detects TypeScript from tsconfig.json", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "tsconfig.json"), "{}"); @@ -56,6 +60,10 @@ describe("detectStack", () => { expect(stack.languages).not.toContain("typescript"); }); + // --------------------------------------------------------------------------- + // Languages — Python, Go, Rust + // --------------------------------------------------------------------------- + test("detects Python from pyproject.toml", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "pyproject.toml"), "[project]\nname = 'test'"); @@ -88,6 +96,127 @@ describe("detectStack", () => { expect(stack.languages).toContain("rust"); }); + // --------------------------------------------------------------------------- + // Languages — Ruby, Java, PHP, Swift, Elixir, Dart, C#, Scala, Zig + // --------------------------------------------------------------------------- + + test("detects Ruby from Gemfile", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "Gemfile"), 'source "https://rubygems.org"'); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("ruby"); + }); + + test("detects Ruby from .ruby-version", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, ".ruby-version"), "3.3.0"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("ruby"); + }); + + test("detects Java from pom.xml", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "pom.xml"), ""); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("java"); + }); + + test("detects Java from build.gradle", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "build.gradle"), "plugins {}"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("java"); + }); + + test("detects Java from build.gradle.kts", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "build.gradle.kts"), "plugins {}"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("java"); + }); + + test("detects PHP from composer.json", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "composer.json"), + JSON.stringify({ name: "vendor/pkg" }) + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("php"); + }); + + test("detects Swift from Package.swift", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "Package.swift"), "// swift-tools-version:5.9"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("swift"); + }); + + test("detects Elixir from mix.exs", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "mix.exs"), + "defmodule MyApp.MixProject do\nend" + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("elixir"); + }); + + test("detects Dart from pubspec.yaml", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "pubspec.yaml"), "name: my_app"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("dart"); + }); + + test("detects C# from global.json", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "global.json"), + JSON.stringify({ sdk: { version: "8.0.0" } }) + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("csharp"); + }); + + test("detects C# from Directory.Build.props", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "Directory.Build.props"), ""); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("csharp"); + }); + + test("detects Scala from build.sbt", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "build.sbt"), 'name := "my-app"'); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("scala"); + }); + + test("detects Zig from build.zig", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "build.zig"), 'const std = @import("std");'); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("zig"); + }); + + // --------------------------------------------------------------------------- + // Runtimes + // --------------------------------------------------------------------------- + test("detects Bun runtime from bun.lock", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); @@ -106,6 +235,10 @@ describe("detectStack", () => { expect(stack.runtimes).toContain("deno"); }); + // --------------------------------------------------------------------------- + // Frameworks — JS/TS config-file-based + // --------------------------------------------------------------------------- + test("detects Next.js framework from next.config.ts", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( @@ -119,26 +252,46 @@ describe("detectStack", () => { expect(stack.frameworks).toContain("react"); }); - test("detects Express framework from package.json dependencies", async () => { + test("detects Vite framework from vite.config.ts", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "test", dependencies: { express: "^4.0.0" } }) - ); + writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); + writeFileSync(join(tempDir, "vite.config.ts"), "export default {}"); const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("express"); + expect(stack.frameworks).toContain("vite"); }); - test("detects Vite framework from vite.config.ts", async () => { + test("detects Nuxt framework from nuxt.config.ts", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); - writeFileSync(join(tempDir, "vite.config.ts"), "export default {}"); + writeFileSync(join(tempDir, "nuxt.config.ts"), "export default {}"); const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("vite"); + expect(stack.frameworks).toContain("nuxt"); + }); + + test("detects Astro framework from astro.config.mjs", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); + writeFileSync(join(tempDir, "astro.config.mjs"), "export default {}"); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("astro"); + }); + + test("detects Svelte from svelte.config.js", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); + writeFileSync(join(tempDir, "svelte.config.js"), "export default {}"); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("svelte"); }); + // --------------------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------------------- + test("returns empty arrays for empty directory", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); @@ -157,10 +310,12 @@ describe("detectStack", () => { ); writeFileSync(join(tempDir, "go.mod"), "module example.com/test"); writeFileSync(join(tempDir, "requirements.txt"), "flask"); + writeFileSync(join(tempDir, "Gemfile"), 'source "https://rubygems.org"'); const stack = await detectStack(tempDir); expect(stack.languages).toContain("typescript"); expect(stack.languages).toContain("go"); expect(stack.languages).toContain("python"); + expect(stack.languages).toContain("ruby"); }); }); diff --git a/tests/helpers/telemetry.test.ts b/tests/helpers/telemetry.test.ts index c4833c69..85a8b608 100644 --- a/tests/helpers/telemetry.test.ts +++ b/tests/helpers/telemetry.test.ts @@ -175,7 +175,7 @@ describe("telemetry", () => { used_max_warnings: false, }) ).not.toThrow(); - // Payload including optional fields (files_scanned, durations) + // Payload including optional fields (files_scanned, durations, stack) expect(() => trackCheckResult({ total_rules: 10, @@ -194,6 +194,9 @@ describe("telemetry", () => { files_scanned: 42, load_duration_ms: 15, check_duration_ms: 200, + languages: ["typescript", "python"], + runtimes: ["bun"], + frameworks: ["react", "nextjs"], }) ).not.toThrow(); });