From 40f3321e6396b831f38941c54bfa54145305d5f2 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 16:20:05 -0300 Subject: [PATCH 1/8] feat(telemetry): track project languages, runtimes, and frameworks on check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich the `check_completed` PostHog event with the project's detected stack (languages, runtimes, frameworks) by wiring the existing `detectStack()` helper into `trackCheckResult()`. Stack detection runs in parallel with rule loading so it adds no latency. Failures are silently caught — telemetry still fires without stack data. Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- .../project_test_isolation_gotchas.md | 1 + src/commands/check.ts | 12 ++++++ src/helpers/telemetry.ts | 6 +++ tests/commands/check-action.test.ts | 39 +++++++++++++++++++ tests/helpers/telemetry.test.ts | 5 ++- 5 files changed, 62 insertions(+), 1 deletion(-) 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..f3cc0ee0 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,11 @@ 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. + // Attach .catch() immediately so a failure never surfaces as unhandled. + const stackPromise = detectStack(projectRoot).catch(() => null); + let loadResults; const loadStart = performance.now(); try { @@ -146,6 +152,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 +173,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/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/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(); }); From e65a19748f8083ebc4c73f991ed71850934ba51a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 17:10:27 -0300 Subject: [PATCH 2/8] feat(stack-detect): expand language and framework detection heuristics Languages added: Ruby (Gemfile, .ruby-version), Java (pom.xml, build.gradle, build.gradle.kts), PHP (composer.json), Swift (Package.swift), Elixir (mix.exs), Dart (pubspec.yaml), C# (global.json, Directory.Build.props), Scala (build.sbt), Zig (build.zig). Frameworks added: - Config-file: Nuxt, Astro, Svelte (nuxt/astro/svelte.config.*) - JS/TS deps: Vue, Angular, Solid, Ember, Koa, Elysia, NestJS, Gatsby - Non-JS: Rails (bin/rails, config/routes.rb), Django (manage.py), Laravel (artisan), Flutter (pubspec.yaml flutter: key), Phoenix (mix.exs :phoenix dep) Also extracts hasConfig() helper to reduce config-extension boilerplate. Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- src/helpers/stack-detect.ts | 152 +++++++++++-- tests/helpers/stack-detect.test.ts | 342 ++++++++++++++++++++++++++++- 2 files changed, 465 insertions(+), 29 deletions(-) diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index 7f9cbead..a11546ee 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -11,9 +11,18 @@ export interface DetectedStack { frameworks: string[]; } +/** 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}`))); +} + /** * 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. Used by the greenfield wizard to recommend packs and + * by telemetry to track ecosystem adoption. */ export async function detectStack(projectRoot: string): Promise { const languages: string[] = []; @@ -38,6 +47,8 @@ export async function detectStack(projectRoot: string): Promise { }; // --- 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 +73,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,43 +140,89 @@ export async function detectStack(projectRoot: string): Promise { } // --- Frameworks --- - // Next.js: next.config.* (any extension) - const nextConfigExtensions = ["js", "cjs", "mjs", "ts", "mts", "cts", "json"]; + + // JS/TS config-file-based detection if ( - nextConfigExtensions.some((ext) => - existsSync(join(projectRoot, `next.config.${ext}`)) - ) + hasConfig(projectRoot, "next.config", [...JS_CONFIG_EXTENSIONS, "json"]) ) { frameworks.push("nextjs"); } - // Remix: remix.config.* - const remixConfigExtensions = ["js", "cjs", "mjs", "ts"]; - if ( - remixConfigExtensions.some((ext) => - existsSync(join(projectRoot, `remix.config.${ext}`)) - ) - ) { + if (hasConfig(projectRoot, "remix.config", JS_CONFIG_EXTENSIONS)) { frameworks.push("remix"); } - // Vite: vite.config.* - const viteConfigExtensions = ["js", "cjs", "mjs", "ts", "mts", "cts"]; - if ( - viteConfigExtensions.some((ext) => - existsSync(join(projectRoot, `vite.config.${ext}`)) - ) - ) { + if (hasConfig(projectRoot, "vite.config", JS_CONFIG_EXTENSIONS)) { frameworks.push("vite"); } - // Fastify, Express, Hono from package.json dependencies - if (deps.fastify) frameworks.push("fastify"); + if (hasConfig(projectRoot, "nuxt.config", JS_CONFIG_EXTENSIONS)) { + frameworks.push("nuxt"); + } + + if (hasConfig(projectRoot, "astro.config", JS_CONFIG_EXTENSIONS)) { + frameworks.push("astro"); + } + + if (hasConfig(projectRoot, "svelte.config", ["js", "cjs", "mjs"])) { + frameworks.push("svelte"); + } + + // JS/TS dependency-based detection + 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.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"); - // React from package.json dependencies - if (deps.react) frameworks.push("react"); + // Ruby — Rails detection via conventional directory structure + if ( + existsSync(join(projectRoot, "bin", "rails")) || + existsSync(join(projectRoot, "config", "routes.rb")) + ) { + frameworks.push("rails"); + } + + // Python — Django detection via manage.py + if (existsSync(join(projectRoot, "manage.py"))) { + frameworks.push("django"); + } + + // 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 }); diff --git a/tests/helpers/stack-detect.test.ts b/tests/helpers/stack-detect.test.ts index 5535c48c..1f2b77fd 100644 --- a/tests/helpers/stack-detect.test.ts +++ b/tests/helpers/stack-detect.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { describe, expect, test, afterEach } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -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,6 +252,46 @@ describe("detectStack", () => { expect(stack.frameworks).toContain("react"); }); + test("detects Vite framework from vite.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 {}"); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("vite"); + }); + + 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, "nuxt.config.ts"), "export default {}"); + + const stack = await detectStack(tempDir); + 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"); + }); + + // --------------------------------------------------------------------------- + // Frameworks — JS/TS dependency-based + // --------------------------------------------------------------------------- + test("detects Express framework from package.json dependencies", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( @@ -130,15 +303,172 @@ describe("detectStack", () => { expect(stack.frameworks).toContain("express"); }); - test("detects Vite framework from vite.config.ts", async () => { + test("detects Vue from package.json dependencies", 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, "package.json"), + JSON.stringify({ name: "t", dependencies: { vue: "^3" } }) + ); const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("vite"); + expect(stack.frameworks).toContain("vue"); + }); + + test("detects Angular from @angular/core dependency", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "@angular/core": "^17" } }) + ); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("angular"); + }); + + test("detects Solid from solid-js dependency", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "solid-js": "^1" } }) + ); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("solid"); + }); + + test("detects NestJS from @nestjs/core dependency", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { "@nestjs/core": "^10" } }) + ); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("nestjs"); + }); + + test("detects Koa from koa dependency", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { koa: "^2" } }) + ); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("koa"); + }); + + test("detects Elysia from elysia dependency", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { elysia: "^1" } }) + ); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("elysia"); + }); + + // --------------------------------------------------------------------------- + // Frameworks — 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 stack = await detectStack(tempDir); + expect(stack.languages).toContain("ruby"); + expect(stack.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.application.routes.draw {}" + ); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("rails"); + }); + + test("detects Django from manage.py", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "pyproject.toml"), "[project]\nname = 'test'"); + writeFileSync(join(tempDir, "manage.py"), "#!/usr/bin/env python"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("python"); + expect(stack.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 stack = await detectStack(tempDir); + expect(stack.languages).toContain("php"); + expect(stack.frameworks).toContain("laravel"); + }); + + test("detects Flutter from pubspec.yaml with flutter dependency", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "pubspec.yaml"), + "name: my_app\ndependencies:\n flutter:\n sdk: flutter\n" + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("dart"); + expect(stack.frameworks).toContain("flutter"); + }); + + test("does not detect Flutter for plain Dart project", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "pubspec.yaml"), + "name: my_cli\ndependencies:\n args: ^2.0.0\n" + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("dart"); + expect(stack.frameworks).not.toContain("flutter"); + }); + + test("detects Phoenix from mix.exs with :phoenix dependency", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "mix.exs"), + 'defmodule MyApp.MixProject do\n defp deps do\n [{:phoenix, "~> 1.7"}]\n end\nend' + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("elixir"); + expect(stack.frameworks).toContain("phoenix"); + }); + + test("does not detect Phoenix for plain Elixir project", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "mix.exs"), + "defmodule MyApp.MixProject do\n defp deps, do: []\nend" + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("elixir"); + expect(stack.frameworks).not.toContain("phoenix"); + }); + + // --------------------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------------------- + test("returns empty arrays for empty directory", async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); @@ -157,10 +487,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"); }); }); From 5b45829692a2fea22407bcc1baa7c810088c7f1c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 17:34:02 -0300 Subject: [PATCH 3/8] feat(stack-detect): add caching, Python/UI/testing framework detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caching: sentinel-based disk cache at ~/.archgate/cache/ — stats ~20 marker files (~1ms) to fingerprint the project; on cache hit, skips the full detection scan entirely. Cache invalidates when any sentinel file (package.json, Gemfile, go.mod, etc.) is added, removed, or modified. New framework detections: - Python: FastAPI, Streamlit, Flask (from requirements.txt / pyproject.toml) - UI: MUI, Chakra UI, Headless UI, Radix, shadcn - TanStack: Query, Router, Start, Form, Table - CSS: Tailwind CSS (config file + dep) - Server: tRPC - ORM: Prisma, Drizzle - Testing: Jest, Vitest, Playwright, Cypress, Storybook Split tests into stack-detect.test.ts (languages/runtimes/config) and stack-detect-frameworks.test.ts (dep-based/non-JS/caching) to stay under the 500-line lint limit. Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- src/helpers/stack-detect.ts | 238 +++++++++++- tests/helpers/stack-detect-frameworks.test.ts | 341 ++++++++++++++++++ tests/helpers/stack-detect.test.ts | 179 +-------- 3 files changed, 575 insertions(+), 183 deletions(-) create mode 100644 tests/helpers/stack-detect-frameworks.test.ts diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index a11546ee..b2a327c3 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { logDebug } from "./log"; +import { internalPath } from "./paths"; export interface DetectedStack { languages: string[]; @@ -19,12 +21,140 @@ function hasConfig(dir: string, basename: string, exts: string[]): boolean { return exts.some((ext) => existsSync(join(dir, `${basename}.${ext}`))); } +// --------------------------------------------------------------------------- +// Sentinel-based disk cache +// --------------------------------------------------------------------------- + +/** + * Files whose presence/absence or modification signals a stack change. + * We only stat these — the full detection scans more files but these + * cover every language/runtime marker. If none change, the stack hasn't. + */ +const SENTINEL_FILES = [ + "package.json", + "Gemfile", + ".ruby-version", + "go.mod", + "pyproject.toml", + "requirements.txt", + "Cargo.toml", + "mix.exs", + "pubspec.yaml", + "composer.json", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "Package.swift", + "build.sbt", + "build.zig", + "global.json", + "Directory.Build.props", + "tsconfig.json", + "bun.lock", + "deno.json", +]; + +interface StackCache { + /** Fingerprint of sentinel file existence + mtimes */ + fingerprint: string; + stack: DetectedStack; +} + +/** + * 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`); +} + +function readCache(projectRoot: string): StackCache | null { + const cachePath = getCachePath(projectRoot); + if (!existsSync(cachePath)) return null; + try { + const data = JSON.parse(readFileSync(cachePath, "utf-8")) as StackCache; + return data; + } catch { + return null; + } +} + +async function writeCache( + projectRoot: string, + fingerprint: string, + stack: DetectedStack +): Promise { + const cachePath = getCachePath(projectRoot); + try { + const dir = join(cachePath, ".."); + mkdirSync(dir, { recursive: true }); + const data: StackCache = { fingerprint, stack }; + 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 and - * by telemetry to track ecosystem adoption. + * 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 = 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[] = []; @@ -168,12 +298,37 @@ export async function detectStack(projectRoot: string): Promise { frameworks.push("svelte"); } - // JS/TS dependency-based detection + if ( + hasConfig(projectRoot, "tailwind.config", JS_CONFIG_EXTENSIONS) || + existsSync(join(projectRoot, "tailwind.config.json")) + ) { + frameworks.push("tailwindcss"); + } + + // 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"); @@ -181,6 +336,17 @@ export async function detectStack(projectRoot: string): Promise { 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 ( @@ -190,11 +356,17 @@ export async function detectStack(projectRoot: string): Promise { frameworks.push("rails"); } - // Python — Django detection via manage.py + // Python — framework detection via manage.py / pyproject.toml deps if (existsSync(join(projectRoot, "manage.py"))) { frameworks.push("django"); } + // 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"); + // PHP — Laravel detection via artisan if (existsSync(join(projectRoot, "artisan"))) { frameworks.push("laravel"); @@ -228,3 +400,59 @@ export async function detectStack(projectRoot: string): Promise { return { languages, runtimes, frameworks }; } + +// --------------------------------------------------------------------------- +// Python dependency helpers +// --------------------------------------------------------------------------- + +/** + * Best-effort extraction of Python dependency names from pyproject.toml + * and requirements.txt. Returns a Set of lowercase package names. + * No toml parser needed — we just regex for common patterns. + */ +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; + // Extract package name (before any version specifier, extras, etc.) + const match = /^([a-z0-9][a-z0-9._-]*)/iu.exec(trimmed); + if (match) deps.add(match[1].toLowerCase().replaceAll("_", "-")); + } + } catch { + logDebug("Failed to read requirements.txt"); + } + } + + // pyproject.toml — look for dependencies = ["name", "name>=1.0"] + const pyprojectPath = join(projectRoot, "pyproject.toml"); + if (existsSync(pyprojectPath)) { + try { + const text = await Bun.file(pyprojectPath).text(); + // Match dependency names in brackets lists after "dependencies" + const depMatches = text.matchAll( + /^\s*(?:dependencies|install_requires)\s*=\s*\[([^\]]*)\]/gmu + ); + for (const m of depMatches) { + const block = m[1]; + const nameMatches = block.matchAll( + /["']([a-z0-9][a-z0-9._-]*)[\s>= { + 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 1f2b77fd..85391a9a 100644 --- a/tests/helpers/stack-detect.test.ts +++ b/tests/helpers/stack-detect.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { describe, expect, test, afterEach } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -288,183 +288,6 @@ describe("detectStack", () => { expect(stack.frameworks).toContain("svelte"); }); - // --------------------------------------------------------------------------- - // Frameworks — JS/TS dependency-based - // --------------------------------------------------------------------------- - - test("detects Express framework from package.json dependencies", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "test", dependencies: { express: "^4.0.0" } }) - ); - - const stack = await detectStack(tempDir); - expect(stack.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" } }) - ); - - const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("vue"); - }); - - test("detects Angular from @angular/core dependency", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "@angular/core": "^17" } }) - ); - - const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("angular"); - }); - - test("detects Solid from solid-js dependency", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "solid-js": "^1" } }) - ); - - const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("solid"); - }); - - test("detects NestJS from @nestjs/core dependency", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "@nestjs/core": "^10" } }) - ); - - const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("nestjs"); - }); - - test("detects Koa from koa dependency", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { koa: "^2" } }) - ); - - const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("koa"); - }); - - test("detects Elysia from elysia dependency", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { elysia: "^1" } }) - ); - - const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("elysia"); - }); - - // --------------------------------------------------------------------------- - // Frameworks — 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 stack = await detectStack(tempDir); - expect(stack.languages).toContain("ruby"); - expect(stack.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.application.routes.draw {}" - ); - - const stack = await detectStack(tempDir); - expect(stack.frameworks).toContain("rails"); - }); - - test("detects Django from manage.py", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "pyproject.toml"), "[project]\nname = 'test'"); - writeFileSync(join(tempDir, "manage.py"), "#!/usr/bin/env python"); - - const stack = await detectStack(tempDir); - expect(stack.languages).toContain("python"); - expect(stack.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 stack = await detectStack(tempDir); - expect(stack.languages).toContain("php"); - expect(stack.frameworks).toContain("laravel"); - }); - - test("detects Flutter from pubspec.yaml with flutter dependency", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "pubspec.yaml"), - "name: my_app\ndependencies:\n flutter:\n sdk: flutter\n" - ); - - const stack = await detectStack(tempDir); - expect(stack.languages).toContain("dart"); - expect(stack.frameworks).toContain("flutter"); - }); - - test("does not detect Flutter for plain Dart project", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "pubspec.yaml"), - "name: my_cli\ndependencies:\n args: ^2.0.0\n" - ); - - const stack = await detectStack(tempDir); - expect(stack.languages).toContain("dart"); - expect(stack.frameworks).not.toContain("flutter"); - }); - - test("detects Phoenix from mix.exs with :phoenix dependency", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "mix.exs"), - 'defmodule MyApp.MixProject do\n defp deps do\n [{:phoenix, "~> 1.7"}]\n end\nend' - ); - - const stack = await detectStack(tempDir); - expect(stack.languages).toContain("elixir"); - expect(stack.frameworks).toContain("phoenix"); - }); - - test("does not detect Phoenix for plain Elixir project", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "mix.exs"), - "defmodule MyApp.MixProject do\n defp deps, do: []\nend" - ); - - const stack = await detectStack(tempDir); - expect(stack.languages).toContain("elixir"); - expect(stack.frameworks).not.toContain("phoenix"); - }); - // --------------------------------------------------------------------------- // Edge cases // --------------------------------------------------------------------------- From 70ce26f3ab7fc2adcd15f2153dd035770895407b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 17:36:56 -0300 Subject: [PATCH 4/8] refactor(stack-detect): use Bun.TOML.parse for pyproject.toml Replace the regex-based pyproject.toml dependency extraction with Bun's built-in TOML parser (Bun.TOML.parse). Properly reads [project].dependencies per PEP 621 instead of pattern-matching against raw text. Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- src/helpers/stack-detect.ts | 47 ++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index b2a327c3..efbb6185 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -405,10 +405,21 @@ export async function detectStackUncached( // 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 - * and requirements.txt. Returns a Set of lowercase package names. - * No toml parser needed — we just regex for common patterns. + * (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(); @@ -422,35 +433,33 @@ async function readPythonDeps(projectRoot: string): Promise> { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue; - // Extract package name (before any version specifier, extras, etc.) - const match = /^([a-z0-9][a-z0-9._-]*)/iu.exec(trimmed); - if (match) deps.add(match[1].toLowerCase().replaceAll("_", "-")); + const name = extractPyPackageName(trimmed); + if (name) deps.add(name); } } catch { logDebug("Failed to read requirements.txt"); } } - // pyproject.toml — look for dependencies = ["name", "name>=1.0"] + // pyproject.toml — use Bun's built-in TOML parser for reliable extraction const pyprojectPath = join(projectRoot, "pyproject.toml"); if (existsSync(pyprojectPath)) { try { - const text = await Bun.file(pyprojectPath).text(); - // Match dependency names in brackets lists after "dependencies" - const depMatches = text.matchAll( - /^\s*(?:dependencies|install_requires)\s*=\s*\[([^\]]*)\]/gmu - ); - for (const m of depMatches) { - const block = m[1]; - const nameMatches = block.matchAll( - /["']([a-z0-9][a-z0-9._-]*)[\s>=; + // PEP 621: [project] dependencies = ["fastapi>=0.100", ...] + const project = parsed.project as Record | undefined; + const depList = project?.dependencies; + if (Array.isArray(depList)) { + for (const spec of depList) { + if (typeof spec === "string") { + const name = extractPyPackageName(spec); + if (name) deps.add(name); + } } } } catch { - logDebug("Failed to read pyproject.toml"); + logDebug("Failed to parse pyproject.toml"); } } From 862832bb04ed8202f5dbd2d0125a584541d5ebef Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 17:41:58 -0300 Subject: [PATCH 5/8] refactor(stack-detect): use Bun.file().json() for cache, Zod for validation - Cache reads use Bun.file().json() instead of readFileSync + JSON.parse (ARCH-010 compliance, removes the last two warnings) - Cache payload validated with Zod safeParse instead of unsafe `as` cast - pyproject.toml parsed with Bun.TOML.parse instead of regex Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- src/helpers/stack-detect.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index efbb6185..a8f88703 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { z } from "zod"; + import { logDebug } from "./log"; import { internalPath } from "./paths"; @@ -13,6 +15,17 @@ 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, +}); + /** Config file extensions commonly used by JS/TS frameworks. */ const JS_CONFIG_EXTENSIONS = ["js", "cjs", "mjs", "ts", "mts", "cts"]; @@ -54,11 +67,7 @@ const SENTINEL_FILES = [ "deno.json", ]; -interface StackCache { - /** Fingerprint of sentinel file existence + mtimes */ - fingerprint: string; - stack: DetectedStack; -} +type StackCache = z.infer; /** * Build a fingerprint from sentinel file stats. Two projects with identical @@ -90,12 +99,13 @@ function getCachePath(projectRoot: string): string { return internalPath("cache", `stack-${projectHash(projectRoot)}.json`); } -function readCache(projectRoot: string): StackCache | null { +async function readCache(projectRoot: string): Promise { const cachePath = getCachePath(projectRoot); if (!existsSync(cachePath)) return null; try { - const data = JSON.parse(readFileSync(cachePath, "utf-8")) as StackCache; - return data; + const raw = await Bun.file(cachePath).json(); + const result = StackCacheSchema.safeParse(raw); + return result.success ? result.data : null; } catch { return null; } @@ -132,7 +142,7 @@ export async function detectStack(projectRoot: string): Promise { const fingerprint = buildFingerprint(projectRoot); // Check disk cache - const cached = readCache(projectRoot); + const cached = await readCache(projectRoot); if (cached && cached.fingerprint === fingerprint) { logDebug("Stack cache hit for", projectRoot); return cached.stack; From 8fb2fe20e4116d67937bd41ea38604eb2e1b57d4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 17:49:16 -0300 Subject: [PATCH 6/8] refactor(stack-detect): replace all type assertions with Zod schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PackageJsonSchema and PyprojectSchema to validate external file contents via safeParse instead of unsafe `as` casts. The file now has zero type assertions — all external data (cache, package.json, pyproject.toml) is validated through Zod before use. Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- src/helpers/stack-detect.ts | 40 +++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index a8f88703..3abec3e0 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -26,6 +26,19 @@ const StackCacheSchema = z.object({ 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"]; @@ -171,19 +184,21 @@ export async function detectStackUncached( 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 --- @@ -456,16 +471,11 @@ async function readPythonDeps(projectRoot: string): Promise> { if (existsSync(pyprojectPath)) { try { const toml = await Bun.file(pyprojectPath).text(); - const parsed = Bun.TOML.parse(toml) as Record; - // PEP 621: [project] dependencies = ["fastapi>=0.100", ...] - const project = parsed.project as Record | undefined; - const depList = project?.dependencies; - if (Array.isArray(depList)) { - for (const spec of depList) { - if (typeof spec === "string") { - const name = extractPyPackageName(spec); - if (name) deps.add(name); - } + 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 { From e18443945c0527b63a66dc22ff0a4a94eb03eaa2 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 20:46:41 -0300 Subject: [PATCH 7/8] fix: address PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound stackPromise with 500ms timeout via Promise.race so pathological projects can't stall command exit (check.ts) - Expand SENTINEL_FILES to cover all scanned markers: bunfig.toml, deno.jsonc, setup.py, manage.py, artisan, bin/rails, config/routes.rb, and JS/TS framework configs (next/remix/vite/nuxt/astro/svelte/tailwind) - Drop redundant mkdirSync from writeCache — Bun.write() auto-creates parent dirs since v1.0.16 Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- src/commands/check.ts | 7 +++-- src/helpers/stack-detect.ts | 52 +++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/commands/check.ts b/src/commands/check.ts index f3cc0ee0..bedb1fab 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -63,8 +63,11 @@ export function registerCheckCommand(program: Command) { // Run stack detection in parallel with rule loading — both are fast I/O // and independent. Stack info enriches the telemetry event at the end. - // Attach .catch() immediately so a failure never surfaces as unhandled. - const stackPromise = detectStack(projectRoot).catch(() => null); + // 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(); diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index 3abec3e0..81d5ac45 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, statSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; import { z } from "zod"; @@ -53,31 +53,56 @@ function hasConfig(dir: string, basename: string, exts: string[]): boolean { /** * Files whose presence/absence or modification signals a stack change. - * We only stat these — the full detection scans more files but these - * cover every language/runtime marker. If none change, the stack hasn't. + * Covers every marker that detectStackUncached() inspects so cached + * results stay fresh. Adding a file here is cheap (~0.05ms per stat). */ const SENTINEL_FILES = [ + // Languages "package.json", - "Gemfile", - ".ruby-version", - "go.mod", + "tsconfig.json", "pyproject.toml", "requirements.txt", + "setup.py", + "go.mod", "Cargo.toml", - "mix.exs", - "pubspec.yaml", - "composer.json", + "Gemfile", + ".ruby-version", "pom.xml", "build.gradle", "build.gradle.kts", + "composer.json", "Package.swift", - "build.sbt", - "build.zig", + "mix.exs", + "pubspec.yaml", "global.json", "Directory.Build.props", - "tsconfig.json", + "build.sbt", + "build.zig", + // Runtimes "bun.lock", + "bunfig.toml", "deno.json", + "deno.jsonc", + // Frameworks — config files + "next.config.ts", + "next.config.js", + "next.config.mjs", + "remix.config.ts", + "remix.config.js", + "vite.config.ts", + "vite.config.js", + "nuxt.config.ts", + "nuxt.config.js", + "astro.config.mjs", + "astro.config.ts", + "svelte.config.js", + "tailwind.config.ts", + "tailwind.config.js", + // Frameworks — non-JS markers + "manage.py", + "artisan", + "bin/rails", + "config/routes.rb", ]; type StackCache = z.infer; @@ -131,9 +156,8 @@ async function writeCache( ): Promise { const cachePath = getCachePath(projectRoot); try { - const dir = join(cachePath, ".."); - mkdirSync(dir, { recursive: true }); 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 { From 43ccdc5e7ae9a9e114356d02ca35606646439d90 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 6 Jul 2026 21:10:56 -0300 Subject: [PATCH 8/8] refactor(stack-detect): unify config detection via FRAMEWORK_CONFIGS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract FRAMEWORK_CONFIGS as a single source of truth for framework config basenames, their scanned extensions, and framework names. Both sentinel generation and detection now derive from this list, so they can never drift apart. Covers all extension variants (cjs/mjs/mts/cts/json) that hasConfig() scans — previously the sentinel list only tracked .ts/.js for most frameworks, leaving config variants like next.config.json or vite.config.mts uncovered. Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- src/helpers/stack-detect.ts | 79 ++++++++++++++----------------------- 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index 81d5ac45..dfb0e8bd 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -51,12 +51,29 @@ function hasConfig(dir: string, basename: string, exts: string[]): boolean { // 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. - * Covers every marker that detectStackUncached() inspects so cached - * results stay fresh. Adding a file here is cheap (~0.05ms per stat). + * Generated from the same constants that detectStackUncached() uses so + * sentinels and detection never drift apart. */ -const SENTINEL_FILES = [ +const SENTINEL_FILES: string[] = [ // Languages "package.json", "tsconfig.json", @@ -83,21 +100,10 @@ const SENTINEL_FILES = [ "bunfig.toml", "deno.json", "deno.jsonc", - // Frameworks — config files - "next.config.ts", - "next.config.js", - "next.config.mjs", - "remix.config.ts", - "remix.config.js", - "vite.config.ts", - "vite.config.js", - "nuxt.config.ts", - "nuxt.config.js", - "astro.config.mjs", - "astro.config.ts", - "svelte.config.js", - "tailwind.config.ts", - "tailwind.config.js", + // Frameworks — config files (generated from FRAMEWORK_CONFIGS) + ...FRAMEWORK_CONFIGS.flatMap(([, base, exts]) => + exts.map((ext) => `${base}.${ext}`) + ), // Frameworks — non-JS markers "manage.py", "artisan", @@ -320,38 +326,11 @@ export async function detectStackUncached( // --- Frameworks --- - // JS/TS config-file-based detection - if ( - hasConfig(projectRoot, "next.config", [...JS_CONFIG_EXTENSIONS, "json"]) - ) { - frameworks.push("nextjs"); - } - - if (hasConfig(projectRoot, "remix.config", JS_CONFIG_EXTENSIONS)) { - frameworks.push("remix"); - } - - if (hasConfig(projectRoot, "vite.config", JS_CONFIG_EXTENSIONS)) { - frameworks.push("vite"); - } - - if (hasConfig(projectRoot, "nuxt.config", JS_CONFIG_EXTENSIONS)) { - frameworks.push("nuxt"); - } - - if (hasConfig(projectRoot, "astro.config", JS_CONFIG_EXTENSIONS)) { - frameworks.push("astro"); - } - - if (hasConfig(projectRoot, "svelte.config", ["js", "cjs", "mjs"])) { - frameworks.push("svelte"); - } - - if ( - hasConfig(projectRoot, "tailwind.config", JS_CONFIG_EXTENSIONS) || - existsSync(join(projectRoot, "tailwind.config.json")) - ) { - frameworks.push("tailwindcss"); + // 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); + } } // JS/TS dependency-based detection — UI frameworks & libraries