diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index dbc4535a..62a94ba1 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1711,18 +1711,17 @@ This reads `.archgate/imports.json` and displays each source, version, and the A ## How ID remapping works -When you import ADRs, the original IDs (e.g., `TS-001`) are **remapped** to fit your project's ID sequence. For example, if your project already has `ARCH-001` through `ARCH-005`, the imported ADRs will receive `ARCH-006`, `ARCH-007`, etc. +When you import ADRs, the original IDs are **remapped** to match your project's domain prefixes. Each ADR's `domain` field determines which prefix it gets — for example, an ADR with `domain: frontend` becomes `FE-XXX`, while one with `domain: backend` becomes `BE-XXX`. Each domain has its own counter, so importing a pack with mixed domains produces correctly prefixed IDs without collisions. -You can override the prefix with `--prefix`: +For example, importing a pack with three frontend ADRs and two backend ADRs into a project that already has `FE-001` and `BE-001` produces: -```bash -archgate adr import packs/security --prefix SEC -``` +- `FE-002`, `FE-003`, `FE-004` (frontend) +- `BE-002`, `BE-003` (backend) The remapping ensures: 1. No ID collisions with existing ADRs -2. A single consistent numbering scheme across your project +2. Each domain maintains its own numbering sequence 3. Imported rules files work immediately without manual edits ## The imports.json manifest @@ -1746,13 +1745,12 @@ This manifest lets you track provenance — where each imported ADR came from an ## Command options reference -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--yes` | Skip the confirmation prompt | -| `--json` | Output results as JSON | -| `--dry-run` | Preview changes without writing files | -| `--prefix ` | Override the ID prefix for imported ADRs | -| `--list` | List previously imported ADRs | +| Option | Description | +| ----------- | ------------------------------------- | +| `--yes` | Skip the confirmation prompt | +| `--json` | Output results as JSON | +| `--dry-run` | Preview changes without writing files | +| `--list` | List previously imported ADRs | --- diff --git a/docs/src/content/docs/guides/importing-adrs.mdx b/docs/src/content/docs/guides/importing-adrs.mdx index 9eded3a1..7909dac7 100644 --- a/docs/src/content/docs/guides/importing-adrs.mdx +++ b/docs/src/content/docs/guides/importing-adrs.mdx @@ -89,18 +89,17 @@ This reads `.archgate/imports.json` and displays each source, version, and the A ## How ID remapping works -When you import ADRs, the original IDs (e.g., `TS-001`) are **remapped** to fit your project's ID sequence. For example, if your project already has `ARCH-001` through `ARCH-005`, the imported ADRs will receive `ARCH-006`, `ARCH-007`, etc. +When you import ADRs, the original IDs are **remapped** to match your project's domain prefixes. Each ADR's `domain` field determines which prefix it gets — for example, an ADR with `domain: frontend` becomes `FE-XXX`, while one with `domain: backend` becomes `BE-XXX`. Each domain has its own counter, so importing a pack with mixed domains produces correctly prefixed IDs without collisions. -You can override the prefix with `--prefix`: +For example, importing a pack with three frontend ADRs and two backend ADRs into a project that already has `FE-001` and `BE-001` produces: -```bash -archgate adr import packs/security --prefix SEC -``` +- `FE-002`, `FE-003`, `FE-004` (frontend) +- `BE-002`, `BE-003` (backend) The remapping ensures: 1. No ID collisions with existing ADRs -2. A single consistent numbering scheme across your project +2. Each domain maintains its own numbering sequence 3. Imported rules files work immediately without manual edits ## The imports.json manifest @@ -124,10 +123,9 @@ This manifest lets you track provenance — where each imported ADR came from an ## Command options reference -| Option | Description | -| ------------------- | ---------------------------------------- | -| `--yes` | Skip the confirmation prompt | -| `--json` | Output results as JSON | -| `--dry-run` | Preview changes without writing files | -| `--prefix ` | Override the ID prefix for imported ADRs | -| `--list` | List previously imported ADRs | +| Option | Description | +| ----------- | ------------------------------------- | +| `--yes` | Skip the confirmation prompt | +| `--json` | Output results as JSON | +| `--dry-run` | Preview changes without writing files | +| `--list` | List previously imported ADRs | diff --git a/docs/src/content/docs/pt-br/guides/importing-adrs.mdx b/docs/src/content/docs/pt-br/guides/importing-adrs.mdx index cebdb98b..ec83f5b7 100644 --- a/docs/src/content/docs/pt-br/guides/importing-adrs.mdx +++ b/docs/src/content/docs/pt-br/guides/importing-adrs.mdx @@ -89,18 +89,17 @@ Isso lê `.archgate/imports.json` e exibe cada fonte, versão e os IDs de ADR pr ## Como funciona o remapeamento de IDs -Quando você importa ADRs, os IDs originais (ex: `TS-001`) são **remapeados** para se adequar à sequência de IDs do seu projeto. Por exemplo, se seu projeto já tem `ARCH-001` até `ARCH-005`, os ADRs importados receberão `ARCH-006`, `ARCH-007`, etc. +Quando você importa ADRs, os IDs originais são **remapeados** para corresponder aos prefixos de domínio do seu projeto. O campo `domain` de cada ADR determina qual prefixo ele recebe — por exemplo, um ADR com `domain: frontend` vira `FE-XXX`, enquanto um com `domain: backend` vira `BE-XXX`. Cada domínio tem seu próprio contador, então importar um pack com domínios mistos produz IDs corretamente prefixados sem colisões. -Você pode sobrescrever o prefixo com `--prefix`: +Por exemplo, importar um pack com três ADRs de frontend e dois de backend em um projeto que já tem `FE-001` e `BE-001` produz: -```bash -archgate adr import packs/security --prefix SEC -``` +- `FE-002`, `FE-003`, `FE-004` (frontend) +- `BE-002`, `BE-003` (backend) O remapeamento garante: 1. Nenhuma colisão de ID com ADRs existentes -2. Um esquema de numeração único e consistente em todo o projeto +2. Cada domínio mantém sua própria sequência de numeração 3. Arquivos de regras importados funcionam imediatamente sem edições manuais ## O manifesto imports.json @@ -124,10 +123,9 @@ Este manifesto permite rastrear a proveniência — de onde cada ADR importado v ## Referência de opções do comando -| Opção | Descrição | -| ------------------- | ----------------------------------------------- | -| `--yes` | Pula o prompt de confirmação | -| `--json` | Saída em formato JSON | -| `--dry-run` | Preview sem escrever arquivos | -| `--prefix ` | Sobrescreve o prefixo de ID dos ADRs importados | -| `--list` | Lista ADRs importados anteriormente | +| Opção | Descrição | +| ----------- | ----------------------------------- | +| `--yes` | Pula o prompt de confirmação | +| `--json` | Saída em formato JSON | +| `--dry-run` | Preview sem escrever arquivos | +| `--list` | Lista ADRs importados anteriormente | diff --git a/src/commands/adr/import.ts b/src/commands/adr/import.ts index 75da9bce..09efd3de 100644 --- a/src/commands/adr/import.ts +++ b/src/commands/adr/import.ts @@ -23,7 +23,10 @@ import { exitWith } from "../../helpers/exit"; import { logDebug, logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { resolvedProjectPaths } from "../../helpers/project-config"; +import { + getMergedDomainPrefixes, + resolvedProjectPaths, +} from "../../helpers/project-config"; import { resolveSource, shallowClone, @@ -76,6 +79,7 @@ interface AdrToImport { rulesPath: string | null; originalId: string; title: string; + domain?: string; source: string; packVersion?: string; } @@ -139,6 +143,7 @@ async function collectAdrsToImport( rulesPath: rulesFile ?? null, originalId: adr.frontmatter.id, title: adr.frontmatter.title, + domain: adr.frontmatter.domain, source, packVersion: target.packMeta.version, }); @@ -151,6 +156,7 @@ async function collectAdrsToImport( rulesPath: target.rulesFile, originalId: adr.frontmatter.id, title: adr.frontmatter.title, + domain: adr.frontmatter.domain, source, }); } @@ -234,7 +240,6 @@ export function registerAdrImportCommand(adr: Command) { .option("--yes", "Skip confirmation prompt", false) .option("--json", "Output as JSON", false) .option("--dry-run", "Preview changes without writing", false) - .option("--prefix ", "Override ID prefix for imported ADRs") .option("--list", "List previously imported ADRs", false) .action(async (sources, opts) => { const projectRoot = findProjectRoot(); @@ -285,21 +290,36 @@ export function registerAdrImportCommand(adr: Command) { // ---------- Determine prefix & remap IDs ---------- - const prefix = opts.prefix ?? "ARCH"; mkdirSync(paths.adrsDir, { recursive: true }); - let nextIdStr = getNextId(paths.adrsDir, prefix); + // Resolve each ADR's domain to the project's prefix for that domain. + const domainPrefixes = getMergedDomainPrefixes(projectRoot); + + // Track the next available ID per prefix to avoid collisions + const nextIdByPrefix = new Map(); + const idMap: Array<{ original: string; newId: string; title: string }> = []; for (const adr of adrsToImport) { + const prefix = (adr.domain && domainPrefixes[adr.domain]) || "ARCH"; + + if (!nextIdByPrefix.has(prefix)) { + nextIdByPrefix.set(prefix, getNextId(paths.adrsDir, prefix)); + } + + const nextId = nextIdByPrefix.get(prefix)!; idMap.push({ original: adr.originalId, - newId: nextIdStr, + newId: nextId, title: adr.title, }); - const num = parseInt(nextIdStr.replace(`${prefix}-`, ""), 10) + 1; - nextIdStr = `${prefix}-${String(num).padStart(3, "0")}`; + + const num = parseInt(nextId.replace(`${prefix}-`, ""), 10) + 1; + nextIdByPrefix.set( + prefix, + `${prefix}-${String(num).padStart(3, "0")}` + ); } // ---------- Preview ---------- diff --git a/src/commands/adr/index.ts b/src/commands/adr/index.ts index ffb6c651..a4bc72fd 100644 --- a/src/commands/adr/index.ts +++ b/src/commands/adr/index.ts @@ -7,6 +7,7 @@ import { registerDomainCommand } from "./domain/index"; import { registerAdrImportCommand } from "./import"; import { registerAdrListCommand } from "./list"; import { registerAdrShowCommand } from "./show"; +import { registerAdrSyncCommand } from "./sync"; import { registerAdrUpdateCommand } from "./update"; export function registerAdrCommand(program: Command) { @@ -18,6 +19,7 @@ export function registerAdrCommand(program: Command) { registerAdrImportCommand(adr); registerAdrListCommand(adr); registerAdrShowCommand(adr); + registerAdrSyncCommand(adr); registerAdrUpdateCommand(adr); registerDomainCommand(adr); } diff --git a/src/commands/adr/sync.ts b/src/commands/adr/sync.ts new file mode 100644 index 00000000..409ef41a --- /dev/null +++ b/src/commands/adr/sync.ts @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { styleText } from "node:util"; + +import type { Command } from "@commander-js/extra-typings"; + +import { + ImportsManifestSchema, + type ImportsManifest, +} from "../../formats/pack"; +import { exitWith } from "../../helpers/exit"; +import { logDebug, logError, logWarn } from "../../helpers/log"; +import { formatJSON, isAgentContext } from "../../helpers/output"; +import { findProjectRoot } from "../../helpers/paths"; +import { resolvedProjectPaths } from "../../helpers/project-config"; +import { resolveSource, shallowClone } from "../../helpers/registry"; + +// ---------- Types ---------- + +interface AdrDiff { + adrId: string; + source: string; + localPath: string; + upstreamPath: string; + hasChanges: boolean; + /** Human-readable summary of what changed */ + summary: string; +} + +interface SyncResult { + checked: number; + withChanges: number; + upToDate: number; + errors: number; + diffs: AdrDiff[]; +} + +// ---------- Imports manifest I/O ---------- + +function loadImportsManifest(projectRoot: string): ImportsManifest { + const importsPath = join(projectRoot, ".archgate", "imports.json"); + if (!existsSync(importsPath)) return { imports: [] }; + const raw = readFileSync(importsPath, "utf-8"); + return ImportsManifestSchema.parse(JSON.parse(raw)); +} + +function saveImportsManifest( + projectRoot: string, + manifest: ImportsManifest +): void { + const importsPath = join(projectRoot, ".archgate", "imports.json"); + const content = JSON.stringify(manifest, null, 2) + "\n"; + Bun.write(importsPath, content); +} + +// ---------- Diff helpers ---------- + +/** + * Find the local ADR file by ID in the adrs directory. + */ +function findLocalAdr(adrsDir: string, adrId: string): string | null { + if (!existsSync(adrsDir)) return null; + const files = readdirSync(adrsDir); + const match = files.find( + (f) => f.endsWith(".md") && f.startsWith(`${adrId}-`) + ); + return match ? join(adrsDir, match) : null; +} + +/** + * Generate a human-readable summary of which sections changed between + * two ADR markdown files. + */ +function diffSummary(localContent: string, upstreamContent: string): string { + if (localContent === upstreamContent) return "No changes"; + + const localLines = localContent.split("\n"); + const upstreamLines = upstreamContent.split("\n"); + + const changedSections: string[] = []; + + // Track which sections have differences + const localSections = new Map(); + const upstreamSections = new Map(); + + for (const [lines, sections] of [ + [localLines, localSections], + [upstreamLines, upstreamSections], + ] as const) { + let section = "header"; + for (const line of lines) { + const headingMatch = line.match(/^#{1,3}\s+(.+)/u); + if (headingMatch) { + section = headingMatch[1].trim(); + } + if (!sections.has(section)) sections.set(section, []); + sections.get(section)!.push(line); + } + } + + for (const [section, localLines] of localSections) { + const upstreamLines = upstreamSections.get(section); + if (!upstreamLines || localLines.join("\n") !== upstreamLines.join("\n")) { + changedSections.push(section); + } + } + + // Check for new sections in upstream + for (const section of upstreamSections.keys()) { + if (!localSections.has(section) && !changedSections.includes(section)) { + changedSections.push(section); + } + } + + if (changedSections.length === 0) return "Whitespace or formatting changes"; + return `Changed: ${changedSections.join(", ")}`; +} + +// ---------- Cleanup ---------- + +function cleanup(dirs: string[]): void { + for (const dir of dirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + logDebug("Failed to clean up temp dir:", dir); + } + } +} + +// ---------- Command registration ---------- + +export function registerAdrSyncCommand(adr: Command) { + adr + .command("sync") + .description("Check for upstream updates to imported ADRs") + .argument("[source...]", "Source filter(s) — sync only matching imports") + .option("--check", "Exit 1 if upstream has updates (CI mode)", false) + .option("--yes", "Skip confirmation prompts", false) + .option("--json", "Output as JSON", false) + .action(async (sources, opts) => { + const projectRoot = findProjectRoot(); + if (!projectRoot) { + logError("No .archgate/ directory found. Run `archgate init` first."); + await exitWith(1); + return; + } + + try { + const paths = resolvedProjectPaths(projectRoot); + const useJson = opts.json || isAgentContext(); + const manifest = loadImportsManifest(projectRoot); + + if (manifest.imports.length === 0) { + if (useJson) { + console.log( + formatJSON( + { status: "empty", message: "No imported ADRs found." }, + opts.json ? true : undefined + ) + ); + } else { + console.log("No imported ADRs found."); + } + return; + } + + // Filter imports by source args if provided + let imports = manifest.imports; + if (sources.length > 0) { + imports = imports.filter((entry) => + sources.some((s) => entry.source.includes(s)) + ); + if (imports.length === 0) { + if (useJson) { + console.log( + formatJSON( + { + status: "no-match", + message: "No imports match the given source filter(s).", + }, + opts.json ? true : undefined + ) + ); + } else { + console.log("No imports match the given source filter(s)."); + } + return; + } + } + + // Clone upstream repos and compare + const tempDirs: string[] = []; + const cloneCache = new Map(); + const result: SyncResult = { + checked: 0, + withChanges: 0, + upToDate: 0, + errors: 0, + diffs: [], + }; + + for (const entry of imports) { + let resolved; + try { + resolved = resolveSource(entry.source); + } catch (err) { + logWarn( + `Cannot resolve source "${entry.source}": ${err instanceof Error ? err.message : String(err)}` + ); + result.errors++; + continue; + } + + const cacheKey = `${resolved.repoUrl}#${resolved.ref ?? ""}`; + let cloneDir = cloneCache.get(cacheKey); + + if (!cloneDir) { + try { + cloneDir = await shallowClone(resolved.repoUrl, resolved.ref); // oxlint-disable-line no-await-in-loop -- sequential by design (dedup cache) + cloneCache.set(cacheKey, cloneDir); + tempDirs.push(cloneDir); + } catch (err) { + logWarn( + `Failed to clone ${resolved.repoUrl}: ${err instanceof Error ? err.message : String(err)}` + ); + result.errors++; + continue; + } + } + + // Compare each ADR in this import entry + for (const adrId of entry.adrIds) { + result.checked++; + + const localPath = findLocalAdr(paths.adrsDir, adrId); + if (!localPath) { + logWarn(`Local ADR ${adrId} not found in ${paths.adrsDir}`); + result.errors++; + continue; + } + + // Find the upstream ADR file + const upstreamSubpath = resolved.subpath; + const upstreamAdrsDir = join(cloneDir, upstreamSubpath, "adrs"); + + if (!existsSync(upstreamAdrsDir)) { + logDebug(`Upstream adrs dir not found: ${upstreamAdrsDir}`); + result.errors++; + continue; + } + + const upstreamFiles = readdirSync(upstreamAdrsDir).filter((f) => + f.endsWith(".md") + ); + + // We need to find the upstream file. Since IDs were remapped on import, + // we compare content structure. For simplicity, match by position in the + // import list or by comparing titles/content. + // A reasonable heuristic: use the order in adrIds vs the sorted upstream files. + const adrIndex = entry.adrIds.indexOf(adrId); + const upstreamFile = + adrIndex < upstreamFiles.length + ? join(upstreamAdrsDir, upstreamFiles.sort()[adrIndex]) + : null; + + if (!upstreamFile || !existsSync(upstreamFile)) { + logDebug(`Upstream ADR file not found for ${adrId}`); + result.errors++; + continue; + } + + const localContent = readFileSync(localPath, "utf-8"); + const upstreamContent = readFileSync(upstreamFile, "utf-8"); + + // Strip frontmatter ID for comparison (since IDs were remapped) + const stripId = (content: string) => + content.replace(/^(id:\s*).*$/mu, ""); + + const hasChanges = + stripId(localContent) !== stripId(upstreamContent); + + const diff: AdrDiff = { + adrId, + source: entry.source, + localPath, + upstreamPath: upstreamFile, + hasChanges, + summary: hasChanges + ? diffSummary(localContent, upstreamContent) + : "Up to date", + }; + + result.diffs.push(diff); + if (hasChanges) { + result.withChanges++; + } else { + result.upToDate++; + } + } + } + + // --- Check mode: report and exit --- + if (opts.check) { + if (useJson) { + console.log( + formatJSON( + { + status: + result.withChanges > 0 ? "updates-available" : "up-to-date", + checked: result.checked, + withChanges: result.withChanges, + upToDate: result.upToDate, + errors: result.errors, + diffs: result.diffs + .filter((d) => d.hasChanges) + .map((d) => ({ + adrId: d.adrId, + source: d.source, + summary: d.summary, + })), + }, + opts.json ? true : undefined + ) + ); + } else if (result.withChanges > 0) { + console.log( + styleText( + "yellow", + `${result.withChanges} ADR(s) have upstream updates:` + ) + ); + for (const diff of result.diffs.filter((d) => d.hasChanges)) { + console.log(` ${diff.adrId} (${diff.source}): ${diff.summary}`); + } + } else { + console.log( + styleText("green", "All imported ADRs are up to date.") + ); + } + + cleanup(tempDirs); + if (result.withChanges > 0) { + await exitWith(1); + } + return; + } + + // --- Interactive mode: prompt for each changed ADR --- + if (result.withChanges === 0) { + if (useJson) { + console.log( + formatJSON( + { + status: "up-to-date", + checked: result.checked, + withChanges: 0, + upToDate: result.upToDate, + }, + opts.json ? true : undefined + ) + ); + } else { + console.log( + styleText("green", "All imported ADRs are up to date.") + ); + } + cleanup(tempDirs); + return; + } + + if (!useJson) { + console.log( + styleText( + "bold", + `\n${result.withChanges} ADR(s) have upstream updates:\n` + ) + ); + } + + let updatedCount = 0; + const changedDiffs = result.diffs.filter((d) => d.hasChanges); + + for (const diff of changedDiffs) { + if (!useJson) { + console.log( + `${styleText("bold", diff.adrId)} (${diff.source}): ${diff.summary}` + ); + } + + let action: "keep" | "take" | "skip" = "skip"; + + if (opts.yes) { + action = "take"; + } else if (!useJson && process.stdin.isTTY) { + // oxlint-disable-next-line no-await-in-loop -- sequential interactive prompts + const { default: inquirer } = await import("inquirer"); + // oxlint-disable-next-line no-await-in-loop -- sequential interactive prompts + const { choice } = await inquirer.prompt([ + { + type: "list", + name: "choice", + message: `${diff.adrId}: What would you like to do?`, + choices: [ + { name: "Keep local", value: "keep" }, + { name: "Take upstream", value: "take" }, + { name: "Skip", value: "skip" }, + ], + }, + ]); + action = choice; + } + + if (action === "take") { + // Read upstream content and rewrite the ID to match local + const upstreamContent = readFileSync(diff.upstreamPath, "utf-8"); + const rewritten = upstreamContent.replace( + /^(id:\s*).*$/mu, + `$1${diff.adrId}` + ); + await Bun.write(diff.localPath, rewritten); // oxlint-disable-line no-await-in-loop -- sequential writes after interactive prompt + updatedCount++; + if (!useJson) { + console.log( + styleText("green", ` Updated ${diff.adrId} from upstream`) + ); + } + } else if (action === "keep" && !useJson) { + console.log( + styleText("dim", ` Kept local version of ${diff.adrId}`) + ); + } + } + + // Update imports.json timestamps + const updatedManifest = loadImportsManifest(projectRoot); + for (const entry of updatedManifest.imports) { + if ( + sources.length === 0 || + sources.some((s) => entry.source.includes(s)) + ) { + entry.importedAt = new Date().toISOString(); + } + } + saveImportsManifest(projectRoot, updatedManifest); + + cleanup(tempDirs); + + if (useJson) { + console.log( + formatJSON( + { + status: "synced", + checked: result.checked, + updated: updatedCount, + withChanges: result.withChanges, + upToDate: result.upToDate, + }, + opts.json ? true : undefined + ) + ); + } else { + console.log(""); + if (updatedCount > 0) { + console.log( + styleText("green", `Synced ${updatedCount} ADR(s) from upstream.`) + ); + } else { + console.log("No ADRs were updated."); + } + } + } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; + logError(err instanceof Error ? err.message : String(err)); + await exitWith(1); + } + }); +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 535f8497..ea374848 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -20,7 +20,13 @@ import { isPublicRepo, shouldShareRepoIdentity, } from "../helpers/repo"; -import { trackInitResult, trackProjectInitialized } from "../helpers/telemetry"; +import { + trackGreenfieldWizardShown, + trackInitResult, + trackPackImportedAtInit, + trackProjectInitialized, + trackWizardSkipped, +} from "../helpers/telemetry"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; const EDITOR_DIRS: Record = { @@ -182,6 +188,11 @@ export function registerInitCommand(program: Command) { } : {}), }); + + // --- Greenfield wizard: offer starter packs when no ADRs exist --- + if (process.stdin.isTTY && !hadExistingProject) { + await runGreenfieldWizard(process.cwd()); + } } catch (err) { // Re-throw ExitPromptError so main().catch() handles Ctrl+C (exit 130) if (err instanceof Error && err.name === "ExitPromptError") throw err; @@ -195,6 +206,106 @@ export function registerInitCommand(program: Command) { }); } +/** + * Greenfield wizard: detect project stack, recommend packs, and import + * selected ones. Only shown in interactive mode when no ADRs existed before init. + */ +async function runGreenfieldWizard(projectRoot: string): Promise { + const { default: inquirer } = await import("inquirer"); + + trackGreenfieldWizardShown(); + + console.log(""); + const { wantPacks } = await inquirer.prompt([ + { + type: "list", + name: "wantPacks", + message: + "No existing ADRs detected. Would you like to import starter packs?", + choices: [ + { name: "Yes, pick packs now (recommended)", value: true }, + { name: "No, start empty", value: false }, + ], + }, + ]); + // Windows cursor-reset — see editor-detect.ts for explanation. + if (process.stdout.isTTY) cursorTo(process.stdout, 0); + + if (!wantPacks) { + trackWizardSkipped(); + return; + } + + const { detectStack } = await import("../helpers/stack-detect"); + const { recommendPacks } = await import("../helpers/pack-recommend"); + + const stack = await detectStack(projectRoot); + + // Show detected stack summary + const stackParts: string[] = []; + if (stack.languages.length > 0) stackParts.push(...stack.languages); + if (stack.runtimes.length > 0) stackParts.push(...stack.runtimes); + if (stack.frameworks.length > 0) stackParts.push(...stack.frameworks); + if (stackParts.length > 0) { + console.log( + styleText( + "dim", + `Detected: ${stackParts.map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(", ")}` + ) + ); + } + + console.log(""); + const recommendations = await recommendPacks(stack); + + if (recommendations.length === 0) { + console.log("No matching packs found in the registry."); + return; + } + + const { selectedPacks } = await inquirer.prompt([ + { + type: "checkbox", + name: "selectedPacks", + message: "Select packs to import:", + choices: recommendations.map((rec) => ({ + name: `${rec.packPath.padEnd(30)} ${String(rec.adrCount).padStart(2)} ADRs (${rec.matchedTags.join(", ")})`, + value: rec.packPath, + checked: rec.relevance === "high", + })), + }, + ]); + // Windows cursor-reset + if (process.stdout.isTTY) cursorTo(process.stdout, 0); + + if (selectedPacks.length === 0) { + console.log("No packs selected."); + return; + } + + // Import selected packs via subprocess to reuse existing import logic + const args = [process.argv[0], "adr", "import", "--yes", ...selectedPacks]; + const proc = Bun.spawn(args, { + cwd: projectRoot, + stdout: "inherit", + stderr: "inherit", + }); + await proc.exited; + + if (proc.exitCode === 0) { + trackPackImportedAtInit(selectedPacks); + console.log(""); + console.log( + styleText( + "green", + `Imported ${selectedPacks.length} pack(s). Run \`archgate check\` to see your baseline.` + ) + ); + } else { + logWarn("Some packs may not have imported successfully."); + } +} + /** * Print manual plugin installation instructions when the editor CLI is not available. */ diff --git a/src/helpers/pack-recommend.ts b/src/helpers/pack-recommend.ts new file mode 100644 index 00000000..8df9fbaf --- /dev/null +++ b/src/helpers/pack-recommend.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +import { parsePackMetadata } from "../formats/pack"; +import { logDebug } from "./log"; +import type { DetectedStack } from "./stack-detect"; + +export interface PackRecommendation { + packPath: string; + packName: string; + description: string; + adrCount: number; + matchedTags: string[]; + relevance: "high" | "medium"; +} + +/** + * Match a single pack tag against the detected stack. + * Returns the relevance level or null if no match. + */ +function matchTag( + tag: string, + stack: DetectedStack +): { relevance: "high" | "medium"; tag: string } | null { + const [namespace, value] = tag.split(":"); + if (!namespace || !value) return null; + + switch (namespace) { + case "language": + if (stack.languages.includes(value)) { + return { relevance: "high", tag }; + } + return null; + case "runtime": + if (stack.runtimes.includes(value)) { + return { relevance: "high", tag }; + } + return null; + case "framework": + if (stack.frameworks.includes(value)) { + return { relevance: "high", tag }; + } + return null; + case "concern": + // Concern tags match everyone with medium relevance + return { relevance: "medium", tag }; + default: + return null; + } +} + +/** + * Count ADR markdown files in a pack's adrs/ directory. + */ +function countAdrs(packDir: string): number { + const adrsDir = join(packDir, "adrs"); + if (!existsSync(adrsDir)) return 0; + return readdirSync(adrsDir).filter((f) => f.endsWith(".md")).length; +} + +/** + * Scan a registry directory for packs and recommend those matching + * the detected stack. Caller is responsible for providing the registry + * directory (e.g. from a shallow clone). + */ +export function recommendPacksFromDir( + stack: DetectedStack, + registryDir: string +): PackRecommendation[] { + const packsDir = join(registryDir, "packs"); + if (!existsSync(packsDir)) { + logDebug("No packs/ directory found in registry:", registryDir); + return []; + } + + const packDirs = readdirSync(packsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + const recommendations: PackRecommendation[] = []; + + for (const packName of packDirs) { + const packDir = join(packsDir, packName); + const yamlPath = join(packDir, "archgate-pack.yaml"); + if (!existsSync(yamlPath)) continue; + + let packMeta; + try { + const content = readFileSync(yamlPath, "utf-8"); + packMeta = parsePackMetadata(content); + } catch (err) { + logDebug(`Failed to parse pack metadata for ${packName}:`, String(err)); + continue; + } + + const matchedTags: string[] = []; + let bestRelevance: "high" | "medium" = "medium"; + + for (const tag of packMeta.tags) { + const match = matchTag(tag, stack); + if (match) { + matchedTags.push(match.tag); + if (match.relevance === "high") { + bestRelevance = "high"; + } + } + } + + // Only recommend packs that have at least one matching tag + if (matchedTags.length === 0) continue; + + recommendations.push({ + packPath: `packs/${packName}`, + packName, + description: packMeta.description, + adrCount: countAdrs(packDir), + matchedTags, + relevance: bestRelevance, + }); + } + + // Sort: high relevance first, then alphabetically + recommendations.sort((a, b) => { + if (a.relevance !== b.relevance) { + return a.relevance === "high" ? -1 : 1; + } + return a.packName.localeCompare(b.packName); + }); + + return recommendations; +} + +/** + * Resolve the registry (via shallow clone), scan for packs matching the + * detected stack, and return recommendations. Cleans up the clone when done. + */ +export async function recommendPacks( + stack: DetectedStack, + _registryDir?: string +): Promise { + // Import shallowClone lazily to avoid circular dependencies + const { shallowClone } = await import("./registry"); + + let cloneDir: string | undefined; + try { + cloneDir = await shallowClone( + "https://github.com/archgate/awesome-adrs.git" + ); + return recommendPacksFromDir(stack, cloneDir); + } finally { + if (cloneDir) { + try { + rmSync(cloneDir, { recursive: true, force: true }); + } catch { + logDebug("Failed to clean up registry clone:", cloneDir); + } + } + } +} diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts new file mode 100644 index 00000000..7f9cbead --- /dev/null +++ b/src/helpers/stack-detect.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { logDebug } from "./log"; + +export interface DetectedStack { + languages: string[]; + runtimes: string[]; + frameworks: string[]; +} + +/** + * Detect the project's language, runtime, and framework from files present + * in the project root. Used by the greenfield wizard to recommend packs. + */ +export async function detectStack(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; + + if (hasPkgJson) { + try { + pkgJson = (await Bun.file(pkgJsonPath).json()) as Record; + } catch { + logDebug("Failed to parse package.json"); + } + } + + const deps = { + ...(pkgJson?.dependencies as Record | undefined), + ...(pkgJson?.devDependencies as Record | undefined), + }; + + // --- Languages --- + const hasTsConfig = existsSync(join(projectRoot, "tsconfig.json")); + const hasTsDep = Boolean(deps.typescript); + if (hasTsConfig || hasTsDep) { + languages.push("typescript"); + } else if (hasPkgJson) { + languages.push("javascript"); + } + + if ( + existsSync(join(projectRoot, "pyproject.toml")) || + existsSync(join(projectRoot, "requirements.txt")) || + existsSync(join(projectRoot, "setup.py")) + ) { + languages.push("python"); + } + + if (existsSync(join(projectRoot, "go.mod"))) { + languages.push("go"); + } + + if (existsSync(join(projectRoot, "Cargo.toml"))) { + languages.push("rust"); + } + + // --- Runtimes --- + if (hasPkgJson) { + runtimes.push("node"); + } + + if ( + existsSync(join(projectRoot, "bun.lock")) || + existsSync(join(projectRoot, "bunfig.toml")) + ) { + runtimes.push("bun"); + } + + if ( + existsSync(join(projectRoot, "deno.json")) || + existsSync(join(projectRoot, "deno.jsonc")) + ) { + runtimes.push("deno"); + } + + // --- 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"); + } + + // Remix: remix.config.* + const remixConfigExtensions = ["js", "cjs", "mjs", "ts"]; + if ( + remixConfigExtensions.some((ext) => + existsSync(join(projectRoot, `remix.config.${ext}`)) + ) + ) { + frameworks.push("remix"); + } + + // Vite: vite.config.* + const viteConfigExtensions = ["js", "cjs", "mjs", "ts", "mts", "cts"]; + if ( + viteConfigExtensions.some((ext) => + existsSync(join(projectRoot, `vite.config.${ext}`)) + ) + ) { + frameworks.push("vite"); + } + + // 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"); + + // React from package.json dependencies + if (deps.react) frameworks.push("react"); + + logDebug("Detected stack:", { languages, runtimes, frameworks }); + + return { languages, runtimes, frameworks }; +} diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index 8fbf4c0a..f64ec97b 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -389,9 +389,7 @@ export function trackUpgradeResult(properties: { trackEvent("upgrade_completed", properties); } -/** - * Track the outcome of `archgate login`. - */ +/** Track the outcome of `archgate login`. */ export function trackLoginResult(properties: { subcommand: "login" | "logout" | "refresh" | "status"; success: boolean; @@ -400,16 +398,32 @@ export function trackLoginResult(properties: { trackEvent("login_completed", properties); } -/** - * Track preference changes so we can measure opt-out rate. Fires one last - * event right before disabling telemetry, and a fresh event when re-enabling. - */ +/** Track preference changes (opt-out rate). Fires one last event before disabling, fresh event when re-enabling. */ export function trackTelemetryPreferenceChange(properties: { enabled: boolean; }): void { trackEvent("telemetry_preference_changed", properties); } +/** Track when the greenfield wizard prompt is displayed. */ +export function trackGreenfieldWizardShown(): void { + trackEvent("adoption.greenfield_wizard_shown"); +} + +/** Track packs imported via wizard. Only official registry names collected; third-party counted, not identified. */ +export function trackPackImportedAtInit(packs: string[]): void { + const official = packs.filter((p) => p.startsWith("packs/")); + trackEvent("adoption.pack_imported_at_init", { + official_packs: official, + third_party_count: packs.length - official.length, + }); +} + +/** Track when user chooses "No, start empty" in the greenfield wizard. */ +export function trackWizardSkipped(): void { + trackEvent("adoption.wizard_skipped"); +} + /** * Track registration of a custom ADR domain. The domain name and prefix are * architectural category labels (e.g. "security" / "SEC"), not user data — @@ -471,9 +485,7 @@ export async function flushTelemetry(timeoutMs = 3000): Promise { /** Reset telemetry state. For testing only. */ export function _resetTelemetry(): void { - if (client) { - client.shutdown().catch(() => {}); - } + if (client) client.shutdown().catch(() => {}); client = null; initialized = false; distinctId = ""; diff --git a/tests/commands/adr/import.test.ts b/tests/commands/adr/import.test.ts index 9d0e4378..cb805c93 100644 --- a/tests/commands/adr/import.test.ts +++ b/tests/commands/adr/import.test.ts @@ -45,12 +45,12 @@ describe("registerAdrImportCommand", () => { expect(dryRunOpt).toBeDefined(); }); - test("accepts --prefix option", () => { + test("does not have a --prefix option (domain-aware remapping)", () => { const parent = new Command("adr"); registerAdrImportCommand(parent); const sub = parent.commands.find((c) => c.name() === "import")!; const prefixOpt = sub.options.find((o) => o.long === "--prefix"); - expect(prefixOpt).toBeDefined(); + expect(prefixOpt).toBeUndefined(); }); test("accepts --list option", () => { diff --git a/tests/commands/adr/sync.test.ts b/tests/commands/adr/sync.test.ts new file mode 100644 index 00000000..16b185cb --- /dev/null +++ b/tests/commands/adr/sync.test.ts @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerAdrSyncCommand } from "../../../src/commands/adr/sync"; +import { safeRmSync } from "../../test-utils"; + +describe("adr sync command", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-sync-")); + originalCwd = process.cwd(); + Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; + logSpy = spyOn(console, "log").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + delete Bun.env.ARCHGATE_PROJECT_CEILING; + safeRmSync(tempDir); + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + function scaffoldProject(): void { + mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); + mkdirSync(join(tempDir, ".archgate", "lint"), { recursive: true }); + } + + test("registers sync command with correct options", () => { + const parent = new Command("adr").exitOverride(); + registerAdrSyncCommand(parent); + + const sync = parent.commands.find((c) => c.name() === "sync"); + expect(sync).toBeDefined(); + expect(sync!.description()).toBe( + "Check for upstream updates to imported ADRs" + ); + + // Check options exist + const optionNames = sync!.options.map((o) => o.long); + expect(optionNames).toContain("--check"); + expect(optionNames).toContain("--yes"); + expect(optionNames).toContain("--json"); + }); + + test("prints empty message when no imports exist", async () => { + scaffoldProject(); + process.chdir(tempDir); + + const parent = new Command("adr").exitOverride(); + registerAdrSyncCommand(parent); + + await parent.parseAsync(["node", "adr", "sync"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("No imported ADRs found."); + }); + + test("prints empty message in JSON mode when no imports exist", async () => { + scaffoldProject(); + process.chdir(tempDir); + + const parent = new Command("adr").exitOverride(); + registerAdrSyncCommand(parent); + + await parent.parseAsync(["node", "adr", "sync", "--json"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + const parsed = JSON.parse(allOutput); + expect(parsed.status).toBe("empty"); + }); + + test("exits with error when .archgate/ directory is missing", async () => { + process.chdir(tempDir); + + const parent = new Command("adr").exitOverride(); + registerAdrSyncCommand(parent); + + await expect(parent.parseAsync(["node", "adr", "sync"])).rejects.toThrow( + "process.exit" + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + test("filters by source when source args provided", async () => { + scaffoldProject(); + process.chdir(tempDir); + + // Write an imports manifest with entries + writeFileSync( + join(tempDir, ".archgate", "imports.json"), + JSON.stringify( + { + imports: [ + { + source: "packs/typescript-strict", + version: "0.1.0", + importedAt: "2026-01-15T12:00:00.000Z", + adrIds: ["ARCH-001"], + }, + ], + }, + null, + 2 + ) + "\n" + ); + + const parent = new Command("adr").exitOverride(); + registerAdrSyncCommand(parent); + + // Filter by a non-matching source + await parent.parseAsync(["node", "adr", "sync", "packs/nonexistent"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("No imports match the given source filter(s)."); + }); +}); diff --git a/tests/helpers/pack-recommend.test.ts b/tests/helpers/pack-recommend.test.ts new file mode 100644 index 00000000..128db9e0 --- /dev/null +++ b/tests/helpers/pack-recommend.test.ts @@ -0,0 +1,211 @@ +// 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 { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { recommendPacksFromDir } from "../../src/helpers/pack-recommend"; +import type { DetectedStack } from "../../src/helpers/stack-detect"; +import { safeRmSync } from "../test-utils"; + +describe("recommendPacksFromDir", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) safeRmSync(tempDir); + }); + + function createPack( + registryDir: string, + name: string, + opts: { tags: string[]; adrCount: number; description?: string } + ): void { + const packDir = join(registryDir, "packs", name); + const adrsDir = join(packDir, "adrs"); + mkdirSync(adrsDir, { recursive: true }); + + const yaml = [ + `name: ${name}`, + `version: 0.1.0`, + `description: ${opts.description ?? `Test pack ${name}`}`, + `maintainers:`, + ` - github: testuser`, + `tags:`, + ...opts.tags.map((t) => ` - ${t}`), + ].join("\n"); + writeFileSync(join(packDir, "archgate-pack.yaml"), yaml); + + for (let i = 1; i <= opts.adrCount; i++) { + const id = `TP-${String(i).padStart(3, "0")}`; + writeFileSync( + join(adrsDir, `${id}-rule-${i}.md`), + `---\nid: ${id}\ntitle: Rule ${i}\n---\n# Rule ${i}\n` + ); + } + } + + test("returns high relevance for language match", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "typescript-strict", { + tags: ["language:typescript"], + adrCount: 4, + }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: ["node"], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(1); + expect(recs[0].packName).toBe("typescript-strict"); + expect(recs[0].relevance).toBe("high"); + expect(recs[0].adrCount).toBe(4); + expect(recs[0].matchedTags).toContain("language:typescript"); + }); + + test("returns medium relevance for concern tags", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "security", { + tags: ["concern:security"], + adrCount: 3, + }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: ["node"], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(1); + expect(recs[0].packName).toBe("security"); + expect(recs[0].relevance).toBe("medium"); + }); + + test("sorts high relevance before medium", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "security", { + tags: ["concern:security"], + adrCount: 3, + }); + createPack(tempDir, "typescript-strict", { + tags: ["language:typescript"], + adrCount: 4, + }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: ["node"], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(2); + expect(recs[0].relevance).toBe("high"); + expect(recs[1].relevance).toBe("medium"); + }); + + test("matches framework tags with high relevance", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "nextjs-app", { + tags: ["framework:nextjs", "language:typescript"], + adrCount: 3, + }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: ["node"], + frameworks: ["nextjs"], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(1); + expect(recs[0].relevance).toBe("high"); + expect(recs[0].matchedTags).toContain("framework:nextjs"); + expect(recs[0].matchedTags).toContain("language:typescript"); + }); + + test("excludes packs with no matching tags", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "rust-safety", { + tags: ["language:rust"], + adrCount: 2, + }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: ["node"], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(0); + }); + + test("returns empty array when no packs directory exists", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: [], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(0); + }); + + test("counts ADR files correctly", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "testing", { tags: ["concern:testing"], adrCount: 5 }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: [], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(1); + expect(recs[0].adrCount).toBe(5); + }); + + test("matches runtime tags", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "bun-best-practices", { + tags: ["runtime:bun"], + adrCount: 2, + }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: ["bun"], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(1); + expect(recs[0].relevance).toBe("high"); + expect(recs[0].matchedTags).toContain("runtime:bun"); + }); + + test("alphabetical sort within same relevance", () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + createPack(tempDir, "zebra", { tags: ["concern:zebra"], adrCount: 1 }); + createPack(tempDir, "alpha", { tags: ["concern:alpha"], adrCount: 1 }); + + const stack: DetectedStack = { + languages: ["typescript"], + runtimes: [], + frameworks: [], + }; + + const recs = recommendPacksFromDir(stack, tempDir); + expect(recs).toHaveLength(2); + expect(recs[0].packName).toBe("alpha"); + expect(recs[1].packName).toBe("zebra"); + }); +}); diff --git a/tests/helpers/stack-detect.test.ts b/tests/helpers/stack-detect.test.ts new file mode 100644 index 00000000..5535c48c --- /dev/null +++ b/tests/helpers/stack-detect.test.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test, afterEach } from "bun:test"; +import { 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", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) safeRmSync(tempDir); + }); + + test("detects TypeScript from tsconfig.json", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "tsconfig.json"), "{}"); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "test" }) + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("typescript"); + expect(stack.languages).not.toContain("javascript"); + expect(stack.runtimes).toContain("node"); + }); + + test("detects TypeScript from devDependencies", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "test", + devDependencies: { typescript: "^5.0.0" }, + }) + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("typescript"); + expect(stack.languages).not.toContain("javascript"); + }); + + test("detects JavaScript when package.json exists without TypeScript", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "test" }) + ); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("javascript"); + expect(stack.languages).not.toContain("typescript"); + }); + + test("detects Python from pyproject.toml", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "pyproject.toml"), "[project]\nname = 'test'"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("python"); + }); + + test("detects Python from requirements.txt", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "requirements.txt"), "flask==2.0.0"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("python"); + }); + + test("detects Go from go.mod", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "go.mod"), "module example.com/test"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("go"); + }); + + test("detects Rust from Cargo.toml", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "Cargo.toml"), '[package]\nname = "test"'); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("rust"); + }); + + test("detects Bun runtime from bun.lock", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); + writeFileSync(join(tempDir, "bun.lock"), "# bun lockfile"); + + const stack = await detectStack(tempDir); + expect(stack.runtimes).toContain("bun"); + expect(stack.runtimes).toContain("node"); + }); + + test("detects Deno runtime from deno.json", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "deno.json"), "{}"); + + const stack = await detectStack(tempDir); + expect(stack.runtimes).toContain("deno"); + }); + + test("detects Next.js framework from next.config.ts", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "t", dependencies: { react: "^18" } }) + ); + writeFileSync(join(tempDir, "next.config.ts"), "export default {}"); + + const stack = await detectStack(tempDir); + expect(stack.frameworks).toContain("nextjs"); + expect(stack.frameworks).toContain("react"); + }); + + 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 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("returns empty arrays for empty directory", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + + const stack = await detectStack(tempDir); + expect(stack.languages).toEqual([]); + expect(stack.runtimes).toEqual([]); + expect(stack.frameworks).toEqual([]); + }); + + test("detects multiple languages in a polyglot project", async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + writeFileSync(join(tempDir, "tsconfig.json"), "{}"); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ name: "test" }) + ); + writeFileSync(join(tempDir, "go.mod"), "module example.com/test"); + writeFileSync(join(tempDir, "requirements.txt"), "flask"); + + const stack = await detectStack(tempDir); + expect(stack.languages).toContain("typescript"); + expect(stack.languages).toContain("go"); + expect(stack.languages).toContain("python"); + }); +});