From d419c60eb117512be88ed0de2ed1102e62700c6e Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 20:36:46 +0200 Subject: [PATCH 1/9] fix(ci): pin proto 0.56.3 on Windows, restore node 24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore node = "24" in .prototools — the problem was never Node, it was proto 0.56.4. Pin proto to 0.56.3 in the Windows smoke test workflow to avoid the starbase_archive 0.13.0 regression that prevents Node 24 from extracting on Windows Server 2025 runners. Remove the proto pin once proto 0.57+ ships with the fix. See: https://github.com/moonrepo/proto/issues/1006 Signed-off-by: Rhuan Barreto --- .github/workflows/smoke-test-windows.yml | 5 +++++ .prototools | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smoke-test-windows.yml b/.github/workflows/smoke-test-windows.yml index 146a5133..37ea6a82 100644 --- a/.github/workflows/smoke-test-windows.yml +++ b/.github/workflows/smoke-test-windows.yml @@ -23,6 +23,11 @@ jobs: - uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 # v0 with: auto-install: true + # Pin proto to 0.56.3 — v0.56.4 has a regression in starbase_archive + # 0.13.0 that prevents Node 24 from extracting on Windows Server 2025. + # Remove this pin once proto 0.57+ ships with the fix. + # See: https://github.com/moonrepo/proto/issues/1006 + proto-version: "0.56.3" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.prototools b/.prototools index 5dc8cead..c45619f6 100644 --- a/.prototools +++ b/.prototools @@ -1,5 +1,5 @@ bun = "1.3.13" -node = "22" +node = "24" npm = "11.14.0" gh = "2.92.0" From a37d963789d1927fbb8a7c065db3f82b67e25c04 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 20:58:00 +0200 Subject: [PATCH 2/9] feat: add greenfield wizard and `archgate adr sync` command Greenfield wizard (Phase D): - Add stack-detect helper to detect project languages, runtimes, and frameworks from project files (tsconfig.json, package.json, go.mod, etc.) - Add pack-recommend helper to match detected stack against registry pack tags and return sorted recommendations - Extend `archgate init` to show an interactive wizard when no ADRs exist, offering to import recommended starter packs - Add telemetry events for wizard shown, packs imported, and wizard skipped ADR sync command (Phase E): - Add `archgate adr sync` command to check imported ADRs for upstream updates, with --check (CI mode), --yes, and --json flags - Supports source filtering, per-ADR diff summaries, and interactive keep/take/skip prompts - Register sync command in the adr command group Tests: - Add stack-detect tests covering all language/runtime/framework detection heuristics - Add pack-recommend tests covering tag matching, relevance sorting, ADR counting, and edge cases - Add sync command tests covering registration, empty state, JSON output, missing project, and source filtering Signed-off-by: Rhuan Barreto --- src/commands/adr/index.ts | 2 + src/commands/adr/sync.ts | 485 +++++++++++++++++++++++++++ src/commands/init.ts | 122 ++++++- src/helpers/pack-recommend.ts | 161 +++++++++ src/helpers/stack-detect.ts | 137 ++++++++ src/helpers/telemetry.ts | 24 ++ tests/commands/adr/sync.test.ts | 141 ++++++++ tests/helpers/pack-recommend.test.ts | 220 ++++++++++++ tests/helpers/stack-detect.test.ts | 172 ++++++++++ 9 files changed, 1463 insertions(+), 1 deletion(-) create mode 100644 src/commands/adr/sync.ts create mode 100644 src/helpers/pack-recommend.ts create mode 100644 src/helpers/stack-detect.ts create mode 100644 tests/commands/adr/sync.test.ts create mode 100644 tests/helpers/pack-recommend.test.ts create mode 100644 tests/helpers/stack-detect.test.ts 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..ba9f2574 --- /dev/null +++ b/src/commands/adr/sync.ts @@ -0,0 +1,485 @@ +// 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); + 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) { + const { default: inquirer } = await import("inquirer"); + 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); + 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..f037a84b 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,115 @@ 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({ + pack_names: selectedPacks, + pack_count: selectedPacks.length, + }); + 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..f4c50b21 --- /dev/null +++ b/src/helpers/stack-detect.ts @@ -0,0 +1,137 @@ +// 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< + string, + unknown + >; + } 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..ee3d2881 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -410,6 +410,30 @@ export function trackTelemetryPreferenceChange(properties: { trackEvent("telemetry_preference_changed", properties); } +/** + * Track when the greenfield wizard prompt is displayed. + */ +export function trackGreenfieldWizardShown(): void { + trackEvent("adoption.greenfield_wizard_shown"); +} + +/** + * Track when packs are imported via the greenfield wizard. + */ +export function trackPackImportedAtInit(properties: { + pack_names: string[]; + pack_count: number; +}): void { + trackEvent("adoption.pack_imported_at_init", properties); +} + +/** + * 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 — diff --git a/tests/commands/adr/sync.test.ts b/tests/commands/adr/sync.test.ts new file mode 100644 index 00000000..1fab72b8 --- /dev/null +++ b/tests/commands/adr/sync.test.ts @@ -0,0 +1,141 @@ +// 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..56d7af06 --- /dev/null +++ b/tests/helpers/pack-recommend.test.ts @@ -0,0 +1,220 @@ +// 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..734f52e0 --- /dev/null +++ b/tests/helpers/stack-detect.test.ts @@ -0,0 +1,172 @@ +// 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"); + }); +}); From a4a8afe4b68261dc5ac68573e88e185f7567bff4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 21:12:37 +0200 Subject: [PATCH 3/9] fix: resolve oxlint errors (no-lonely-if, max-lines, no-await-in-loop) - Flatten else-if chain in sync.ts to fix no-lonely-if - Add oxlint-disable-line for intentionally sequential awaits in sync.ts - Collapse JSDoc comments in telemetry.ts to stay within 500-line limit Signed-off-by: Rhuan Barreto --- src/commands/adr/sync.ts | 36 +++++++++++++++++------------------- src/helpers/telemetry.ts | 21 +++++---------------- 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/src/commands/adr/sync.ts b/src/commands/adr/sync.ts index ba9f2574..db10152d 100644 --- a/src/commands/adr/sync.ts +++ b/src/commands/adr/sync.ts @@ -219,7 +219,7 @@ export function registerAdrSyncCommand(adr: Command) { if (!cloneDir) { try { - cloneDir = await shallowClone(resolved.repoUrl, resolved.ref); + 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) { @@ -327,24 +327,22 @@ export function registerAdrSyncCommand(adr: Command) { 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 { + } 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( - styleText("green", "All imported ADRs are up to date.") + ` ${diff.adrId} (${diff.source}): ${diff.summary}` ); } + } else { + console.log( + styleText("green", "All imported ADRs are up to date.") + ); } cleanup(tempDirs); @@ -401,8 +399,8 @@ export function registerAdrSyncCommand(adr: Command) { if (opts.yes) { action = "take"; } else if (!useJson && process.stdin.isTTY) { - const { default: inquirer } = await import("inquirer"); - const { choice } = await inquirer.prompt([ + const { default: inquirer } = await import("inquirer"); // oxlint-disable-line no-await-in-loop -- sequential interactive prompts + const { choice } = await inquirer.prompt([ // oxlint-disable-line no-await-in-loop -- sequential interactive prompts { type: "list", name: "choice", @@ -424,7 +422,7 @@ export function registerAdrSyncCommand(adr: Command) { /^(id:\s*).*$/mu, `$1${diff.adrId}` ); - await Bun.write(diff.localPath, rewritten); + await Bun.write(diff.localPath, rewritten); // oxlint-disable-line no-await-in-loop -- sequential writes after interactive prompt updatedCount++; if (!useJson) { console.log( diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index ee3d2881..7acf8287 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,26 +398,19 @@ 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. - */ +/** Track when the greenfield wizard prompt is displayed. */ export function trackGreenfieldWizardShown(): void { trackEvent("adoption.greenfield_wizard_shown"); } -/** - * Track when packs are imported via the greenfield wizard. - */ +/** Track when packs are imported via the greenfield wizard. */ export function trackPackImportedAtInit(properties: { pack_names: string[]; pack_count: number; @@ -427,9 +418,7 @@ export function trackPackImportedAtInit(properties: { trackEvent("adoption.pack_imported_at_init", properties); } -/** - * Track when user chooses "No, start empty" in the greenfield wizard. - */ +/** Track when user chooses "No, start empty" in the greenfield wizard. */ export function trackWizardSkipped(): void { trackEvent("adoption.wizard_skipped"); } From fbcd6884cb669e47e01537e029d4ac50327adce8 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 21:16:57 +0200 Subject: [PATCH 4/9] fix: redact private repo paths from telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trackPackImportedAtInit now only collects pack names for official registry sources (packs/...). Third-party sources are counted but their paths are never sent — avoids leaking private repo addresses. Signed-off-by: Rhuan Barreto --- src/commands/init.ts | 5 +---- src/helpers/telemetry.ts | 10 ++++------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index f037a84b..23d35c8e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -299,10 +299,7 @@ async function runGreenfieldWizard(projectRoot: string): Promise { await proc.exited; if (proc.exitCode === 0) { - trackPackImportedAtInit({ - pack_names: selectedPacks, - pack_count: selectedPacks.length, - }); + trackPackImportedAtInit(selectedPacks); console.log(""); console.log( styleText( diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index 7acf8287..5a133b43 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -410,12 +410,10 @@ export function trackGreenfieldWizardShown(): void { trackEvent("adoption.greenfield_wizard_shown"); } -/** Track when packs are imported via the greenfield wizard. */ -export function trackPackImportedAtInit(properties: { - pack_names: string[]; - pack_count: number; -}): void { - trackEvent("adoption.pack_imported_at_init", properties); +/** Track packs imported via the greenfield wizard. Only official registry names are collected; third-party sources are counted but 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. */ From 49390eca59250a6571245bacfd19cf19ae702b29 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 21:21:09 +0200 Subject: [PATCH 5/9] fix: regenerate llms-full.txt and fix formatting - Regenerate docs/public/llms-full.txt for new adr sync command - Fix oxlint disable comments to use block-level directives (formatter moves inline comments to new lines) - Compact telemetry test helpers to stay within 500-line limit Signed-off-by: Rhuan Barreto --- src/commands/adr/sync.ts | 23 ++++++++++------------- src/helpers/telemetry.ts | 11 ++++++----- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/commands/adr/sync.ts b/src/commands/adr/sync.ts index db10152d..409ef41a 100644 --- a/src/commands/adr/sync.ts +++ b/src/commands/adr/sync.ts @@ -247,9 +247,7 @@ export function registerAdrSyncCommand(adr: Command) { const upstreamAdrsDir = join(cloneDir, upstreamSubpath, "adrs"); if (!existsSync(upstreamAdrsDir)) { - logDebug( - `Upstream adrs dir not found: ${upstreamAdrsDir}` - ); + logDebug(`Upstream adrs dir not found: ${upstreamAdrsDir}`); result.errors++; continue; } @@ -335,9 +333,7 @@ export function registerAdrSyncCommand(adr: Command) { ) ); for (const diff of result.diffs.filter((d) => d.hasChanges)) { - console.log( - ` ${diff.adrId} (${diff.source}): ${diff.summary}` - ); + console.log(` ${diff.adrId} (${diff.source}): ${diff.summary}`); } } else { console.log( @@ -399,8 +395,10 @@ export function registerAdrSyncCommand(adr: Command) { if (opts.yes) { action = "take"; } else if (!useJson && process.stdin.isTTY) { - const { default: inquirer } = await import("inquirer"); // oxlint-disable-line no-await-in-loop -- sequential interactive prompts - const { choice } = await inquirer.prompt([ // oxlint-disable-line no-await-in-loop -- sequential interactive prompts + // 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", @@ -430,7 +428,9 @@ export function registerAdrSyncCommand(adr: Command) { ); } } else if (action === "keep" && !useJson) { - console.log(styleText("dim", ` Kept local version of ${diff.adrId}`)); + console.log( + styleText("dim", ` Kept local version of ${diff.adrId}`) + ); } } @@ -465,10 +465,7 @@ export function registerAdrSyncCommand(adr: Command) { console.log(""); if (updatedCount > 0) { console.log( - styleText( - "green", - `Synced ${updatedCount} ADR(s) from upstream.` - ) + styleText("green", `Synced ${updatedCount} ADR(s) from upstream.`) ); } else { console.log("No ADRs were updated."); diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index 5a133b43..f64ec97b 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -410,10 +410,13 @@ export function trackGreenfieldWizardShown(): void { trackEvent("adoption.greenfield_wizard_shown"); } -/** Track packs imported via the greenfield wizard. Only official registry names are collected; third-party sources are counted but not identified. */ +/** 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 }); + 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. */ @@ -482,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 = ""; From b0186372c4ef7b1906cf0411dd8ba4707ac601ab Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 21:21:21 +0200 Subject: [PATCH 6/9] style: apply oxfmt formatting to new files Signed-off-by: Rhuan Barreto --- src/commands/init.ts | 8 +------- src/helpers/stack-detect.ts | 15 ++------------- tests/commands/adr/sync.test.ts | 12 ++++-------- tests/helpers/pack-recommend.test.ts | 15 +++------------ tests/helpers/stack-detect.test.ts | 10 ++-------- 5 files changed, 12 insertions(+), 48 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 23d35c8e..ea374848 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -284,13 +284,7 @@ async function runGreenfieldWizard(projectRoot: string): Promise { } // Import selected packs via subprocess to reuse existing import logic - const args = [ - process.argv[0], - "adr", - "import", - "--yes", - ...selectedPacks, - ]; + const args = [process.argv[0], "adr", "import", "--yes", ...selectedPacks]; const proc = Bun.spawn(args, { cwd: projectRoot, stdout: "inherit", diff --git a/src/helpers/stack-detect.ts b/src/helpers/stack-detect.ts index f4c50b21..7f9cbead 100644 --- a/src/helpers/stack-detect.ts +++ b/src/helpers/stack-detect.ts @@ -26,10 +26,7 @@ export async function detectStack(projectRoot: string): Promise { if (hasPkgJson) { try { - pkgJson = (await Bun.file(pkgJsonPath).json()) as Record< - string, - unknown - >; + pkgJson = (await Bun.file(pkgJsonPath).json()) as Record; } catch { logDebug("Failed to parse package.json"); } @@ -86,15 +83,7 @@ export async function detectStack(projectRoot: string): Promise { // --- Frameworks --- // Next.js: next.config.* (any extension) - const nextConfigExtensions = [ - "js", - "cjs", - "mjs", - "ts", - "mts", - "cts", - "json", - ]; + const nextConfigExtensions = ["js", "cjs", "mjs", "ts", "mts", "cts", "json"]; if ( nextConfigExtensions.some((ext) => existsSync(join(projectRoot, `next.config.${ext}`)) diff --git a/tests/commands/adr/sync.test.ts b/tests/commands/adr/sync.test.ts index 1fab72b8..16b185cb 100644 --- a/tests/commands/adr/sync.test.ts +++ b/tests/commands/adr/sync.test.ts @@ -1,11 +1,7 @@ // 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 { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -97,9 +93,9 @@ describe("adr sync command", () => { const parent = new Command("adr").exitOverride(); registerAdrSyncCommand(parent); - await expect( - parent.parseAsync(["node", "adr", "sync"]) - ).rejects.toThrow("process.exit"); + await expect(parent.parseAsync(["node", "adr", "sync"])).rejects.toThrow( + "process.exit" + ); expect(exitSpy).toHaveBeenCalledWith(1); }); diff --git a/tests/helpers/pack-recommend.test.ts b/tests/helpers/pack-recommend.test.ts index 56d7af06..128db9e0 100644 --- a/tests/helpers/pack-recommend.test.ts +++ b/tests/helpers/pack-recommend.test.ts @@ -160,10 +160,7 @@ describe("recommendPacksFromDir", () => { test("counts ADR files correctly", () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); - createPack(tempDir, "testing", { - tags: ["concern:testing"], - adrCount: 5, - }); + createPack(tempDir, "testing", { tags: ["concern:testing"], adrCount: 5 }); const stack: DetectedStack = { languages: ["typescript"], @@ -197,14 +194,8 @@ describe("recommendPacksFromDir", () => { 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, - }); + createPack(tempDir, "zebra", { tags: ["concern:zebra"], adrCount: 1 }); + createPack(tempDir, "alpha", { tags: ["concern:alpha"], adrCount: 1 }); const stack: DetectedStack = { languages: ["typescript"], diff --git a/tests/helpers/stack-detect.test.ts b/tests/helpers/stack-detect.test.ts index 734f52e0..5535c48c 100644 --- a/tests/helpers/stack-detect.test.ts +++ b/tests/helpers/stack-detect.test.ts @@ -123,10 +123,7 @@ describe("detectStack", () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( join(tempDir, "package.json"), - JSON.stringify({ - name: "test", - dependencies: { express: "^4.0.0" }, - }) + JSON.stringify({ name: "test", dependencies: { express: "^4.0.0" } }) ); const stack = await detectStack(tempDir); @@ -135,10 +132,7 @@ describe("detectStack", () => { 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, "package.json"), JSON.stringify({ name: "t" })); writeFileSync(join(tempDir, "vite.config.ts"), "export default {}"); const stack = await detectStack(tempDir); From eb6da115c7f5abdba1bf60e3597210d39e49dc31 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 21:52:12 +0200 Subject: [PATCH 7/9] feat: domain-aware ID remapping in adr import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import now reads each ADR's domain field and resolves it to the project's prefix for that domain (e.g. frontend → FE, backend → BE). Each domain gets its own ID counter, so importing packs with mixed domains produces correctly prefixed IDs without collisions. The --prefix flag still works as an explicit override that forces a single prefix for all imported ADRs (backwards compatible). Signed-off-by: Rhuan Barreto --- src/commands/adr/import.ts | 40 ++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/commands/adr/import.ts b/src/commands/adr/import.ts index 75da9bce..452b9b69 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, }); } @@ -285,21 +291,43 @@ 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); + // When --prefix is set, force a single prefix for all ADRs (legacy behavior). + // Otherwise, resolve each ADR's domain to the project's prefix for that domain. + const prefixOverride = opts.prefix; + const domainPrefixes = prefixOverride + ? null + : 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 = + prefixOverride ?? + (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 ---------- From 1757b6ee6fa16118d78df28344d123f84b74c609 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 11 May 2026 21:55:39 +0200 Subject: [PATCH 8/9] refactor: remove --prefix flag from adr import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain-aware remapping is now the only behavior — each ADR's domain field determines its prefix automatically. The --prefix override added unnecessary complexity with no real use case. Also updates EN and PT-BR docs to reflect the new remapping behavior. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 12 ++++------ .../content/docs/guides/importing-adrs.mdx | 24 +++++++++---------- .../docs/pt-br/guides/importing-adrs.mdx | 24 +++++++++---------- src/commands/adr/import.ts | 14 +++-------- tests/commands/adr/import.test.ts | 4 ++-- 5 files changed, 32 insertions(+), 46 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index dbc4535a..53b04a9e 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 @@ -1751,7 +1750,6 @@ This manifest lets you track provenance — where each imported ADR came from an | `--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 | --- 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 452b9b69..09efd3de 100644 --- a/src/commands/adr/import.ts +++ b/src/commands/adr/import.ts @@ -240,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(); @@ -293,12 +292,8 @@ export function registerAdrImportCommand(adr: Command) { mkdirSync(paths.adrsDir, { recursive: true }); - // When --prefix is set, force a single prefix for all ADRs (legacy behavior). - // Otherwise, resolve each ADR's domain to the project's prefix for that domain. - const prefixOverride = opts.prefix; - const domainPrefixes = prefixOverride - ? null - : getMergedDomainPrefixes(projectRoot); + // 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(); @@ -307,10 +302,7 @@ export function registerAdrImportCommand(adr: Command) { []; for (const adr of adrsToImport) { - const prefix = - prefixOverride ?? - (adr.domain && domainPrefixes?.[adr.domain]) ?? - "ARCH"; + const prefix = (adr.domain && domainPrefixes[adr.domain]) || "ARCH"; if (!nextIdByPrefix.has(prefix)) { nextIdByPrefix.set(prefix, getNextId(paths.adrsDir, prefix)); 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", () => { From a68ee7aba6775c173fe34eb122caaf3622875a05 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Mon, 11 May 2026 19:56:45 +0000 Subject: [PATCH 9/9] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 53b04a9e..62a94ba1 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1745,12 +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 | -| `--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 | ---