diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 9bee6269..076744e5 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -3143,10 +3143,10 @@ archgate plugin install --editor cursor Run all automated ADR compliance checks against the codebase. ```bash -archgate check [options] +archgate check [options] [files...] ``` -Loads every ADR with `rules: true` in its frontmatter, executes the companion `.rules.ts` file, and reports violations with file paths and line numbers. +Loads every ADR with `rules: true` in its frontmatter, executes the companion `.rules.ts` file, and reports violations with file paths and line numbers. When file paths are provided as positional arguments, only ADRs whose `files` patterns match those files are executed. ### Options @@ -3158,13 +3158,19 @@ Loads every ADR with `rules: true` in its frontmatter, executes the companion `. | `--adr ` | Only check rules from a specific ADR | | `--verbose` | Show passing rules and timing info | +### Arguments + +| Argument | Description | +| ------------ | --------------------------------------------------------------------------------------------------------------- | +| `[files...]` | Optional file paths to scope checks to. Only ADRs whose `files` patterns match will run. Supports stdin piping. | + ### Exit codes -| Code | Meaning | -| ---- | --------------------------------------------- | -| 0 | All rules pass. No violations found. | -| 1 | One or more violations detected. | -| 2 | Internal error (e.g., malformed ADR or rule). | +| Code | Meaning | +| ---- | -------------------------------------------------------------------- | +| 0 | All rules pass. No violations found. | +| 1 | One or more violations detected. | +| 2 | Rule execution error (e.g., malformed rule, security scanner block). | ### Examples @@ -3186,6 +3192,18 @@ Check a single ADR: archgate check --adr ARCH-001 ``` +Check specific files (only matching ADRs run): + +```bash +archgate check src/foo.ts src/bar.ts +``` + +Pipe from git (check only changed files): + +```bash +git diff --name-only | archgate check --json +``` + Get JSON output for CI integration: ```bash @@ -3225,6 +3243,9 @@ When `--json` is used, the output is a single JSON object: { "message": "Command file must export a register*Command function", "file": "src/commands/broken.ts", + "line": 1, + "endLine": 1, + "endColumn": 42, "severity": "error" } ], @@ -3235,6 +3256,22 @@ When `--json` is used, the output is a single JSON object: } ``` +#### Violation fields + +| Field | Type | Description | +| ----------- | ------- | ------------------------------------------------------ | +| `message` | string | What the violation is | +| `file` | string? | Relative file path | +| `line` | number? | Start line (1-based) | +| `endLine` | number? | End line (1-based) — for precise editor highlighting | +| `endColumn` | number? | End column (0-based) — for precise editor highlighting | +| `fix` | string? | Suggested fix (guidance only) | +| `severity` | string | `"error"`, `"warning"`, or `"info"` | + +#### Blocked rule files + +When a rule file is blocked by the security scanner (e.g., uses `Bun.spawn()`) or a companion `.rules.ts` file is missing, the result appears in the JSON output with `status: "error"` and `ruleId: "security-scan"`. Violations include the exact file and line of the blocked code (or the `rules: true` line in the ADR for missing companions). + --- ## archgate adr create @@ -3718,16 +3755,22 @@ interface ReportDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; } ``` -| Field | Type | Required | Description | -| --------- | -------- | -------- | --------------------------------------- | -| `message` | `string` | Yes | Human-readable description of the issue | -| `file` | `string` | No | File path where the issue was found | -| `line` | `number` | No | Line number in the file | -| `fix` | `string` | No | Suggested fix or remediation action | +| Field | Type | Required | Description | +| ----------- | -------- | -------- | ------------------------------------------------------------------- | +| `message` | `string` | Yes | Human-readable description of the issue | +| `file` | `string` | No | File path where the issue was found | +| `line` | `number` | No | Start line number (1-based) | +| `endLine` | `number` | No | End line number (1-based) — for precise editor range highlighting | +| `endColumn` | `number` | No | End column number (0-based) — for precise editor range highlighting | +| `fix` | `string` | No | Suggested fix or remediation action | + +When `endLine` and `endColumn` are provided, editors (VS Code, Cursor) can highlight the exact expression that violates the rule, rather than the entire line. If omitted, the full line at `line` is highlighted. --- @@ -3778,20 +3821,24 @@ interface ViolationDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; severity: Severity; } ``` -| Field | Type | Description | -| ---------- | ---------- | ------------------------------------ | -| `ruleId` | `string` | Rule ID from the `rules` object key | -| `adrId` | `string` | ADR ID from the frontmatter | -| `message` | `string` | Human-readable description | -| `file` | `string?` | File path where the issue was found | -| `line` | `number?` | Line number in the file | -| `fix` | `string?` | Suggested fix | -| `severity` | `Severity` | Effective severity of this violation | +| Field | Type | Description | +| ----------- | ---------- | ------------------------------------------------------ | +| `ruleId` | `string` | Rule ID from the `rules` object key | +| `adrId` | `string` | ADR ID from the frontmatter | +| `message` | `string` | Human-readable description | +| `file` | `string?` | File path where the issue was found | +| `line` | `number?` | Start line number (1-based) | +| `endLine` | `number?` | End line (1-based) — for precise editor highlighting | +| `endColumn` | `number?` | End column (0-based) — for precise editor highlighting | +| `fix` | `string?` | Suggested fix | +| `severity` | `Severity` | Effective severity of this violation | --- diff --git a/docs/src/content/docs/pt-br/reference/cli-commands.mdx b/docs/src/content/docs/pt-br/reference/cli-commands.mdx index 2bcfed77..b72b0d17 100644 --- a/docs/src/content/docs/pt-br/reference/cli-commands.mdx +++ b/docs/src/content/docs/pt-br/reference/cli-commands.mdx @@ -275,10 +275,10 @@ archgate plugin install --editor cursor Executa todas as verificações automatizadas de conformidade com ADRs no codebase. ```bash -archgate check [options] +archgate check [options] [files...] ``` -Carrega cada ADR com `rules: true` no frontmatter, executa o arquivo `.rules.ts` complementar e reporta violações com caminhos de arquivo e números de linha. +Carrega cada ADR com `rules: true` no frontmatter, executa o arquivo `.rules.ts` complementar e reporta violações com caminhos de arquivo e números de linha. Quando caminhos de arquivo são fornecidos como argumentos posicionais, apenas ADRs cujos padrões `files` correspondem a esses arquivos são executados. ### Opções @@ -290,13 +290,19 @@ Carrega cada ADR com `rules: true` no frontmatter, executa o arquivo `.rules.ts` | `--adr ` | Verificar apenas regras de um ADR específico | | `--verbose` | Mostrar regras aprovadas e informações de tempo | +### Argumentos + +| Argumento | Descrição | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[files...]` | Caminhos de arquivo opcionais para limitar as verificações. Apenas ADRs cujos padrões `files` correspondem serão executados. Suporta pipe via stdin. | + ### Códigos de saída -| Código | Significado | -| ------ | ------------------------------------------------------ | -| 0 | Todas as regras passaram. Nenhuma violação encontrada. | -| 1 | Uma ou mais violações detectadas. | -| 2 | Erro interno (ex.: ADR ou regra malformada). | +| Código | Significado | +| ------ | ------------------------------------------------------------------------------------ | +| 0 | Todas as regras passaram. Nenhuma violação encontrada. | +| 1 | Uma ou mais violações detectadas. | +| 2 | Erro na execução de regra (ex.: regra malformada, bloqueio do scanner de segurança). | ### Exemplos @@ -318,6 +324,18 @@ Verificar um único ADR: archgate check --adr ARCH-001 ``` +Verificar arquivos específicos (apenas ADRs correspondentes são executados): + +```bash +archgate check src/foo.ts src/bar.ts +``` + +Pipe do git (verificar apenas arquivos alterados): + +```bash +git diff --name-only | archgate check --json +``` + Obter saída JSON para integração com CI: ```bash @@ -357,6 +375,9 @@ Quando `--json` é usado, a saída é um único objeto JSON: { "message": "Command file must export a register*Command function", "file": "src/commands/broken.ts", + "line": 1, + "endLine": 1, + "endColumn": 42, "severity": "error" } ], @@ -367,6 +388,22 @@ Quando `--json` é usado, a saída é um único objeto JSON: } ``` +#### Campos de violação + +| Campo | Tipo | Descrição | +| ----------- | ------- | ------------------------------------------------------- | +| `message` | string | Descrição da violação | +| `file` | string? | Caminho relativo do arquivo | +| `line` | number? | Linha inicial (base 1) | +| `endLine` | number? | Linha final (base 1) — para destaque preciso no editor | +| `endColumn` | number? | Coluna final (base 0) — para destaque preciso no editor | +| `fix` | string? | Correção sugerida (apenas orientação) | +| `severity` | string | `"error"`, `"warning"` ou `"info"` | + +#### Arquivos de regra bloqueados + +Quando um arquivo de regra é bloqueado pelo scanner de segurança (ex.: usa `Bun.spawn()`) ou um arquivo `.rules.ts` complementar está ausente, o resultado aparece na saída JSON com `status: "error"` e `ruleId: "security-scan"`. As violações incluem o arquivo e a linha exata do código bloqueado (ou a linha `rules: true` no ADR para arquivos complementares ausentes). + --- ## archgate adr create diff --git a/docs/src/content/docs/pt-br/reference/rule-api.mdx b/docs/src/content/docs/pt-br/reference/rule-api.mdx index 955d3154..1f675453 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -214,16 +214,22 @@ interface ReportDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; } ``` -| Campo | Tipo | Obrigatório | Descrição | -| --------- | -------- | ----------- | ------------------------------------------------- | -| `message` | `string` | Sim | Descrição legível do problema | -| `file` | `string` | Não | Caminho do arquivo onde o problema foi encontrado | -| `line` | `number` | Não | Número da linha no arquivo | -| `fix` | `string` | Não | Sugestão de correção ou ação de remediação | +| Campo | Tipo | Obrigatório | Descrição | +| ----------- | -------- | ----------- | ----------------------------------------------------------------- | +| `message` | `string` | Sim | Descrição legível do problema | +| `file` | `string` | Não | Caminho do arquivo onde o problema foi encontrado | +| `line` | `number` | Não | Número da linha inicial (base 1) | +| `endLine` | `number` | Não | Número da linha final (base 1) — para destaque preciso no editor | +| `endColumn` | `number` | Não | Número da coluna final (base 0) — para destaque preciso no editor | +| `fix` | `string` | Não | Sugestão de correção ou ação de remediação | + +Quando `endLine` e `endColumn` são fornecidos, editores (VS Code, Cursor) podem destacar a expressão exata que viola a regra, em vez da linha inteira. Se omitidos, a linha completa em `line` é destacada. --- @@ -274,20 +280,24 @@ interface ViolationDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; severity: Severity; } ``` -| Campo | Tipo | Descrição | -| ---------- | ---------- | ------------------------------------------------- | -| `ruleId` | `string` | ID da regra da chave do objeto `rules` | -| `adrId` | `string` | ID do ADR do frontmatter | -| `message` | `string` | Descrição legível | -| `file` | `string?` | Caminho do arquivo onde o problema foi encontrado | -| `line` | `number?` | Número da linha no arquivo | -| `fix` | `string?` | Sugestão de correção | -| `severity` | `Severity` | Severidade efetiva desta violação | +| Campo | Tipo | Descrição | +| ----------- | ---------- | ------------------------------------------------------- | +| `ruleId` | `string` | ID da regra da chave do objeto `rules` | +| `adrId` | `string` | ID do ADR do frontmatter | +| `message` | `string` | Descrição legível | +| `file` | `string?` | Caminho do arquivo onde o problema foi encontrado | +| `line` | `number?` | Número da linha inicial (base 1) | +| `endLine` | `number?` | Linha final (base 1) — para destaque preciso no editor | +| `endColumn` | `number?` | Coluna final (base 0) — para destaque preciso no editor | +| `fix` | `string?` | Sugestão de correção | +| `severity` | `Severity` | Severidade efetiva desta violação | --- diff --git a/docs/src/content/docs/reference/cli-commands.mdx b/docs/src/content/docs/reference/cli-commands.mdx index e6b169ef..e4d93872 100644 --- a/docs/src/content/docs/reference/cli-commands.mdx +++ b/docs/src/content/docs/reference/cli-commands.mdx @@ -277,10 +277,10 @@ archgate plugin install --editor cursor Run all automated ADR compliance checks against the codebase. ```bash -archgate check [options] +archgate check [options] [files...] ``` -Loads every ADR with `rules: true` in its frontmatter, executes the companion `.rules.ts` file, and reports violations with file paths and line numbers. +Loads every ADR with `rules: true` in its frontmatter, executes the companion `.rules.ts` file, and reports violations with file paths and line numbers. When file paths are provided as positional arguments, only ADRs whose `files` patterns match those files are executed. ### Options @@ -292,13 +292,19 @@ Loads every ADR with `rules: true` in its frontmatter, executes the companion `. | `--adr ` | Only check rules from a specific ADR | | `--verbose` | Show passing rules and timing info | +### Arguments + +| Argument | Description | +| ------------ | --------------------------------------------------------------------------------------------------------------- | +| `[files...]` | Optional file paths to scope checks to. Only ADRs whose `files` patterns match will run. Supports stdin piping. | + ### Exit codes -| Code | Meaning | -| ---- | --------------------------------------------- | -| 0 | All rules pass. No violations found. | -| 1 | One or more violations detected. | -| 2 | Internal error (e.g., malformed ADR or rule). | +| Code | Meaning | +| ---- | -------------------------------------------------------------------- | +| 0 | All rules pass. No violations found. | +| 1 | One or more violations detected. | +| 2 | Rule execution error (e.g., malformed rule, security scanner block). | ### Examples @@ -320,6 +326,18 @@ Check a single ADR: archgate check --adr ARCH-001 ``` +Check specific files (only matching ADRs run): + +```bash +archgate check src/foo.ts src/bar.ts +``` + +Pipe from git (check only changed files): + +```bash +git diff --name-only | archgate check --json +``` + Get JSON output for CI integration: ```bash @@ -359,6 +377,9 @@ When `--json` is used, the output is a single JSON object: { "message": "Command file must export a register*Command function", "file": "src/commands/broken.ts", + "line": 1, + "endLine": 1, + "endColumn": 42, "severity": "error" } ], @@ -369,6 +390,22 @@ When `--json` is used, the output is a single JSON object: } ``` +#### Violation fields + +| Field | Type | Description | +| ----------- | ------- | ------------------------------------------------------ | +| `message` | string | What the violation is | +| `file` | string? | Relative file path | +| `line` | number? | Start line (1-based) | +| `endLine` | number? | End line (1-based) — for precise editor highlighting | +| `endColumn` | number? | End column (0-based) — for precise editor highlighting | +| `fix` | string? | Suggested fix (guidance only) | +| `severity` | string | `"error"`, `"warning"`, or `"info"` | + +#### Blocked rule files + +When a rule file is blocked by the security scanner (e.g., uses `Bun.spawn()`) or a companion `.rules.ts` file is missing, the result appears in the JSON output with `status: "error"` and `ruleId: "security-scan"`. Violations include the exact file and line of the blocked code (or the `rules: true` line in the ADR for missing companions). + --- ## archgate adr create diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index 85ba116e..d260908f 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -214,16 +214,22 @@ interface ReportDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; } ``` -| Field | Type | Required | Description | -| --------- | -------- | -------- | --------------------------------------- | -| `message` | `string` | Yes | Human-readable description of the issue | -| `file` | `string` | No | File path where the issue was found | -| `line` | `number` | No | Line number in the file | -| `fix` | `string` | No | Suggested fix or remediation action | +| Field | Type | Required | Description | +| ----------- | -------- | -------- | ------------------------------------------------------------------- | +| `message` | `string` | Yes | Human-readable description of the issue | +| `file` | `string` | No | File path where the issue was found | +| `line` | `number` | No | Start line number (1-based) | +| `endLine` | `number` | No | End line number (1-based) — for precise editor range highlighting | +| `endColumn` | `number` | No | End column number (0-based) — for precise editor range highlighting | +| `fix` | `string` | No | Suggested fix or remediation action | + +When `endLine` and `endColumn` are provided, editors (VS Code, Cursor) can highlight the exact expression that violates the rule, rather than the entire line. If omitted, the full line at `line` is highlighted. --- @@ -274,20 +280,24 @@ interface ViolationDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; severity: Severity; } ``` -| Field | Type | Description | -| ---------- | ---------- | ------------------------------------ | -| `ruleId` | `string` | Rule ID from the `rules` object key | -| `adrId` | `string` | ADR ID from the frontmatter | -| `message` | `string` | Human-readable description | -| `file` | `string?` | File path where the issue was found | -| `line` | `number?` | Line number in the file | -| `fix` | `string?` | Suggested fix | -| `severity` | `Severity` | Effective severity of this violation | +| Field | Type | Description | +| ----------- | ---------- | ------------------------------------------------------ | +| `ruleId` | `string` | Rule ID from the `rules` object key | +| `adrId` | `string` | ADR ID from the frontmatter | +| `message` | `string` | Human-readable description | +| `file` | `string?` | File path where the issue was found | +| `line` | `number?` | Start line number (1-based) | +| `endLine` | `number?` | End line (1-based) — for precise editor highlighting | +| `endColumn` | `number?` | End column (0-based) — for precise editor highlighting | +| `fix` | `string?` | Suggested fix | +| `severity` | `Severity` | Effective severity of this violation | --- diff --git a/src/commands/check.ts b/src/commands/check.ts index 5706056e..724aa276 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -31,9 +31,9 @@ export function registerCheckCommand(program: Command) { process.exit(1); } - let loadedAdrs; + let loadResults; try { - loadedAdrs = await loadRuleAdrs(projectRoot, opts.adr); + loadResults = await loadRuleAdrs(projectRoot, opts.adr); } catch (err) { logError( err instanceof Error ? err.message : `Failed to load rules: ${err}` @@ -43,7 +43,7 @@ export function registerCheckCommand(program: Command) { const useJson = opts.json || (!opts.ci && isAgentContext()); - if (loadedAdrs.length === 0) { + if (loadResults.length === 0) { if (useJson) { console.log( formatJSON( @@ -78,7 +78,7 @@ export function registerCheckCommand(program: Command) { filterFiles = [...filterFiles, ...piped]; } - const result = await runChecks(projectRoot, loadedAdrs, { + const result = await runChecks(projectRoot, loadResults, { staged: opts.staged, files: filterFiles.length > 0 ? filterFiles : undefined, }); diff --git a/src/engine/context.ts b/src/engine/context.ts index 878a0e5e..b831fa99 100644 --- a/src/engine/context.ts +++ b/src/engine/context.ts @@ -245,9 +245,9 @@ export async function buildReviewContext( let checkSummary: ReportSummary | null = null; if (options?.runChecks) { - const loadedAdrs = await loadRuleAdrs(projectRoot); - if (loadedAdrs.length > 0) { - const checkResult = await runChecks(projectRoot, loadedAdrs, { staged }); + const loadResults = await loadRuleAdrs(projectRoot); + if (loadResults.length > 0) { + const checkResult = await runChecks(projectRoot, loadResults, { staged }); checkSummary = buildSummary(checkResult, { maxViolationsPerRule }); } else { checkSummary = { ...EMPTY_SUMMARY }; diff --git a/src/engine/loader.ts b/src/engine/loader.ts index f9ee88f2..841d4677 100644 --- a/src/engine/loader.ts +++ b/src/engine/loader.ts @@ -22,6 +22,9 @@ const RuleSetSchema = z.object({ }) ), }); +import { relative } from "node:path"; + +import type { ViolationDetail } from "../formats/rules"; import { logDebug, logError } from "../helpers/log"; import { projectPaths } from "../helpers/paths"; import { ensureRulesShim } from "../helpers/rules-shim"; @@ -32,13 +35,54 @@ export interface LoadedAdr { ruleSet: RuleSet; } +export interface BlockedAdr { + adr: AdrDocument; + error: string; + violations: Array<{ + message: string; + file: string; + line: number; + column: number; + endLine: number; + endColumn: number; + }>; +} + +export type LoadResult = + | { type: "loaded"; value: LoadedAdr } + | { type: "blocked"; value: BlockedAdr }; + +/** Convert a BlockedAdr into a RuleResult-shaped object for reporting. */ +export function blockedToRuleResult(projectRoot: string, b: BlockedAdr) { + const id = b.adr.frontmatter.id; + return { + ruleId: "security-scan", + adrId: id, + description: "Rule file security scan", + violations: b.violations.map( + (v): ViolationDetail => ({ + message: v.message, + file: relative(projectRoot, v.file).replaceAll("\\", "/"), + line: v.line, + endLine: v.endLine, + endColumn: v.endColumn, + severity: "error", + adrId: id, + ruleId: "security-scan", + }) + ), + error: b.error, + durationMs: 0, + }; +} + /** * Discover ADRs with rules: true and dynamically import their companion .rules.ts files. */ export async function loadRuleAdrs( projectRoot: string, filterAdrId?: string -): Promise { +): Promise { const pp = projectPaths(projectRoot); // Ensure rules.d.ts exists so .rules.ts files get type checking @@ -80,15 +124,43 @@ export async function loadRuleAdrs( // Phase 2: Verify companion files exist and import rule sets in parallel const ruleResults = await Promise.all( - ruleAdrs.map(async ({ file, adr }) => { + ruleAdrs.map(async ({ file, adr }): Promise => { const baseName = basename(file, ".md"); const rulesFile = join(adrsDir, `${baseName}.rules.ts`); const rulesFileExists = await Bun.file(rulesFile).exists(); if (!rulesFileExists) { - throw new Error( - `ADR ${adr.frontmatter.id} has rules: true but no companion file found: ${rulesFile}` - ); + // Find the "rules: true" line in the ADR file for precise highlighting + const adrPath = join(adrsDir, file); + const adrContent = await Bun.file(adrPath).text(); + const adrLines = adrContent.split("\n"); + let rulesLine = 1; + let rulesEndCol = 0; + for (let i = 0; i < adrLines.length; i++) { + const match = adrLines[i].match(/^rules:\s*true/); + if (match) { + rulesLine = i + 1; + rulesEndCol = adrLines[i].length; + break; + } + } + return { + type: "blocked", + value: { + adr, + error: `ADR ${adr.frontmatter.id} has rules: true but no companion file found`, + violations: [ + { + message: `No companion .rules.ts file found. Create ${baseName}.rules.ts or set rules: false.`, + file: adrPath, + line: rulesLine, + column: 0, + endLine: rulesLine, + endColumn: rulesEndCol, + }, + ], + }, + }; } // Security gate: scan rule source for banned patterns before executing. @@ -101,9 +173,21 @@ export async function loadRuleAdrs( for (const v of scanViolations) { logError(`${rulesFile}:${v.line}:${v.column} - ${v.message}`); } - throw new Error( - `ADR ${adr.frontmatter.id}: rule file blocked by security scanner (${scanViolations.length} violation${scanViolations.length === 1 ? "" : "s"})` - ); + return { + type: "blocked", + value: { + adr, + error: `ADR ${adr.frontmatter.id}: rule file blocked by security scanner (${scanViolations.length} violation${scanViolations.length === 1 ? "" : "s"})`, + violations: scanViolations.map((v) => ({ + message: v.message, + file: rulesFile, + line: v.line, + column: v.column, + endLine: v.endLine, + endColumn: v.endColumn, + })), + }, + }; } // Cache-bust: Bun caches import() per-process, so append a timestamp @@ -114,18 +198,23 @@ export async function loadRuleAdrs( const parsed = RuleSetSchema.safeParse(mod.default); if (!parsed.success) { - throw new Error( - `ADR ${adr.frontmatter.id}: companion file does not export a valid RuleSet as default` - ); + return { + type: "blocked", + value: { + adr, + error: `ADR ${adr.frontmatter.id}: companion file does not export a valid RuleSet as default`, + violations: [], + }, + }; } const ruleSet: RuleSet = parsed.data; logDebug( `Loaded ${Object.keys(ruleSet.rules).length} rules from ${adr.frontmatter.id}` ); - return { adr, ruleSet }; + return { type: "loaded", value: { adr, ruleSet } }; }) ); - return ruleResults.filter((r): r is LoadedAdr => r !== null); + return ruleResults; } diff --git a/src/engine/reporter.ts b/src/engine/reporter.ts index 43cd67ee..3e88b93a 100644 --- a/src/engine/reporter.ts +++ b/src/engine/reporter.ts @@ -25,6 +25,8 @@ export interface ReportSummary { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; severity: Severity; }>; @@ -91,6 +93,8 @@ export function buildSummary( message: v.message, file: v.file, line: v.line, + endLine: v.endLine, + endColumn: v.endColumn, fix: v.fix, severity: v.severity, })), diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index 334fc256..5d473c5f 100644 --- a/src/engine/rule-scanner.ts +++ b/src/engine/rule-scanner.ts @@ -16,10 +16,13 @@ export interface ScanViolation { message: string; line: number; column: number; + endLine: number; + endColumn: number; } interface AstLoc { start: { line: number; column: number }; + end: { line: number; column: number }; } interface AstNode { @@ -28,10 +31,12 @@ interface AstNode { [key: string]: unknown; } -function loc(node: AstNode): { line: number; column: number } { +function loc(node: AstNode) { return { line: node.loc?.start.line ?? 0, column: node.loc?.start.column ?? 0, + endLine: node.loc?.end.line ?? 0, + endColumn: node.loc?.end.column ?? 0, }; } diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 11540add..54234bc4 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -9,6 +9,7 @@ import type { } from "../formats/rules"; import { logDebug } from "../helpers/log"; import { resolveScopedFiles, getStagedFiles } from "./git-files"; +import { type LoadResult, blockedToRuleResult } from "./loader"; /** * Resolve a user-supplied path and ensure it stays within projectRoot. @@ -53,8 +54,6 @@ function safeGlob(pattern: string): void { throw new Error(`Glob pattern "${pattern}" is absolute — access denied`); } } -import type { LoadedAdr } from "./loader"; - const RULE_TIMEOUT_MS = 30_000; export interface RuleResult { @@ -179,12 +178,20 @@ function createRuleContext( */ export async function runChecks( projectRoot: string, - loadedAdrs: LoadedAdr[], + loadResults: LoadResult[], options: { staged?: boolean; files?: string[] } = {} ): Promise { const startTime = performance.now(); const changedFiles = options.staged ? await getStagedFiles(projectRoot) : []; - const results: RuleResult[] = []; + const results: RuleResult[] = loadResults + .filter((lr) => lr.type === "blocked") + .map((lr) => blockedToRuleResult(projectRoot, lr.value)); + const loadedAdrs = loadResults + .filter( + (lr): lr is Extract => + lr.type === "loaded" + ) + .map((lr) => lr.value); // Resolve user-specified files to relative paths for intersection let filterFiles: Set | undefined; diff --git a/src/formats/rules.ts b/src/formats/rules.ts index 37804159..8cb2600b 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -19,6 +19,8 @@ export interface ViolationDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; severity: Severity; } diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index 1345f927..39f8bcf5 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -24,6 +24,8 @@ declare interface ViolationDetail { message: string; file?: string; line?: number; + endLine?: number; + endColumn?: number; fix?: string; severity: Severity; } diff --git a/tests/engine/loader-security.test.ts b/tests/engine/loader-security.test.ts index 8a82bf25..0f912122 100644 --- a/tests/engine/loader-security.test.ts +++ b/tests/engine/loader-security.test.ts @@ -55,7 +55,10 @@ export default { ` ); - await expect(loadRuleAdrs(tempDir)).rejects.toThrow( + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + expect((results[0] as { value: { error: string } }).value.error).toContain( "blocked by security scanner" ); }); @@ -78,7 +81,10 @@ export default { ` ); - await expect(loadRuleAdrs(tempDir)).rejects.toThrow( + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + expect((results[0] as { value: { error: string } }).value.error).toContain( "blocked by security scanner" ); }); @@ -101,7 +107,10 @@ export default { ` ); - await expect(loadRuleAdrs(tempDir)).rejects.toThrow( + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + expect((results[0] as { value: { error: string } }).value.error).toContain( "blocked by security scanner" ); }); @@ -124,7 +133,10 @@ export default { ` ); - await expect(loadRuleAdrs(tempDir)).rejects.toThrow( + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + expect((results[0] as { value: { error: string } }).value.error).toContain( "blocked by security scanner" ); }); @@ -155,7 +167,11 @@ export default { const loaded = await loadRuleAdrs(tempDir); expect(loaded).toHaveLength(1); - expect(loaded[0].adr.frontmatter.id).toBe("SEC-005"); + expect(loaded[0].type).toBe("loaded"); + expect( + (loaded[0] as { value: { adr: { frontmatter: { id: string } } } }).value + .adr.frontmatter.id + ).toBe("SEC-005"); }); test("allows rule with safe imports (node:path)", async () => { diff --git a/tests/engine/loader.test.ts b/tests/engine/loader.test.ts index 34a49743..114195d1 100644 --- a/tests/engine/loader.test.ts +++ b/tests/engine/loader.test.ts @@ -50,8 +50,10 @@ describe("loadRuleAdrs", () => { const loaded = await loadRuleAdrs(tempDir); expect(loaded).toHaveLength(1); - expect(loaded[0].adr.frontmatter.id).toBe("TEST-001"); - expect(Object.keys(loaded[0].ruleSet.rules)).toEqual(["sample-rule"]); + expect(loaded[0].type).toBe("loaded"); + const first = loaded[0] as Extract<(typeof loaded)[0], { type: "loaded" }>; + expect(first.value.adr.frontmatter.id).toBe("TEST-001"); + expect(Object.keys(first.value.ruleSet.rules)).toEqual(["sample-rule"]); }); test("skips ADR with rules: false", async () => { @@ -64,15 +66,21 @@ describe("loadRuleAdrs", () => { expect(loaded).toHaveLength(0); }); - test("throws when companion file is missing", () => { + test("returns blocked result when companion file is missing", async () => { copyFileSync( join(fixturesDir, "TEST-004-missing-companion.md"), join(tempDir, ".archgate", "adrs", "TEST-004-missing-companion.md") ); - expect(loadRuleAdrs(tempDir)).rejects.toThrow( - "has rules: true but no companion file found" - ); + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + const blocked = results[0] as Extract< + (typeof results)[0], + { type: "blocked" } + >; + expect(blocked.value.error).toContain("no companion file found"); + expect(blocked.value.violations).toHaveLength(1); }); test("filters by ADR ID", async () => { diff --git a/tests/engine/runner-security.test.ts b/tests/engine/runner-security.test.ts index 4c125a90..ea4bf992 100644 --- a/tests/engine/runner-security.test.ts +++ b/tests/engine/runner-security.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { LoadedAdr } from "../../src/engine/loader"; +import type { LoadResult } from "../../src/engine/loader"; import { runChecks } from "../../src/engine/runner"; import type { AdrDocument } from "../../src/formats/adr"; import type { RuleSet } from "../../src/formats/rules"; @@ -25,20 +25,23 @@ describe("runChecks path sandboxing", () => { function makeLoadedAdr( overrides: Partial = {}, ruleSet: RuleSet = EMPTY_RULE_SET - ): LoadedAdr { + ): LoadResult { return { - adr: { - frontmatter: { - id: "SEC-001", - title: "Security Test", - domain: "general", - rules: true, - ...overrides, + type: "loaded", + value: { + adr: { + frontmatter: { + id: "SEC-001", + title: "Security Test", + domain: "general", + rules: true, + ...overrides, + }, + body: "", + filePath: "/test.md", }, - body: "", - filePath: "/test.md", + ruleSet, }, - ruleSet, }; } diff --git a/tests/engine/runner.test.ts b/tests/engine/runner.test.ts index e0382a9e..3ec79fdb 100644 --- a/tests/engine/runner.test.ts +++ b/tests/engine/runner.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { LoadedAdr } from "../../src/engine/loader"; +import type { LoadResult } from "../../src/engine/loader"; import { runChecks } from "../../src/engine/runner"; import type { AdrDocument } from "../../src/formats/adr"; import type { RuleSet } from "../../src/formats/rules"; @@ -25,20 +25,23 @@ describe("runChecks", () => { function makeLoadedAdr( overrides: Partial = {}, ruleSet: RuleSet = EMPTY_RULE_SET - ): LoadedAdr { + ): LoadResult { return { - adr: { - frontmatter: { - id: "TEST-001", - title: "Test", - domain: "general", - rules: true, - ...overrides, + type: "loaded", + value: { + adr: { + frontmatter: { + id: "TEST-001", + title: "Test", + domain: "general", + rules: true, + ...overrides, + }, + body: "", + filePath: "/test.md", }, - body: "", - filePath: "/test.md", + ruleSet, }, - ruleSet, }; }