diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 8cb9edeb..72f40479 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -253,6 +253,7 @@ export default defineConfig({ }, { label: "Rule API", slug: "reference/rule-api" }, { label: "ADR Schema", slug: "reference/adr-schema" }, + { label: "Configuration", slug: "reference/configuration" }, { label: "Telemetry", slug: "reference/telemetry" }, { label: "Privacy Policy", slug: "reference/privacy-policy" }, ], diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index c8e004bd..4333083b 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -706,7 +706,7 @@ archgate adr domain add security SEC archgate adr domain remove security ``` -Custom domain → prefix mappings persist in `.archgate/config.json` and are merged with the built-ins at read time. A registered custom domain behaves exactly like a built-in: `archgate adr create --domain security` auto-generates IDs like `SEC-001`, and `archgate adr list --domain security` filters to those ADRs. +Custom domain-to-prefix mappings persist in [`.archgate/config.json`](/reference/configuration/) and are merged with the built-ins at read time. A registered custom domain behaves exactly like a built-in: `archgate adr create --domain security` auto-generates IDs like `SEC-001`, and `archgate adr list --domain security` filters to those ADRs. ### Naming rules @@ -4029,6 +4029,100 @@ Archgate upgraded to 0.4.0 successfully. --- +## Reference: Configuration + +Source: https://cli.archgate.dev/reference/configuration/ + +The `.archgate/config.json` file stores project-level configuration that is committed to version control and shared across the team. + +This file is created automatically by `archgate init` (when custom domains are registered) or when you manually add configuration. It lives inside the `.archgate/` directory at your project root. + +## Schema + +```json +{ + "domains": { "security": "SEC", "compliance": "COMP" }, + "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" } +} +``` + +### `domains` + +Custom domain-to-prefix mappings. See [Custom Domains](/concepts/domains/#custom-domains) for details. + +| Key | Type | Description | +| ------ | -------- | ------------------------------------------------------------------------------------------- | +| _name_ | `string` | Domain name (lowercase kebab-case, 2-32 chars) maps to an ID prefix (uppercase, 2-10 chars) | + +These are merged with the built-in domains (`backend`, `frontend`, `data`, `architecture`, `general`) at read time. Custom entries cannot override built-in names or prefixes. + +### `paths` + +Override default directories for ADRs and rules. + +| Field | Type | Default | Description | +| ------- | -------- | ---------------- | ----------------------------------------- | +| `adrs` | `string` | `.archgate/adrs` | Relative path to the ADR directory | +| `rules` | `string` | `.archgate/lint` | Relative path to the rules/lint directory | + +Both fields are optional. When omitted, the default `.archgate/adrs/` and `.archgate/lint/` directories are used. + +#### Path validation + +- Paths **must be relative** to the project root -- absolute paths (e.g., `/docs/adrs`, `C:\docs\adrs`) are rejected. +- Paths **must not contain `..` segments** -- traversal above the project root is not allowed (e.g., `../other-repo/adrs` is rejected). +- Paths use forward slashes (`/`) as separators, matching standard glob conventions. + +## Custom ADR directory + +By default, ADRs live in `.archgate/adrs/`. To store them in a different directory (e.g., `docs/adrs/`), add a `paths` section to `.archgate/config.json`: + +```json +{ "paths": { "adrs": "docs/adrs" } } +``` + +After adding the configuration: + +1. Create the target directory (e.g., `mkdir -p docs/adrs`) +2. Move existing ADR files and their companion `.rules.ts` files from `.archgate/adrs/` to the new directory +3. Run `archgate check` to verify the rules still load correctly + +All CLI commands (`archgate adr list`, `archgate adr create`, `archgate check`, `archgate review-context`) automatically read the configured directory. + +:::caution +The `.archgate/` directory must still exist -- it is the project marker used by the CLI to locate your project root. Do not delete it after configuring custom paths. + +### Example: monorepo documentation folder + +A common pattern is placing ADRs alongside other documentation: + +``` +my-project/ + .archgate/ + config.json # { "paths": { "adrs": "docs/adrs" } } + lint/ + rules.d.ts + docs/ + adrs/ + ARCH-001-api-design.md + ARCH-001-api-design.rules.ts + BE-001-database-access.md + BE-001-database-access.rules.ts + rules.d.ts # auto-generated by archgate check + guides/ + ... + src/ + ... +``` + +## Notes + +- The `paths` configuration is a **team-wide setting** -- it is committed to version control and applies to all team members. There is no user-level override for ADR paths. +- Changing the configuration requires manually editing `.archgate/config.json` after running `archgate init`. +- The `rules.d.ts` type definitions file is automatically written to both `.archgate/` and the parent of the configured ADR directory, so companion `.rules.ts` files resolve their `/// ` directive correctly. + +--- + ## Reference: Privacy Policy Source: https://cli.archgate.dev/reference/privacy-policy/ diff --git a/docs/src/content/docs/concepts/domains.mdx b/docs/src/content/docs/concepts/domains.mdx index 183cd4ba..8154f478 100644 --- a/docs/src/content/docs/concepts/domains.mdx +++ b/docs/src/content/docs/concepts/domains.mdx @@ -110,7 +110,7 @@ archgate adr domain add security SEC archgate adr domain remove security ``` -Custom domain → prefix mappings persist in `.archgate/config.json` and are merged with the built-ins at read time. A registered custom domain behaves exactly like a built-in: `archgate adr create --domain security` auto-generates IDs like `SEC-001`, and `archgate adr list --domain security` filters to those ADRs. +Custom domain-to-prefix mappings persist in [`.archgate/config.json`](/reference/configuration/) and are merged with the built-ins at read time. A registered custom domain behaves exactly like a built-in: `archgate adr create --domain security` auto-generates IDs like `SEC-001`, and `archgate adr list --domain security` filters to those ADRs. ### Naming rules diff --git a/docs/src/content/docs/pt-br/concepts/domains.mdx b/docs/src/content/docs/pt-br/concepts/domains.mdx index fe693f0f..210a074a 100644 --- a/docs/src/content/docs/pt-br/concepts/domains.mdx +++ b/docs/src/content/docs/pt-br/concepts/domains.mdx @@ -110,7 +110,7 @@ archgate adr domain add security SEC archgate adr domain remove security ``` -Os mapeamentos de domínio personalizado → prefixo persistem em `.archgate/config.json` e são mesclados com os integrados no momento da leitura. Um domínio personalizado registrado se comporta exatamente como um integrado: `archgate adr create --domain security` gera IDs como `SEC-001`, e `archgate adr list --domain security` filtra esses ADRs. +Os mapeamentos de domínio personalizado para prefixo persistem em [`.archgate/config.json`](/reference/configuration/) e são mesclados com os integrados no momento da leitura. Um domínio personalizado registrado se comporta exatamente como um integrado: `archgate adr create --domain security` gera IDs como `SEC-001`, e `archgate adr list --domain security` filtra esses ADRs. ### Regras de nomenclatura diff --git a/docs/src/content/docs/pt-br/reference/configuration.mdx b/docs/src/content/docs/pt-br/reference/configuration.mdx new file mode 100644 index 00000000..492aa6c1 --- /dev/null +++ b/docs/src/content/docs/pt-br/reference/configuration.mdx @@ -0,0 +1,93 @@ +--- +title: Configuração +description: Referência do arquivo de configuração .archgate/config.json. Configure diretórios personalizados para ADRs, mapeamentos de domínios e configurações do projeto. +--- + +O arquivo `.archgate/config.json` armazena configurações do projeto que são versionadas no controle de versão e compartilhadas com toda a equipe. + +Este arquivo é criado automaticamente pelo `archgate init` (quando domínios customizados são registrados) ou quando você adiciona configurações manualmente. Ele fica dentro do diretório `.archgate/` na raiz do seu projeto. + +## Schema + +```json +{ + "domains": { "security": "SEC", "compliance": "COMP" }, + "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" } +} +``` + +### `domains` + +Mapeamentos personalizados de domínio para prefixo. Veja [Domínios Personalizados](/concepts/domains/#custom-domains) para detalhes. + +| Chave | Tipo | Descrição | +| ------ | -------- | ------------------------------------------------------------------------------------------------------- | +| _nome_ | `string` | Nome do domínio (kebab-case minúsculo, 2-32 chars) mapeia para um prefixo de ID (maiúsculo, 2-10 chars) | + +Esses são mesclados com os domínios built-in (`backend`, `frontend`, `data`, `architecture`, `general`) em tempo de leitura. Entradas personalizadas não podem sobrescrever nomes ou prefixos built-in. + +### `paths` + +Sobrescreve os diretórios padrão para ADRs e regras. + +| Campo | Tipo | Padrão | Descrição | +| ------- | -------- | ---------------- | ------------------------------------------------ | +| `adrs` | `string` | `.archgate/adrs` | Caminho relativo para o diretório de ADRs | +| `rules` | `string` | `.archgate/lint` | Caminho relativo para o diretório de regras/lint | + +Ambos os campos são opcionais. Quando omitidos, os diretórios padrão `.archgate/adrs/` e `.archgate/lint/` são usados. + +#### Validação de caminhos + +- Caminhos **devem ser relativos** à raiz do projeto -- caminhos absolutos (ex: `/docs/adrs`, `C:\docs\adrs`) são rejeitados. +- Caminhos **não devem conter segmentos `..`** -- travessia acima da raiz do projeto não é permitida (ex: `../other-repo/adrs` é rejeitado). +- Caminhos usam barras (`/`) como separadores, seguindo as convenções padrão de glob. + +## Diretório personalizado de ADRs + +Por padrão, ADRs ficam em `.archgate/adrs/`. Para armazená-los em um diretório diferente (ex: `docs/adrs/`), adicione uma seção `paths` ao `.archgate/config.json`: + +```json +{ "paths": { "adrs": "docs/adrs" } } +``` + +Após adicionar a configuração: + +1. Crie o diretório de destino (ex: `mkdir -p docs/adrs`) +2. Mova os arquivos ADR existentes e seus arquivos `.rules.ts` complementares de `.archgate/adrs/` para o novo diretório +3. Execute `archgate check` para verificar se as regras ainda carregam corretamente + +Todos os comandos do CLI (`archgate adr list`, `archgate adr create`, `archgate check`, `archgate review-context`) leem automaticamente o diretório configurado. + +:::caution +O diretório `.archgate/` deve continuar existindo -- ele é o marcador de projeto usado pelo CLI para localizar a raiz do seu projeto. Não o exclua após configurar caminhos personalizados. +::: + +### Exemplo: pasta de documentação em monorepo + +Um padrão comum é colocar ADRs junto com outra documentação: + +``` +my-project/ + .archgate/ + config.json # { "paths": { "adrs": "docs/adrs" } } + lint/ + rules.d.ts + docs/ + adrs/ + ARCH-001-api-design.md + ARCH-001-api-design.rules.ts + BE-001-database-access.md + BE-001-database-access.rules.ts + rules.d.ts # gerado automaticamente pelo archgate check + guides/ + ... + src/ + ... +``` + +## Notas + +- A configuração `paths` é uma **definição de equipe** -- é versionada no controle de versão e se aplica a todos os membros da equipe. Não há override de nível de usuário para caminhos de ADR. +- Alterar a configuração requer editar manualmente `.archgate/config.json` após executar `archgate init`. +- O arquivo de definições de tipo `rules.d.ts` é escrito automaticamente tanto em `.archgate/` quanto no diretório pai do diretório de ADR configurado, para que os arquivos `.rules.ts` complementares resolvam corretamente sua diretiva `/// `. diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx new file mode 100644 index 00000000..0d5803bf --- /dev/null +++ b/docs/src/content/docs/reference/configuration.mdx @@ -0,0 +1,93 @@ +--- +title: Configuration +description: Reference for the .archgate/config.json project configuration file. Configure custom ADR directories, domain mappings, and project-level settings. +--- + +The `.archgate/config.json` file stores project-level configuration that is committed to version control and shared across the team. + +This file is created automatically by `archgate init` (when custom domains are registered) or when you manually add configuration. It lives inside the `.archgate/` directory at your project root. + +## Schema + +```json +{ + "domains": { "security": "SEC", "compliance": "COMP" }, + "paths": { "adrs": "docs/adrs", "rules": "docs/adrs" } +} +``` + +### `domains` + +Custom domain-to-prefix mappings. See [Custom Domains](/concepts/domains/#custom-domains) for details. + +| Key | Type | Description | +| ------ | -------- | ------------------------------------------------------------------------------------------- | +| _name_ | `string` | Domain name (lowercase kebab-case, 2-32 chars) maps to an ID prefix (uppercase, 2-10 chars) | + +These are merged with the built-in domains (`backend`, `frontend`, `data`, `architecture`, `general`) at read time. Custom entries cannot override built-in names or prefixes. + +### `paths` + +Override default directories for ADRs and rules. + +| Field | Type | Default | Description | +| ------- | -------- | ---------------- | ----------------------------------------- | +| `adrs` | `string` | `.archgate/adrs` | Relative path to the ADR directory | +| `rules` | `string` | `.archgate/lint` | Relative path to the rules/lint directory | + +Both fields are optional. When omitted, the default `.archgate/adrs/` and `.archgate/lint/` directories are used. + +#### Path validation + +- Paths **must be relative** to the project root -- absolute paths (e.g., `/docs/adrs`, `C:\docs\adrs`) are rejected. +- Paths **must not contain `..` segments** -- traversal above the project root is not allowed (e.g., `../other-repo/adrs` is rejected). +- Paths use forward slashes (`/`) as separators, matching standard glob conventions. + +## Custom ADR directory + +By default, ADRs live in `.archgate/adrs/`. To store them in a different directory (e.g., `docs/adrs/`), add a `paths` section to `.archgate/config.json`: + +```json +{ "paths": { "adrs": "docs/adrs" } } +``` + +After adding the configuration: + +1. Create the target directory (e.g., `mkdir -p docs/adrs`) +2. Move existing ADR files and their companion `.rules.ts` files from `.archgate/adrs/` to the new directory +3. Run `archgate check` to verify the rules still load correctly + +All CLI commands (`archgate adr list`, `archgate adr create`, `archgate check`, `archgate review-context`) automatically read the configured directory. + +:::caution +The `.archgate/` directory must still exist -- it is the project marker used by the CLI to locate your project root. Do not delete it after configuring custom paths. +::: + +### Example: monorepo documentation folder + +A common pattern is placing ADRs alongside other documentation: + +``` +my-project/ + .archgate/ + config.json # { "paths": { "adrs": "docs/adrs" } } + lint/ + rules.d.ts + docs/ + adrs/ + ARCH-001-api-design.md + ARCH-001-api-design.rules.ts + BE-001-database-access.md + BE-001-database-access.rules.ts + rules.d.ts # auto-generated by archgate check + guides/ + ... + src/ + ... +``` + +## Notes + +- The `paths` configuration is a **team-wide setting** -- it is committed to version control and applies to all team members. There is no user-level override for ADR paths. +- Changing the configuration requires manually editing `.archgate/config.json` after running `archgate init`. +- The `rules.d.ts` type definitions file is automatically written to both `.archgate/` and the parent of the configured ADR directory, so companion `.rules.ts` files resolve their `/// ` directive correctly. diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index 5547a3d4..a60755d6 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -9,10 +9,11 @@ import { createAdrFile } from "../../helpers/adr-writer"; import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; -import { findProjectRoot, projectPaths } from "../../helpers/paths"; +import { findProjectRoot } from "../../helpers/paths"; import { getAllDomainNames, resolveDomainPrefix, + resolvedProjectPaths, } from "../../helpers/project-config"; export function registerAdrCreateCommand(adr: Command) { @@ -37,7 +38,7 @@ export function registerAdrCreateCommand(adr: Command) { } try { - const paths = projectPaths(projectRoot); + const paths = resolvedProjectPaths(projectRoot); let domain: AdrDomain; let title: string; diff --git a/src/commands/adr/list.ts b/src/commands/adr/list.ts index a69d9b6a..02dee029 100644 --- a/src/commands/adr/list.ts +++ b/src/commands/adr/list.ts @@ -9,7 +9,8 @@ import { parseAllAdrs } from "../../engine/loader"; import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; -import { findProjectRoot, projectPaths } from "../../helpers/paths"; +import { findProjectRoot } from "../../helpers/paths"; +import { resolvedProjectPaths } from "../../helpers/project-config"; export function registerAdrListCommand(adr: Command) { adr @@ -26,7 +27,7 @@ export function registerAdrListCommand(adr: Command) { } try { - const paths = projectPaths(projectRoot); + const paths = resolvedProjectPaths(projectRoot); if (!existsSync(paths.adrsDir)) { console.log("No ADRs found."); diff --git a/src/commands/adr/show.ts b/src/commands/adr/show.ts index e580a569..e48c23ff 100644 --- a/src/commands/adr/show.ts +++ b/src/commands/adr/show.ts @@ -5,7 +5,8 @@ import type { Command } from "@commander-js/extra-typings"; import { findAdrFileById } from "../../helpers/adr-writer"; import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; -import { findProjectRoot, projectPaths } from "../../helpers/paths"; +import { findProjectRoot } from "../../helpers/paths"; +import { resolvedProjectPaths } from "../../helpers/project-config"; export function registerAdrShowCommand(adr: Command) { adr @@ -21,7 +22,7 @@ export function registerAdrShowCommand(adr: Command) { } try { - const { adrsDir } = projectPaths(projectRoot); + const { adrsDir } = resolvedProjectPaths(projectRoot); const adr = await findAdrFileById(adrsDir, id); if (!adr) { diff --git a/src/commands/adr/update.ts b/src/commands/adr/update.ts index 72b3dee6..8623d0cc 100644 --- a/src/commands/adr/update.ts +++ b/src/commands/adr/update.ts @@ -6,8 +6,11 @@ import { updateAdrFile } from "../../helpers/adr-writer"; import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; -import { findProjectRoot, projectPaths } from "../../helpers/paths"; -import { resolveDomainPrefix } from "../../helpers/project-config"; +import { findProjectRoot } from "../../helpers/paths"; +import { + resolveDomainPrefix, + resolvedProjectPaths, +} from "../../helpers/project-config"; export function registerAdrUpdateCommand(adr: Command) { adr @@ -33,7 +36,7 @@ export function registerAdrUpdateCommand(adr: Command) { await exitWith(1); return; } - const paths = projectPaths(projectRoot); + const paths = resolvedProjectPaths(projectRoot); const files = opts.files ? opts.files diff --git a/src/engine/loader.ts b/src/engine/loader.ts index db48bf89..fc7b5045 100644 --- a/src/engine/loader.ts +++ b/src/engine/loader.ts @@ -28,7 +28,7 @@ import { relative } from "node:path"; import type { ViolationDetail } from "../formats/rules"; import { logDebug } from "../helpers/log"; -import { projectPaths } from "../helpers/paths"; +import { resolvedProjectPaths } from "../helpers/project-config"; import { ensureRulesShim } from "../helpers/rules-shim"; import { scanRuleSource } from "./rule-scanner"; @@ -164,7 +164,7 @@ export function parseAllAdrs(projectRoot: string): Promise { const cached = parsedAdrsCache.get(projectRoot); if (cached) return cached; - const pp = projectPaths(projectRoot); + const pp = resolvedProjectPaths(projectRoot); const adrsDir = pp.adrsDir; const promise = (async () => { @@ -202,11 +202,12 @@ export async function loadRuleAdrs( projectRoot: string, filterAdrId?: string ): Promise { - const pp = projectPaths(projectRoot); + const pp = resolvedProjectPaths(projectRoot); // Ensure rules.d.ts exists so .rules.ts files get type checking - // without requiring node_modules (supports non-JS projects) - await ensureRulesShim(projectRoot); + // without requiring node_modules (supports non-JS projects). + // When ADRs live in a custom directory, also write the shim there. + await ensureRulesShim(projectRoot, pp.adrsDir); const adrsDir = pp.adrsDir; diff --git a/src/formats/project-config.ts b/src/formats/project-config.ts index 6ec1b3f8..cf051663 100644 --- a/src/formats/project-config.ts +++ b/src/formats/project-config.ts @@ -23,9 +23,30 @@ export const DomainPrefixSchema = z "domain prefix must be uppercase (e.g. 'SEC', 'MLOPS')" ); +/** + * Validate that a path is relative and does not escape the project root. + * Rejects absolute paths (leading `/`, `\`, or drive letters like `C:\`) + * and `..` segments that could traverse above the project root. + */ +const RelativePathSchema = z + .string() + .min(1, "path must not be empty") + .refine((p) => !/^[/\\]/u.test(p) && !/^[A-Za-z]:[/\\]/u.test(p), { + message: "path must be relative (no leading / or drive letter)", + }) + .refine((p) => !/(^|\/)\.\.($|\/)/u.test(p.replaceAll("\\", "/")), { + message: "path must not contain '..' segments", + }); + +export const PathsConfigSchema = z.object({ + adrs: RelativePathSchema.optional(), + rules: RelativePathSchema.optional(), +}); + export const ProjectConfigSchema = z .object({ domains: z.record(DomainNameSchema, DomainPrefixSchema).default({}), + paths: PathsConfigSchema.optional(), }) .default({ domains: {} }); diff --git a/src/helpers/install-info.ts b/src/helpers/install-info.ts index 541b3bd5..2d1b55fc 100644 --- a/src/helpers/install-info.ts +++ b/src/helpers/install-info.ts @@ -11,6 +11,7 @@ import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { internalPath } from "./paths"; +import { resolvedProjectPaths } from "./project-config"; // --------------------------------------------------------------------------- // Install method detection (cached) @@ -78,8 +79,9 @@ export interface ProjectContext { * cheap enough to re-run on every event, and worth it for accuracy. */ export function getProjectContext(): ProjectContext { - const adrsDir = join(process.cwd(), ".archgate", "adrs"); - const hasProject = existsSync(adrsDir); + const cwd = process.cwd(); + const archgateDir = join(cwd, ".archgate"); + const hasProject = existsSync(archgateDir); if (!hasProject) { return { @@ -90,6 +92,14 @@ export function getProjectContext(): ProjectContext { }; } + // Use resolved paths so we scan the configured ADR directory, + // not just the default `.archgate/adrs/`. + const { adrsDir } = resolvedProjectPaths(cwd); + + if (!existsSync(adrsDir)) { + return { hasProject: true, adrCount: 0, adrWithRulesCount: 0, domains: [] }; + } + try { const entries = readdirSync(adrsDir); const mdFiles = entries.filter((f) => f.endsWith(".md")); diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 5ca65b88..9204c662 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -2,7 +2,7 @@ // Copyright 2026 Archgate import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; -import { join, dirname } from "node:path"; +import { join, dirname, resolve } from "node:path"; import { logDebug } from "./log"; @@ -112,18 +112,40 @@ export function createPathIfNotExists(path: string) { } /** - * Walk up from cwd to find the nearest directory containing .archgate/adrs/. - * Returns the project root path or null if not found. + * Walk up from cwd to find the nearest directory containing an archgate + * project. A directory is a project root when it has either: + * - `.archgate/adrs/` — standard project layout + * - `.archgate/lint/` — also created by `archgate init` + * + * Both directories are created by `archgate init` and are project-specific. + * We cannot match on `.archgate/` alone because `~/.archgate/` is the + * CLI's user-level cache directory (binary installs, credentials, etc.) + * and would produce false positives. We also avoid matching on + * `.archgate/config.json` because `~/.archgate/config.json` stores + * telemetry settings. + * + * **Test isolation:** Set `ARCHGATE_PROJECT_CEILING` to a directory path + * to prevent the walk-up from escaping above it — analogous to git's + * `GIT_CEILING_DIRECTORIES`. The ceiling directory itself is still + * checked, but the walk stops there. */ export function findProjectRoot(startDir?: string): string | null { + const ceilingEnv = Bun.env.ARCHGATE_PROJECT_CEILING; + const ceiling = ceilingEnv ? resolve(ceilingEnv) : null; let dir = startDir ?? process.cwd(); while (true) { const adrsDir = join(dir, ".archgate", "adrs"); - if (existsSync(adrsDir)) { + const lintDir = join(dir, ".archgate", "lint"); + if (existsSync(adrsDir) || existsSync(lintDir)) { return dir; } + // Don't walk above the ceiling directory + if (ceiling && resolve(dir) === ceiling) { + return null; + } + const parent = dirname(dir); if (parent === dir) { return null; diff --git a/src/helpers/project-config.ts b/src/helpers/project-config.ts index 1436a674..9e0769d2 100644 --- a/src/helpers/project-config.ts +++ b/src/helpers/project-config.ts @@ -10,6 +10,7 @@ */ import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import { DOMAIN_PREFIXES as DEFAULT_DOMAIN_PREFIXES, @@ -202,3 +203,33 @@ export async function removeCustomDomain( await saveProjectConfig(projectRoot, next); return { config: next, removed: true }; } + +/** + * Resolve project paths with config-aware overrides. + * + * Reads `.archgate/config.json` and applies any custom `paths.adrs` or + * `paths.rules` overrides. When `paths.rules` is not set, rules are + * loaded from the same directory as ADRs (co-location convention). + * Falls back to the standard `.archgate/adrs/` and `.archgate/lint/` + * defaults when no `paths` config is present. + */ +export function resolvedProjectPaths(projectRoot: string): { + root: string; + adrsDir: string; + lintDir: string; +} { + const defaults = projectPaths(projectRoot); + const config = loadProjectConfig(projectRoot); + + if (!config.paths) return defaults; + + return { + root: defaults.root, + adrsDir: config.paths.adrs + ? join(projectRoot, config.paths.adrs) + : defaults.adrsDir, + lintDir: config.paths.rules + ? join(projectRoot, config.paths.rules) + : defaults.lintDir, + }; +} diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index 4a933e14..982b8446 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate +import { dirname, join } from "node:path"; + import { logDebug } from "./log"; import { projectPath } from "./paths"; @@ -121,14 +123,10 @@ export async function writeRulesShim(projectRoot: string): Promise { } /** - * Ensure rules.d.ts exists and is up-to-date. Skips the disk write when the - * on-disk content already matches — `archgate check` calls this every run, - * and the content only changes when the CLI version changes. + * Write `rules.d.ts` to a specific path if it doesn't already match. + * Returns true when a disk write occurred, false when skipped (up-to-date). */ -export async function ensureRulesShim(projectRoot: string): Promise { - const dtsPath = projectPath(projectRoot, "rules.d.ts"); - const expected = generateRulesDts(); - +async function ensureShimAt(dtsPath: string, expected: string): Promise { try { const existing = await Bun.file(dtsPath).text(); if (existing === expected) { @@ -142,3 +140,34 @@ export async function ensureRulesShim(projectRoot: string): Promise { await Bun.write(dtsPath, expected); logDebug("Rules type definitions written:", dtsPath); } + +/** + * Ensure rules.d.ts exists and is up-to-date. Skips the disk write when the + * on-disk content already matches — `archgate check` calls this every run, + * and the content only changes when the CLI version changes. + * + * When `customAdrsDir` is provided and differs from the default + * `.archgate/adrs/`, a copy of `rules.d.ts` is also written to the parent + * of that directory so that companion `.rules.ts` files' triple-slash + * `/// ` resolves correctly. + */ +export async function ensureRulesShim( + projectRoot: string, + customAdrsDir?: string +): Promise { + const defaultDtsPath = projectPath(projectRoot, "rules.d.ts"); + const expected = generateRulesDts(); + + await ensureShimAt(defaultDtsPath, expected); + + // When ADRs live in a custom directory, the `.rules.ts` files use + // `/// ` which resolves relative to + // their own directory. Write a shim to the parent of the custom ADR dir. + if (customAdrsDir) { + const defaultAdrsDir = join(projectRoot, ".archgate", "adrs"); + if (customAdrsDir !== defaultAdrsDir) { + const customDtsPath = join(dirname(customAdrsDir), "rules.d.ts"); + await ensureShimAt(customDtsPath, expected); + } + } +} diff --git a/tests/commands/adr/create.test.ts b/tests/commands/adr/create.test.ts index 3d138a81..de37ef69 100644 --- a/tests/commands/adr/create.test.ts +++ b/tests/commands/adr/create.test.ts @@ -50,6 +50,8 @@ describe("adr create action handler", () => { beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-create-test-")); originalCwd = process.cwd(); + // Prevent findProjectRoot() from walking above the temp dir + Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; logSpy = spyOn(console, "log").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); @@ -58,6 +60,7 @@ describe("adr create action handler", () => { afterEach(() => { process.chdir(originalCwd); + delete Bun.env.ARCHGATE_PROJECT_CEILING; rmSync(tempDir, { recursive: true, force: true }); logSpy.mockRestore(); exitSpy.mockRestore(); diff --git a/tests/commands/adr/list.test.ts b/tests/commands/adr/list.test.ts index 5eb152e1..431aaee5 100644 --- a/tests/commands/adr/list.test.ts +++ b/tests/commands/adr/list.test.ts @@ -72,6 +72,8 @@ describe("adr list action handler", () => { beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-list-test-")); originalCwd = process.cwd(); + // Prevent findProjectRoot() from walking above the temp dir + Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; logSpy = spyOn(console, "log").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); @@ -80,6 +82,7 @@ describe("adr list action handler", () => { afterEach(() => { process.chdir(originalCwd); + delete Bun.env.ARCHGATE_PROJECT_CEILING; rmSync(tempDir, { recursive: true, force: true }); logSpy.mockRestore(); exitSpy.mockRestore(); diff --git a/tests/engine/loader.test.ts b/tests/engine/loader.test.ts index 9f2dc3fb..8a910332 100644 --- a/tests/engine/loader.test.ts +++ b/tests/engine/loader.test.ts @@ -11,7 +11,8 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadRuleAdrs } from "../../src/engine/loader"; +import { loadRuleAdrs, parseAllAdrs } from "../../src/engine/loader"; +import { saveProjectConfig } from "../../src/helpers/project-config"; describe("loadRuleAdrs", () => { let tempDir: string; @@ -203,4 +204,50 @@ export default { expect(blocked.value.error).toContain("2 violations"); expect(blocked.value.violations).toHaveLength(2); }); + + test("loads ADRs from custom directory configured in config.json", async () => { + // Create a custom ADR directory outside .archgate/ + const customAdrsDir = join(tempDir, "docs", "adrs"); + mkdirSync(customAdrsDir, { recursive: true }); + + // Configure the custom path + await saveProjectConfig(tempDir, { + domains: {}, + paths: { adrs: "docs/adrs" }, + }); + + // Place ADR + rules in the custom directory + copyFileSync( + join(fixturesDir, "TEST-001-sample.md"), + join(customAdrsDir, "TEST-001-sample.md") + ); + writeRulesTs(customAdrsDir, "TEST-001-sample"); + + const loaded = await loadRuleAdrs(tempDir); + expect(loaded).toHaveLength(1); + expect(loaded[0].type).toBe("loaded"); + const first = loaded[0] as Extract<(typeof loaded)[0], { type: "loaded" }>; + expect(first.value.adr.frontmatter.id).toBe("TEST-001"); + }); + + test("parseAllAdrs reads from custom directory", async () => { + // Configure custom path + const customAdrsDir = join(tempDir, "governance"); + mkdirSync(customAdrsDir, { recursive: true }); + + await saveProjectConfig(tempDir, { + domains: {}, + paths: { adrs: "governance" }, + }); + + // Place ADR in custom dir, NOT in .archgate/adrs/ + copyFileSync( + join(fixturesDir, "TEST-001-sample.md"), + join(customAdrsDir, "TEST-001-sample.md") + ); + + const parsed = await parseAllAdrs(tempDir); + expect(parsed).toHaveLength(1); + expect(parsed[0].adr.frontmatter.id).toBe("TEST-001"); + }); }); diff --git a/tests/formats/project-config-fuzz.test.ts b/tests/formats/project-config-fuzz.test.ts index 3583dfff..8346f5ed 100644 --- a/tests/formats/project-config-fuzz.test.ts +++ b/tests/formats/project-config-fuzz.test.ts @@ -7,6 +7,7 @@ import fc from "fast-check"; import { DomainNameSchema, DomainPrefixSchema, + PathsConfigSchema, ProjectConfigSchema, } from "../../src/formats/project-config"; @@ -205,4 +206,95 @@ describe("ProjectConfigSchema fuzz", () => { } } }); + + test("accepts config with valid paths", () => { + const cases = [ + { domains: {}, paths: { adrs: "docs/adrs" } }, + { domains: {}, paths: { rules: "custom/rules" } }, + { domains: {}, paths: { adrs: "docs/adrs", rules: "docs/adrs" } }, + { domains: {}, paths: {} }, + ]; + for (const c of cases) { + const result = ProjectConfigSchema.safeParse(c); + expect(result.success).toBe(true); + } + }); + + test("rejects config with absolute paths", () => { + const cases = [ + { domains: {}, paths: { adrs: "/absolute/path" } }, + { domains: {}, paths: { rules: "/etc/rules" } }, + { domains: {}, paths: { adrs: "C:\\absolute\\path" } }, + ]; + for (const c of cases) { + const result = ProjectConfigSchema.safeParse(c); + expect(result.success).toBe(false); + } + }); + + test("rejects config with '..' path segments", () => { + const cases = [ + { domains: {}, paths: { adrs: "../escape" } }, + { domains: {}, paths: { adrs: "docs/../../escape" } }, + { domains: {}, paths: { rules: ".." } }, + ]; + for (const c of cases) { + const result = ProjectConfigSchema.safeParse(c); + expect(result.success).toBe(false); + } + }); +}); + +// --------------------------------------------------------------------------- +// PathsConfigSchema +// --------------------------------------------------------------------------- + +describe("PathsConfigSchema fuzz", () => { + test("safeParse never throws on arbitrary strings for adrs/rules", () => { + fc.assert( + fc.property(fc.string({ minLength: 0, maxLength: 50 }), (input) => { + const result = PathsConfigSchema.safeParse({ + adrs: input, + rules: input, + }); + expect(result).toHaveProperty("success"); + }), + { numRuns: NUM_RUNS } + ); + }); + + test("accepts valid relative paths", () => { + const valid = [ + "docs/adrs", + "custom", + "a/b/c/d", + "src/governance/adrs", + "adrs", + ]; + for (const p of valid) { + const result = PathsConfigSchema.safeParse({ adrs: p }); + expect(result.success).toBe(true); + } + }); + + test("rejects absolute paths", () => { + const absolute = ["/root", "\\root", "C:\\path", "D:/path"]; + for (const p of absolute) { + const result = PathsConfigSchema.safeParse({ adrs: p }); + expect(result.success).toBe(false); + } + }); + + test("rejects paths with '..' segments", () => { + const escaping = ["..", "../foo", "foo/../bar", "foo/.."]; + for (const p of escaping) { + const result = PathsConfigSchema.safeParse({ adrs: p }); + expect(result.success).toBe(false); + } + }); + + test("rejects empty strings", () => { + const result = PathsConfigSchema.safeParse({ adrs: "" }); + expect(result.success).toBe(false); + }); }); diff --git a/tests/helpers/paths.test.ts b/tests/helpers/paths.test.ts index 798ba1d9..18407a15 100644 --- a/tests/helpers/paths.test.ts +++ b/tests/helpers/paths.test.ts @@ -12,9 +12,12 @@ describe("findProjectRoot", () => { beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-paths-test-")); + // Prevent findProjectRoot() from walking above the temp dir + Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; }); afterEach(() => { + delete Bun.env.ARCHGATE_PROJECT_CEILING; rmSync(tempDir, { recursive: true, force: true }); }); @@ -25,6 +28,15 @@ describe("findProjectRoot", () => { expect(result).toBe(tempDir); }); + test("finds root when .archgate/lint/ exists (custom ADR paths)", () => { + // A project with custom ADR paths may not have .archgate/adrs/, + // but .archgate/lint/ is always created by `archgate init`. + mkdirSync(join(tempDir, ".archgate", "lint"), { recursive: true }); + + const result = findProjectRoot(tempDir); + expect(result).toBe(tempDir); + }); + test("finds root from a subdirectory", () => { mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); const subDir = join(tempDir, "src", "commands"); @@ -34,8 +46,32 @@ describe("findProjectRoot", () => { expect(result).toBe(tempDir); }); - test("returns null when no .archgate/adrs/ found", () => { + test("returns null when no project markers found", () => { + // With the ceiling set, the walk-up is isolated to tempDir const result = findProjectRoot(tempDir); expect(result).toBeNull(); }); + + test("does not match directory without .archgate/adrs/ or .archgate/lint/", () => { + // A directory with only a bare .archgate/ (like ~/.archgate/ user cache) + // should NOT be detected as a project root. + const parent = join(tempDir, "parent"); + const child = join(parent, "child"); + mkdirSync(child, { recursive: true }); + // Create bare .archgate/ with no adrs/ or lint/ + mkdirSync(join(parent, ".archgate"), { recursive: true }); + + const result = findProjectRoot(child); + expect(result).toBeNull(); + }); + + test("respects ARCHGATE_PROJECT_CEILING to isolate tests", () => { + // The ceiling prevents walk-up past the temp dir, even if + // ~/.archgate/adrs/ exists on the host machine. + const nested = join(tempDir, "deep", "nested"); + mkdirSync(nested, { recursive: true }); + + const result = findProjectRoot(nested); + expect(result).toBeNull(); + }); }); diff --git a/tests/helpers/project-config.test.ts b/tests/helpers/project-config.test.ts index 056e9983..8ccfa2ca 100644 --- a/tests/helpers/project-config.test.ts +++ b/tests/helpers/project-config.test.ts @@ -14,6 +14,7 @@ import { loadProjectConfig, removeCustomDomain, resolveDomainPrefix, + resolvedProjectPaths, saveProjectConfig, } from "../../src/helpers/project-config"; @@ -135,4 +136,65 @@ describe("project-config", () => { await saveProjectConfig(projectRoot, { domains: { infra: "INFRA" } }); expect(loadProjectConfig(projectRoot).domains.infra).toBe("INFRA"); }); + + // ------------------------------------------------------------------------- + // resolvedProjectPaths + // ------------------------------------------------------------------------- + + describe("resolvedProjectPaths", () => { + test("returns defaults when no paths config is set", () => { + const paths = resolvedProjectPaths(projectRoot); + expect(paths.adrsDir).toBe(join(projectRoot, ".archgate", "adrs")); + expect(paths.lintDir).toBe(join(projectRoot, ".archgate", "lint")); + }); + + test("overrides adrsDir when paths.adrs is configured", async () => { + await saveProjectConfig(projectRoot, { + domains: {}, + paths: { adrs: "docs/adrs" }, + }); + const paths = resolvedProjectPaths(projectRoot); + expect(paths.adrsDir).toBe(join(projectRoot, "docs", "adrs")); + // lintDir stays default when paths.rules is not set + expect(paths.lintDir).toBe(join(projectRoot, ".archgate", "lint")); + }); + + test("overrides lintDir when paths.rules is configured", async () => { + await saveProjectConfig(projectRoot, { + domains: {}, + paths: { rules: "docs/rules" }, + }); + const paths = resolvedProjectPaths(projectRoot); + // adrsDir stays default when paths.adrs is not set + expect(paths.adrsDir).toBe(join(projectRoot, ".archgate", "adrs")); + expect(paths.lintDir).toBe(join(projectRoot, "docs", "rules")); + }); + + test("overrides both when paths.adrs and paths.rules are configured", async () => { + await saveProjectConfig(projectRoot, { + domains: {}, + paths: { adrs: "docs/adrs", rules: "docs/rules" }, + }); + const paths = resolvedProjectPaths(projectRoot); + expect(paths.adrsDir).toBe(join(projectRoot, "docs", "adrs")); + expect(paths.lintDir).toBe(join(projectRoot, "docs", "rules")); + }); + + test("root always points to .archgate/ regardless of paths config", async () => { + await saveProjectConfig(projectRoot, { + domains: {}, + paths: { adrs: "custom/adrs" }, + }); + const paths = resolvedProjectPaths(projectRoot); + expect(paths.root).toBe(join(projectRoot, ".archgate")); + }); + + test("ignores invalid config and falls back to defaults", async () => { + // Write a malformed config file + const configPath = join(projectRoot, ".archgate", "config.json"); + await Bun.write(configPath, "not valid json"); + const paths = resolvedProjectPaths(projectRoot); + expect(paths.adrsDir).toBe(join(projectRoot, ".archgate", "adrs")); + }); + }); }); diff --git a/tests/integration/cli-harness.ts b/tests/integration/cli-harness.ts index dbfcd6e4..2035b9e8 100644 --- a/tests/integration/cli-harness.ts +++ b/tests/integration/cli-harness.ts @@ -36,6 +36,10 @@ export async function runCli( NO_COLOR: "1", CI: "1", ARCHGATE_TELEMETRY: "0", + // Prevent findProjectRoot() from walking above the test directory + // and discovering the user's ~/.archgate/ — analogous to git's + // GIT_CEILING_DIRECTORIES. + ARCHGATE_PROJECT_CEILING: cwd, ...env, }, });