Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/adapter/cli/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -126,7 +132,7 @@ async function runCheck(
throw new CliExit(EXIT_VIOLATION);
}
} finally {
await checkout?.cleanup();
await Promise.all([checkout?.cleanup(), rulesCheckout?.cleanup()]);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/adapter/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>', 'rule name, module path, or directory')
.option('--rules <rule>', 'rule name, module path, directory, or Git repository URL[#revision]')
.option('--tags <tags...>', 'run rules matching any listed tag', [])
.option('--exclude-tags <tags...>', 'exclude rules matching any listed tag', [])
.option('--json', 'print violations as {durationMs, reports} JSON on stdout')
Expand Down
4 changes: 4 additions & 0 deletions src/adapter/cli/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -101,6 +102,9 @@ export interface ExecutionServices extends RuleServices {
export interface CheckServices extends ExecutionServices {
readonly collectChanges: (root: string, base: string) => Promise<DiffChanges>;
readonly checkoutPullRequest: (selector: string, baseBranch?: string) => Promise<PullRequestCheckout>;
readonly checkoutRuleRepository: (
selector: string,
) => Promise<RuleRepositoryCheckout | undefined>;
readonly commentOnPullRequest: (
root: string,
comments: readonly ViolationComment[],
Expand Down
152 changes: 152 additions & 0 deletions src/adapter/git/rules.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<Record<string, string | undefined>>,
): (selector: string) => Promise<RuleRepositoryCheckout | undefined> {
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<void> => 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;
}
};
}
4 changes: 4 additions & 0 deletions src/infra/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
52 changes: 52 additions & 0 deletions tests/cli-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading