diff --git a/.archgate/adrs/ARCH-015-cli-command-documentation-coverage.md b/.archgate/adrs/ARCH-015-cli-command-documentation-coverage.md new file mode 100644 index 00000000..4ee17df3 --- /dev/null +++ b/.archgate/adrs/ARCH-015-cli-command-documentation-coverage.md @@ -0,0 +1,107 @@ +--- +id: ARCH-015 +title: CLI Command Documentation Coverage +domain: architecture +rules: true +files: + - "src/commands/**/*.ts" + - "docs/src/content/docs/reference/cli/**/*.mdx" +--- + +## Context + +The CLI reference docs under `docs/src/content/docs/reference/cli/` are the authoritative surface users consult when choosing or using a command. A missing docs page is a silent failure — the command works, the test suite passes, but users (and the LLM-facing [llms-full.txt](../../docs/public/llms-full.txt) artifact) cannot discover it. + +Drift happens in two directions: + +1. **Undocumented commands.** A new `register*Command(program)` call lands in [src/cli.ts](../../src/cli.ts) without a matching `.mdx` file. Recent example: the `telemetry` command shipped without `docs/src/content/docs/reference/cli/telemetry.mdx`. +2. **Orphan docs.** A command is removed but its docs page lingers, advertising a flag that no longer exists. + +**Alternatives considered:** + +- **Manual review only.** Relies on reviewers remembering to check both surfaces on every command change. Has already failed (see the `telemetry` gap above). +- **Auto-generate the docs from `--help` output.** Eliminates drift but loses the hand-written prose (examples, troubleshooting sections, tips) that distinguish our reference pages from a flat flag dump. +- **A single monolithic `cli.mdx`.** Centralises everything into one file, which kills the per-command URL structure users link to and breaks Starlight's per-page search indexing. + +An automated cross-check that pairs every top-level command with exactly one `.mdx` file — while letting each page stay hand-written — preserves the prose quality we want and catches drift mechanically. + +**Cross-references:** + +- [ARCH-001 — Command Structure](./ARCH-001-command-structure.md) defines the `src/commands/.ts` / `src/commands//index.ts` convention the check relies on. +- [GEN-001 — Documentation Site](./GEN-001-documentation-site.md) establishes the docs site and the `reference/cli/` URL namespace. +- [GEN-002 — Documentation Internationalization](./GEN-002-docs-i18n.rules.ts) already enforces that every EN page has a pt-br mirror, so this ADR only needs to cross-check EN ↔ commands. + +## Decision + +Every top-level CLI command registered via `register*Command(program)` in [src/cli.ts](../../src/cli.ts) MUST have a corresponding reference page at `docs/src/content/docs/reference/cli/.mdx`. Conversely, every `.mdx` file in that directory (except `index.mdx`) MUST correspond to a top-level command. + +**Scope:** + +- **Top-level commands only.** Subcommands (e.g. `adr create`, `adr domain add`) are documented inline within their parent command's `.mdx` file. They MUST NOT get their own top-level reference page (e.g. there is no `adr-create.mdx`). +- **EN only.** The pt-br mirror requirement is enforced by the `GEN-002/i18n-page-parity` rule — this ADR does not duplicate that check. +- **`index.mdx` is exempt.** It is the landing page for the `reference/cli/` section, not a command page. + +Command names are derived from the [src/commands/](../../src/commands/) directory using the convention established in ARCH-001: + +- `src/commands/.ts` → command name `` (e.g. `check.ts` → `check`) +- `src/commands//index.ts` → command group `` (e.g. `adr/index.ts` → `adr`) + +Nested subcommand files (`src/commands/adr/create.ts`, `src/commands/adr/domain/index.ts`, etc.) are NOT treated as top-level commands and contribute nothing to the expected docs set. + +## Do's and Don'ts + +### Do + +- **DO** create `docs/src/content/docs/reference/cli/.mdx` in the same PR that adds a new top-level command +- **DO** document subcommands inline within the parent command's `.mdx` file (e.g. `adr domain` subcommands live under a `## archgate adr domain` heading inside `adr.mdx`) +- **DO** delete the corresponding `.mdx` file in the same PR that removes a top-level command +- **DO** follow the existing `.mdx` structure: frontmatter (`title`, `description`), one-line intro, subcommand table where applicable, options table, examples, troubleshooting where applicable +- **DO** create a matching pt-br mirror at `docs/src/content/docs/pt-br/reference/cli/.mdx` — the `GEN-002/i18n-page-parity` rule will flag its absence separately + +### Don't + +- **DON'T** create a separate `.mdx` file for each subcommand (no `adr-create.mdx`, no `login-status.mdx`) +- **DON'T** leave an orphan `.mdx` file after removing a command — delete it in the same PR +- **DON'T** document a command exclusively inline in another page (e.g. burying `telemetry` under `init.mdx`) — every top-level command gets its own file +- **DON'T** bypass the rule by renaming the command file without renaming the docs file — keep the stems aligned + +## Consequences + +### Positive + +- **Discoverability guaranteed.** Every command shipped in the CLI has a dedicated, linkable reference page — no more silent omissions. +- **Orphan detection.** Docs pages that outlive their command are flagged automatically, keeping the reference section truthful. +- **Cheap to enforce.** The rule reads directory listings only — no AST parsing of `src/cli.ts`, no `--help` invocation, no cross-process work. +- **Aligns with existing conventions.** Piggybacks on the `src/commands/.ts` / `src/commands//index.ts` pattern from ARCH-001 without introducing new metadata. +- **Composes with GEN-002.** This rule handles command↔EN-doc parity; GEN-002 handles EN↔pt-br parity. Together they guarantee every command has docs in every supported locale. + +### Negative + +- **Prose overhead on new commands.** Adding a top-level command now requires writing a reference page, not just code + tests. Mitigated by the short, templated structure of existing `.mdx` files (most are under 100 lines). +- **False negative for exotic layouts.** If a contributor invents a new command-registration pattern that bypasses both `src/commands/.ts` and `src/commands//index.ts`, the rule won't see it. ARCH-001 forbids this, so the drift would be caught there first. + +### Risks + +- **A rename splits command and docs.** Renaming `src/commands/old.ts` → `src/commands/new.ts` without renaming `old.mdx` → `new.mdx` triggers two violations (orphan + missing). **Mitigation:** the rule's `fix` suggestions explicitly recommend the rename, and `bun run validate` catches it before the PR lands. +- **Rule rejects a deliberately undocumented internal command.** If a command is experimental and not yet ready for public docs, the rule blocks its introduction. **Mitigation:** either (a) don't register it in `src/cli.ts` until it is user-facing, or (b) ship a stub `.mdx` marked "Experimental — subject to change". The rule intentionally does not support a shared exemption list; decisions to ship commands undocumented should be deliberate and visible. + +## Compliance and Enforcement + +### Automated Enforcement + +- **Archgate rule** `ARCH-015/cli-command-has-docs-page`: Enumerates top-level commands under `src/commands/` and `.mdx` pages under `docs/src/content/docs/reference/cli/`, then reports any mismatch in either direction (missing docs, orphan docs). Severity: `error`. Runs as part of `bun run validate` via `archgate check`. + +### Manual Enforcement + +Code reviewers MUST verify: + +1. New top-level commands come with a reference page in the same PR +2. Removed commands have their reference page deleted in the same PR +3. Subcommand documentation is folded into the parent `.mdx`, not a new file +4. pt-br mirrors accompany EN changes (GEN-002 will fail the build otherwise, but reviewers should not rely on CI alone) + +## References + +- [ARCH-001 — Command Structure](./ARCH-001-command-structure.md) — Command registration convention the rule relies on +- [GEN-001 — Documentation Site](./GEN-001-documentation-site.md) — Overall docs site structure and URL scheme +- [GEN-002 — Documentation Internationalization](./GEN-002-docs-i18n.rules.ts) — EN↔pt-br parity enforcement, composes with this ADR diff --git a/.archgate/adrs/ARCH-015-cli-command-documentation-coverage.rules.ts b/.archgate/adrs/ARCH-015-cli-command-documentation-coverage.rules.ts new file mode 100644 index 00000000..194507f0 --- /dev/null +++ b/.archgate/adrs/ARCH-015-cli-command-documentation-coverage.rules.ts @@ -0,0 +1,74 @@ +/// + +const COMMANDS_DIR = "src/commands"; +const DOCS_DIR = "docs/src/content/docs/reference/cli"; + +/** + * Docs files that do NOT correspond to a command. `index.mdx` is the + * landing page for the CLI reference section, not a command page. + */ +const EXEMPT_DOC_STEMS = new Set(["index"]); + +export default { + rules: { + "cli-command-has-docs-page": { + description: + "Every top-level CLI command (src/commands/.ts or src/commands//index.ts) must have a corresponding reference page at docs/src/content/docs/reference/cli/.mdx, and vice versa", + severity: "error", + async check(ctx) { + // Discover top-level command names from src/commands/. + // Per ARCH-001, top-level commands live at either + // src/commands/.ts — single-file command + // src/commands//index.ts — command group + // Nested files like src/commands//create.ts or + // src/commands///index.ts are subcommands and NOT + // independent top-level commands. + const commandNames = new Set(); + + const topLevelFiles = await ctx.glob(`${COMMANDS_DIR}/*.ts`); + for (const file of topLevelFiles) { + const name = file.slice(COMMANDS_DIR.length + 1, -".ts".length); + commandNames.add(name); + } + + const groupIndexFiles = await ctx.glob(`${COMMANDS_DIR}/*/index.ts`); + for (const file of groupIndexFiles) { + const rel = file.slice(COMMANDS_DIR.length + 1); + const [name] = rel.split("/"); + commandNames.add(name); + } + + // Collect docs stems. + const docFiles = await ctx.glob(`${DOCS_DIR}/*.mdx`); + const docStems = new Set(); + for (const file of docFiles) { + const stem = file.slice(DOCS_DIR.length + 1, -".mdx".length); + docStems.add(stem); + } + + // Command -> docs: missing pages. + for (const name of [...commandNames].sort()) { + if (!docStems.has(name)) { + ctx.report.violation({ + message: `CLI command "${name}" has no reference page at ${DOCS_DIR}/${name}.mdx`, + file: `${COMMANDS_DIR}/${name}.ts`, + fix: `Create ${DOCS_DIR}/${name}.mdx documenting the command and its subcommands (mirror the structure of a neighbouring page like adr.mdx or login.mdx)`, + }); + } + } + + // Docs -> command: orphan pages. + for (const stem of [...docStems].sort()) { + if (EXEMPT_DOC_STEMS.has(stem)) continue; + if (!commandNames.has(stem)) { + ctx.report.violation({ + message: `Reference page "${stem}.mdx" has no corresponding CLI command under ${COMMANDS_DIR}/`, + file: `${DOCS_DIR}/${stem}.mdx`, + fix: `Either create ${COMMANDS_DIR}/${stem}.ts (or ${COMMANDS_DIR}/${stem}/index.ts) to match, or remove the orphan page`, + }); + } + } + }, + }, + }, +} satisfies RuleSet; diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 5f788544..137a9c6e 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -461,13 +461,13 @@ The prefix comes from the ADR's domain (see [Domains](/concepts/domains/)). The Every ADR document starts with a YAML frontmatter block between `---` delimiters. The frontmatter is the machine-readable metadata that Archgate uses to load, filter, and scope rules. -| Field | Type | Required | Description | -| -------- | ------------ | -------- | ---------------------------------------------------------------- | -| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | -| `title` | string | Yes | Human-readable title of the decision | -| `domain` | enum | Yes | One of: `backend`, `frontend`, `data`, `architecture`, `general` | -| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | -| `files` | string array | No | Glob patterns that scope which files the rules check | +| Field | Type | Required | Description | +| -------- | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | +| `title` | string | Yes | Human-readable title of the decision | +| `domain` | string | Yes | Registered domain name. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. [Custom domains](/concepts/domains/#custom-domains) can be added via `archgate adr domain add`. | +| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | +| `files` | string array | No | Glob patterns that scope which files the rules check | The `files` field is optional. When present, it restricts rule execution to only the files matching the given globs. When absent, rules run against all project files. For example, `files: ["src/commands/**/*.ts"]` limits checks to command files only. @@ -691,6 +691,43 @@ When deciding which domain an ADR belongs to, consider who needs to follow it: When in doubt between `architecture` and a specific domain, prefer the more specific domain. Reserve `architecture` for decisions that genuinely cut across boundaries. +## Custom Domains + +When the built-in five are a genuine mismatch for a category of decisions — for example, `security`, `ml-ops`, or `compliance` — you can register a custom domain via the CLI: + +```bash +# See what's currently recognised in this project +archgate adr domain list + +# Register a new domain with its ID prefix +archgate adr domain add security SEC + +# Remove a custom domain (built-ins cannot be removed) +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. + +### Naming rules + +- **Name** — lowercase kebab-case, 2–32 characters (e.g., `security`, `ml-ops`, `compliance`). +- **Prefix** — uppercase letters, digits, or underscores, 2–10 characters (e.g., `SEC`, `MLOPS`, `COMP`). +- Custom names and prefixes cannot collide with built-ins or any other custom entry. + +### When to prefer a built-in + +The built-in five are deliberately opinionated. Before registering a custom domain, check whether the decision can be folded under an existing one: + +- A decision about auth middleware usually fits under `backend`, even if the motivation is security. +- A decision about schema versioning usually fits under `data`, even if the motivation is compliance. +- A decision that spans multiple technical areas usually fits under `architecture`. + +Reach for a custom domain only when none of the built-ins is a genuine fit — for example, when you have a dedicated team or compliance regime that needs its own governance surface. + +### AI agent guidance + +When using the Archgate editor plugin to author ADRs, agents are instructed to default to the built-in domains and to ask before introducing a custom one. They'll surface the merged list via `archgate adr domain list` and only register a new domain after confirming with you that no built-in fits. + --- ## Core Concepts: Rules @@ -2010,7 +2047,7 @@ archgate adr create You will be prompted for: -1. **Domain** -- one of `backend`, `frontend`, `data`, `architecture`, or `general` +1. **Domain** -- one of the built-in `backend`, `frontend`, `data`, `architecture`, or `general`, plus any [custom domains](/concepts/domains/#custom-domains) the project has registered via `archgate adr domain add` 2. **Title** -- a short, descriptive name for the decision 3. **File patterns** -- optional comma-separated globs that scope rule checking (e.g., `src/commands/**/*.ts`) @@ -2026,14 +2063,14 @@ archgate adr create --title "API Response Format" --domain backend --files "src/ Available flags: -| Flag | Description | -| ----------------- | ------------------------------------------------------ | -| `--title ` | ADR title (required for non-interactive mode) | -| `--domain <name>` | Domain: backend, frontend, data, architecture, general | -| `--files <globs>` | Comma-separated file patterns for rule scoping | -| `--rules` | Set `rules: true` in frontmatter | -| `--body <md>` | Full ADR body markdown (skip template) | -| `--json` | Output the result as JSON | +| Flag | Description | +| ----------------- | --------------------------------------------------------------------- | +| `--title <title>` | ADR title (required for non-interactive mode) | +| `--domain <name>` | Domain name (built-in or [custom](/concepts/domains/#custom-domains)) | +| `--files <globs>` | Comma-separated file patterns for rule scoping | +| `--rules` | Set `rules: true` in frontmatter | +| `--body <md>` | Full ADR body markdown (skip template) | +| `--json` | Output the result as JSON | ## The generated template @@ -2296,7 +2333,7 @@ The `--id` and `--body` flags are required. All other frontmatter fields (`--tit 6. **Write rules for decisions that can be checked automatically.** If a rule can catch a violation before code review, set `rules: true` and write a companion `.rules.ts` file. See the [Writing Rules](/guides/writing-rules/) guide. -7. **Use domain prefixes to organize.** The domain field (`backend`, `frontend`, `data`, `architecture`, `general`) determines the ID prefix and helps filter ADRs by area. +7. **Use domain prefixes to organize.** The domain field determines the ID prefix and helps filter ADRs by area. Prefer the five built-ins (`backend`, `frontend`, `data`, `architecture`, `general`); register a [custom domain](/concepts/domains/#custom-domains) only when none of them is a genuine fit. The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) include an ADR Author skill that creates and updates ADRs following your project's conventions. The Quality Manager skill also proposes new ADRs when it detects recurring patterns. [Sign up for beta access](https://plugins.archgate.dev). @@ -2624,13 +2661,13 @@ files: ["src/commands/**/*.ts"] ### Fields -| Field | Type | Required | Description | -| -------- | ---------- | -------- | ---------------------------------------------------------------------------------- | -| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | -| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | -| `domain` | `enum` | Yes | Domain category. One of: `backend`, `frontend`, `data`, `architecture`, `general`. | -| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | -| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | +| Field | Type | Required | Description | +| -------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | +| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | +| `domain` | `string` | Yes | A registered domain name in lowercase kebab-case. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains are those registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | +| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | +| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | ### id @@ -2646,6 +2683,8 @@ A short, descriptive name for the architectural decision. Displayed in `archgate Groups related ADRs together. The domain also determines the ID prefix used by `archgate adr create`. +Projects can extend the built-in set with custom domains via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Custom domain → prefix mappings live in `.archgate/config.json` and are merged with the built-ins at read time. + ### rules Set to `true` when this ADR has a companion `.rules.ts` file. When `archgate check` runs, it skips ADRs where `rules` is `false`. @@ -2670,6 +2709,8 @@ files: ["src/api/**/*.ts", "src/middleware/**/*.ts"] Each domain maps to a prefix used in the ADR ID convention. +### Built-in domains + | Domain | Prefix | Example ID | | -------------- | ------ | ---------- | | `backend` | `BE` | `BE-001` | @@ -2680,6 +2721,12 @@ Each domain maps to a prefix used in the ADR ID convention. The `archgate adr create` command uses this mapping to auto-generate IDs. +### Custom domains + +Projects can register additional domain → prefix mappings via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Once registered, custom domains behave like built-ins: `archgate adr create --domain <name>` auto-generates IDs using the associated prefix, and ADRs with custom domains parse cleanly. + +See the [Domains concept page](/concepts/domains/) for guidance on when to introduce a custom domain. + --- ## File Naming Convention @@ -2865,15 +2912,17 @@ Invalid ADR frontmatter in ARCH-001-example.md: - domain: Required ``` -### Invalid enum values +### Invalid domain format -If `domain` is not one of the valid values: +If `domain` is not a valid kebab-case identifier (e.g., has uppercase letters or spaces): ``` Invalid ADR frontmatter in ARCH-001-example.md: - - domain: Invalid enum value. Expected 'backend' | 'frontend' | 'data' | 'architecture' | 'general', received 'security' + - domain: domain must be lowercase kebab-case (e.g. 'backend', 'ml-ops') ``` +Note: the parser accepts any name that matches the kebab-case pattern. Whether a specific name is "known" to the project — and therefore has a prefix that `archgate adr create` can use — depends on the built-in set plus any custom domains registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Creating an ADR with an unregistered domain name fails with a "Unknown ADR domain" error that suggests running `archgate adr domain add`. + ### Type mismatches If `rules` is a string instead of a boolean: @@ -2905,14 +2954,14 @@ The ADR ID is auto-generated with the domain prefix and the next available seque ### Options -| Option | Description | -| -------------------- | --------------------------------------------------------------------- | -| `--title <title>` | ADR title (skip interactive prompt) | -| `--domain <domain>` | ADR domain (`backend`, `frontend`, `data`, `architecture`, `general`) | -| `--files <patterns>` | File patterns, comma-separated | -| `--body <markdown>` | Full ADR body markdown (skip template) | -| `--rules` | Set `rules: true` in frontmatter | -| `--json` | Output as JSON | +| Option | Description | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--title <title>` | ADR title (skip interactive prompt) | +| `--domain <domain>` | ADR domain. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains must first be registered via [`archgate adr domain add`](#archgate-adr-domain). | +| `--files <patterns>` | File patterns, comma-separated | +| `--body <markdown>` | Full ADR body markdown (skip template) | +| `--rules` | Set `rules: true` in frontmatter | +| `--json` | Output as JSON | ### Examples @@ -3015,15 +3064,15 @@ Replaces the ADR body with the provided markdown. Frontmatter fields (`--title`, ### Options -| Option | Required | Description | -| -------------------- | -------- | --------------------------------------------------------------------- | -| `--id <id>` | Yes | ADR ID to update (e.g., `ARCH-001`) | -| `--body <markdown>` | Yes | Full replacement ADR body markdown | -| `--title <title>` | No | New ADR title (preserves existing if omitted) | -| `--domain <domain>` | No | New domain (`backend`, `frontend`, `data`, `architecture`, `general`) | -| `--files <patterns>` | No | New file patterns, comma-separated (preserves existing if omitted) | -| `--rules` | No | Set `rules: true` in frontmatter | -| `--json` | No | Output as JSON | +| Option | Required | Description | +| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--id <id>` | Yes | ADR ID to update (e.g., `ARCH-001`) | +| `--body <markdown>` | Yes | Full replacement ADR body markdown | +| `--title <title>` | No | New ADR title (preserves existing if omitted) | +| `--domain <domain>` | No | New domain. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains must first be registered via [`archgate adr domain add`](#archgate-adr-domain). | +| `--files <patterns>` | No | New file patterns, comma-separated (preserves existing if omitted) | +| `--rules` | No | Set `rules: true` in frontmatter | +| `--json` | No | Output as JSON | ### Example @@ -3036,6 +3085,69 @@ archgate adr update \ --- +## archgate adr domain + +Manage custom ADR domains. Custom domains are name → ID-prefix mappings persisted in `.archgate/config.json` and merged with the five built-ins (`backend`, `frontend`, `data`, `architecture`, `general`) at read time. + +Use this command when a decision doesn't cleanly fit any built-in domain. Before registering a new one, check whether the decision can be folded under an existing domain — built-ins are the default and a custom domain should only be introduced when no built-in is a genuine fit. + +```bash +archgate adr domain <subcommand> [options] +``` + +### Subcommands + +| Subcommand | Description | +| ----------------------------------------- | -------------------------------------------------------------- | +| `archgate adr domain list` | Show all merged (built-in + custom) domains and their prefixes | +| `archgate adr domain add <name> <prefix>` | Register a custom domain | +| `archgate adr domain remove <name>` | Unregister a custom domain (built-ins cannot be removed) | + +### Naming rules + +- `<name>` — lowercase kebab-case, 2–32 chars (e.g., `security`, `ml-ops`) +- `<prefix>` — uppercase letters, digits, or underscores, 2–10 chars (e.g., `SEC`, `MLOPS`) +- Custom names and prefixes cannot collide with built-ins or with any other custom entry. + +### Options + +| Option | Applies to | Description | +| -------- | --------------- | -------------- | +| `--json` | all subcommands | Output as JSON | + +### Examples + +List built-in and custom domains: + +```bash +archgate adr domain list +``` + +``` +Domain Prefix Source +──────────────────────────────── +architecture ARCH default +backend BE default +data DATA default +frontend FE default +general GEN default +security SEC custom +``` + +Register a custom domain: + +```bash +archgate adr domain add security SEC +``` + +Remove a custom domain: + +```bash +archgate adr domain remove security +``` + +--- + ## Reference: archgate check Source: https://cli.archgate.dev/reference/cli/check/ @@ -3633,6 +3745,82 @@ archgate session-context cursor --session-id abc123 --- +## Reference: archgate telemetry + +Source: https://cli.archgate.dev/reference/cli/telemetry/ + +Manage anonymous usage data collection for the Archgate CLI. Telemetry is opt-out: enabled by default, never captures personally identifiable information, and anonymizes the client IP server-side. + +```bash +archgate telemetry <subcommand> +``` + +See [CLI telemetry](/reference/telemetry/) for the full privacy policy, the list of events emitted, and how telemetry interacts with `ARCHGATE_TELEMETRY=0` and CI environments. + +## Subcommands + +| Subcommand | Description | +| ---------------------------- | --------------------------------------------------- | +| `archgate telemetry status` | Show whether telemetry is enabled for this CLI user | +| `archgate telemetry enable` | Enable anonymous usage data collection | +| `archgate telemetry disable` | Disable anonymous usage data collection | + +All three subcommands read and write `~/.archgate/config.json`, which persists the opt-in state and the anonymous install ID used as the telemetry `distinct_id`. + +## Examples + +Check the current state: + +```bash +archgate telemetry status +``` + +``` +Telemetry is enabled. +Anonymous usage data helps improve Archgate. No personal information is collected. + +To disable: `archgate telemetry disable` or set ARCHGATE_TELEMETRY=0 +Learn more: https://cli.archgate.dev/reference/telemetry +``` + +Disable telemetry: + +```bash +archgate telemetry disable +``` + +``` +Telemetry disabled. No usage data will be collected. +``` + +Re-enable telemetry: + +```bash +archgate telemetry enable +``` + +``` +Telemetry enabled. Thank you for helping improve Archgate. +``` + +## Environment variable override + +Setting `ARCHGATE_TELEMETRY=0` (or `false`, `no`, `off`, case-insensitive) disables telemetry for a single invocation regardless of the persisted state. Use this in CI or shared environments where you want opt-out without mutating the user's config: + +```bash +ARCHGATE_TELEMETRY=0 archgate check +``` + +`archgate telemetry status` detects the override and reports: + +``` +Telemetry is disabled (ARCHGATE_TELEMETRY environment variable). +``` + +When the override is in effect, running `archgate telemetry enable` persists the opt-in but prints a note that the env var continues to disable telemetry until it is unset. + +--- + ## Reference: archgate upgrade Source: https://cli.archgate.dev/reference/cli/upgrade/ diff --git a/docs/src/content/docs/concepts/adrs.mdx b/docs/src/content/docs/concepts/adrs.mdx index 3ccac075..73be5c60 100644 --- a/docs/src/content/docs/concepts/adrs.mdx +++ b/docs/src/content/docs/concepts/adrs.mdx @@ -47,13 +47,13 @@ The prefix comes from the ADR's domain (see [Domains](/concepts/domains/)). The Every ADR document starts with a YAML frontmatter block between `---` delimiters. The frontmatter is the machine-readable metadata that Archgate uses to load, filter, and scope rules. -| Field | Type | Required | Description | -| -------- | ------------ | -------- | ---------------------------------------------------------------- | -| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | -| `title` | string | Yes | Human-readable title of the decision | -| `domain` | enum | Yes | One of: `backend`, `frontend`, `data`, `architecture`, `general` | -| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | -| `files` | string array | No | Glob patterns that scope which files the rules check | +| Field | Type | Required | Description | +| -------- | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | +| `title` | string | Yes | Human-readable title of the decision | +| `domain` | string | Yes | Registered domain name. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. [Custom domains](/concepts/domains/#custom-domains) can be added via `archgate adr domain add`. | +| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | +| `files` | string array | No | Glob patterns that scope which files the rules check | The `files` field is optional. When present, it restricts rule execution to only the files matching the given globs. When absent, rules run against all project files. For example, `files: ["src/commands/**/*.ts"]` limits checks to command files only. diff --git a/docs/src/content/docs/concepts/domains.mdx b/docs/src/content/docs/concepts/domains.mdx index e6f698de..183cd4ba 100644 --- a/docs/src/content/docs/concepts/domains.mdx +++ b/docs/src/content/docs/concepts/domains.mdx @@ -94,3 +94,40 @@ When deciding which domain an ADR belongs to, consider who needs to follow it: - If it is a process or convention rather than a technical decision, use `general`. When in doubt between `architecture` and a specific domain, prefer the more specific domain. Reserve `architecture` for decisions that genuinely cut across boundaries. + +## Custom Domains + +When the built-in five are a genuine mismatch for a category of decisions — for example, `security`, `ml-ops`, or `compliance` — you can register a custom domain via the CLI: + +```bash +# See what's currently recognised in this project +archgate adr domain list + +# Register a new domain with its ID prefix +archgate adr domain add security SEC + +# Remove a custom domain (built-ins cannot be removed) +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. + +### Naming rules + +- **Name** — lowercase kebab-case, 2–32 characters (e.g., `security`, `ml-ops`, `compliance`). +- **Prefix** — uppercase letters, digits, or underscores, 2–10 characters (e.g., `SEC`, `MLOPS`, `COMP`). +- Custom names and prefixes cannot collide with built-ins or any other custom entry. + +### When to prefer a built-in + +The built-in five are deliberately opinionated. Before registering a custom domain, check whether the decision can be folded under an existing one: + +- A decision about auth middleware usually fits under `backend`, even if the motivation is security. +- A decision about schema versioning usually fits under `data`, even if the motivation is compliance. +- A decision that spans multiple technical areas usually fits under `architecture`. + +Reach for a custom domain only when none of the built-ins is a genuine fit — for example, when you have a dedicated team or compliance regime that needs its own governance surface. + +### AI agent guidance + +When using the Archgate editor plugin to author ADRs, agents are instructed to default to the built-in domains and to ask before introducing a custom one. They'll surface the merged list via `archgate adr domain list` and only register a new domain after confirming with you that no built-in fits. diff --git a/docs/src/content/docs/guides/writing-adrs.mdx b/docs/src/content/docs/guides/writing-adrs.mdx index e4a68298..3c713d63 100644 --- a/docs/src/content/docs/guides/writing-adrs.mdx +++ b/docs/src/content/docs/guides/writing-adrs.mdx @@ -17,7 +17,7 @@ archgate adr create You will be prompted for: -1. **Domain** -- one of `backend`, `frontend`, `data`, `architecture`, or `general` +1. **Domain** -- one of the built-in `backend`, `frontend`, `data`, `architecture`, or `general`, plus any [custom domains](/concepts/domains/#custom-domains) the project has registered via `archgate adr domain add` 2. **Title** -- a short, descriptive name for the decision 3. **File patterns** -- optional comma-separated globs that scope rule checking (e.g., `src/commands/**/*.ts`) @@ -33,14 +33,14 @@ archgate adr create --title "API Response Format" --domain backend --files "src/ Available flags: -| Flag | Description | -| ----------------- | ------------------------------------------------------ | -| `--title <title>` | ADR title (required for non-interactive mode) | -| `--domain <name>` | Domain: backend, frontend, data, architecture, general | -| `--files <globs>` | Comma-separated file patterns for rule scoping | -| `--rules` | Set `rules: true` in frontmatter | -| `--body <md>` | Full ADR body markdown (skip template) | -| `--json` | Output the result as JSON | +| Flag | Description | +| ----------------- | --------------------------------------------------------------------- | +| `--title <title>` | ADR title (required for non-interactive mode) | +| `--domain <name>` | Domain name (built-in or [custom](/concepts/domains/#custom-domains)) | +| `--files <globs>` | Comma-separated file patterns for rule scoping | +| `--rules` | Set `rules: true` in frontmatter | +| `--body <md>` | Full ADR body markdown (skip template) | +| `--json` | Output the result as JSON | ## The generated template @@ -303,7 +303,7 @@ The `--id` and `--body` flags are required. All other frontmatter fields (`--tit 6. **Write rules for decisions that can be checked automatically.** If a rule can catch a violation before code review, set `rules: true` and write a companion `.rules.ts` file. See the [Writing Rules](/guides/writing-rules/) guide. -7. **Use domain prefixes to organize.** The domain field (`backend`, `frontend`, `data`, `architecture`, `general`) determines the ID prefix and helps filter ADRs by area. +7. **Use domain prefixes to organize.** The domain field determines the ID prefix and helps filter ADRs by area. Prefer the five built-ins (`backend`, `frontend`, `data`, `architecture`, `general`); register a [custom domain](/concepts/domains/#custom-domains) only when none of them is a genuine fit. :::tip[Let AI agents write ADRs for you] The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) include an ADR Author skill that creates and updates ADRs following your project's conventions. The Quality Manager skill also proposes new ADRs when it detects recurring patterns. [Sign up for beta access](https://plugins.archgate.dev). diff --git a/docs/src/content/docs/pt-br/concepts/adrs.mdx b/docs/src/content/docs/pt-br/concepts/adrs.mdx index 292881d4..6044d5b5 100644 --- a/docs/src/content/docs/pt-br/concepts/adrs.mdx +++ b/docs/src/content/docs/pt-br/concepts/adrs.mdx @@ -47,13 +47,13 @@ O prefixo vem do domínio do ADR (veja [Domínios](/concepts/domains/)). O núme Todo documento ADR começa com um bloco de frontmatter YAML entre delimitadores `---`. O frontmatter é o metadado legível por máquina que o Archgate usa para carregar, filtrar e definir o escopo das regras. -| Campo | Tipo | Obrigatório | Descrição | -| -------- | ------------ | ----------- | --------------------------------------------------------------- | -| `id` | string | Sim | Identificador único como `ARCH-001` ou `BE-003` | -| `title` | string | Sim | Título legível da decisão | -| `domain` | enum | Sim | Um de: `backend`, `frontend`, `data`, `architecture`, `general` | -| `rules` | boolean | Sim | Se este ADR tem um arquivo `.rules.ts` complementar | -| `files` | string array | Não | Padrões glob que definem quais arquivos as regras verificam | +| Campo | Tipo | Obrigatório | Descrição | +| -------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Sim | Identificador único como `ARCH-001` ou `BE-003` | +| `title` | string | Sim | Título legível da decisão | +| `domain` | string | Sim | Nome de domínio registrado. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. [Domínios personalizados](/concepts/domains/#domínios-personalizados) podem ser adicionados via `archgate adr domain add`. | +| `rules` | boolean | Sim | Se este ADR tem um arquivo `.rules.ts` complementar | +| `files` | string array | Não | Padrões glob que definem quais arquivos as regras verificam | O campo `files` é opcional. Quando presente, restringe a execução das regras apenas aos arquivos que correspondem aos globs fornecidos. Quando ausente, as regras são executadas contra todos os arquivos do projeto. Por exemplo, `files: ["src/commands/**/*.ts"]` limita as verificações apenas aos arquivos de comando. diff --git a/docs/src/content/docs/pt-br/concepts/domains.mdx b/docs/src/content/docs/pt-br/concepts/domains.mdx index e25b5652..fe693f0f 100644 --- a/docs/src/content/docs/pt-br/concepts/domains.mdx +++ b/docs/src/content/docs/pt-br/concepts/domains.mdx @@ -94,3 +94,40 @@ Ao decidir a qual domínio um ADR pertence, considere quem precisa segui-lo: - Se é um processo ou convenção em vez de uma decisão técnica, use `general`. Em caso de dúvida entre `architecture` e um domínio específico, prefira o domínio mais específico. Reserve `architecture` para decisões que genuinamente cruzam fronteiras. + +## Domínios Personalizados + +Quando os cinco domínios integrados realmente não encaixam uma categoria de decisões — por exemplo, `security`, `ml-ops` ou `compliance` — você pode registrar um domínio personalizado via CLI: + +```bash +# Ver o conjunto reconhecido atualmente neste projeto +archgate adr domain list + +# Registrar um novo domínio com seu prefixo de ID +archgate adr domain add security SEC + +# Remover um domínio personalizado (integrados não podem ser removidos) +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. + +### Regras de nomenclatura + +- **Nome** — kebab-case em minúsculas, 2–32 caracteres (ex.: `security`, `ml-ops`, `compliance`). +- **Prefixo** — letras maiúsculas, dígitos ou underscores, 2–10 caracteres (ex.: `SEC`, `MLOPS`, `COMP`). +- Nomes e prefixos personalizados não podem colidir com integrados ou com qualquer outro personalizado. + +### Quando preferir um integrado + +Os cinco integrados são deliberadamente opinativos. Antes de registrar um domínio personalizado, verifique se a decisão pode ser agrupada sob um existente: + +- Uma decisão sobre middleware de autenticação geralmente cabe em `backend`, mesmo que a motivação seja de segurança. +- Uma decisão sobre versionamento de schema geralmente cabe em `data`, mesmo que a motivação seja de compliance. +- Uma decisão que abrange múltiplas áreas técnicas geralmente cabe em `architecture`. + +Recorra a um domínio personalizado apenas quando nenhum dos integrados for uma opção genuína — por exemplo, quando você tem uma equipe dedicada ou um regime de compliance que precisa de sua própria superfície de governança. + +### Orientação para agentes de IA + +Ao usar o plugin de editor do Archgate para criar ADRs, os agentes são instruídos a priorizar os domínios integrados e a perguntar antes de introduzir um personalizado. Eles mostrarão a lista mesclada via `archgate adr domain list` e só registrarão um novo domínio depois de confirmar com você que nenhum integrado serve. diff --git a/docs/src/content/docs/pt-br/guides/writing-adrs.mdx b/docs/src/content/docs/pt-br/guides/writing-adrs.mdx index 04edc259..622c83d9 100644 --- a/docs/src/content/docs/pt-br/guides/writing-adrs.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-adrs.mdx @@ -17,7 +17,7 @@ archgate adr create Você será solicitado a fornecer: -1. **Domain** -- um entre `backend`, `frontend`, `data`, `architecture` ou `general` +1. **Domain** -- um dos integrados `backend`, `frontend`, `data`, `architecture` ou `general`, mais quaisquer [domínios personalizados](/concepts/domains/#domínios-personalizados) que o projeto tenha registrado via `archgate adr domain add` 2. **Title** -- um nome curto e descritivo para a decisão 3. **File patterns** -- globs opcionais separados por vírgula que delimitam o escopo da verificação de regras (ex.: `src/commands/**/*.ts`) @@ -33,14 +33,14 @@ archgate adr create --title "API Response Format" --domain backend --files "src/ Flags disponíveis: -| Flag | Descrição | -| ----------------- | -------------------------------------------------------------- | -| `--title <title>` | Título do ADR (obrigatório no modo não-interativo) | -| `--domain <name>` | Domínio: backend, frontend, data, architecture, general | -| `--files <globs>` | Padrões de arquivo separados por vírgula para escopo de regras | -| `--rules` | Define `rules: true` no frontmatter | -| `--body <md>` | Corpo completo do ADR em markdown (pula o template) | -| `--json` | Exibe o resultado como JSON | +| Flag | Descrição | +| ----------------- | ------------------------------------------------------------------------------------------ | +| `--title <title>` | Título do ADR (obrigatório no modo não-interativo) | +| `--domain <name>` | Nome do domínio (integrado ou [personalizado](/concepts/domains/#domínios-personalizados)) | +| `--files <globs>` | Padrões de arquivo separados por vírgula para escopo de regras | +| `--rules` | Define `rules: true` no frontmatter | +| `--body <md>` | Corpo completo do ADR em markdown (pula o template) | +| `--json` | Exibe o resultado como JSON | ## O template gerado @@ -303,7 +303,7 @@ As flags `--id` e `--body` são obrigatórias. Todos os outros campos do frontma 6. **Escreva regras para decisões que podem ser verificadas automaticamente.** Se uma regra consegue detectar uma violação antes da revisão de código, defina `rules: true` e escreva um arquivo `.rules.ts` complementar. Veja o guia [Escrevendo Regras](/guides/writing-rules/). -7. **Use prefixos de domínio para organizar.** O campo domain (`backend`, `frontend`, `data`, `architecture`, `general`) determina o prefixo do ID e ajuda a filtrar ADRs por área. +7. **Use prefixos de domínio para organizar.** O campo domain determina o prefixo do ID e ajuda a filtrar ADRs por área. Prefira os cinco integrados (`backend`, `frontend`, `data`, `architecture`, `general`); registre um [domínio personalizado](/concepts/domains/#domínios-personalizados) apenas quando nenhum deles for uma opção genuína. :::tip[Deixe agentes de IA escreverem ADRs para você] Os plugins de editor para [Claude Code](/guides/claude-code-plugin/) e [Cursor](/guides/cursor-integration/) incluem uma skill de ADR Author que cria e atualiza ADRs seguindo as convenções do seu projeto. A skill Quality Manager também propõe novos ADRs quando detecta padrões recorrentes. [Inscreva-se para acesso beta](https://plugins.archgate.dev). diff --git a/docs/src/content/docs/pt-br/reference/adr-schema.mdx b/docs/src/content/docs/pt-br/reference/adr-schema.mdx index f032dd03..3003460b 100644 --- a/docs/src/content/docs/pt-br/reference/adr-schema.mdx +++ b/docs/src/content/docs/pt-br/reference/adr-schema.mdx @@ -21,13 +21,13 @@ files: ["src/commands/**/*.ts"] ### Campos -| Campo | Tipo | Obrigatório | Descrição | -| -------- | ---------- | ----------- | -------------------------------------------------------------------------------------- | -| `id` | `string` | Sim | Identificador único. Não pode ser vazio. Convenção: `PREFIX-NNN` (ex.: `ARCH-001`). | -| `title` | `string` | Sim | Título legível da decisão. Não pode ser vazio. | -| `domain` | `enum` | Sim | Categoria de domínio. Um de: `backend`, `frontend`, `data`, `architecture`, `general`. | -| `rules` | `boolean` | Sim | Se este ADR possui um arquivo `.rules.ts` complementar com verificações automatizadas. | -| `files` | `string[]` | Não | Padrões glob que definem o escopo de quais arquivos as regras se aplicam. | +| Campo | Tipo | Obrigatório | Descrição | +| -------- | ---------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Sim | Identificador único. Não pode ser vazio. Convenção: `PREFIX-NNN` (ex.: `ARCH-001`). | +| `title` | `string` | Sim | Título legível da decisão. Não pode ser vazio. | +| `domain` | `string` | Sim | Nome de domínio registrado em kebab-case minúsculo. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. Domínios personalizados são aqueles registrados via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | +| `rules` | `boolean` | Sim | Se este ADR possui um arquivo `.rules.ts` complementar com verificações automatizadas. | +| `files` | `string[]` | Não | Padrões glob que definem o escopo de quais arquivos as regras se aplicam. | ### id @@ -43,6 +43,8 @@ Um nome curto e descritivo para a decisão arquitetural. Exibido na saída de `a Agrupa ADRs relacionados. O domínio também determina o prefixo do ID usado por `archgate adr create`. +Projetos podem estender o conjunto integrado com domínios personalizados via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Os mapeamentos de domínio → prefixo personalizados ficam em `.archgate/config.json` e são mesclados com os integrados no momento da leitura. + ### rules Defina como `true` quando este ADR tem um arquivo `.rules.ts` complementar. Quando `archgate check` é executado, ele pula ADRs onde `rules` é `false`. @@ -67,6 +69,8 @@ files: ["src/api/**/*.ts", "src/middleware/**/*.ts"] Cada domínio mapeia para um prefixo usado na convenção de ID do ADR. +### Domínios integrados + | Domínio | Prefixo | ID de Exemplo | | -------------- | ------- | ------------- | | `backend` | `BE` | `BE-001` | @@ -77,6 +81,12 @@ Cada domínio mapeia para um prefixo usado na convenção de ID do ADR. O comando `archgate adr create` usa esse mapeamento para gerar IDs automaticamente. +### Domínios personalizados + +Projetos podem registrar mapeamentos adicionais de domínio → prefixo via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Uma vez registrados, domínios personalizados se comportam como integrados: `archgate adr create --domain <nome>` gera IDs automaticamente usando o prefixo associado, e ADRs com domínios personalizados são analisados sem erros. + +Veja a [página de conceitos de Domínios](/concepts/domains/) para orientação sobre quando introduzir um domínio personalizado. + --- ## Convenção de Nomenclatura de Arquivos @@ -262,15 +272,17 @@ Invalid ADR frontmatter in ARCH-001-example.md: - domain: Required ``` -### Valores de enum inválidos +### Formato de domain inválido -Se `domain` não for um dos valores válidos: +Se `domain` não for um identificador kebab-case válido (ex.: tem letras maiúsculas ou espaços): ``` Invalid ADR frontmatter in ARCH-001-example.md: - - domain: Invalid enum value. Expected 'backend' | 'frontend' | 'data' | 'architecture' | 'general', received 'security' + - domain: domain must be lowercase kebab-case (e.g. 'backend', 'ml-ops') ``` +Observação: o parser aceita qualquer nome que corresponda ao padrão kebab-case. Se um nome específico é "conhecido" pelo projeto — e portanto tem um prefixo que `archgate adr create` pode usar — depende do conjunto integrado mais quaisquer domínios personalizados registrados via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Criar um ADR com um nome de domínio não registrado falha com um erro "Unknown ADR domain" que sugere executar `archgate adr domain add`. + ### Incompatibilidade de tipos Se `rules` for uma string em vez de um booleano: diff --git a/docs/src/content/docs/pt-br/reference/cli/adr.mdx b/docs/src/content/docs/pt-br/reference/cli/adr.mdx index 7263eea7..f4b94943 100644 --- a/docs/src/content/docs/pt-br/reference/cli/adr.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/adr.mdx @@ -17,14 +17,14 @@ O ID do ADR é gerado automaticamente com o prefixo do domínio e o próximo nú ### Opções -| Opção | Descrição | -| -------------------- | ------------------------------------------------------------------------- | -| `--title <title>` | Título do ADR (pula o prompt interativo) | -| `--domain <domain>` | Domínio do ADR (`backend`, `frontend`, `data`, `architecture`, `general`) | -| `--files <patterns>` | Padrões de arquivo, separados por vírgula | -| `--body <markdown>` | Corpo completo do ADR em markdown (pula o template) | -| `--rules` | Define `rules: true` no frontmatter | -| `--json` | Saída como JSON | +| Opção | Descrição | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--title <title>` | Título do ADR (pula o prompt interativo) | +| `--domain <domain>` | Domínio do ADR. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. Domínios personalizados devem ser registrados antes via [`archgate adr domain add`](#archgate-adr-domain). | +| `--files <patterns>` | Padrões de arquivo, separados por vírgula | +| `--body <markdown>` | Corpo completo do ADR em markdown (pula o template) | +| `--rules` | Define `rules: true` no frontmatter | +| `--json` | Saída como JSON | ### Exemplos @@ -127,15 +127,15 @@ Substitui o corpo do ADR pelo markdown fornecido. Campos do frontmatter (`--titl ### Opções -| Opção | Obrigatório | Descrição | -| -------------------- | ----------- | ------------------------------------------------------------------------------- | -| `--id <id>` | Sim | ID do ADR a atualizar (ex.: `ARCH-001`) | -| `--body <markdown>` | Sim | Corpo completo do ADR em markdown (substitui o existente) | -| `--title <title>` | Não | Novo título do ADR (preserva o existente se omitido) | -| `--domain <domain>` | Não | Novo domínio (`backend`, `frontend`, `data`, `architecture`, `general`) | -| `--files <patterns>` | Não | Novos padrões de arquivo, separados por vírgula (preserva existente se omitido) | -| `--rules` | Não | Define `rules: true` no frontmatter | -| `--json` | Não | Saída como JSON | +| Opção | Obrigatório | Descrição | +| -------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--id <id>` | Sim | ID do ADR a atualizar (ex.: `ARCH-001`) | +| `--body <markdown>` | Sim | Corpo completo do ADR em markdown (substitui o existente) | +| `--title <title>` | Não | Novo título do ADR (preserva o existente se omitido) | +| `--domain <domain>` | Não | Novo domínio. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. Domínios personalizados devem ser registrados antes via [`archgate adr domain add`](#archgate-adr-domain). | +| `--files <patterns>` | Não | Novos padrões de arquivo, separados por vírgula (preserva existente se omitido) | +| `--rules` | Não | Define `rules: true` no frontmatter | +| `--json` | Não | Saída como JSON | ### Exemplo @@ -145,3 +145,66 @@ archgate adr update \ --title "Updated Command Structure" \ --body "## Context\n\nUpdated context..." ``` + +--- + +## archgate adr domain + +Gerencia domínios ADR personalizados. Domínios personalizados são mapeamentos nome → prefixo de ID persistidos em `.archgate/config.json` e mesclados com os cinco integrados (`backend`, `frontend`, `data`, `architecture`, `general`) no momento da leitura. + +Use este comando quando uma decisão não se encaixar claramente em nenhum domínio integrado. Antes de registrar um novo, verifique se a decisão pode ser agrupada sob um domínio existente — integrados são o padrão e um domínio personalizado deve ser introduzido apenas quando nenhum integrado for uma opção genuína. + +```bash +archgate adr domain <subcommand> [options] +``` + +### Subcomandos + +| Subcomando | Descrição | +| ----------------------------------------- | ------------------------------------------------------------------------------- | +| `archgate adr domain list` | Exibe todos os domínios mesclados (integrados + personalizados) e seus prefixos | +| `archgate adr domain add <name> <prefix>` | Registra um domínio personalizado | +| `archgate adr domain remove <name>` | Remove um domínio personalizado (integrados não podem ser removidos) | + +### Regras de nomenclatura + +- `<name>` — kebab-case em minúsculas, 2–32 caracteres (ex.: `security`, `ml-ops`) +- `<prefix>` — letras maiúsculas, dígitos ou underscores, 2–10 caracteres (ex.: `SEC`, `MLOPS`) +- Nomes e prefixos personalizados não podem colidir com integrados ou com qualquer outra entrada personalizada. + +### Opções + +| Opção | Aplica-se a | Descrição | +| -------- | -------------------- | --------------- | +| `--json` | todos os subcomandos | Saída como JSON | + +### Exemplos + +Listar domínios integrados e personalizados: + +```bash +archgate adr domain list +``` + +``` +Domain Prefix Source +──────────────────────────────── +architecture ARCH default +backend BE default +data DATA default +frontend FE default +general GEN default +security SEC custom +``` + +Registrar um domínio personalizado: + +```bash +archgate adr domain add security SEC +``` + +Remover um domínio personalizado: + +```bash +archgate adr domain remove security +``` diff --git a/docs/src/content/docs/pt-br/reference/cli/telemetry.mdx b/docs/src/content/docs/pt-br/reference/cli/telemetry.mdx new file mode 100644 index 00000000..3cea2f43 --- /dev/null +++ b/docs/src/content/docs/pt-br/reference/cli/telemetry.mdx @@ -0,0 +1,74 @@ +--- +title: archgate telemetry +description: "Gerencia a coleta anônima de dados de uso da CLI Archgate." +--- + +Gerencia a coleta anônima de dados de uso da CLI Archgate. A telemetria é opt-out: habilitada por padrão, nunca captura informações pessoalmente identificáveis e anonimiza o IP do cliente no lado do servidor. + +```bash +archgate telemetry <subcommand> +``` + +Veja [Telemetria da CLI](/reference/telemetry/) para a política de privacidade completa, a lista de eventos emitidos e como a telemetria interage com `ARCHGATE_TELEMETRY=0` e ambientes CI. + +## Subcomandos + +| Subcomando | Descrição | +| ---------------------------- | --------------------------------------------------------------- | +| `archgate telemetry status` | Mostra se a telemetria está habilitada para este usuário da CLI | +| `archgate telemetry enable` | Habilita a coleta anônima de dados de uso | +| `archgate telemetry disable` | Desabilita a coleta anônima de dados de uso | + +Os três subcomandos leem e gravam em `~/.archgate/config.json`, que persiste o estado de opt-in e o ID de instalação anônimo usado como `distinct_id` da telemetria. + +## Exemplos + +Verificar o estado atual: + +```bash +archgate telemetry status +``` + +``` +Telemetry is enabled. +Anonymous usage data helps improve Archgate. No personal information is collected. + +To disable: `archgate telemetry disable` or set ARCHGATE_TELEMETRY=0 +Learn more: https://cli.archgate.dev/reference/telemetry +``` + +Desabilitar a telemetria: + +```bash +archgate telemetry disable +``` + +``` +Telemetry disabled. No usage data will be collected. +``` + +Reabilitar a telemetria: + +```bash +archgate telemetry enable +``` + +``` +Telemetry enabled. Thank you for helping improve Archgate. +``` + +## Variável de ambiente para override + +Definir `ARCHGATE_TELEMETRY=0` (ou `false`, `no`, `off`, case-insensitive) desabilita a telemetria para uma única invocação, independentemente do estado persistido. Use isso em CI ou ambientes compartilhados onde você quer opt-out sem alterar a configuração do usuário: + +```bash +ARCHGATE_TELEMETRY=0 archgate check +``` + +`archgate telemetry status` detecta o override e reporta: + +``` +Telemetry is disabled (ARCHGATE_TELEMETRY environment variable). +``` + +Quando o override está em efeito, executar `archgate telemetry enable` persiste o opt-in mas imprime uma nota de que a variável de ambiente continua desabilitando a telemetria até ser removida. diff --git a/docs/src/content/docs/reference/adr-schema.mdx b/docs/src/content/docs/reference/adr-schema.mdx index ac92b136..2a7d2243 100644 --- a/docs/src/content/docs/reference/adr-schema.mdx +++ b/docs/src/content/docs/reference/adr-schema.mdx @@ -21,13 +21,13 @@ files: ["src/commands/**/*.ts"] ### Fields -| Field | Type | Required | Description | -| -------- | ---------- | -------- | ---------------------------------------------------------------------------------- | -| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | -| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | -| `domain` | `enum` | Yes | Domain category. One of: `backend`, `frontend`, `data`, `architecture`, `general`. | -| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | -| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | +| Field | Type | Required | Description | +| -------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | +| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | +| `domain` | `string` | Yes | A registered domain name in lowercase kebab-case. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains are those registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | +| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | +| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | ### id @@ -43,6 +43,8 @@ A short, descriptive name for the architectural decision. Displayed in `archgate Groups related ADRs together. The domain also determines the ID prefix used by `archgate adr create`. +Projects can extend the built-in set with custom domains via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Custom domain → prefix mappings live in `.archgate/config.json` and are merged with the built-ins at read time. + ### rules Set to `true` when this ADR has a companion `.rules.ts` file. When `archgate check` runs, it skips ADRs where `rules` is `false`. @@ -67,6 +69,8 @@ files: ["src/api/**/*.ts", "src/middleware/**/*.ts"] Each domain maps to a prefix used in the ADR ID convention. +### Built-in domains + | Domain | Prefix | Example ID | | -------------- | ------ | ---------- | | `backend` | `BE` | `BE-001` | @@ -77,6 +81,12 @@ Each domain maps to a prefix used in the ADR ID convention. The `archgate adr create` command uses this mapping to auto-generate IDs. +### Custom domains + +Projects can register additional domain → prefix mappings via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Once registered, custom domains behave like built-ins: `archgate adr create --domain <name>` auto-generates IDs using the associated prefix, and ADRs with custom domains parse cleanly. + +See the [Domains concept page](/concepts/domains/) for guidance on when to introduce a custom domain. + --- ## File Naming Convention @@ -262,15 +272,17 @@ Invalid ADR frontmatter in ARCH-001-example.md: - domain: Required ``` -### Invalid enum values +### Invalid domain format -If `domain` is not one of the valid values: +If `domain` is not a valid kebab-case identifier (e.g., has uppercase letters or spaces): ``` Invalid ADR frontmatter in ARCH-001-example.md: - - domain: Invalid enum value. Expected 'backend' | 'frontend' | 'data' | 'architecture' | 'general', received 'security' + - domain: domain must be lowercase kebab-case (e.g. 'backend', 'ml-ops') ``` +Note: the parser accepts any name that matches the kebab-case pattern. Whether a specific name is "known" to the project — and therefore has a prefix that `archgate adr create` can use — depends on the built-in set plus any custom domains registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). Creating an ADR with an unregistered domain name fails with a "Unknown ADR domain" error that suggests running `archgate adr domain add`. + ### Type mismatches If `rules` is a string instead of a boolean: diff --git a/docs/src/content/docs/reference/cli/adr.mdx b/docs/src/content/docs/reference/cli/adr.mdx index 9ca1c992..c0e3e002 100644 --- a/docs/src/content/docs/reference/cli/adr.mdx +++ b/docs/src/content/docs/reference/cli/adr.mdx @@ -17,14 +17,14 @@ The ADR ID is auto-generated with the domain prefix and the next available seque ### Options -| Option | Description | -| -------------------- | --------------------------------------------------------------------- | -| `--title <title>` | ADR title (skip interactive prompt) | -| `--domain <domain>` | ADR domain (`backend`, `frontend`, `data`, `architecture`, `general`) | -| `--files <patterns>` | File patterns, comma-separated | -| `--body <markdown>` | Full ADR body markdown (skip template) | -| `--rules` | Set `rules: true` in frontmatter | -| `--json` | Output as JSON | +| Option | Description | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--title <title>` | ADR title (skip interactive prompt) | +| `--domain <domain>` | ADR domain. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains must first be registered via [`archgate adr domain add`](#archgate-adr-domain). | +| `--files <patterns>` | File patterns, comma-separated | +| `--body <markdown>` | Full ADR body markdown (skip template) | +| `--rules` | Set `rules: true` in frontmatter | +| `--json` | Output as JSON | ### Examples @@ -127,15 +127,15 @@ Replaces the ADR body with the provided markdown. Frontmatter fields (`--title`, ### Options -| Option | Required | Description | -| -------------------- | -------- | --------------------------------------------------------------------- | -| `--id <id>` | Yes | ADR ID to update (e.g., `ARCH-001`) | -| `--body <markdown>` | Yes | Full replacement ADR body markdown | -| `--title <title>` | No | New ADR title (preserves existing if omitted) | -| `--domain <domain>` | No | New domain (`backend`, `frontend`, `data`, `architecture`, `general`) | -| `--files <patterns>` | No | New file patterns, comma-separated (preserves existing if omitted) | -| `--rules` | No | Set `rules: true` in frontmatter | -| `--json` | No | Output as JSON | +| Option | Required | Description | +| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--id <id>` | Yes | ADR ID to update (e.g., `ARCH-001`) | +| `--body <markdown>` | Yes | Full replacement ADR body markdown | +| `--title <title>` | No | New ADR title (preserves existing if omitted) | +| `--domain <domain>` | No | New domain. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains must first be registered via [`archgate adr domain add`](#archgate-adr-domain). | +| `--files <patterns>` | No | New file patterns, comma-separated (preserves existing if omitted) | +| `--rules` | No | Set `rules: true` in frontmatter | +| `--json` | No | Output as JSON | ### Example @@ -145,3 +145,66 @@ archgate adr update \ --title "Updated Command Structure" \ --body "## Context\n\nUpdated context..." ``` + +--- + +## archgate adr domain + +Manage custom ADR domains. Custom domains are name → ID-prefix mappings persisted in `.archgate/config.json` and merged with the five built-ins (`backend`, `frontend`, `data`, `architecture`, `general`) at read time. + +Use this command when a decision doesn't cleanly fit any built-in domain. Before registering a new one, check whether the decision can be folded under an existing domain — built-ins are the default and a custom domain should only be introduced when no built-in is a genuine fit. + +```bash +archgate adr domain <subcommand> [options] +``` + +### Subcommands + +| Subcommand | Description | +| ----------------------------------------- | -------------------------------------------------------------- | +| `archgate adr domain list` | Show all merged (built-in + custom) domains and their prefixes | +| `archgate adr domain add <name> <prefix>` | Register a custom domain | +| `archgate adr domain remove <name>` | Unregister a custom domain (built-ins cannot be removed) | + +### Naming rules + +- `<name>` — lowercase kebab-case, 2–32 chars (e.g., `security`, `ml-ops`) +- `<prefix>` — uppercase letters, digits, or underscores, 2–10 chars (e.g., `SEC`, `MLOPS`) +- Custom names and prefixes cannot collide with built-ins or with any other custom entry. + +### Options + +| Option | Applies to | Description | +| -------- | --------------- | -------------- | +| `--json` | all subcommands | Output as JSON | + +### Examples + +List built-in and custom domains: + +```bash +archgate adr domain list +``` + +``` +Domain Prefix Source +──────────────────────────────── +architecture ARCH default +backend BE default +data DATA default +frontend FE default +general GEN default +security SEC custom +``` + +Register a custom domain: + +```bash +archgate adr domain add security SEC +``` + +Remove a custom domain: + +```bash +archgate adr domain remove security +``` diff --git a/docs/src/content/docs/reference/cli/telemetry.mdx b/docs/src/content/docs/reference/cli/telemetry.mdx new file mode 100644 index 00000000..8f711860 --- /dev/null +++ b/docs/src/content/docs/reference/cli/telemetry.mdx @@ -0,0 +1,74 @@ +--- +title: archgate telemetry +description: "Manage anonymous usage data collection for the Archgate CLI." +--- + +Manage anonymous usage data collection for the Archgate CLI. Telemetry is opt-out: enabled by default, never captures personally identifiable information, and anonymizes the client IP server-side. + +```bash +archgate telemetry <subcommand> +``` + +See [CLI telemetry](/reference/telemetry/) for the full privacy policy, the list of events emitted, and how telemetry interacts with `ARCHGATE_TELEMETRY=0` and CI environments. + +## Subcommands + +| Subcommand | Description | +| ---------------------------- | --------------------------------------------------- | +| `archgate telemetry status` | Show whether telemetry is enabled for this CLI user | +| `archgate telemetry enable` | Enable anonymous usage data collection | +| `archgate telemetry disable` | Disable anonymous usage data collection | + +All three subcommands read and write `~/.archgate/config.json`, which persists the opt-in state and the anonymous install ID used as the telemetry `distinct_id`. + +## Examples + +Check the current state: + +```bash +archgate telemetry status +``` + +``` +Telemetry is enabled. +Anonymous usage data helps improve Archgate. No personal information is collected. + +To disable: `archgate telemetry disable` or set ARCHGATE_TELEMETRY=0 +Learn more: https://cli.archgate.dev/reference/telemetry +``` + +Disable telemetry: + +```bash +archgate telemetry disable +``` + +``` +Telemetry disabled. No usage data will be collected. +``` + +Re-enable telemetry: + +```bash +archgate telemetry enable +``` + +``` +Telemetry enabled. Thank you for helping improve Archgate. +``` + +## Environment variable override + +Setting `ARCHGATE_TELEMETRY=0` (or `false`, `no`, `off`, case-insensitive) disables telemetry for a single invocation regardless of the persisted state. Use this in CI or shared environments where you want opt-out without mutating the user's config: + +```bash +ARCHGATE_TELEMETRY=0 archgate check +``` + +`archgate telemetry status` detects the override and reports: + +``` +Telemetry is disabled (ARCHGATE_TELEMETRY environment variable). +``` + +When the override is in effect, running `archgate telemetry enable` persists the opt-in but prints a note that the env var continues to disable telemetry until it is unset. diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index 88ec9032..2b4ee9dd 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -1,24 +1,26 @@ import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import inquirer from "inquirer"; -import { ADR_DOMAINS, type AdrDomain } from "../../formats/adr"; +import type { AdrDomain } from "../../formats/adr"; 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"; - -const domainOption = new Option("--domain <domain>", "ADR domain").choices( - ADR_DOMAINS -); +import { + getAllDomainNames, + resolveDomainPrefix, +} from "../../helpers/project-config"; export function registerAdrCreateCommand(adr: Command) { adr .command("create") .description("Create a new ADR") .option("--title <title>", "ADR title (skip interactive prompt)") - .addOption(domainOption) + .option( + "--domain <domain>", + "ADR domain (built-in or registered via `archgate domain add`)" + ) .option("--files <patterns>", "File patterns, comma-separated") .option("--body <markdown>", "Full ADR body markdown (skip template)") .option("--rules", "Set rules: true in frontmatter") @@ -51,13 +53,14 @@ export function registerAdrCreateCommand(adr: Command) { : undefined; body = opts.body; } else { + const choices = getAllDomainNames(projectRoot); // Interactive mode const answers = await inquirer.prompt([ { type: "list", name: "domain", message: "Domain:", - choices: ADR_DOMAINS.map((d) => ({ name: d, value: d })), + choices: choices.map((d) => ({ name: d, value: d })), }, { type: "input", @@ -83,9 +86,12 @@ export function registerAdrCreateCommand(adr: Command) { : undefined; } + const prefix = resolveDomainPrefix(projectRoot, domain); + const result = await createAdrFile(paths.adrsDir, { title, domain, + prefix, files, body, rules: opts.rules, diff --git a/src/commands/adr/domain/add.ts b/src/commands/adr/domain/add.ts new file mode 100644 index 00000000..f6e5e438 --- /dev/null +++ b/src/commands/adr/domain/add.ts @@ -0,0 +1,50 @@ +import type { Command } from "@commander-js/extra-typings"; + +import { exitWith } from "../../../helpers/exit"; +import { logError } from "../../../helpers/log"; +import { formatJSON, isAgentContext } from "../../../helpers/output"; +import { findProjectRoot } from "../../../helpers/paths"; +import { addCustomDomain } from "../../../helpers/project-config"; +import { trackCustomDomainAdded } from "../../../helpers/telemetry"; + +export function registerDomainAddCommand(domain: Command) { + domain + .command("add") + .description("Register a custom ADR domain with an ID prefix") + .argument("<name>", "Domain name (lowercase kebab-case, e.g. 'security')") + .argument("<prefix>", "ID prefix (uppercase, e.g. 'SEC')") + .option("--json", "Output as JSON") + .action(async (name, prefix, options) => { + const projectRoot = findProjectRoot(); + if (!projectRoot) { + logError("No .archgate/ directory found. Run `archgate init` first."); + await exitWith(1); + return; + } + + try { + const config = await addCustomDomain(projectRoot, name, prefix); + const totalCustom = Object.keys(config.domains).length; + trackCustomDomainAdded({ + domain_name: name, + prefix, + total_custom_domains: totalCustom, + }); + + const useJson = options.json || isAgentContext(); + if (useJson) { + console.log( + formatJSON( + { domain: name, prefix, added: true }, + options.json ? true : undefined + ) + ); + } else { + console.log(`Registered custom domain: ${name} → ${prefix}`); + } + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + await exitWith(1); + } + }); +} diff --git a/src/commands/adr/domain/index.ts b/src/commands/adr/domain/index.ts new file mode 100644 index 00000000..e305eb35 --- /dev/null +++ b/src/commands/adr/domain/index.ts @@ -0,0 +1,15 @@ +import type { Command } from "@commander-js/extra-typings"; + +import { registerDomainAddCommand } from "./add"; +import { registerDomainListCommand } from "./list"; +import { registerDomainRemoveCommand } from "./remove"; + +export function registerDomainCommand(program: Command) { + const domain = program + .command("domain") + .description("Manage custom ADR domains (name → ID prefix mappings)"); + + registerDomainListCommand(domain); + registerDomainAddCommand(domain); + registerDomainRemoveCommand(domain); +} diff --git a/src/commands/adr/domain/list.ts b/src/commands/adr/domain/list.ts new file mode 100644 index 00000000..ef1f227e --- /dev/null +++ b/src/commands/adr/domain/list.ts @@ -0,0 +1,58 @@ +import { styleText } from "node:util"; + +import type { Command } from "@commander-js/extra-typings"; + +import { exitWith } from "../../../helpers/exit"; +import { logError } from "../../../helpers/log"; +import { formatJSON, isAgentContext } from "../../../helpers/output"; +import { findProjectRoot } from "../../../helpers/paths"; +import { listDomainEntries } from "../../../helpers/project-config"; + +export function registerDomainListCommand(domain: Command) { + domain + .command("list") + .description("List all ADR domains (built-in and custom)") + .option("--json", "Output as JSON") + .action((options) => { + const projectRoot = findProjectRoot(); + if (!projectRoot) { + logError("No .archgate/ directory found. Run `archgate init` first."); + exitWith(1); + return; + } + + try { + const entries = listDomainEntries(projectRoot); + const useJson = options.json || isAgentContext(); + + if (useJson) { + console.log(formatJSON(entries, options.json ? true : undefined)); + return; + } + + const nameWidth = 16; + const prefixWidth = 10; + + console.log( + styleText( + "bold", + `${"Domain".padEnd(nameWidth)}${"Prefix".padEnd(prefixWidth)}Source` + ) + ); + console.log( + styleText( + "dim", + `${"─".repeat(nameWidth)}${"─".repeat(prefixWidth)}${"─".repeat(8)}` + ) + ); + for (const entry of entries) { + console.log( + `${entry.domain.padEnd(nameWidth)}${entry.prefix.padEnd(prefixWidth)}${entry.source}` + ); + } + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + exitWith(1); + } + }); +} diff --git a/src/commands/adr/domain/remove.ts b/src/commands/adr/domain/remove.ts new file mode 100644 index 00000000..841a1c36 --- /dev/null +++ b/src/commands/adr/domain/remove.ts @@ -0,0 +1,70 @@ +import type { Command } from "@commander-js/extra-typings"; + +import { exitWith } from "../../../helpers/exit"; +import { logError } from "../../../helpers/log"; +import { formatJSON, isAgentContext } from "../../../helpers/output"; +import { findProjectRoot } from "../../../helpers/paths"; +import { + loadProjectConfig, + removeCustomDomain, +} from "../../../helpers/project-config"; +import { trackCustomDomainRemoved } from "../../../helpers/telemetry"; + +export function registerDomainRemoveCommand(domain: Command) { + domain + .command("remove") + .description("Remove a custom ADR domain from the project config") + .argument("<name>", "Domain name to remove") + .option("--json", "Output as JSON") + .action(async (name, options) => { + const projectRoot = findProjectRoot(); + if (!projectRoot) { + logError("No .archgate/ directory found. Run `archgate init` first."); + await exitWith(1); + return; + } + + try { + const existingPrefix = loadProjectConfig(projectRoot).domains[name]; + const { config, removed } = await removeCustomDomain(projectRoot, name); + + if (!removed) { + const useJson = options.json || isAgentContext(); + if (useJson) { + console.log( + formatJSON( + { domain: name, removed: false }, + options.json ? true : undefined + ) + ); + } else { + console.log( + `Domain '${name}' is not registered as a custom domain.` + ); + } + return; + } + + trackCustomDomainRemoved({ + domain_name: name, + prefix: existingPrefix ?? "", + total_custom_domains: Object.keys(config.domains).length, + }); + + const useJson = options.json || isAgentContext(); + if (useJson) { + console.log( + formatJSON( + { domain: name, removed: true }, + options.json ? true : undefined + ) + ); + } else { + console.log(`Removed custom domain: ${name}`); + } + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + await exitWith(1); + } + }); +} diff --git a/src/commands/adr/index.ts b/src/commands/adr/index.ts index bfc18578..4f802e56 100644 --- a/src/commands/adr/index.ts +++ b/src/commands/adr/index.ts @@ -1,6 +1,7 @@ import type { Command } from "@commander-js/extra-typings"; import { registerAdrCreateCommand } from "./create"; +import { registerDomainCommand } from "./domain/index"; import { registerAdrListCommand } from "./list"; import { registerAdrShowCommand } from "./show"; import { registerAdrUpdateCommand } from "./update"; @@ -14,4 +15,5 @@ export function registerAdrCommand(program: Command) { registerAdrListCommand(adr); registerAdrShowCommand(adr); registerAdrUpdateCommand(adr); + registerDomainCommand(adr); } diff --git a/src/commands/adr/list.ts b/src/commands/adr/list.ts index 89e50534..a4ed3b0a 100644 --- a/src/commands/adr/list.ts +++ b/src/commands/adr/list.ts @@ -3,9 +3,8 @@ import { join } from "node:path"; import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; -import { ADR_DOMAINS, parseAdr, type AdrDocument } from "../../formats/adr"; +import { parseAdr, type AdrDocument } from "../../formats/adr"; import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; @@ -31,9 +30,7 @@ export function registerAdrListCommand(adr: Command) { .command("list") .description("List all ADRs") .option("--json", "Output as JSON") - .addOption( - new Option("--domain <domain>", "Filter by domain").choices(ADR_DOMAINS) - ) + .option("--domain <domain>", "Filter by domain") .action(async (options) => { const projectRoot = findProjectRoot(); if (!projectRoot) { diff --git a/src/commands/adr/update.ts b/src/commands/adr/update.ts index 199d34ff..afc67a42 100644 --- a/src/commands/adr/update.ts +++ b/src/commands/adr/update.ts @@ -1,16 +1,11 @@ import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; -import { ADR_DOMAINS } from "../../formats/adr"; 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"; - -const domainOption = new Option("--domain <domain>", "new ADR domain").choices( - ADR_DOMAINS -); +import { resolveDomainPrefix } from "../../helpers/project-config"; export function registerAdrUpdateCommand(adr: Command) { adr @@ -19,7 +14,10 @@ export function registerAdrUpdateCommand(adr: Command) { .requiredOption("--id <id>", "ADR ID to update (e.g., ARCH-001)") .requiredOption("--body <markdown>", "Full replacement ADR body markdown") .option("--title <title>", "New ADR title (preserves existing if omitted)") - .addOption(domainOption) + .option( + "--domain <domain>", + "New ADR domain (built-in or registered via `archgate domain add`)" + ) .option( "--files <patterns>", "New file patterns, comma-separated (preserves existing if omitted)" @@ -43,6 +41,12 @@ export function registerAdrUpdateCommand(adr: Command) { : undefined; try { + if (opts.domain) { + // Validate the domain against the merged config now so users get + // a clear error instead of a stale prefix mismatch later. + resolveDomainPrefix(projectRoot, opts.domain); + } + const result = await updateAdrFile(paths.adrsDir, { id: opts.id, body: opts.body, diff --git a/src/commands/review-context.ts b/src/commands/review-context.ts index f8643a2b..1b5b328c 100644 --- a/src/commands/review-context.ts +++ b/src/commands/review-context.ts @@ -1,18 +1,11 @@ import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import { buildReviewContext } from "../engine/context"; -import { ADR_DOMAINS } from "../formats/adr"; import { exitWith } from "../helpers/exit"; import { logError } from "../helpers/log"; import { formatJSON } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; -const domainOption = new Option( - "--domain <domain>", - "filter to a single domain" -).choices(ADR_DOMAINS); - export function registerReviewContextCommand(program: Command) { program .command("review-context") @@ -21,7 +14,7 @@ export function registerReviewContextCommand(program: Command) { ) .option("--staged", "Only include git-staged files") .option("--run-checks", "Include ADR compliance check results") - .addOption(domainOption) + .option("--domain <domain>", "Filter to a single domain") .action(async (opts) => { const projectRoot = findProjectRoot(); if (!projectRoot) { diff --git a/src/formats/adr.ts b/src/formats/adr.ts index 62d02777..bf1f0f9f 100644 --- a/src/formats/adr.ts +++ b/src/formats/adr.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +import { DOMAIN_NAME_PATTERN } from "./project-config"; + +/** + * Built-in domains shipped with every project. Custom domains can be + * registered via `archgate domain add` and live in `.archgate/config.json`. + * Validation against the merged (defaults ∪ custom) set happens at the + * command/helper layer via `resolveDomainPrefix` in `helpers/project-config`. + */ export const ADR_DOMAINS = [ "backend", "frontend", @@ -8,9 +16,9 @@ export const ADR_DOMAINS = [ "general", ] as const; -export type AdrDomain = (typeof ADR_DOMAINS)[number]; +export type AdrDomain = string; -export const DOMAIN_PREFIXES: Record<AdrDomain, string> = { +export const DOMAIN_PREFIXES: Record<(typeof ADR_DOMAINS)[number], string> = { backend: "BE", frontend: "FE", data: "DATA", @@ -21,7 +29,14 @@ export const DOMAIN_PREFIXES: Record<AdrDomain, string> = { export const AdrFrontmatterSchema = z.object({ id: z.string().min(1), title: z.string().min(1), - domain: z.enum(ADR_DOMAINS), + domain: z + .string() + .min(2) + .max(32) + .regex( + DOMAIN_NAME_PATTERN, + "domain must be lowercase kebab-case (e.g. 'backend', 'ml-ops')" + ), rules: z.boolean(), files: z.array(z.string()).optional(), }); diff --git a/src/formats/project-config.ts b/src/formats/project-config.ts new file mode 100644 index 00000000..83acac59 --- /dev/null +++ b/src/formats/project-config.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const DOMAIN_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +export const DOMAIN_PREFIX_PATTERN = /^[A-Z][A-Z0-9_]*$/; + +export const DomainNameSchema = z + .string() + .min(2) + .max(32) + .regex( + DOMAIN_NAME_PATTERN, + "domain name must be lowercase kebab-case (e.g. 'security', 'ml-ops')" + ); + +export const DomainPrefixSchema = z + .string() + .min(2) + .max(10) + .regex( + DOMAIN_PREFIX_PATTERN, + "domain prefix must be uppercase (e.g. 'SEC', 'MLOPS')" + ); + +export const ProjectConfigSchema = z + .object({ + domains: z.record(DomainNameSchema, DomainPrefixSchema).default({}), + }) + .default({ domains: {} }); + +export type ProjectConfig = z.infer<typeof ProjectConfigSchema>; diff --git a/src/helpers/adr-writer.ts b/src/helpers/adr-writer.ts index a554bfd9..30ce7428 100644 --- a/src/helpers/adr-writer.ts +++ b/src/helpers/adr-writer.ts @@ -73,18 +73,28 @@ export interface CreateAdrResult { /** * Create an ADR file on disk. + * `prefix` overrides the built-in DOMAIN_PREFIXES lookup — callers that + * support custom domains should resolve it via `resolveDomainPrefix` from + * `helpers/project-config` and pass it explicitly. */ export async function createAdrFile( adrsDir: string, opts: { title: string; domain: AdrDomain; + prefix?: string; files?: string[]; body?: string; rules?: boolean; } ): Promise<CreateAdrResult> { - const prefix = DOMAIN_PREFIXES[opts.domain]; + const prefix = + opts.prefix ?? DOMAIN_PREFIXES[opts.domain as keyof typeof DOMAIN_PREFIXES]; + if (!prefix) { + throw new Error( + `No prefix registered for domain '${opts.domain}'. Pass opts.prefix or register via \`archgate domain add\`.` + ); + } const id = getNextId(adrsDir, prefix); const slug = slugify(opts.title); const content = buildAdrContent({ id, ...opts }); diff --git a/src/helpers/project-config.ts b/src/helpers/project-config.ts new file mode 100644 index 00000000..0ad52df9 --- /dev/null +++ b/src/helpers/project-config.ts @@ -0,0 +1,202 @@ +/** + * project-config.ts — Read/write `.archgate/config.json` at project root. + * + * Currently stores custom ADR domain → prefix mappings that extend the + * built-in defaults (backend/frontend/data/architecture/general). Defaults + * are never persisted; they are merged in at read time and cannot be + * overwritten or removed via the config. + */ + +import { existsSync, readFileSync } from "node:fs"; + +import { + DOMAIN_PREFIXES as DEFAULT_DOMAIN_PREFIXES, + ADR_DOMAINS as DEFAULT_DOMAINS, +} from "../formats/adr"; +import { + DomainNameSchema, + DomainPrefixSchema, + ProjectConfigSchema, + type ProjectConfig, +} from "../formats/project-config"; +import { logDebug } from "./log"; +import { createPathIfNotExists, projectPath, projectPaths } from "./paths"; + +const CONFIG_FILE = "config.json"; + +function configPath(projectRoot: string): string { + return projectPath(projectRoot, CONFIG_FILE); +} + +const EMPTY_CONFIG: ProjectConfig = { domains: {} }; + +/** + * Load the project config from `.archgate/config.json`. Returns an empty + * config (no custom domains) when the file is missing or malformed. + * Caller side-effect free — no caching to avoid stale reads after writes + * from this same process (e.g., `domain add` followed by `adr create`). + */ +export function loadProjectConfig(projectRoot: string): ProjectConfig { + const path = configPath(projectRoot); + if (!existsSync(path)) return EMPTY_CONFIG; + + try { + const text = readFileSync(path, "utf-8"); + const raw = JSON.parse(text) as unknown; + const result = ProjectConfigSchema.safeParse(raw); + if (!result.success) { + logDebug("Project config invalid, using empty:", result.error.message); + return EMPTY_CONFIG; + } + return result.data; + } catch (err) { + logDebug("Project config read failed, using empty:", String(err)); + return EMPTY_CONFIG; + } +} + +/** + * Write the project config to disk. + */ +export async function saveProjectConfig( + projectRoot: string, + config: ProjectConfig +): Promise<void> { + const path = configPath(projectRoot); + createPathIfNotExists(projectPaths(projectRoot).root); + await Bun.write(path, JSON.stringify(config, null, 2) + "\n"); + logDebug("Project config saved:", path); +} + +/** + * Merge built-in defaults with any custom domains from the project config. + * Custom domains cannot overwrite defaults — defaults win on conflict. + */ +export function getMergedDomainPrefixes( + projectRoot: string +): Record<string, string> { + const config = loadProjectConfig(projectRoot); + return { ...config.domains, ...DEFAULT_DOMAIN_PREFIXES }; +} + +export function getAllDomainNames(projectRoot: string): string[] { + return Object.keys(getMergedDomainPrefixes(projectRoot)).sort(); +} + +/** + * Resolve the ID prefix for a given domain name against the merged config. + * Throws when the domain is unknown — callers should surface a useful error + * mentioning `archgate domain add` or the list of known domains. + */ +export function resolveDomainPrefix( + projectRoot: string, + domain: string +): string { + const prefixes = getMergedDomainPrefixes(projectRoot); + const prefix = prefixes[domain]; + if (!prefix) { + const known = Object.keys(prefixes).sort().join(", "); + throw new Error( + `Unknown ADR domain '${domain}'. Known domains: ${known}. ` + + `Register a custom domain with \`archgate domain add <name> <prefix>\`.` + ); + } + return prefix; +} + +export function isDefaultDomain(domain: string): boolean { + return (DEFAULT_DOMAINS as readonly string[]).includes(domain); +} + +export interface DomainEntry { + domain: string; + prefix: string; + source: "default" | "custom"; +} + +export function listDomainEntries(projectRoot: string): DomainEntry[] { + const config = loadProjectConfig(projectRoot); + const custom = config.domains; + const defaults = DEFAULT_DOMAIN_PREFIXES as Record<string, string>; + const merged: DomainEntry[] = []; + + for (const [domain, prefix] of Object.entries(defaults)) { + merged.push({ domain, prefix, source: "default" }); + } + for (const [domain, prefix] of Object.entries(custom)) { + if (isDefaultDomain(domain)) continue; + merged.push({ domain, prefix, source: "custom" }); + } + + return merged.sort((a, b) => a.domain.localeCompare(b.domain)); +} + +/** + * Add a custom domain to the project config. Validates name + prefix format + * and rejects collisions with defaults. Does not catch writes — callers + * handle any I/O errors. + */ +export async function addCustomDomain( + projectRoot: string, + domain: string, + prefix: string +): Promise<ProjectConfig> { + const nameResult = DomainNameSchema.safeParse(domain); + if (!nameResult.success) { + throw new Error(nameResult.error.issues[0].message); + } + const prefixResult = DomainPrefixSchema.safeParse(prefix); + if (!prefixResult.success) { + throw new Error(prefixResult.error.issues[0].message); + } + if (isDefaultDomain(domain)) { + throw new Error( + `'${domain}' is a built-in domain and cannot be overridden.` + ); + } + + const defaults = DEFAULT_DOMAIN_PREFIXES as Record<string, string>; + const usedDefaultPrefix = Object.entries(defaults).find( + ([, p]) => p === prefix + ); + if (usedDefaultPrefix) { + throw new Error( + `Prefix '${prefix}' is already used by built-in domain '${usedDefaultPrefix[0]}'.` + ); + } + + const config = loadProjectConfig(projectRoot); + const collision = Object.entries(config.domains).find( + ([name, p]) => name !== domain && p === prefix + ); + if (collision) { + throw new Error( + `Prefix '${prefix}' is already used by custom domain '${collision[0]}'.` + ); + } + + const next: ProjectConfig = { + ...config, + domains: { ...config.domains, [domain]: prefix }, + }; + await saveProjectConfig(projectRoot, next); + return next; +} + +export async function removeCustomDomain( + projectRoot: string, + domain: string +): Promise<{ config: ProjectConfig; removed: boolean }> { + if (isDefaultDomain(domain)) { + throw new Error(`'${domain}' is a built-in domain and cannot be removed.`); + } + const config = loadProjectConfig(projectRoot); + if (!(domain in config.domains)) { + return { config, removed: false }; + } + const nextDomains = { ...config.domains }; + delete nextDomains[domain]; + const next: ProjectConfig = { ...config, domains: nextDomains }; + await saveProjectConfig(projectRoot, next); + return { config: next, removed: true }; +} diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index 70ec74c6..db578b84 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -342,6 +342,28 @@ export function trackTelemetryPreferenceChange(properties: { trackEvent("telemetry_preference_changed", properties); } +/** + * Track registration of a custom ADR domain. The domain name and prefix are + * architectural category labels (e.g. "security" / "SEC"), not user data — + * capturing them lets us see which categories repos adopt outside the + * built-in five and informs whether to promote any to defaults. + */ +export function trackCustomDomainAdded(properties: { + domain_name: string; + prefix: string; + total_custom_domains: number; +}): void { + trackEvent("custom_domain_added", properties); +} + +export function trackCustomDomainRemoved(properties: { + domain_name: string; + prefix: string; + total_custom_domains: number; +}): void { + trackEvent("custom_domain_removed", properties); +} + /** * Flush pending events to PostHog. Call before process exit to ensure * events are delivered. diff --git a/tests/commands/adr/domain/add.test.ts b/tests/commands/adr/domain/add.test.ts new file mode 100644 index 00000000..7f6d51fa --- /dev/null +++ b/tests/commands/adr/domain/add.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerDomainCommand } from "../../../../src/commands/adr/domain/index"; + +function makeProgram(): Command { + const adr = new Command("adr").exitOverride(); + registerDomainCommand(adr); + return adr; +} + +describe("adr domain add", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType<typeof spyOn>; + let exitSpy: ReturnType<typeof spyOn>; + let errorSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-domain-add-")); + mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); + originalCwd = process.cwd(); + process.chdir(tempDir); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + test("writes config and subsequent list shows the custom entry", async () => { + const program = makeProgram(); + await program.parseAsync([ + "node", + "adr", + "domain", + "add", + "security", + "SEC", + ]); + + expect(existsSync(join(tempDir, ".archgate", "config.json"))).toBe(true); + + const out = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(out).toContain("security"); + + logSpy.mockClear(); + const program2 = makeProgram(); + await program2.parseAsync(["node", "adr", "domain", "list", "--json"]); + const raw = logSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + const parsed = JSON.parse(raw); + const sec = (parsed as Array<{ domain: string; source: string }>).find( + (e) => e.domain === "security" + ); + expect(sec?.source).toBe("custom"); + }); + + test("rejects built-in names", async () => { + const program = makeProgram(); + await expect( + program.parseAsync(["node", "adr", "domain", "add", "backend", "BE2"]) + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/commands/adr/domain/list.test.ts b/tests/commands/adr/domain/list.test.ts new file mode 100644 index 00000000..a38f1f88 --- /dev/null +++ b/tests/commands/adr/domain/list.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerDomainCommand } from "../../../../src/commands/adr/domain/index"; + +function makeProgram(): Command { + const adr = new Command("adr").exitOverride(); + registerDomainCommand(adr); + return adr; +} + +describe("adr domain list", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType<typeof spyOn>; + let exitSpy: ReturnType<typeof spyOn>; + let errorSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-domain-list-")); + mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); + originalCwd = process.cwd(); + process.chdir(tempDir); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + test("shows built-in domains even with no config", async () => { + const program = makeProgram(); + await program.parseAsync(["node", "adr", "domain", "list"]); + const out = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(out).toContain("backend"); + expect(out).toContain("default"); + }); +}); diff --git a/tests/commands/adr/domain/remove.test.ts b/tests/commands/adr/domain/remove.test.ts new file mode 100644 index 00000000..8f022951 --- /dev/null +++ b/tests/commands/adr/domain/remove.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerDomainCommand } from "../../../../src/commands/adr/domain/index"; + +function makeProgram(): Command { + const adr = new Command("adr").exitOverride(); + registerDomainCommand(adr); + return adr; +} + +describe("adr domain remove", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType<typeof spyOn>; + let exitSpy: ReturnType<typeof spyOn>; + let errorSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-domain-remove-")); + mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); + originalCwd = process.cwd(); + process.chdir(tempDir); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + test("deletes a custom entry", async () => { + const p1 = makeProgram(); + await p1.parseAsync(["node", "adr", "domain", "add", "security", "SEC"]); + + logSpy.mockClear(); + const p2 = makeProgram(); + await p2.parseAsync(["node", "adr", "domain", "remove", "security"]); + const out = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(out).toContain("Removed custom domain"); + }); + + test("refuses built-in domains", async () => { + const program = makeProgram(); + await expect( + program.parseAsync(["node", "adr", "domain", "remove", "backend"]) + ).rejects.toThrow("process.exit"); + }); + + test("missing entry reports not-registered", async () => { + const program = makeProgram(); + await program.parseAsync(["node", "adr", "domain", "remove", "ghost"]); + const out = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(out).toContain("not registered"); + }); +}); diff --git a/tests/fixtures/invalid-adr-bad-domain.md b/tests/fixtures/invalid-adr-bad-domain.md index 60715037..d6651271 100644 --- a/tests/fixtures/invalid-adr-bad-domain.md +++ b/tests/fixtures/invalid-adr-bad-domain.md @@ -1,10 +1,10 @@ --- id: BAD-001 title: Bad Domain ADR -domain: invalid-domain +domain: Invalid Domain rules: false --- # Bad Domain -This ADR has an invalid domain value. +This ADR has an invalid domain value (uppercase and space). diff --git a/tests/helpers/project-config.test.ts b/tests/helpers/project-config.test.ts new file mode 100644 index 00000000..1a225c9c --- /dev/null +++ b/tests/helpers/project-config.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + addCustomDomain, + getAllDomainNames, + getMergedDomainPrefixes, + isDefaultDomain, + listDomainEntries, + loadProjectConfig, + removeCustomDomain, + resolveDomainPrefix, + saveProjectConfig, +} from "../../src/helpers/project-config"; + +describe("project-config", () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "archgate-project-config-")); + mkdirSync(join(projectRoot, ".archgate"), { recursive: true }); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + }); + + test("loadProjectConfig returns empty when file missing", () => { + expect(loadProjectConfig(projectRoot)).toEqual({ domains: {} }); + }); + + test("addCustomDomain persists to disk and merges with defaults", async () => { + await addCustomDomain(projectRoot, "security", "SEC"); + const config = loadProjectConfig(projectRoot); + expect(config.domains.security).toBe("SEC"); + expect(existsSync(join(projectRoot, ".archgate", "config.json"))).toBe( + true + ); + + const merged = getMergedDomainPrefixes(projectRoot); + expect(merged.security).toBe("SEC"); + expect(merged.backend).toBe("BE"); + }); + + test("addCustomDomain rejects built-in domain names", async () => { + await expect( + addCustomDomain(projectRoot, "backend", "BE2") + ).rejects.toThrow(/built-in/); + }); + + test("addCustomDomain rejects invalid name format", async () => { + await expect( + addCustomDomain(projectRoot, "Bad Name", "BAD") + ).rejects.toThrow(/kebab-case/); + }); + + test("addCustomDomain rejects invalid prefix format", async () => { + await expect( + addCustomDomain(projectRoot, "infra", "lower") + ).rejects.toThrow(/uppercase/); + }); + + test("addCustomDomain rejects prefix already used by a default", async () => { + await expect( + addCustomDomain(projectRoot, "backend2", "BE") + ).rejects.toThrow(/built-in domain/); + }); + + test("addCustomDomain rejects prefix already used by another custom domain", async () => { + await addCustomDomain(projectRoot, "security", "SEC"); + await expect( + addCustomDomain(projectRoot, "secrets", "SEC") + ).rejects.toThrow(/already used/); + }); + + test("removeCustomDomain deletes the entry", async () => { + await addCustomDomain(projectRoot, "security", "SEC"); + const { removed } = await removeCustomDomain(projectRoot, "security"); + expect(removed).toBe(true); + expect(loadProjectConfig(projectRoot).domains.security).toBeUndefined(); + }); + + test("removeCustomDomain returns false when not present", async () => { + const { removed } = await removeCustomDomain(projectRoot, "security"); + expect(removed).toBe(false); + }); + + test("removeCustomDomain rejects built-in domains", async () => { + await expect(removeCustomDomain(projectRoot, "backend")).rejects.toThrow( + /built-in/ + ); + }); + + test("resolveDomainPrefix falls back to built-in prefixes", () => { + expect(resolveDomainPrefix(projectRoot, "backend")).toBe("BE"); + }); + + test("resolveDomainPrefix returns custom prefix when registered", async () => { + await addCustomDomain(projectRoot, "security", "SEC"); + expect(resolveDomainPrefix(projectRoot, "security")).toBe("SEC"); + }); + + test("resolveDomainPrefix throws on unknown domain with helpful hint", () => { + expect(() => resolveDomainPrefix(projectRoot, "nope")).toThrow( + /archgate domain add/ + ); + }); + + test("getAllDomainNames merges defaults with custom domains", async () => { + await addCustomDomain(projectRoot, "security", "SEC"); + const names = getAllDomainNames(projectRoot); + expect(names).toContain("backend"); + expect(names).toContain("security"); + }); + + test("listDomainEntries tags built-in vs custom source", async () => { + await addCustomDomain(projectRoot, "security", "SEC"); + const entries = listDomainEntries(projectRoot); + const be = entries.find((e) => e.domain === "backend"); + const sec = entries.find((e) => e.domain === "security"); + expect(be?.source).toBe("default"); + expect(sec?.source).toBe("custom"); + }); + + test("isDefaultDomain recognises built-ins", () => { + expect(isDefaultDomain("backend")).toBe(true); + expect(isDefaultDomain("security")).toBe(false); + }); + + test("saveProjectConfig + loadProjectConfig roundtrip", async () => { + await saveProjectConfig(projectRoot, { domains: { infra: "INFRA" } }); + expect(loadProjectConfig(projectRoot).domains.infra).toBe("INFRA"); + }); +});