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,
},
});