@alex-dbs/coderuler 0.0.0-stage → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +175 -0
- package/README.md +218 -2
- package/dist/adapter/cli/commands/check.d.ts +3 -0
- package/dist/adapter/cli/commands/check.js +93 -0
- package/dist/adapter/cli/commands/deps.d.ts +3 -0
- package/dist/adapter/cli/commands/deps.js +4 -0
- package/dist/adapter/cli/commands/inspect.d.ts +3 -0
- package/dist/adapter/cli/commands/inspect.js +59 -0
- package/dist/adapter/cli/commands/rules.d.ts +10 -0
- package/dist/adapter/cli/commands/rules.js +17 -0
- package/dist/adapter/cli/commands/skill.d.ts +4 -0
- package/dist/adapter/cli/commands/skill.js +7 -0
- package/dist/adapter/cli/commands/test.d.ts +3 -0
- package/dist/adapter/cli/commands/test.js +78 -0
- package/dist/adapter/cli/commands/update.d.ts +3 -0
- package/dist/adapter/cli/commands/update.js +7 -0
- package/dist/adapter/cli/commands/validate.d.ts +3 -0
- package/dist/adapter/cli/commands/validate.js +12 -0
- package/dist/adapter/cli/exit.d.ts +7 -0
- package/dist/adapter/cli/exit.js +10 -0
- package/dist/adapter/cli/inspect-json.d.ts +9 -0
- package/dist/adapter/cli/inspect-json.js +160 -0
- package/dist/adapter/cli/inspect-usage.d.ts +9 -0
- package/dist/adapter/cli/inspect-usage.js +14 -0
- package/dist/adapter/cli/program.d.ts +4 -0
- package/dist/adapter/cli/program.js +216 -0
- package/dist/adapter/cli/progress.d.ts +22 -0
- package/dist/adapter/cli/progress.js +79 -0
- package/dist/adapter/cli/reports.d.ts +47 -0
- package/dist/adapter/cli/reports.js +735 -0
- package/dist/adapter/cli/self-update.d.ts +1 -0
- package/dist/adapter/cli/self-update.js +89 -0
- package/dist/adapter/cli/services.d.ts +101 -0
- package/dist/adapter/cli/services.js +1 -0
- package/dist/adapter/cli/text.d.ts +5 -0
- package/dist/adapter/cli/text.js +36 -0
- package/dist/adapter/cli/version.d.ts +1 -0
- package/dist/adapter/cli/version.js +3 -0
- package/dist/adapter/decision/jev.d.ts +11 -0
- package/dist/adapter/decision/jev.js +138 -0
- package/dist/adapter/filesystem/dependencies.d.ts +26 -0
- package/dist/adapter/filesystem/dependencies.js +577 -0
- package/dist/adapter/filesystem/dependency-installation.d.ts +11 -0
- package/dist/adapter/filesystem/dependency-installation.js +373 -0
- package/dist/adapter/filesystem/project.d.ts +14 -0
- package/dist/adapter/filesystem/project.js +387 -0
- package/dist/adapter/filesystem/rule-resolution.d.ts +4 -0
- package/dist/adapter/filesystem/rule-resolution.js +516 -0
- package/dist/adapter/filesystem/rule-test-worker.d.ts +17 -0
- package/dist/adapter/filesystem/rule-test-worker.js +47 -0
- package/dist/adapter/filesystem/rule-tests.d.ts +3 -0
- package/dist/adapter/filesystem/rule-tests.js +264 -0
- package/dist/adapter/filesystem/rules.d.ts +13 -0
- package/dist/adapter/filesystem/rules.js +242 -0
- package/dist/adapter/git/changes.d.ts +1 -0
- package/dist/adapter/git/changes.js +54 -0
- package/dist/adapter/git/environment.d.ts +1 -0
- package/dist/adapter/git/environment.js +10 -0
- package/dist/adapter/github/api.d.ts +5 -0
- package/dist/adapter/github/api.js +40 -0
- package/dist/adapter/github/comments.d.ts +3 -0
- package/dist/adapter/github/comments.js +235 -0
- package/dist/adapter/github/pull-request.d.ts +16 -0
- package/dist/adapter/github/pull-request.js +139 -0
- package/dist/adapter/scope/astgrep-languages.d.ts +5 -0
- package/dist/adapter/scope/astgrep-languages.js +119 -0
- package/dist/adapter/scope/astgrep.d.ts +14 -0
- package/dist/adapter/scope/astgrep.js +183 -0
- package/dist/adapter/scope/csv.d.ts +4 -0
- package/dist/adapter/scope/csv.js +158 -0
- package/dist/adapter/scope/file.d.ts +2 -0
- package/dist/adapter/scope/file.js +4 -0
- package/dist/adapter/scope/json.d.ts +7 -0
- package/dist/adapter/scope/json.js +132 -0
- package/dist/adapter/scope/lines.d.ts +10 -0
- package/dist/adapter/scope/lines.js +42 -0
- package/dist/adapter/scope/protobuf-options.d.ts +29 -0
- package/dist/adapter/scope/protobuf-options.js +64 -0
- package/dist/adapter/scope/protobuf-parser.d.ts +30 -0
- package/dist/adapter/scope/protobuf-parser.js +772 -0
- package/dist/adapter/scope/protobuf.d.ts +5 -0
- package/dist/adapter/scope/protobuf.js +53 -0
- package/dist/adapter/scope/structured.d.ts +10 -0
- package/dist/adapter/scope/structured.js +84 -0
- package/dist/adapter/scope/yaml.d.ts +7 -0
- package/dist/adapter/scope/yaml.js +224 -0
- package/dist/adapter/skills/cli.d.ts +10 -0
- package/dist/adapter/skills/cli.js +77 -0
- package/dist/app/concurrency.d.ts +1 -0
- package/dist/app/concurrency.js +44 -0
- package/dist/app/evaluators.d.ts +14 -0
- package/dist/app/evaluators.js +175 -0
- package/dist/app/execute.d.ts +2 -0
- package/dist/app/execute.js +427 -0
- package/dist/app/jsonpath.d.ts +1 -0
- package/dist/app/jsonpath.js +8 -0
- package/dist/app/ports.d.ts +165 -0
- package/dist/app/ports.js +1 -0
- package/dist/app/rule-tests.d.ts +20 -0
- package/dist/app/rule-tests.js +11 -0
- package/dist/app/rule.d.ts +121 -0
- package/dist/app/rule.js +24 -0
- package/dist/app/selection.d.ts +2 -0
- package/dist/app/selection.js +23 -0
- package/dist/app/usage.d.ts +2 -0
- package/dist/app/usage.js +13 -0
- package/dist/app/validate.d.ts +11 -0
- package/dist/app/validate.js +415 -0
- package/dist/app/violation-comments.d.ts +10 -0
- package/dist/app/violation-comments.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +11 -0
- package/dist/infra/runtime.d.ts +3 -0
- package/dist/infra/runtime.js +168 -0
- package/dist/infra/settings.d.ts +5 -0
- package/dist/infra/settings.js +91 -0
- package/package.json +86 -4
- package/skills/coderuler-new-rule/SKILL.md +141 -0
- package/skills/coderuler-new-rule/reference.md +447 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { isolatedGitEnvironment } from '../git/environment.js';
|
|
6
|
+
import { createGithubApi, mapping } from './api.js';
|
|
7
|
+
import { originRepository, pullNumber, repositoryName } from './pull-request.js';
|
|
8
|
+
async function git(root, args, env) {
|
|
9
|
+
return await new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn('git', [...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
11
|
+
let stdout = '';
|
|
12
|
+
child.stdout.setEncoding('utf8');
|
|
13
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
14
|
+
child.stderr.resume();
|
|
15
|
+
child.on('error', () => reject(new Error('Cannot run Git to resolve the GitHub pull request.')));
|
|
16
|
+
child.on('close', code => {
|
|
17
|
+
if (code === 0)
|
|
18
|
+
resolve(stdout.trim());
|
|
19
|
+
else
|
|
20
|
+
reject(new Error(`Git ${args[0]} failed; check the origin remote and current branch (detached HEAD is unsupported).`));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function actionsPullRequest(env) {
|
|
25
|
+
const ref = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(env.GITHUB_REF ?? '');
|
|
26
|
+
if (ref)
|
|
27
|
+
return { repository: repositoryName(env.GITHUB_REPOSITORY), number: pullNumber(Number(ref[1])) };
|
|
28
|
+
if (!env.GITHUB_EVENT_PATH)
|
|
29
|
+
return undefined;
|
|
30
|
+
let event;
|
|
31
|
+
try {
|
|
32
|
+
event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error('Cannot read the GitHub pull request event at GITHUB_EVENT_PATH; check that it is readable JSON.');
|
|
36
|
+
}
|
|
37
|
+
const pull = mapping(mapping(event)?.pull_request);
|
|
38
|
+
const base = mapping(mapping(pull?.base)?.repo);
|
|
39
|
+
if (!pull || !base)
|
|
40
|
+
throw new Error('GitHub event does not identify a pull request and its base repository.');
|
|
41
|
+
return { repository: repositoryName(base.full_name), number: pullNumber(pull.number) };
|
|
42
|
+
}
|
|
43
|
+
function rightRuns(patch) {
|
|
44
|
+
const runs = [];
|
|
45
|
+
let hunk;
|
|
46
|
+
const finish = () => {
|
|
47
|
+
if (hunk?.valid && hunk.oldRead === hunk.oldCount && hunk.rightRead === hunk.rightCount)
|
|
48
|
+
runs.push(...hunk.runs);
|
|
49
|
+
};
|
|
50
|
+
const lines = patch.split('\n');
|
|
51
|
+
for (let index = 0; index < lines.length; index++) {
|
|
52
|
+
const text = lines[index];
|
|
53
|
+
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)$/.exec(text);
|
|
54
|
+
if (header) {
|
|
55
|
+
finish();
|
|
56
|
+
const old = Number(header[1]);
|
|
57
|
+
const right = Number(header[3]);
|
|
58
|
+
const oldCount = Number(header[2] ?? 1);
|
|
59
|
+
const rightCount = Number(header[4] ?? 1);
|
|
60
|
+
hunk = { old, right, oldCount, rightCount, oldRead: 0, rightRead: 0, runs: [],
|
|
61
|
+
valid: [old, right, oldCount, rightCount, old + oldCount, right + rightCount].every(Number.isSafeInteger)
|
|
62
|
+
&& (oldCount === 0 || old > 0) && (rightCount === 0 || right > 0) };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!hunk || text === '\' || (index === lines.length - 1 && text === ''))
|
|
66
|
+
continue;
|
|
67
|
+
const prefix = text[0];
|
|
68
|
+
if (prefix !== ' ' && prefix !== '+' && prefix !== '-') {
|
|
69
|
+
hunk.valid = false;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (prefix !== '+')
|
|
73
|
+
hunk.oldRead++;
|
|
74
|
+
if (prefix !== '-') {
|
|
75
|
+
const line = hunk.right + hunk.rightRead++;
|
|
76
|
+
const added = prefix === '+';
|
|
77
|
+
const previous = hunk.runs.at(-1);
|
|
78
|
+
if (previous && previous.added === added && previous.end + 1 === line)
|
|
79
|
+
previous.end = line;
|
|
80
|
+
else
|
|
81
|
+
hunk.runs.push({ start: line, end: line, added });
|
|
82
|
+
}
|
|
83
|
+
if (hunk.oldRead > hunk.oldCount || hunk.rightRead > hunk.rightCount)
|
|
84
|
+
hunk.valid = false;
|
|
85
|
+
}
|
|
86
|
+
finish();
|
|
87
|
+
return runs;
|
|
88
|
+
}
|
|
89
|
+
async function repositoryPaths(root, env) {
|
|
90
|
+
try {
|
|
91
|
+
const invocation = await realpath(root);
|
|
92
|
+
const repository = await realpath(await git(invocation, ['rev-parse', '--show-toplevel'], env));
|
|
93
|
+
const prefix = relative(repository, invocation);
|
|
94
|
+
if (prefix === '..' || prefix.startsWith(`..${sep}`) || isAbsolute(prefix))
|
|
95
|
+
return undefined;
|
|
96
|
+
return { root: repository, invocation };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function matchingFile(repository, filepath, files, env) {
|
|
103
|
+
if (!filepath || isAbsolute(filepath))
|
|
104
|
+
return undefined;
|
|
105
|
+
const absolute = resolve(repository.invocation, filepath);
|
|
106
|
+
const local = relative(repository.root, absolute);
|
|
107
|
+
if (!local || local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local))
|
|
108
|
+
return undefined;
|
|
109
|
+
const path = local.split(sep).join('/');
|
|
110
|
+
const file = files.get(path);
|
|
111
|
+
if (!file || file.status === 'removed' || !file.patch)
|
|
112
|
+
return undefined;
|
|
113
|
+
try {
|
|
114
|
+
// Symlinks and paths escaping through a symlink cannot identify this Git blob.
|
|
115
|
+
if (!(await lstat(absolute)).isFile() || await realpath(absolute) !== absolute)
|
|
116
|
+
return undefined;
|
|
117
|
+
await git(repository.root, ['--literal-pathspecs', 'ls-files', '--error-unmatch', '--', path], env);
|
|
118
|
+
const bytes = await readFile(absolute);
|
|
119
|
+
const sha = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
120
|
+
return sha === file.sha ? file : undefined;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function createGithubCommenter(env) {
|
|
127
|
+
return async (root, comments, pullRequest) => {
|
|
128
|
+
if (comments.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
const token = env.GITHUB_TOKEN?.trim();
|
|
131
|
+
if (!token)
|
|
132
|
+
throw new Error('GITHUB_TOKEN is required to comment on a GitHub pull request.');
|
|
133
|
+
const { request, json } = createGithubApi(token);
|
|
134
|
+
const gitEnv = pullRequest === undefined ? undefined : isolatedGitEnvironment(env);
|
|
135
|
+
let pull = pullRequest ?? await actionsPullRequest(env);
|
|
136
|
+
if (!pull) {
|
|
137
|
+
const repository = originRepository(await git(root, ['remote', 'get-url', 'origin']));
|
|
138
|
+
const branch = await git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
139
|
+
if (!branch)
|
|
140
|
+
throw new Error('Cannot resolve the current branch; detached HEAD is unsupported.');
|
|
141
|
+
const owner = repository.split('/')[0];
|
|
142
|
+
const pulls = await json(`/repos/${repository}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=2`);
|
|
143
|
+
if (!Array.isArray(pulls))
|
|
144
|
+
throw new Error('GitHub API returned an invalid pull request discovery response.');
|
|
145
|
+
if (pulls.length === 0)
|
|
146
|
+
throw new Error('No open pull request found for the current branch on GitHub.');
|
|
147
|
+
if (pulls.length !== 1)
|
|
148
|
+
throw new Error('Multiple open pull requests match the current branch; refusing an ambiguous comment target.');
|
|
149
|
+
pull = { repository, number: pullNumber(mapping(pulls[0])?.number) };
|
|
150
|
+
}
|
|
151
|
+
const path = `/repos/${pull.repository}/pulls/${pull.number}`;
|
|
152
|
+
const readHead = async () => {
|
|
153
|
+
const sha = mapping(mapping(await json(path))?.head)?.sha;
|
|
154
|
+
if (typeof sha !== 'string' || !/^[a-f0-9]{40}$/.test(sha))
|
|
155
|
+
throw new Error('GitHub API returned an invalid pull request head response.');
|
|
156
|
+
return sha;
|
|
157
|
+
};
|
|
158
|
+
const head = await readHead();
|
|
159
|
+
if (pull.head !== undefined && head !== pull.head) {
|
|
160
|
+
throw new Error('GitHub pull request head changed since analysis; refusing stale findings.');
|
|
161
|
+
}
|
|
162
|
+
const files = new Map();
|
|
163
|
+
for (let page = 1; page <= 30; page++) {
|
|
164
|
+
const entries = await json(`${path}/files?per_page=100&page=${page}`);
|
|
165
|
+
if (!Array.isArray(entries) || entries.length > 100)
|
|
166
|
+
throw new Error('GitHub API returned an invalid pull request files response.');
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const file = mapping(entry);
|
|
169
|
+
if (!file || typeof file.filename !== 'string' || typeof file.status !== 'string' || typeof file.sha !== 'string'
|
|
170
|
+
|| !/^[a-f0-9]{40}$/.test(file.sha))
|
|
171
|
+
throw new Error('GitHub API returned an invalid pull request files response.');
|
|
172
|
+
files.set(file.filename, { filename: file.filename, status: file.status, sha: file.sha,
|
|
173
|
+
...(typeof file.patch === 'string' ? { patch: file.patch } : {}) });
|
|
174
|
+
}
|
|
175
|
+
if (entries.length < 100)
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
const repository = comments.some(comment => comment.locations.length > 0) ? await repositoryPaths(root, gitEnv) : undefined;
|
|
179
|
+
const matched = new Map();
|
|
180
|
+
const patches = new Map();
|
|
181
|
+
const inline = [];
|
|
182
|
+
const fallback = [];
|
|
183
|
+
for (const finding of comments) {
|
|
184
|
+
const anchors = new Set();
|
|
185
|
+
for (const location of finding.locations) {
|
|
186
|
+
if (!repository || !Number.isSafeInteger(location.lines.start) || !Number.isSafeInteger(location.lines.end)
|
|
187
|
+
|| location.lines.start <= 0 || location.lines.end < location.lines.start)
|
|
188
|
+
continue;
|
|
189
|
+
let match = matched.get(location.filepath);
|
|
190
|
+
if (!match) {
|
|
191
|
+
match = matchingFile(repository, location.filepath, files, gitEnv);
|
|
192
|
+
matched.set(location.filepath, match);
|
|
193
|
+
}
|
|
194
|
+
const file = await match;
|
|
195
|
+
if (!file?.patch)
|
|
196
|
+
continue;
|
|
197
|
+
let runs = patches.get(file.filename);
|
|
198
|
+
if (!runs) {
|
|
199
|
+
runs = rightRuns(file.patch);
|
|
200
|
+
patches.set(file.filename, runs);
|
|
201
|
+
}
|
|
202
|
+
const run = runs.find(run => run.added && run.start <= location.lines.end && run.end >= location.lines.start)
|
|
203
|
+
?? runs.find(run => !run.added && run.start <= location.lines.end && run.end >= location.lines.start);
|
|
204
|
+
if (!run)
|
|
205
|
+
continue;
|
|
206
|
+
const start = Math.max(run.start, location.lines.start);
|
|
207
|
+
const end = Math.min(run.end, location.lines.end);
|
|
208
|
+
const key = JSON.stringify([file.filename, start, end]);
|
|
209
|
+
if (anchors.has(key))
|
|
210
|
+
continue;
|
|
211
|
+
anchors.add(key);
|
|
212
|
+
inline.push({ path: file.filename, body: finding.body, line: end, side: 'RIGHT',
|
|
213
|
+
...(start < end ? { start_line: start, start_side: 'RIGHT' } : {}) });
|
|
214
|
+
}
|
|
215
|
+
if (anchors.size === 0)
|
|
216
|
+
fallback.push(finding.body);
|
|
217
|
+
}
|
|
218
|
+
if (await readHead() !== head)
|
|
219
|
+
throw new Error('GitHub pull request head changed during comment planning; refusing stale findings.');
|
|
220
|
+
if (inline.length > 0) {
|
|
221
|
+
for (let index = 0; index < inline.length; index += 100) {
|
|
222
|
+
const response = await request(`${path}/reviews`, 'POST', {
|
|
223
|
+
commit_id: head, event: 'COMMENT', body: 'CodeRuler violations', comments: inline.slice(index, index + 100),
|
|
224
|
+
});
|
|
225
|
+
await response.body?.cancel().catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (fallback.length > 0) {
|
|
229
|
+
const response = await request(`/repos/${pull.repository}/issues/${pull.number}/comments`, 'POST', {
|
|
230
|
+
body: `## CodeRuler violations\n\n${fallback.join('\n\n')}\n`,
|
|
231
|
+
});
|
|
232
|
+
await response.body?.cancel().catch(() => undefined);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface PullRequest {
|
|
2
|
+
readonly repository: string;
|
|
3
|
+
readonly number: number;
|
|
4
|
+
readonly head?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface PullRequestCheckout {
|
|
7
|
+
readonly root: string;
|
|
8
|
+
readonly rulesRoot: string;
|
|
9
|
+
readonly changedFiles: ReadonlySet<string>;
|
|
10
|
+
readonly pullRequest: PullRequest;
|
|
11
|
+
cleanup(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function repositoryName(value: unknown): string;
|
|
14
|
+
export declare function pullNumber(value: unknown): number;
|
|
15
|
+
export declare function originRepository(origin: string): string;
|
|
16
|
+
export declare function createPullRequestCheckout(cwd: string, env: Readonly<Record<string, string | undefined>>): (selector: string, baseBranch?: string) => Promise<PullRequestCheckout>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { changedFiles } from '../git/changes.js';
|
|
6
|
+
import { isolatedGitEnvironment } from '../git/environment.js';
|
|
7
|
+
import { createGithubApi, mapping } from './api.js';
|
|
8
|
+
export function repositoryName(value) {
|
|
9
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(value)
|
|
10
|
+
|| value.split('/').some(part => part === '.' || part === '..')) {
|
|
11
|
+
throw new Error('Cannot determine the GitHub repository: expected owner/repository.');
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
export function pullNumber(value) {
|
|
16
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
17
|
+
throw new Error('Cannot determine a valid GitHub pull request number.');
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export function originRepository(origin) {
|
|
22
|
+
let path;
|
|
23
|
+
const scp = /^git@github\.com:(.+)$/i.exec(origin);
|
|
24
|
+
if (scp) {
|
|
25
|
+
path = scp[1];
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(origin);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error('Git origin must be an HTTPS or SSH repository on github.com.');
|
|
34
|
+
}
|
|
35
|
+
if (url.hostname.toLowerCase() !== 'github.com' || !['https:', 'ssh:'].includes(url.protocol)
|
|
36
|
+
|| (url.port !== '' && !(url.protocol === 'ssh:' && url.port === '22')) || url.search || url.hash) {
|
|
37
|
+
throw new Error('Git origin must be an HTTPS or SSH repository on github.com.');
|
|
38
|
+
}
|
|
39
|
+
path = url.pathname.slice(1);
|
|
40
|
+
}
|
|
41
|
+
return repositoryName(path.replace(/\/$/, '').replace(/\.git$/, ''));
|
|
42
|
+
}
|
|
43
|
+
function selectorIdentity(selector) {
|
|
44
|
+
const invalid = '--pr must be a positive pull request number or a GitHub pull request URL.';
|
|
45
|
+
if (/^[0-9]+$/.test(selector)) {
|
|
46
|
+
try {
|
|
47
|
+
return { number: pullNumber(Number(selector)) };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error(invalid);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const match = /^https:\/\/github\.com(?::443)?\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\/pull\/([0-9]+)\/?(?:[?#][\s\S]*)?$/i.exec(selector);
|
|
54
|
+
if (!match)
|
|
55
|
+
throw new Error(invalid);
|
|
56
|
+
try {
|
|
57
|
+
return { repository: repositoryName(match[1]), number: pullNumber(Number(match[2])) };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error(invalid);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createPullRequestCheckout(cwd, env) {
|
|
64
|
+
const isolatedEnv = isolatedGitEnvironment(env);
|
|
65
|
+
const api = createGithubApi(env.GITHUB_TOKEN?.trim() || undefined);
|
|
66
|
+
const runGit = (root, args, allowedExitCodes = [0]) => {
|
|
67
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
68
|
+
const child = spawn('git', [...args], {
|
|
69
|
+
cwd: root,
|
|
70
|
+
env: isolatedEnv,
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
});
|
|
73
|
+
let stdout = '';
|
|
74
|
+
let stderr = '';
|
|
75
|
+
child.stdout.setEncoding('utf8');
|
|
76
|
+
child.stderr.setEncoding('utf8');
|
|
77
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
78
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
79
|
+
child.on('error', error => reject(new Error(`Cannot run Git: ${error.message}`, {
|
|
80
|
+
cause: error,
|
|
81
|
+
})));
|
|
82
|
+
child.on('close', (code, signal) => {
|
|
83
|
+
if (code !== null && allowedExitCodes.includes(code))
|
|
84
|
+
resolve({ stdout, code });
|
|
85
|
+
else
|
|
86
|
+
reject(new Error(`Git ${args[0]} failed: ${stderr.trim() || signal || `exit ${code}`}`));
|
|
87
|
+
});
|
|
88
|
+
return promise;
|
|
89
|
+
};
|
|
90
|
+
const assertNoWorktreeOverride = async (root) => {
|
|
91
|
+
const result = await runGit(root, ['config', '--null', '--get-all', 'core.worktree'], [0, 1]);
|
|
92
|
+
if (result.code === 0) {
|
|
93
|
+
throw new Error('PR checkout requires Git core.worktree to be unset; remove the inherited repository-location override.');
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
return async (selector, baseBranch) => {
|
|
97
|
+
const identity = selectorIdentity(selector);
|
|
98
|
+
await assertNoWorktreeOverride(cwd);
|
|
99
|
+
const transport = identity.repository === undefined
|
|
100
|
+
? (await runGit(cwd, ['remote', 'get-url', 'origin'])).stdout.trim()
|
|
101
|
+
: `https://github.com/${identity.repository}.git`;
|
|
102
|
+
const pullRequest = {
|
|
103
|
+
repository: identity.repository ?? originRepository(transport),
|
|
104
|
+
number: identity.number,
|
|
105
|
+
};
|
|
106
|
+
const response = await api.json(`/repos/${pullRequest.repository}/pulls/${pullRequest.number}`);
|
|
107
|
+
const defaultBranch = mapping(mapping(response)?.base)?.ref;
|
|
108
|
+
if (baseBranch === undefined && (typeof defaultBranch !== 'string' || defaultBranch.trim() === '')) {
|
|
109
|
+
throw new Error('GitHub API returned an invalid pull request response: expected a base branch.');
|
|
110
|
+
}
|
|
111
|
+
const branch = baseBranch ?? defaultBranch;
|
|
112
|
+
const workspace = await mkdtemp(join(await realpath(tmpdir()), 'coderuler-pr-'));
|
|
113
|
+
const root = join(workspace, 'head');
|
|
114
|
+
const rulesRoot = join(workspace, 'base');
|
|
115
|
+
const cleanup = () => rm(workspace, { recursive: true, force: true });
|
|
116
|
+
try {
|
|
117
|
+
await runGit(cwd, ['init', root]);
|
|
118
|
+
await assertNoWorktreeOverride(root);
|
|
119
|
+
await runGit(root, ['remote', 'add', 'origin', transport]);
|
|
120
|
+
await runGit(root, ['check-ref-format', `refs/heads/${branch}`]);
|
|
121
|
+
await runGit(root, [
|
|
122
|
+
'fetch', '--no-tags', 'origin',
|
|
123
|
+
`+refs/heads/${branch}:refs/coderuler/base`,
|
|
124
|
+
`+refs/pull/${pullRequest.number}/head:refs/coderuler/head`,
|
|
125
|
+
]);
|
|
126
|
+
await runGit(root, ['checkout', '--detach', 'refs/coderuler/head']);
|
|
127
|
+
const head = (await runGit(root, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
128
|
+
await runGit(root, ['worktree', 'add', '--detach', rulesRoot, 'refs/coderuler/base']);
|
|
129
|
+
const mergeBase = (await runGit(root, ['merge-base', 'refs/coderuler/base', 'refs/coderuler/head'])).stdout.trim();
|
|
130
|
+
const files = await changedFiles(root, mergeBase, isolatedEnv);
|
|
131
|
+
await assertNoWorktreeOverride(rulesRoot);
|
|
132
|
+
return { root, rulesRoot, changedFiles: files, pullRequest: { ...pullRequest, head }, cleanup };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
await cleanup();
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { parse, type DynamicLangRegistrations } from '@ast-grep/napi';
|
|
2
|
+
type NativeLanguage = Parameters<typeof parse>[0];
|
|
3
|
+
export declare function createAstGrepLanguageRegistrations(): DynamicLangRegistrations;
|
|
4
|
+
export declare function resolveAstGrepLanguage(language: string): NativeLanguage;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { parse, registerDynamicLanguage } from '@ast-grep/napi';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
const BUILTIN_LANGUAGES = {
|
|
4
|
+
css: true,
|
|
5
|
+
html: true,
|
|
6
|
+
javascript: true,
|
|
7
|
+
tsx: true,
|
|
8
|
+
typescript: true,
|
|
9
|
+
};
|
|
10
|
+
const DYNAMIC_LANGUAGES = {
|
|
11
|
+
angular: true,
|
|
12
|
+
bash: true,
|
|
13
|
+
bicep: true,
|
|
14
|
+
c: true,
|
|
15
|
+
cpp: true,
|
|
16
|
+
csharp: true,
|
|
17
|
+
dart: true,
|
|
18
|
+
elixir: true,
|
|
19
|
+
'glimmer-javascript': true,
|
|
20
|
+
'glimmer-typescript': true,
|
|
21
|
+
go: true,
|
|
22
|
+
haskell: true,
|
|
23
|
+
java: true,
|
|
24
|
+
json: true,
|
|
25
|
+
kotlin: true,
|
|
26
|
+
lua: true,
|
|
27
|
+
markdown: true,
|
|
28
|
+
php: true,
|
|
29
|
+
python: true,
|
|
30
|
+
ruby: true,
|
|
31
|
+
rust: true,
|
|
32
|
+
scala: true,
|
|
33
|
+
sql: true,
|
|
34
|
+
swift: true,
|
|
35
|
+
toml: true,
|
|
36
|
+
yaml: true,
|
|
37
|
+
};
|
|
38
|
+
const LANGUAGE_ALIASES = {
|
|
39
|
+
cc: 'cpp',
|
|
40
|
+
'c++': 'cpp',
|
|
41
|
+
cxx: 'cpp',
|
|
42
|
+
cs: 'csharp',
|
|
43
|
+
ex: 'elixir',
|
|
44
|
+
golang: 'go',
|
|
45
|
+
hs: 'haskell',
|
|
46
|
+
kt: 'kotlin',
|
|
47
|
+
md: 'markdown',
|
|
48
|
+
py: 'python',
|
|
49
|
+
rb: 'ruby',
|
|
50
|
+
rs: 'rust',
|
|
51
|
+
yml: 'yaml',
|
|
52
|
+
js: 'javascript',
|
|
53
|
+
jsx: 'javascript',
|
|
54
|
+
ts: 'typescript',
|
|
55
|
+
};
|
|
56
|
+
const loadCommonJs = createRequire(import.meta.url);
|
|
57
|
+
let bundledRegistrationAttempted = false;
|
|
58
|
+
let bundledGrammarsActive = false;
|
|
59
|
+
let bundledRegistrationError = null;
|
|
60
|
+
function isParseable(language) {
|
|
61
|
+
try {
|
|
62
|
+
parse(language, '');
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function canonicalLanguage(language) {
|
|
70
|
+
const folded = language.toLowerCase();
|
|
71
|
+
const alias = Object.hasOwn(LANGUAGE_ALIASES, folded) ? LANGUAGE_ALIASES[folded] : undefined;
|
|
72
|
+
if (alias !== undefined)
|
|
73
|
+
return alias;
|
|
74
|
+
const known = BUILTIN_LANGUAGES[folded] === true || DYNAMIC_LANGUAGES[folded] === true;
|
|
75
|
+
return known ? folded : language;
|
|
76
|
+
}
|
|
77
|
+
function registerBundledGrammars() {
|
|
78
|
+
if (bundledRegistrationAttempted)
|
|
79
|
+
return;
|
|
80
|
+
bundledRegistrationAttempted = true;
|
|
81
|
+
try {
|
|
82
|
+
registerDynamicLanguage(createAstGrepLanguageRegistrations());
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
86
|
+
const error = new Error(`ast-grep could not register its bundled grammars: ${message}`, { cause });
|
|
87
|
+
bundledRegistrationError = error;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function createAstGrepLanguageRegistrations() {
|
|
92
|
+
const registrations = {};
|
|
93
|
+
for (const language of Object.keys(DYNAMIC_LANGUAGES)) {
|
|
94
|
+
registrations[language] = loadCommonJs(`@ast-grep/lang-${language}`);
|
|
95
|
+
}
|
|
96
|
+
return registrations;
|
|
97
|
+
}
|
|
98
|
+
export function resolveAstGrepLanguage(language) {
|
|
99
|
+
const canonical = canonicalLanguage(language);
|
|
100
|
+
if (BUILTIN_LANGUAGES[canonical] === true)
|
|
101
|
+
return canonical;
|
|
102
|
+
if (DYNAMIC_LANGUAGES[canonical] !== true)
|
|
103
|
+
return canonical;
|
|
104
|
+
if (bundledGrammarsActive)
|
|
105
|
+
return canonical;
|
|
106
|
+
if (isParseable(canonical))
|
|
107
|
+
return canonical;
|
|
108
|
+
registerBundledGrammars();
|
|
109
|
+
if (isParseable(canonical)) {
|
|
110
|
+
bundledGrammarsActive = true;
|
|
111
|
+
return canonical;
|
|
112
|
+
}
|
|
113
|
+
if (bundledRegistrationError !== null)
|
|
114
|
+
throw bundledRegistrationError;
|
|
115
|
+
throw new Error(`ast-grep language '${canonical}' is not registered: registerDynamicLanguage had already been called ` +
|
|
116
|
+
`earlier in this process (by the host or another library), and ast-grep honours only the first such ` +
|
|
117
|
+
`call, so the bundled grammars could not be added. Call registerDynamicLanguage exactly once yourself, ` +
|
|
118
|
+
`merging createAstGrepLanguageRegistrations() with your own grammars, and include '${canonical}'.`);
|
|
119
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type NapiConfig } from '@ast-grep/napi';
|
|
2
|
+
import type { ScopeSelector } from '../../app/rule.js';
|
|
3
|
+
export interface AstGrepScopeOptions {
|
|
4
|
+
readonly language: string;
|
|
5
|
+
readonly rule: NapiConfig['rule'];
|
|
6
|
+
readonly constraints?: NapiConfig['constraints'];
|
|
7
|
+
readonly utils?: NapiConfig['utils'];
|
|
8
|
+
readonly tsx?: {
|
|
9
|
+
readonly rule: NapiConfig['rule'];
|
|
10
|
+
readonly constraints?: NapiConfig['constraints'];
|
|
11
|
+
readonly utils?: NapiConfig['utils'];
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare function astGrep(options: AstGrepScopeOptions): ScopeSelector;
|