From c67be7221d0c7df0e4e36f6bb50a0316b760d21a Mon Sep 17 00:00:00 2001 From: Alexandre Dubois Date: Fri, 30 Oct 2026 14:39:38 +0100 Subject: [PATCH] Fetch from git --- src/adapter/cli/commands/check.ts | 16 +- src/adapter/cli/program.ts | 2 +- src/adapter/cli/services.ts | 4 + src/adapter/git/rules.ts | 152 +++++++ src/infra/runtime.ts | 4 + tests/cli-pr.test.ts | 52 +++ tests/cli-rule-repository.test.ts | 474 ++++++++++++++++++++++ website/src/content/docs/cli.md | 8 +- website/src/content/docs/configuration.md | 4 +- 9 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 src/adapter/git/rules.ts create mode 100644 tests/cli-rule-repository.test.ts diff --git a/src/adapter/cli/commands/check.ts b/src/adapter/cli/commands/check.ts index 7f9b6bd..7992223 100644 --- a/src/adapter/cli/commands/check.ts +++ b/src/adapter/cli/commands/check.ts @@ -2,6 +2,7 @@ import { relative, resolve, sep } from 'node:path'; import type { DiffChanges, LoadedRule, RuleReport, RunContext } from '../../../app/ports.js'; import type { PullRequestCheckout } from '../../github/pull-request.js'; +import type { RuleRepositoryCheckout } from '../../git/rules.js'; import { CliExit, EXIT_VIOLATION } from '../exit.js'; import { createProgress } from '../progress.js'; import { @@ -15,7 +16,7 @@ import { renderViolationComments, type WalkthroughRenderer, } from '../reports.js'; -import type { CheckOptions, CheckServices, CliContext } from '../services.js'; +import type { CheckOptions, CheckServices, CliContext, RuleSelectorResolution } from '../services.js'; import { planWalk } from './rules.js'; async function runCheck( @@ -32,6 +33,7 @@ async function runCheck( ? createProgress(context.writeError, () => context.terminal?.columns ?? 80) : undefined; let checkout: PullRequestCheckout | undefined; + let rulesCheckout: RuleRepositoryCheckout | undefined; try { let reports: readonly RuleReport[]; let durationMs: number; @@ -47,9 +49,13 @@ async function runCheck( let changes: DiffChanges | undefined = checkout === undefined ? (diffOnly ? await services.collectChanges(area.root, options.base ?? 'HEAD') : undefined) : { changedFiles: checkout.changedFiles, changedLines: checkout.changedLines }; - const choice = options.rules === undefined - ? {} - : await services.resolveRuleSelector(options.rules, context.cwd, true); + let choice: RuleSelectorResolution = {}; + if (options.rules !== undefined) { + rulesCheckout = await services.checkoutRuleRepository(options.rules); + choice = rulesCheckout === undefined + ? await services.resolveRuleSelector(options.rules, context.cwd, true) + : { explicit: rulesCheckout.root }; + } const prepared = await planWalk( context.cwd, services, @@ -126,7 +132,7 @@ async function runCheck( throw new CliExit(EXIT_VIOLATION); } } finally { - await checkout?.cleanup(); + await Promise.all([checkout?.cleanup(), rulesCheckout?.cleanup()]); } } diff --git a/src/adapter/cli/program.ts b/src/adapter/cli/program.ts index 3cb99ef..ffea9d9 100644 --- a/src/adapter/cli/program.ts +++ b/src/adapter/cli/program.ts @@ -172,7 +172,7 @@ export function createProgram(services: CliServices): Command { .command('check') .description('Evaluate every selected rule against the working tree') .argument('[path]', 'target file or folder that narrows selection', '.') - .option('--rules ', 'rule name, module path, or directory') + .option('--rules ', 'rule name, module path, directory, or Git repository URL[#revision]') .option('--tags ', 'run rules matching any listed tag', []) .option('--exclude-tags ', 'exclude rules matching any listed tag', []) .option('--json', 'print violations as {durationMs, reports} JSON on stdout') diff --git a/src/adapter/cli/services.ts b/src/adapter/cli/services.ts index 8b2dde5..a1d71d5 100644 --- a/src/adapter/cli/services.ts +++ b/src/adapter/cli/services.ts @@ -10,6 +10,7 @@ import type { RuleTestResult } from '../../app/rule-tests.js'; import type { TokenPricing } from '../../app/rule.js'; import type { ViolationComment } from '../../app/violation-comments.js'; import type { PullRequest, PullRequestCheckout } from '../github/pull-request.js'; +import type { RuleRepositoryCheckout } from '../git/rules.js'; import type { SkillOptions } from '../skills/cli.js'; import type { TerminalCapabilities } from './progress.js'; import type { TextRedactor, TextWriter } from './text.js'; @@ -101,6 +102,9 @@ export interface ExecutionServices extends RuleServices { export interface CheckServices extends ExecutionServices { readonly collectChanges: (root: string, base: string) => Promise; readonly checkoutPullRequest: (selector: string, baseBranch?: string) => Promise; + readonly checkoutRuleRepository: ( + selector: string, + ) => Promise; readonly commentOnPullRequest: ( root: string, comments: readonly ViolationComment[], diff --git a/src/adapter/git/rules.ts b/src/adapter/git/rules.ts new file mode 100644 index 0000000..6feff39 --- /dev/null +++ b/src/adapter/git/rules.ts @@ -0,0 +1,152 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { isolatedGitEnvironment } from './environment.js'; + +export interface RuleRepositoryCheckout { + readonly root: string; + cleanup(): Promise; +} + +interface RuleRepositorySelector { + readonly transport: string; + readonly revision: string | undefined; +} + +const invalidRepositoryUrl = 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'; + +function parseRuleRepositorySelector(selector: string): RuleRepositorySelector | undefined { + const schemeMatch = /^([a-z][a-z\d+.-]*):\/\//i.exec(selector); + const schemePrefix = /^([a-z][a-z\d+.-]*):/i.exec(selector); + const windowsDrivePath = /^[a-z]:[\\/]/i.test(selector); + const malformedUrl = schemeMatch === null + && schemePrefix !== null + && !windowsDrivePath + && ( + /^(?:https?|ssh|file|ftp|git(?:\+(?:https?|ssh))?):/i.test(selector) + || /^[\\/]/.test(selector.slice(schemePrefix[0].length)) + || selector.slice(schemePrefix[0].length).includes('@') + ); + const looksLikeUrl = schemeMatch !== null || malformedUrl; + const looksLikeScp = /^[^/@:\s]*@[^/:\s]*:/.test(selector); + if (!looksLikeUrl && !looksLikeScp) return undefined; + + const separator = selector.indexOf('#'); + const transport = separator === -1 ? selector : selector.slice(0, separator); + const revision = separator === -1 ? undefined : selector.slice(separator + 1); + if (revision === '') throw new Error('Rule repository revision must not be empty.'); + + if (looksLikeUrl) { + if (schemeMatch === null) throw new Error(invalidRepositoryUrl); + let url: URL; + try { + url = new URL(transport); + } catch { + throw new Error(invalidRepositoryUrl); + } + if ( + (url.protocol !== 'https:' && url.protocol !== 'ssh:') + || url.hostname === '' + || url.search !== '' + || transport.includes('?') + || (url.protocol === 'https:' && (url.username !== '' || url.password !== '')) + || (url.protocol === 'ssh:' && hasSshPassword(transport, url)) + ) { + throw new Error(invalidRepositoryUrl); + } + } else { + const match = /^([^/@:\s]+)@([^/:\s]+):(.+)$/.exec(transport); + if (match === null) throw new Error(invalidRepositoryUrl); + } + + return { transport, revision }; +} + +function hasSshPassword(transport: string, url: URL): boolean { + if (url.password !== '') return true; + const authorityStart = transport.indexOf('://') + 3; + const authorityEnd = transport.slice(authorityStart).search(/[/?#]/); + const authority = authorityEnd === -1 + ? transport.slice(authorityStart) + : transport.slice(authorityStart, authorityStart + authorityEnd); + const userInfoEnd = authority.lastIndexOf('@'); + return userInfoEnd !== -1 && authority.slice(0, userInfoEnd).includes(':'); +} + +export function createRuleRepositoryCheckout( + env: Readonly>, +): (selector: string) => Promise { + const isolatedEnv = isolatedGitEnvironment(env); + + const runGit = ( + cwd: string, + args: readonly string[], + allowedExitCodes: readonly number[] = [0], + ): Promise<{ readonly stdout: string; readonly code: number | null }> => { + const { promise, resolve, reject } = Promise.withResolvers<{ + readonly stdout: string; + readonly code: number | null; + }>(); + const child = spawn('git', [...args], { + cwd, + env: isolatedEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + child.on('error', error => reject(new Error(`Cannot run Git: ${error.message}`, { + cause: error, + }))); + child.on('close', (code, signal) => { + if (code !== null && allowedExitCodes.includes(code)) resolve({ stdout, code }); + else reject(new Error(`Git ${args[0]} failed: ${stderr.trim() || signal || `exit ${code}`}`)); + }); + return promise; + }; + + return async selector => { + const parsed = parseRuleRepositorySelector(selector); + if (parsed === undefined) return undefined; + + const workspace = await mkdtemp(join(await realpath(tmpdir()), 'coderuler-rules-')); + const root = join(workspace, 'repository'); + const cleanup = (): Promise => rm(workspace, { recursive: true, force: true }); + try { + await runGit(workspace, ['init', root]); + const worktree = await runGit(root, ['config', '--null', '--get-all', 'core.worktree'], [0, 1]); + if (worktree.code === 0) { + throw new Error('Rule repository checkout requires Git core.worktree to be unset; remove the inherited repository-location override.'); + } + + if (parsed.revision !== undefined) { + if ( + parsed.revision.startsWith('+') + && !parsed.revision.startsWith('refs/heads/+') + ) { + throw new Error('Invalid rule repository revision.'); + } + const result = await runGit(root, [ + 'check-ref-format', + `refs/coderuler/${parsed.revision}`, + ], [0, 1]); + if (result.code !== 0) throw new Error('Invalid rule repository revision.'); + } + + await runGit(root, ['remote', 'add', 'origin', parsed.transport]); + await runGit(root, [ + 'fetch', '--depth=1', '--no-tags', '--', 'origin', `${parsed.revision ?? 'HEAD'}:`, + ]); + await runGit(root, ['checkout', '--detach', 'FETCH_HEAD']); + return { root, cleanup }; + } catch (error) { + await cleanup(); + throw error; + } + }; +} diff --git a/src/infra/runtime.ts b/src/infra/runtime.ts index 01747b5..b74989c 100644 --- a/src/infra/runtime.ts +++ b/src/infra/runtime.ts @@ -93,6 +93,10 @@ export function createServices( const { createPullRequestCheckout } = await import('../adapter/github/pull-request.js'); return createPullRequestCheckout(cwd, env)(selector, baseBranch); }, + async checkoutRuleRepository(selector: string) { + const { createRuleRepositoryCheckout } = await import('../adapter/git/rules.js'); + return createRuleRepositoryCheckout(env)(selector); + }, async commentOnPullRequest(root, comments, pullRequest) { const { createGithubCommenter } = await import('../adapter/github/comments.js'); return createGithubCommenter(env)(root, comments, pullRequest); diff --git a/tests/cli-pr.test.ts b/tests/cli-pr.test.ts index 0931071..bcb6069 100644 --- a/tests/cli-pr.test.ts +++ b/tests/cli-pr.test.ts @@ -287,6 +287,58 @@ test('caller-relative --rules intentionally overrides discovery but still requir assert.deepEqual(posts(requests), []); }); +test('check --pr uses remote rules for PR-head changes and removes both checkouts', async context => { + const target = fixture(context, 'remote-rules'); + const requests = await interceptGithub(context, target); + + const ruleRemote = join(target.root, 'rule-remote.git'); + const ruleSource = join(target.root, 'rule-source'); + const ruleTransport = 'https://rules.example.invalid/team/policies.git'; + git(target, 'init', '--bare', ruleRemote); + git(target, 'init', ruleSource); + git(target, '-C', ruleSource, 'config', 'user.name', 'CodeRuler tests'); + git(target, '-C', ruleSource, 'config', 'user.email', 'tests@example.invalid'); + writeFile(join(ruleSource, 'remote.rule.ts'), policy( + `subject => subject.source.includes('BAD') ? violation('Remote policy rejects BAD') : pass()`, + )); + git(target, '-C', ruleSource, 'add', '--all'); + git(target, '-C', ruleSource, 'commit', '-m', 'remote PR policy'); + git(target, '-C', ruleSource, 'push', ruleRemote, 'HEAD:refs/heads/main'); + git(target, '-C', ruleRemote, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + + const globalConfig = join(target.home, '.gitconfig'); + const transportConfig = join(target.home, 'temporary-checkout.gitconfig'); + writeFile(globalConfig, [ + `[includeIf "gitdir:**/coderuler-pr-*/head/.git"]`, + `\tpath = ${JSON.stringify(transportConfig)}`, + `[includeIf "gitdir:**/coderuler-rules-*/repository/.git"]`, + `\tpath = ${JSON.stringify(transportConfig)}`, + '', + ].join('\n')); + writeFile(transportConfig, [ + `[url ${JSON.stringify(target.remote)}]`, + `\tinsteadOf = https://github.com/${PR_REPOSITORY}.git`, + `\tinsteadOf = git@github.com:${PR_REPOSITORY}.git`, + `[url ${JSON.stringify(ruleRemote)}]`, + `\tinsteadOf = ${ruleTransport}`, + '', + ].join('\n')); + + const result = await check(target, ['--pr', '42', '--rules', ruleTransport]); + assert.equal(result.status, 1, result.stderr); + const reports = parseCheckOutput(result, 'remote PR policy'); + assert.deepEqual(evaluations(reports), [ + { rule: 'remote', filepath: 'src/changed.txt', status: 'violation', message: 'Remote policy rejects BAD' }, + ]); + assert.match(reports[0]!.ruleFile, /[/\\]coderuler-rules-[^/\\]+[/\\]repository[/\\]remote\.rule\.ts$/); + assert.deepEqual(posts(requests), []); + assert.deepEqual( + readdirSync(target.temporary).filter(name => name.startsWith('coderuler-pr-') || name.startsWith('coderuler-rules-')), + [], + 'both PR and remote-rule workspaces must be removed', + ); +}); + test('positional targets resolve in PR head and preserve missing, boundary, and symlink errors with cleanup', async context => { let target = fixture(context, 'targets'); const outside = writeFile(join(target.root, 'outside.txt'), 'BAD outside\n'); diff --git a/tests/cli-rule-repository.test.ts b/tests/cli-rule-repository.test.ts new file mode 100644 index 0000000..52da134 --- /dev/null +++ b/tests/cli-rule-repository.test.ts @@ -0,0 +1,474 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; + +import { + createSandbox, + expectFailure, + parseCheckOutput, + runCli, + writeFile, +} from './support.ts'; +import type { CliResult, Sandbox } from './support.ts'; + +const sandbox = createSandbox('coderuler-cli-rule-repository-'); +after(() => sandbox.cleanup()); + +interface RuleRepositoryFixture { + readonly target: Sandbox; + readonly temporary: string; + readonly remote: string; + readonly source: string; + readonly olderCommit: string; +} + +function git(cwd: string, env: NodeJS.ProcessEnv, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function commit(cwd: string, env: NodeJS.ProcessEnv, message: string): string { + git(cwd, env, 'add', '--all'); + git(cwd, env, 'commit', '-m', message); + return git(cwd, env, 'rev-parse', 'HEAD'); +} + +function rootRule(): string { + return `import { Rule, pass, violation } from '@alex-dbs/coderuler'; +export default class Root extends Rule { + files = ['src/*.txt']; + check = async subject => { + const { message } = await import('./helper.ts'); + return message && subject.source.includes('BAD') ? violation(message) : pass(); + }; +} +`; +} + +function nestedRule(): string { + return `import { Rule, pass } from '@alex-dbs/coderuler'; +export default class Nested extends Rule { + files = ['src/*.txt']; + check = () => pass(); +} +`; +} + +function createRuleRepository(label: string): RuleRepositoryFixture { + const isolated = sandbox.isolated(label); + const temporary = join(isolated.root, 'tmp'); + mkdirSync(temporary, { recursive: true }); + const inheritedGit = Object.fromEntries( + Object.keys(process.env).filter(key => key.startsWith('GIT_')).map(key => [key, undefined]), + ); + const globalConfig = join(isolated.home, '.gitconfig'); + const remote = join(isolated.root, 'rules.git'); + const target: Sandbox = { + ...isolated, + env: { + ...isolated.env, + ...inheritedGit, + TMPDIR: temporary, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: globalConfig, + }, + }; + writeFile(globalConfig, `[url ${JSON.stringify(remote)}]\n\tinsteadOf = https://rules.example.invalid/team/policies.git\n\tinsteadOf = https://rules.example.invalid/team/policies\n\tinsteadOf = ssh://git@rules.example.invalid/team/policies.git\n\tinsteadOf = ssh://git@rules.example.invalid:2222/team/policies\n\tinsteadOf = git@rules.example.invalid:team/policies.git\n\tinsteadOf = git@rules.example.invalid:team/policies\n`); + + const source = join(isolated.root, 'policy-source'); + mkdirSync(source, { recursive: true }); + git(source, target.env, 'init', '--initial-branch=main'); + git(source, target.env, 'config', 'user.name', 'CodeRuler tests'); + git(source, target.env, 'config', 'user.email', 'tests@example.invalid'); + git(source, target.env, 'config', 'commit.gpgsign', 'false'); + writeFile(join(source, 'root.rule.ts'), rootRule()); + writeFile(join(source, 'nested/nested.rule.ts'), nestedRule()); + writeFile(join(source, 'helper.ts'), 'export const message = undefined;\n'); + const olderCommit = commit(source, target.env, 'passing remote rules'); + git(source, target.env, 'branch', 'release/rules'); + git(source, target.env, 'branch', 'tag'); + git(source, target.env, 'tag', '-a', 'v1.0.0', '-m', 'passing rules'); + git(source, target.env, 'tag', '-a', 'shared', '-m', 'shared tag'); + git(source, target.env, 'branch', '+name'); + writeFile(join(source, 'helper.ts'), "export const message = 'Remote policy rejects BAD';\n"); + commit(source, target.env, 'rejecting remote rules'); + git(source, target.env, 'branch', 'shared'); + + git(target.cwd, target.env, 'init', '--bare', remote); + git(target.cwd, target.env, '--git-dir', remote, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + git(target.cwd, target.env, '--git-dir', remote, 'config', 'uploadpack.allowReachableSHA1InWant', 'true'); + git(source, target.env, 'push', remote, '--all'); + git(source, target.env, 'push', remote, '--tags'); + + writeFile(join(target.cwd, 'src/example.txt'), 'BAD\n'); + writeFile(join(target.cwd, '.coderuler/local.rule.ts'), "throw new Error('Consumer policy must not load');\n"); + writeFile(join(target.home, '.coderuler/home.rule.ts'), "throw new Error('Home policy must not load');\n"); + return { target, temporary, remote, source, olderCommit }; +} + +function appendTransportRewrite(fixture: RuleRepositoryFixture, selector: string, remote: string): void { + appendFileSync( + fixture.target.env.GIT_CONFIG_GLOBAL!, + `\n[url ${JSON.stringify(remote)}]\n\tinsteadOf = ${selector}\n`, + ); +} + +function createVariantRemote( + fixture: RuleRepositoryFixture, + label: string, + change: (source: string) => void, +): string { + const source = join(fixture.target.root, `${label}-source`); + const remote = join(fixture.target.root, `${label}.git`); + git(fixture.target.cwd, fixture.target.env, 'clone', fixture.source, source); + git(source, fixture.target.env, 'config', 'user.name', 'CodeRuler tests'); + git(source, fixture.target.env, 'config', 'user.email', 'tests@example.invalid'); + git(source, fixture.target.env, 'config', 'commit.gpgsign', 'false'); + change(source); + commit(source, fixture.target.env, `${label} rule repository`); + git(fixture.target.cwd, fixture.target.env, 'init', '--bare', remote); + git(fixture.target.cwd, fixture.target.env, '--git-dir', remote, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + git(source, fixture.target.env, 'push', '--force', remote, 'HEAD:refs/heads/main'); + const selector = `https://rules.example.invalid/team/${label}.git`; + appendTransportRewrite(fixture, selector, remote); + return selector; +} + +function createEmptyRemote(fixture: RuleRepositoryFixture, label: string): string { + const remote = join(fixture.target.root, `${label}.git`); + git(fixture.target.cwd, fixture.target.env, 'init', '--bare', remote); + git(fixture.target.cwd, fixture.target.env, '--git-dir', remote, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + const selector = `https://rules.example.invalid/team/${label}.git`; + appendTransportRewrite(fixture, selector, remote); + return selector; +} + +function assertRuleReports( + result: CliResult, + label: string, + rootStatus: 'pass' | 'violation', + rootMessage: string | undefined, +): void { + assert.equal(result.status, rootStatus === 'violation' ? 1 : 0, result.stderr); + const reports = parseCheckOutput(result, label); + assert.deepEqual(reports.map(report => ({ + name: report.name, + status: report.status, + evaluations: report.evaluations.map(({ subject, result: evaluation }) => { + assert.equal(subject.kind, 'scope'); + if (subject.kind !== 'scope') throw new Error('Expected a file scope'); + return { + filepath: subject.filepath, + status: evaluation.status, + message: evaluation.message, + }; + }), + })), [ + { + name: 'nested', + status: 'pass', + evaluations: [{ filepath: 'src/example.txt', status: 'pass', message: undefined }], + }, + { + name: 'root', + status: rootStatus, + evaluations: [{ filepath: 'src/example.txt', status: rootStatus, message: rootMessage }], + }, + ]); +} + +function assertNoRuleWorkspace(fixture: RuleRepositoryFixture, label: string): void { + assert.deepEqual( + readdirSync(fixture.temporary).filter(name => name.startsWith('coderuler-rules-')), + [], + `${label}: rule checkout workspace should be removed`, + ); +} + +function assertFailure(result: CliResult, label: string, diagnostic?: string | RegExp): void { + expectFailure(result, label); + if (diagnostic === undefined) return; + if (typeof diagnostic === 'string') { + assert.ok(result.stderr.includes(diagnostic), `${label}: expected diagnostic '${diagnostic}' (${result.stderr})`); + } else { + assert.match(result.stderr, diagnostic); + } +} + +function snapshotTree(root: string): readonly (readonly [string, string])[] { + const files: Array = []; + const visit = (directory: string, prefix = ''): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) visit(path, relative); + else if (entry.isFile()) files.push([relative, readFileSync(path).toString('base64')]); + } + }; + visit(root); + return files.sort(([left], [right]) => left.localeCompare(right)); +} + +function callerGitSnapshot(fixture: RuleRepositoryFixture): unknown { + const gitDirectory = join(fixture.target.cwd, '.git'); + return { + head: git(fixture.target.cwd, fixture.target.env, 'rev-parse', 'HEAD'), + index: readFileSync(join(gitDirectory, 'index')).toString('base64'), + entries: git(fixture.target.cwd, fixture.target.env, 'ls-files', '--stage'), + status: git(fixture.target.cwd, fixture.target.env, 'status', '--porcelain'), + source: ['.coderuler/local.rule.ts', 'package.json', 'src/example.txt'].map(path => [ + path, + readFileSync(join(fixture.target.cwd, path)).toString('base64'), + ]), + objects: snapshotTree(join(gitDirectory, 'objects')), + }; +} + +test('loads repository default rules', async () => { + const fixture = createRuleRepository('default-branch'); + const result = await runCli([ + 'check', '.', '--rules', 'https://rules.example.invalid/team/policies.git', '--json', '--output-all', + ], fixture.target); + + assert.equal(result.status, 1, result.stderr); + const reports = parseCheckOutput(result, 'repository default branch'); + assert.deepEqual(reports.map(report => ({ + name: report.name, + status: report.status, + evaluations: report.evaluations.map(({ subject, result: evaluation }) => { + assert.equal(subject.kind, 'scope'); + if (subject.kind !== 'scope') throw new Error('Expected a file scope'); + return { + filepath: subject.filepath, + status: evaluation.status, + message: evaluation.message, + }; + }), + })), [ + { + name: 'nested', + status: 'pass', + evaluations: [{ filepath: 'src/example.txt', status: 'pass', message: undefined }], + }, + { + name: 'root', + status: 'violation', + evaluations: [{ filepath: 'src/example.txt', status: 'violation', message: 'Remote policy rejects BAD' }], + }, + ]); + assert.deepEqual(readdirSync(fixture.temporary).filter(name => name.startsWith('coderuler-rules-')), []); +}); + +test('loads HTTPS and SSH repository URLs with optional .git and SCP-style selectors', async () => { + const fixture = createRuleRepository('transport-forms'); + const selectors = [ + 'https://rules.example.invalid/team/policies.git', + 'https://rules.example.invalid/team/policies', + 'ssh://git@rules.example.invalid/team/policies.git', + 'ssh://git@rules.example.invalid:2222/team/policies', + 'git@rules.example.invalid:team/policies', + ]; + + for (const [index, selector] of selectors.entries()) { + const result = await runCli(['check', '.', '--rules', selector, '--json', '--output-all'], fixture.target); + assertRuleReports(result, `repository transport ${index}`, 'violation', 'Remote policy rejects BAD'); + assertNoRuleWorkspace(fixture, `repository transport ${index}`); + } +}); + +test('selects repository branches, tags and full commit revisions', async () => { + const fixture = createRuleRepository('revision-selection'); + const olderSelectors = [ + `https://rules.example.invalid/team/policies.git#release/rules`, + `https://rules.example.invalid/team/policies.git#v1.0.0`, + `https://rules.example.invalid/team/policies.git#tag`, + `https://rules.example.invalid/team/policies.git#${fixture.olderCommit}`, + `https://rules.example.invalid/team/policies.git#shared`, + `https://rules.example.invalid/team/policies.git#refs/tags/shared`, + `https://rules.example.invalid/team/policies.git#refs/heads/+name`, + ]; + + for (const [index, selector] of olderSelectors.entries()) { + const result = await runCli(['check', '.', '--rules', selector, '--json', '--output-all'], fixture.target); + assertRuleReports(result, `older repository revision ${index}`, 'pass', undefined); + assertNoRuleWorkspace(fixture, `older repository revision ${index}`); + } + + const branch = await runCli([ + 'check', '.', '--rules', 'https://rules.example.invalid/team/policies.git#refs/heads/shared', '--json', '--output-all', + ], fixture.target); + assertRuleReports(branch, 'shared branch revision', 'violation', 'Remote policy rejects BAD'); + assertNoRuleWorkspace(fixture, 'shared branch revision'); + + const encoded = await runCli([ + 'check', '.', '--rules', 'https://rules.example.invalid/team/policies.git#release%2Frules', '--json', '--output-all', + ], fixture.target); + assertFailure(encoded, 'repository revision is not URL-decoded', /fetch/i); + assertNoRuleWorkspace(fixture, 'repository revision is not URL-decoded'); +}); + +test('repository acquisition failures report no partial JSON and clean their workspaces', async () => { + const fixture = createRuleRepository('acquisition-failures'); + const inaccessible = `https://rules.example.invalid/team/inaccessible.git`; + appendTransportRewrite(fixture, inaccessible, join(fixture.target.root, 'missing-remote.git')); + const empty = createEmptyRemote(fixture, 'empty-repository'); + const cases = [ + ['missing revision', 'https://rules.example.invalid/team/policies.git#missing-revision', /fetch/i], + ['inaccessible transport', inaccessible, /fetch|repository|git/i], + ['empty repository', empty, /fetch|repository|git/i], + ] as const; + + for (const [label, selector, diagnostic] of cases) { + const result = await runCli(['check', '.', '--rules', selector, '--json', '--output-all'], fixture.target); + assertFailure(result, label, diagnostic); + assertNoRuleWorkspace(fixture, label); + } +}); + +test('repository loading and evaluation failures do not fall back or leave workspaces', async () => { + const fixture = createRuleRepository('load-evaluation-failures'); + const noRules = createVariantRemote(fixture, 'no-rules', source => { + rmSync(join(source, 'root.rule.ts')); + rmSync(join(source, 'nested'), { recursive: true, force: true }); + }); + const duplicate = createVariantRemote(fixture, 'duplicate-rules', source => { + writeFile(join(source, 'nested/root.rule.ts'), `import { Rule, pass } from '@alex-dbs/coderuler'; +export default class Root extends Rule { + files = ['src/*.txt']; + check = () => pass(); +} +`); + }); + const invalidImport = createVariantRemote(fixture, 'invalid-import', source => { + writeFile(join(source, 'broken.rule.ts'), `import './missing-module.ts'; +export default class Broken {} +`); + }); + const evaluationFailure = createVariantRemote(fixture, 'evaluation-failure', source => { + writeFile(join(source, 'helper.ts'), "throw new Error('Remote evaluation exploded');\n"); + }); + const cases = [ + ['repository without rule modules', noRules, /no rule files/i], + ['duplicate repository rule names', duplicate, /matches 2 rule files/], + ['invalid repository rule import', invalidImport, /missing-module/i], + ['repository runtime evaluation error', evaluationFailure, /Remote evaluation exploded/], + ] as const; + + for (const [label, selector, diagnostic] of cases) { + const result = await runCli(['check', '.', '--rules', selector, '--json', '--output-all'], fixture.target); + assertFailure(result, label, diagnostic); + assertNoRuleWorkspace(fixture, label); + } +}); + +test('rejects invalid repository URLs and revisions without leaking credentials', async () => { + const fixture = createRuleRepository('invalid-selectors'); + const credentialUser = 'private-user'; + const credentialSecret = 'private-rule-password'; + const invalid = [ + ['empty revision', 'https://rules.example.invalid/team/policies.git#', 'Rule repository revision must not be empty.'], + ['unsupported scheme', 'file:///tmp/policies.git', 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['URL query', 'https://rules.example.invalid/team/policies.git?token=private-rule-password', 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['HTTPS credentials', `https://${credentialUser}:${credentialSecret}@rules.example.invalid/team/policies.git`, 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['SSH password', `ssh://git:${credentialSecret}@rules.example.invalid/team/policies.git`, 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['malformed URL', 'https://[invalid-host/team/policies.git', 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['malformed URL credentials', `https:/${credentialUser}:${credentialSecret}@rules.example.invalid/team/policies.git`, 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['SCP selector missing host', 'git@:team/policies.git', 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['SCP selector missing path', 'git@rules.example.invalid:', 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.'], + ['leading refspec modifier', 'https://rules.example.invalid/team/policies.git#+refs/heads/main', 'Invalid rule repository revision.'], + ['invalid ref syntax', 'https://rules.example.invalid/team/policies.git#bad..ref', 'Invalid rule repository revision.'], + ] as const; + + for (const [label, selector, diagnostic] of invalid) { + const result = await runCli(['check', '.', '--rules', selector, '--json', '--output-all'], fixture.target); + assertFailure(result, label, diagnostic); + assert.equal(result.stderr.includes(credentialUser), false, `${label}: credential username leaked`); + assert.equal(result.stderr.includes(credentialSecret), false, `${label}: credential secret leaked`); + assertNoRuleWorkspace(fixture, label); + } +}); + +test('inherited Git location overrides cannot modify the caller repository', async () => { + const fixture = createRuleRepository('inherited-git-locations'); + writeFile(join(fixture.target.cwd, '.gitignore'), 'node_modules/\n'); + git(fixture.target.cwd, fixture.target.env, 'init', '--initial-branch=main'); + git(fixture.target.cwd, fixture.target.env, 'config', 'user.name', 'CodeRuler tests'); + git(fixture.target.cwd, fixture.target.env, 'config', 'user.email', 'tests@example.invalid'); + git(fixture.target.cwd, fixture.target.env, 'config', 'commit.gpgsign', 'false'); + commit(fixture.target.cwd, fixture.target.env, 'caller state before remote rules'); + const before = callerGitSnapshot(fixture); + const gitDirectory = join(fixture.target.cwd, '.git'); + const overrides: NodeJS.ProcessEnv = { + GIT_DIR: gitDirectory, + GIT_WORK_TREE: fixture.target.cwd, + GIT_INDEX_FILE: join(gitDirectory, 'index'), + GIT_OBJECT_DIRECTORY: join(gitDirectory, 'objects'), + GIT_ALTERNATE_OBJECT_DIRECTORIES: join(gitDirectory, 'objects'), + GIT_COMMON_DIR: gitDirectory, + }; + + const result = await runCli([ + 'check', '.', '--rules', 'https://rules.example.invalid/team/policies.git', '--json', '--output-all', + ], fixture.target, overrides); + assertRuleReports(result, 'inherited Git location overrides', 'violation', 'Remote policy rejects BAD'); + assert.deepEqual(callerGitSnapshot(fixture), before); + assertNoRuleWorkspace(fixture, 'inherited Git location overrides'); +}); + +test('refuses inherited core.worktree configuration and cleans the checkout', async () => { + const fixture = createRuleRepository('inherited-core-worktree'); + appendFileSync( + fixture.target.env.GIT_CONFIG_GLOBAL!, + `\n[core]\n\tworktree = ${fixture.target.cwd}\n`, + ); + + const result = await runCli([ + 'check', '.', '--rules', 'https://rules.example.invalid/team/policies.git', '--json', '--output-all', + ], fixture.target); + assertFailure( + result, + 'inherited core.worktree', + 'Rule repository checkout requires Git core.worktree to be unset; remove the inherited repository-location override.', + ); + assertNoRuleWorkspace(fixture, 'inherited core.worktree'); +}); + +test('reports unavailable Git when Node is launched with an empty PATH', async () => { + const fixture = createRuleRepository('missing-git'); + assert.equal(process.execPath.startsWith('/'), true, 'runCli starts Node through its absolute executable path'); + const result = await runCli([ + 'check', '.', '--rules', 'https://rules.example.invalid/team/policies.git', '--json', '--output-all', + ], fixture.target, { PATH: '' }); + assertFailure(result, 'unavailable Git', /Cannot run Git/); + assertNoRuleWorkspace(fixture, 'unavailable Git'); +}); + +test('local rule paths keep literal # characters', async () => { + const fixture = createRuleRepository('local-hash-path'); + writeFile(join(fixture.target.cwd, 'custom/hash#rule.rule.ts'), `import { Rule, pass } from '@alex-dbs/coderuler'; +export default class HashPath extends Rule { + files = ['src/*.txt']; + check = () => pass(); +} +`); + + const result = await runCli([ + 'check', '.', '--rules', 'custom/hash#rule.rule.ts', '--json', '--output-all', + ], fixture.target); + assert.equal(result.status, 0, result.stderr); + const reports = parseCheckOutput(result, 'local rule path with #'); + assert.deepEqual(reports.map(report => ({ name: report.name, status: report.status })), [ + { name: 'hash#rule', status: 'pass' }, + ]); + const [evaluation] = reports[0]!.evaluations; + assert.equal(evaluation?.subject.kind, 'scope'); + if (evaluation?.subject.kind !== 'scope') throw new Error('Expected a file scope'); + assert.equal(evaluation.subject.filepath, 'src/example.txt'); + assertNoRuleWorkspace(fixture, 'local rule path with #'); +}); diff --git a/website/src/content/docs/cli.md b/website/src/content/docs/cli.md index 6a84a64..dc62348 100644 --- a/website/src/content/docs/cli.md +++ b/website/src/content/docs/cli.md @@ -71,7 +71,7 @@ The default target is `.`. A file or folder target narrows selection inside the | Flag | Default | Meaning | | --- | --- | --- | -| `--rules ` | Discovery | Rule name, module path, or directory | +| `--rules ` | Discovery | Rule name, module path, directory, or Git repository URL[#revision] | | `--tags ` | `[]` | Run rules that match any listed tag | | `--exclude-tags ` | `[]` | Exclude rules that match any listed tag | | `--json` | Off | Print one JSON document | @@ -86,6 +86,12 @@ The default target is `.`. A file or folder target narrows selection inside the | `--concurrency ` | `8` | Maximum in-flight selection and evaluation tasks | | `--threshold ` | Rule/environment | Override the AI decision threshold | +An HTTPS or SSH Git URL loads every rule module from the repository's default branch. Add `#revision` to select a branch, tag, or full commit ID. An explicit source replaces project and home discovery, but target paths still refer to the current project. + +```sh +coderuler check . --rules 'https://github.com/acme/company-rules.git#main' +``` + For rule-name and discovery behavior, see the description under [Validate](#validate). Tags are space-separated, exact, and case-sensitive. Exclusion takes precedence over inclusion. With no tag flags, `check` uses every rule. If the filters match no rules, `check` succeeds with zero rules. diff --git a/website/src/content/docs/configuration.md b/website/src/content/docs/configuration.md index 93eb872..c8c8bbd 100644 --- a/website/src/content/docs/configuration.md +++ b/website/src/content/docs/configuration.md @@ -17,7 +17,9 @@ The filename without its rule suffix is the exact rule name. For example, `polic Discovery does not follow symlinks. It skips `old`, `node_modules`, and CodeRuler's temporary installation directories. Ordinary hidden rule folders are not excluded merely because their names start with a dot. -Use `validate [rule]`, `test [rule]`, or `check --rules ` to select a rule name, module path, or directory. A name filters discovered rules; an explicit path or directory replaces discovery. `inspect` takes one module path or exact discovered name, not a rule directory. +Use `validate [rule]`, `test [rule]`, or `check --rules ` to select a rule name, module path, or directory. A name filters discovered rules; an explicit path or directory replaces discovery. `check --rules` also accepts an HTTPS or SSH Git URL with optional `#revision`; this loads the whole repository like a directory. The check target stays in the current project. `inspect` takes one module path or exact discovered name, not a rule directory. + +A shared Git repository is a good way to distribute company-wide rules across projects and maintain them in one place. ## Rule members