@weareikko/code-review 0.9.3 → 0.9.5
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/README.md +1 -1
- package/dist/{cli-BaZloofb.js → cli-tIo5jQvJ.js} +995 -622
- package/dist/cli-tIo5jQvJ.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +4 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/gitlab-review.d.ts +19 -0
- package/dist/gitlab-review.d.ts.map +1 -1
- package/dist/marketplaces.d.ts +63 -0
- package/dist/marketplaces.d.ts.map +1 -0
- package/dist/review.js +1 -1
- package/dist/skills.d.ts +33 -2
- package/dist/skills.d.ts.map +1 -1
- package/package.json +4 -4
- package/dist/cli-BaZloofb.js.map +0 -1
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
1
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import nodeFs, { existsSync, readFileSync } from "node:fs";
|
|
5
|
-
import { getEnvApiKey,
|
|
5
|
+
import { getEnvApiKey, streamSimple } from "@earendil-works/pi-ai/compat";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { parse } from "yaml";
|
|
9
9
|
import { execFile } from "node:child_process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
|
+
import { tracingChannel } from "node:diagnostics_channel";
|
|
12
|
+
import { performance } from "node:perf_hooks";
|
|
11
13
|
import { Agent } from "@earendil-works/pi-agent-core";
|
|
14
|
+
import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
|
|
12
15
|
import { createReadOnlyTools } from "@earendil-works/pi-coding-agent";
|
|
13
16
|
import { createTwoFilesPatch } from "diff";
|
|
14
17
|
import * as git from "isomorphic-git";
|
|
15
18
|
import { Type } from "typebox";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
|
-
import { parse } from "yaml";
|
|
18
19
|
import { SpanKind, SpanStatusCode, context, metrics, trace } from "@opentelemetry/api";
|
|
19
20
|
import { SeverityNumber, logs } from "@opentelemetry/api-logs";
|
|
20
21
|
//#region src/errors.ts
|
|
@@ -373,9 +374,838 @@ var GitHubClient = class {
|
|
|
373
374
|
cursor = threads.pageInfo?.endCursor ?? null;
|
|
374
375
|
if (!cursor) hasNext = false;
|
|
375
376
|
}
|
|
376
|
-
return settled;
|
|
377
|
+
return settled;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/git.ts
|
|
382
|
+
var exec$1 = promisify(execFile);
|
|
383
|
+
var DEFAULT_DIFF_CONTEXT = 20;
|
|
384
|
+
var DEFAULT_CODEQUALITY_ARTIFACTS = [
|
|
385
|
+
"gl-code-quality-report.json",
|
|
386
|
+
"codequality.json",
|
|
387
|
+
"codeclimate.json",
|
|
388
|
+
"code-quality-report.json"
|
|
389
|
+
];
|
|
390
|
+
function gitErrorMessage(error) {
|
|
391
|
+
const err = error;
|
|
392
|
+
return [
|
|
393
|
+
err.message,
|
|
394
|
+
err.stderr,
|
|
395
|
+
err.stdout
|
|
396
|
+
].filter(Boolean).join("\n").trim();
|
|
397
|
+
}
|
|
398
|
+
async function git$1(args, options = {}) {
|
|
399
|
+
try {
|
|
400
|
+
const { stdout } = await exec$1("git", args, {
|
|
401
|
+
cwd: options.cwd,
|
|
402
|
+
maxBuffer: 50 * 1024 * 1024
|
|
403
|
+
});
|
|
404
|
+
return stdout;
|
|
405
|
+
} catch (error) {
|
|
406
|
+
throw new GitError(`git ${args.join(" ")} failed.`, {
|
|
407
|
+
cause: error,
|
|
408
|
+
hint: gitErrorMessage(error)
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function remoteRef(remote, branch) {
|
|
413
|
+
return `refs/remotes/${remote}/${branch}`;
|
|
414
|
+
}
|
|
415
|
+
function getMergeDiffArguments(targetBranch, options = {}) {
|
|
416
|
+
const remote = options.remote ?? "origin";
|
|
417
|
+
const context = options.context ?? DEFAULT_DIFF_CONTEXT;
|
|
418
|
+
return [
|
|
419
|
+
`${remoteRef(remote, targetBranch)}...HEAD`,
|
|
420
|
+
`--unified=${context}`,
|
|
421
|
+
"--"
|
|
422
|
+
];
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Full git argv for the merge diff. `-c core.quotepath=false` keeps non-ASCII
|
|
426
|
+
* paths literal instead of git's default octal-escaped + double-quoted form
|
|
427
|
+
* (`"a/caf\303\251.ts"`), which the comment-position parser cannot match —
|
|
428
|
+
* leaving such comments with invalid one-sided positions that 500 on
|
|
429
|
+
* `bulk_publish`. Diffing literally also gives the reviewer readable paths.
|
|
430
|
+
*/
|
|
431
|
+
function getMergeDiffCommand(targetBranch, options = {}) {
|
|
432
|
+
return [
|
|
433
|
+
"-c",
|
|
434
|
+
"core.quotepath=false",
|
|
435
|
+
"diff",
|
|
436
|
+
...getMergeDiffArguments(targetBranch, options)
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
async function fetchBranch(remote, branch, options) {
|
|
440
|
+
await git$1([
|
|
441
|
+
"fetch",
|
|
442
|
+
"--no-tags",
|
|
443
|
+
remote,
|
|
444
|
+
`+refs/heads/${branch}:${remoteRef(remote, branch)}`
|
|
445
|
+
], options);
|
|
446
|
+
}
|
|
447
|
+
async function isTracked(path, options) {
|
|
448
|
+
try {
|
|
449
|
+
await git$1([
|
|
450
|
+
"ls-files",
|
|
451
|
+
"--error-unmatch",
|
|
452
|
+
"--",
|
|
453
|
+
path
|
|
454
|
+
], options);
|
|
455
|
+
return true;
|
|
456
|
+
} catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_ARTIFACTS, options = {}) {
|
|
461
|
+
const removed = [];
|
|
462
|
+
for (const path of paths) {
|
|
463
|
+
if (await isTracked(path, options)) continue;
|
|
464
|
+
try {
|
|
465
|
+
await unlink(options.cwd ? join(options.cwd, path) : path);
|
|
466
|
+
removed.push(path);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (error.code !== "ENOENT") throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return removed;
|
|
472
|
+
}
|
|
473
|
+
async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
|
|
474
|
+
const remote = options.remote ?? "origin";
|
|
475
|
+
await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
|
|
476
|
+
await git$1([
|
|
477
|
+
"fetch",
|
|
478
|
+
"--unshallow",
|
|
479
|
+
"--no-tags",
|
|
480
|
+
remote
|
|
481
|
+
], options).catch(() => void 0);
|
|
482
|
+
const fetchErrors = [];
|
|
483
|
+
for (const branch of [targetBranch, sourceBranch]) try {
|
|
484
|
+
await fetchBranch(remote, branch, options);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
fetchErrors.push(`${branch}: ${gitErrorMessage(error)}`);
|
|
487
|
+
}
|
|
488
|
+
if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
|
|
489
|
+
try {
|
|
490
|
+
await git$1([
|
|
491
|
+
"merge-base",
|
|
492
|
+
remoteRef(remote, targetBranch),
|
|
493
|
+
"HEAD"
|
|
494
|
+
], options);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
const fetchDetail = fetchErrors.length > 0 ? `\nFetch warnings:\n${fetchErrors.join("\n")}` : "";
|
|
497
|
+
throw new GitError(`Unable to prepare Git history for MR review: merge-base ${remoteRef(remote, targetBranch)} HEAD failed.`, {
|
|
498
|
+
cause: error,
|
|
499
|
+
hint: `Set GIT_DEPTH: 0 or ensure ${remote}/${targetBranch} is fetchable.${fetchDetail}\n${gitErrorMessage(error)}`
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function getMergeDiff(targetBranch, options = {}) {
|
|
504
|
+
return git$1(getMergeDiffCommand(targetBranch, options), options);
|
|
505
|
+
}
|
|
506
|
+
function getMergeCommitLogArguments(targetBranch, options = {}) {
|
|
507
|
+
return [
|
|
508
|
+
`${remoteRef(options.remote ?? "origin", targetBranch)}...HEAD`,
|
|
509
|
+
"--pretty=tformat:commit %h%nAuthor: %an%nDate: %as%n%n%s%n%n%b",
|
|
510
|
+
"--reverse",
|
|
511
|
+
"--no-merges"
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
async function getMergeCommitLog(targetBranch, options = {}) {
|
|
515
|
+
return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Summarize a unified diff into file/line counts for telemetry. Counts one file
|
|
519
|
+
* per `diff --git` header and counts `+`/`-` lines only inside a hunk (after a
|
|
520
|
+
* `@@` header), so the `--- a/file` / `+++ b/file` header lines are excluded and
|
|
521
|
+
* a genuine content line whose text starts with `++`/`--` is still counted. Pure
|
|
522
|
+
* and allocation-light so it can run on the full merge diff without an extra git
|
|
523
|
+
* invocation.
|
|
524
|
+
*/
|
|
525
|
+
function summarizeDiff(diff) {
|
|
526
|
+
let filesChanged = 0;
|
|
527
|
+
let linesAdded = 0;
|
|
528
|
+
let linesRemoved = 0;
|
|
529
|
+
let inHunk = false;
|
|
530
|
+
for (const line of diff.split("\n")) if (line.startsWith("diff --git ")) {
|
|
531
|
+
filesChanged += 1;
|
|
532
|
+
inHunk = false;
|
|
533
|
+
} else if (line.startsWith("@@ ")) inHunk = true;
|
|
534
|
+
else if (inHunk && line.startsWith("+")) linesAdded += 1;
|
|
535
|
+
else if (inHunk && line.startsWith("-")) linesRemoved += 1;
|
|
536
|
+
return {
|
|
537
|
+
filesChanged,
|
|
538
|
+
linesAdded,
|
|
539
|
+
linesRemoved
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/skills.ts
|
|
544
|
+
var SKILL_DIRS = [".agents/skills", ".claude/skills"];
|
|
545
|
+
var RESOURCE_DIRS = ["references"];
|
|
546
|
+
function parseFrontmatter(content) {
|
|
547
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
548
|
+
if (!match) return null;
|
|
549
|
+
let data;
|
|
550
|
+
try {
|
|
551
|
+
data = parse(match[1]);
|
|
552
|
+
} catch {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
if (!data || typeof data !== "object") return null;
|
|
556
|
+
const { name, description } = data;
|
|
557
|
+
if (typeof name !== "string" || typeof description !== "string") return null;
|
|
558
|
+
const trimmedName = name.trim();
|
|
559
|
+
const trimmedDescription = description.trim();
|
|
560
|
+
if (!trimmedName || !trimmedDescription) return null;
|
|
561
|
+
return {
|
|
562
|
+
name: trimmedName,
|
|
563
|
+
description: trimmedDescription
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
async function loadSkillFromDir(dirPath, source) {
|
|
567
|
+
const skillMdPath = join(dirPath, "SKILL.md");
|
|
568
|
+
let content;
|
|
569
|
+
try {
|
|
570
|
+
content = await readFile(skillMdPath, "utf8");
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
const parsed = parseFrontmatter(content);
|
|
575
|
+
if (!parsed) return null;
|
|
576
|
+
const resourceDirs = RESOURCE_DIRS.filter((d) => existsSync(join(dirPath, d)));
|
|
577
|
+
return {
|
|
578
|
+
name: parsed.name,
|
|
579
|
+
description: parsed.description,
|
|
580
|
+
filePath: skillMdPath,
|
|
581
|
+
rootDir: dirPath,
|
|
582
|
+
resourceDirs,
|
|
583
|
+
source
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function resolveBuiltinSkillsDir() {
|
|
587
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
588
|
+
}
|
|
589
|
+
async function loadBuiltinSkill(name) {
|
|
590
|
+
return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
|
|
591
|
+
}
|
|
592
|
+
async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
|
|
593
|
+
const dirs = [];
|
|
594
|
+
let current = cwd;
|
|
595
|
+
while (true) {
|
|
596
|
+
dirs.unshift(current);
|
|
597
|
+
if (current === gitRoot) break;
|
|
598
|
+
const parent = dirname(current);
|
|
599
|
+
if (parent === current) break;
|
|
600
|
+
current = parent;
|
|
601
|
+
}
|
|
602
|
+
const found = /* @__PURE__ */ new Map();
|
|
603
|
+
for (const dir of dirs) for (const skillDir of SKILL_DIRS) {
|
|
604
|
+
const skillsPath = join(dir, skillDir);
|
|
605
|
+
let entries;
|
|
606
|
+
try {
|
|
607
|
+
entries = await readdir(skillsPath);
|
|
608
|
+
} catch {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
for (const entry of entries) {
|
|
612
|
+
const entryPath = join(skillsPath, entry);
|
|
613
|
+
const skill = await loadSkillFromDir(entryPath, "project");
|
|
614
|
+
if (skill) found.set(skill.name, skill);
|
|
615
|
+
else if (warn && existsSync(join(entryPath, "SKILL.md"))) warn(`Skill at ${entryPath} has a SKILL.md but is missing required frontmatter fields (name, description) — skill not loaded.`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return [...found.values()];
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Parse a skill spec string into a typed `SkillSpec` descriptor.
|
|
622
|
+
*
|
|
623
|
+
* Supported spec formats:
|
|
624
|
+
*
|
|
625
|
+
* | Input | Result |
|
|
626
|
+
* |------------------------------------|---------------------------------------------------|
|
|
627
|
+
* | `code-review` | `{ protocol: 'builtin', name: 'code-review' }` |
|
|
628
|
+
* | `npm:my-skill` | `{ protocol: 'npm', packageName: 'my-skill', ... }`|
|
|
629
|
+
* | `npm:@scope/pkg` | `{ protocol: 'npm', packageName: '@scope/pkg', ... }`|
|
|
630
|
+
* | `npm:@scope/bundle/security` | `{ protocol: 'npm', packageName: '@scope/bundle', subpath: 'security' }`|
|
|
631
|
+
* | `npm:bundle/security` | `{ protocol: 'npm', packageName: 'bundle', subpath: 'security' }`|
|
|
632
|
+
* | `file:./path/to/skill` | `{ protocol: 'file', path: './path/to/skill' }` |
|
|
633
|
+
* | `file:/absolute/path` | `{ protocol: 'file', path: '/absolute/path' }` |
|
|
634
|
+
* | `git:https://host/org/s.git` | `{ protocol: 'git', url: 'https://host/org/s.git', ref: '', subpath: '' }` |
|
|
635
|
+
* | `git:https://host/org/b.git#v1/sec`| `{ protocol: 'git', url: 'https://host/org/b.git', ref: 'v1', subpath: 'sec' }` |
|
|
636
|
+
* | `git+ssh://git@host/org/s.git` | `{ protocol: 'git', url: 'ssh://git@host/org/s.git', ref: '', subpath: '' }` |
|
|
637
|
+
* | `acme:dev/aria-apg` (acme known) | `{ protocol: 'marketplace', marketplace: 'acme', plugin: 'dev', skill: 'aria-apg' }` |
|
|
638
|
+
*
|
|
639
|
+
* A spec whose portion before the first `:` matches a registered marketplace
|
|
640
|
+
* name (from `knownMarketplaces`) is parsed as a `marketplace` reference. The
|
|
641
|
+
* remainder must be `<plugin>/<skill>` — an explicit skill is always required.
|
|
642
|
+
*/
|
|
643
|
+
function parseSkillSpec(spec, knownMarketplaces = /* @__PURE__ */ new Set()) {
|
|
644
|
+
if (spec.startsWith("file:")) return {
|
|
645
|
+
protocol: "file",
|
|
646
|
+
path: spec.slice(5)
|
|
647
|
+
};
|
|
648
|
+
if (spec.startsWith("npm:")) {
|
|
649
|
+
const rest = spec.slice(4);
|
|
650
|
+
if (rest.startsWith("@")) {
|
|
651
|
+
const parts = rest.split("/");
|
|
652
|
+
if (parts.length < 2) return {
|
|
653
|
+
protocol: "npm",
|
|
654
|
+
packageName: rest,
|
|
655
|
+
subpath: ""
|
|
656
|
+
};
|
|
657
|
+
return {
|
|
658
|
+
protocol: "npm",
|
|
659
|
+
packageName: `${parts[0]}/${parts[1]}`,
|
|
660
|
+
subpath: parts.slice(2).join("/")
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const slashIdx = rest.indexOf("/");
|
|
664
|
+
if (slashIdx === -1) return {
|
|
665
|
+
protocol: "npm",
|
|
666
|
+
packageName: rest,
|
|
667
|
+
subpath: ""
|
|
668
|
+
};
|
|
669
|
+
return {
|
|
670
|
+
protocol: "npm",
|
|
671
|
+
packageName: rest.slice(0, slashIdx),
|
|
672
|
+
subpath: rest.slice(slashIdx + 1)
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (spec.startsWith("git+") || spec.startsWith("git:")) return parseGitSpec(spec);
|
|
676
|
+
const colonIdx = spec.indexOf(":");
|
|
677
|
+
if (colonIdx > 0 && knownMarketplaces.has(spec.slice(0, colonIdx))) return parseMarketplaceSkillSpec(spec, colonIdx);
|
|
678
|
+
return {
|
|
679
|
+
protocol: "builtin",
|
|
680
|
+
name: spec
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Parse the `<marketplace>:<plugin>/<skill>` selector into its parts. The
|
|
685
|
+
* marketplace name has already been matched against the registry by the caller;
|
|
686
|
+
* `colonIdx` is the index of the separating `:`. Throws a `ConfigError` when the
|
|
687
|
+
* `<plugin>/<skill>` remainder is malformed (a skill name is always required).
|
|
688
|
+
*/
|
|
689
|
+
function parseMarketplaceSkillSpec(spec, colonIdx) {
|
|
690
|
+
const marketplace = spec.slice(0, colonIdx);
|
|
691
|
+
const rest = spec.slice(colonIdx + 1);
|
|
692
|
+
const slashIdx = rest.indexOf("/");
|
|
693
|
+
const plugin = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
|
|
694
|
+
const skill = slashIdx === -1 ? "" : rest.slice(slashIdx + 1);
|
|
695
|
+
if (!plugin || !skill || skill.includes("/")) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Marketplace skills use the form "<marketplace>:<plugin>/<skill>", e.g. "${marketplace}:dev/aria-apg".` });
|
|
696
|
+
return {
|
|
697
|
+
protocol: "marketplace",
|
|
698
|
+
marketplace,
|
|
699
|
+
plugin,
|
|
700
|
+
skill
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Parse a `git:` / `git+ssh:` skill spec into its URL, pinned ref, and subpath.
|
|
705
|
+
*
|
|
706
|
+
* - `git:<url>` strips the `git:` marker; what follows is the clone URL
|
|
707
|
+
* (e.g. `git:https://host/org/repo.git`).
|
|
708
|
+
* - `git+<transport>://…` strips the leading `git+`, leaving a URL git
|
|
709
|
+
* understands directly (`git+ssh://git@host/…` → `ssh://git@host/…`), matching
|
|
710
|
+
* npm's `package.json` git-dependency convention.
|
|
711
|
+
*
|
|
712
|
+
* An optional `#<ref>[/<subpath>]` fragment pins the ref (tag, branch, or
|
|
713
|
+
* commit) and, after the first `/`, points at a skill directory inside the repo.
|
|
714
|
+
*/
|
|
715
|
+
function parseGitSpec(spec) {
|
|
716
|
+
const raw = spec.startsWith("git+") ? spec.slice(4) : spec.slice(4);
|
|
717
|
+
let parsed;
|
|
718
|
+
try {
|
|
719
|
+
parsed = new URL(raw);
|
|
720
|
+
} catch {
|
|
721
|
+
return {
|
|
722
|
+
protocol: "git",
|
|
723
|
+
url: raw,
|
|
724
|
+
ref: "",
|
|
725
|
+
subpath: ""
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
const fragment = parsed.hash ? parsed.hash.slice(1) : "";
|
|
729
|
+
parsed.hash = "";
|
|
730
|
+
const url = parsed.toString();
|
|
731
|
+
const slashIdx = fragment.indexOf("/");
|
|
732
|
+
if (slashIdx === -1) return {
|
|
733
|
+
protocol: "git",
|
|
734
|
+
url,
|
|
735
|
+
ref: fragment,
|
|
736
|
+
subpath: ""
|
|
737
|
+
};
|
|
738
|
+
return {
|
|
739
|
+
protocol: "git",
|
|
740
|
+
url,
|
|
741
|
+
ref: fragment.slice(0, slashIdx),
|
|
742
|
+
subpath: fragment.slice(slashIdx + 1)
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Resolve the directory for an npm-installed skill by walking `node_modules`
|
|
747
|
+
* upward from `cwd` (supports monorepo hoisting). Returns the resolved path
|
|
748
|
+
* or `null` if the package / subpath cannot be found.
|
|
749
|
+
*/
|
|
750
|
+
async function resolveNpmSkillDir(packageName, subpath, cwd) {
|
|
751
|
+
let current = cwd;
|
|
752
|
+
while (true) {
|
|
753
|
+
const candidate = subpath ? join(current, "node_modules", packageName, subpath) : join(current, "node_modules", packageName);
|
|
754
|
+
if (existsSync(candidate)) return candidate;
|
|
755
|
+
const parent = dirname(current);
|
|
756
|
+
if (parent === current) break;
|
|
757
|
+
current = parent;
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
/** Strip a leading `git+` transport marker (`git+ssh://…` → `ssh://…`). */
|
|
762
|
+
function normalizeGitUrl(raw) {
|
|
763
|
+
return raw.startsWith("git+") ? raw.slice(4) : raw;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Remove embedded credentials from a URL so it is safe to show in logs, hints,
|
|
767
|
+
* and errors. `https://user:token@host/path` becomes `https://***@host/path`.
|
|
768
|
+
* Non-URL strings are returned with a best-effort `//user@` → `//***@` scrub.
|
|
769
|
+
*/
|
|
770
|
+
function redactUrl(url) {
|
|
771
|
+
try {
|
|
772
|
+
const parsed = new URL(url);
|
|
773
|
+
if (parsed.username || parsed.password) {
|
|
774
|
+
parsed.username = "***";
|
|
775
|
+
parsed.password = "";
|
|
776
|
+
}
|
|
777
|
+
return parsed.toString();
|
|
778
|
+
} catch {
|
|
779
|
+
return url.replace(/\/\/[^/@]+@/, "//***@");
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
|
|
783
|
+
function resolveSkillCacheDir() {
|
|
784
|
+
return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Stable cache-directory name for a git skill. Keyed on the clone URL plus the
|
|
788
|
+
* pinned ref so that two refs of the same repo never share a cache entry.
|
|
789
|
+
*/
|
|
790
|
+
function gitSkillCacheKey(url, ref) {
|
|
791
|
+
return createHash("sha256").update(`${url}#${ref}`).digest("hex").slice(0, 16);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Shallow-clone `url` at `ref` into `dir`. Using init + a single-ref fetch +
|
|
795
|
+
* `checkout FETCH_HEAD` (rather than `clone --branch`) means a branch, tag, or
|
|
796
|
+
* commit SHA all resolve through the same path; an empty `ref` fetches the
|
|
797
|
+
* remote's default branch via `HEAD`.
|
|
798
|
+
*/
|
|
799
|
+
async function gitShallowClone(url, ref, dir) {
|
|
800
|
+
await git$1([
|
|
801
|
+
"init",
|
|
802
|
+
"--quiet",
|
|
803
|
+
dir
|
|
804
|
+
]);
|
|
805
|
+
await git$1([
|
|
806
|
+
"remote",
|
|
807
|
+
"add",
|
|
808
|
+
"origin",
|
|
809
|
+
url
|
|
810
|
+
], { cwd: dir });
|
|
811
|
+
await git$1([
|
|
812
|
+
"fetch",
|
|
813
|
+
"--depth",
|
|
814
|
+
"1",
|
|
815
|
+
"--quiet",
|
|
816
|
+
"origin",
|
|
817
|
+
ref || "HEAD"
|
|
818
|
+
], { cwd: dir });
|
|
819
|
+
await git$1([
|
|
820
|
+
"checkout",
|
|
821
|
+
"--quiet",
|
|
822
|
+
"FETCH_HEAD"
|
|
823
|
+
], { cwd: dir });
|
|
824
|
+
}
|
|
825
|
+
/** Monotonic counter making each clone's temp dir unique within the process. */
|
|
826
|
+
var cloneSeq = 0;
|
|
827
|
+
/**
|
|
828
|
+
* Resolve a git URL + ref to a local clone directory, reusing the on-disk cache
|
|
829
|
+
* when possible. The clone lands in a temp sibling first and is renamed into
|
|
830
|
+
* place atomically, so a crashed or concurrent clone never leaves a half-written
|
|
831
|
+
* cache entry. With `refresh`, any cached copy is discarded first. Shared by
|
|
832
|
+
* `git:` skills and Claude-plugin marketplaces (see `marketplaces.ts`).
|
|
833
|
+
*/
|
|
834
|
+
async function cloneGitRepo(url, ref, options) {
|
|
835
|
+
const repoDir = join(options.cacheDir, gitSkillCacheKey(url, ref));
|
|
836
|
+
if (options.refresh) await rm(repoDir, {
|
|
837
|
+
recursive: true,
|
|
838
|
+
force: true
|
|
839
|
+
});
|
|
840
|
+
else if (existsSync(join(repoDir, ".git"))) return repoDir;
|
|
841
|
+
else if (existsSync(repoDir)) await rm(repoDir, {
|
|
842
|
+
recursive: true,
|
|
843
|
+
force: true
|
|
844
|
+
});
|
|
845
|
+
await mkdir(options.cacheDir, { recursive: true });
|
|
846
|
+
const tmpDir = `${repoDir}.tmp-${process.pid}-${cloneSeq += 1}`;
|
|
847
|
+
await rm(tmpDir, {
|
|
848
|
+
recursive: true,
|
|
849
|
+
force: true
|
|
850
|
+
});
|
|
851
|
+
try {
|
|
852
|
+
await gitShallowClone(url, ref, tmpDir);
|
|
853
|
+
try {
|
|
854
|
+
await rename(tmpDir, repoDir);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
if (existsSync(join(repoDir, ".git"))) {
|
|
857
|
+
await rm(tmpDir, {
|
|
858
|
+
recursive: true,
|
|
859
|
+
force: true
|
|
860
|
+
});
|
|
861
|
+
return repoDir;
|
|
862
|
+
}
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
} catch (error) {
|
|
866
|
+
await rm(tmpDir, {
|
|
867
|
+
recursive: true,
|
|
868
|
+
force: true
|
|
869
|
+
});
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
return repoDir;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Load a skill by its spec string (`code-review`, `npm:@scope/pkg`, `file:./path`,
|
|
876
|
+
* `git:https://…`, …).
|
|
877
|
+
*
|
|
878
|
+
* Resolution order:
|
|
879
|
+
* 1. `builtin` — package-bundled `skills/<name>/`
|
|
880
|
+
* 2. `npm:` — `node_modules/<packageName>[/subpath]` walked up from `cwd`
|
|
881
|
+
* 3. `file:` — direct filesystem path (relative paths resolved from `cwd`)
|
|
882
|
+
* 4. `git:` / `git+ssh:` — shallow clone at the pinned ref, cached on disk,
|
|
883
|
+
* loading `SKILL.md` from the repo root or the `#<ref>/<subpath>` directory
|
|
884
|
+
*
|
|
885
|
+
* Throws a `ConfigError` if the spec cannot be resolved or the resolved
|
|
886
|
+
* directory does not contain a valid `SKILL.md`.
|
|
887
|
+
*/
|
|
888
|
+
async function loadNamedSkill(spec, cwd, options = {}) {
|
|
889
|
+
const parsed = parseSkillSpec(spec);
|
|
890
|
+
if (parsed.protocol === "builtin") {
|
|
891
|
+
const skill = await loadBuiltinSkill(parsed.name);
|
|
892
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No built-in skill named "${parsed.name}" was found. Check the skill name, or use npm: / file: to reference external skills.` });
|
|
893
|
+
return skill;
|
|
894
|
+
}
|
|
895
|
+
if (parsed.protocol === "npm") {
|
|
896
|
+
const dir = await resolveNpmSkillDir(parsed.packageName, parsed.subpath, cwd);
|
|
897
|
+
if (dir === null) {
|
|
898
|
+
const pkgRef = parsed.subpath ? `${parsed.packageName} (subpath "${parsed.subpath}")` : parsed.packageName;
|
|
899
|
+
throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Package ${pkgRef} was not found in node_modules. Run \`npm install ${parsed.packageName}\` in the project.` });
|
|
900
|
+
}
|
|
901
|
+
const skill = await loadSkillFromDir(dir, "npm");
|
|
902
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
|
|
903
|
+
return skill;
|
|
904
|
+
}
|
|
905
|
+
if (parsed.protocol === "file") {
|
|
906
|
+
const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
|
|
907
|
+
const skill = await loadSkillFromDir(resolvedPath, "file");
|
|
908
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No valid SKILL.md was found at "${resolvedPath}". Check that the path points to a skill directory.` });
|
|
909
|
+
return skill;
|
|
910
|
+
}
|
|
911
|
+
if (parsed.protocol === "marketplace") throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: "Marketplace skills must be resolved through a registered marketplace, not loaded directly." });
|
|
912
|
+
let repoDir;
|
|
913
|
+
try {
|
|
914
|
+
repoDir = await cloneGitRepo(parsed.url, parsed.ref, {
|
|
915
|
+
cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
|
|
916
|
+
refresh: options.refresh ?? false
|
|
917
|
+
});
|
|
918
|
+
} catch (error) {
|
|
919
|
+
const atRef = parsed.ref ? ` at ref "${parsed.ref}"` : "";
|
|
920
|
+
throw new ConfigError(`Cannot load skill: "${spec}"`, {
|
|
921
|
+
cause: error,
|
|
922
|
+
hint: `Failed to clone "${redactUrl(parsed.url)}"${atRef}. Check the URL, the ref, and your git credentials. For GitLab, prefer the SSH form: git+ssh://git@host/group/project.git`
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
|
|
926
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: parsed.subpath ? `The cloned repository has no valid SKILL.md at subpath "${parsed.subpath}".` : "The cloned repository has no valid SKILL.md at its root. If the skill lives in a subdirectory, point at it with \"#<ref>/<subpath>\"." });
|
|
927
|
+
return skill;
|
|
928
|
+
}
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region src/marketplaces.ts
|
|
931
|
+
/**
|
|
932
|
+
* Marketplace manifest formats we know how to read. Different vendors ship the
|
|
933
|
+
* same open SKILL.md standard but package it differently: Claude Code uses a
|
|
934
|
+
* `.claude-plugin/marketplace.json` catalog (`anthropic`), OpenAI Codex uses a
|
|
935
|
+
* `.codex-plugin/` layout (a future `codex` format). Only `anthropic` is
|
|
936
|
+
* implemented today; the type is the extension point for the rest.
|
|
937
|
+
*/
|
|
938
|
+
var MARKETPLACE_FORMATS = ["anthropic"];
|
|
939
|
+
/** The default format assumed when a marketplace declaration omits a prefix. */
|
|
940
|
+
var DEFAULT_MARKETPLACE_FORMAT = "anthropic";
|
|
941
|
+
/**
|
|
942
|
+
* Marketplace names that collide with skill-spec protocol prefixes
|
|
943
|
+
* (`npm:`, `file:`, `git:`) or the bare-name builtin lookup, and so cannot be
|
|
944
|
+
* used as a marketplace name — `<name>:<plugin>/<skill>` would be ambiguous.
|
|
945
|
+
*/
|
|
946
|
+
var RESERVED_MARKETPLACE_NAMES = new Set([
|
|
947
|
+
"npm",
|
|
948
|
+
"file",
|
|
949
|
+
"git",
|
|
950
|
+
"builtin"
|
|
951
|
+
]);
|
|
952
|
+
var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
953
|
+
var FORMAT_PREFIX_PATTERN = /^([a-z][a-z0-9-]*):(?!\/\/)/;
|
|
954
|
+
/**
|
|
955
|
+
* Parse a single marketplace declaration: `<name>=<[format:]url[#ref]>`.
|
|
956
|
+
*
|
|
957
|
+
* - `acme=https://host/group/tools.git#1.0.0` → default `anthropic` format
|
|
958
|
+
* - `acme=anthropic:https://host/group/tools.git#1.0.0` → explicit format
|
|
959
|
+
* - `acme=git+ssh://git@host/group/tools.git#main` → SSH transport
|
|
960
|
+
*
|
|
961
|
+
* The `#<ref>` fragment is taken whole as the ref (branch names may contain
|
|
962
|
+
* `/`). Throws a `ConfigError` with an actionable hint on any malformed input.
|
|
963
|
+
*/
|
|
964
|
+
function parseMarketplaceEntry(entry) {
|
|
965
|
+
const eqIdx = entry.indexOf("=");
|
|
966
|
+
if (eqIdx <= 0) throw new ConfigError(`Invalid marketplace declaration: "${redactUrl(entry)}"`, { hint: "Use <name>=<git-url>[#ref], e.g. acme=https://host/group/tools.git#1.0.0." });
|
|
967
|
+
const name = entry.slice(0, eqIdx).trim();
|
|
968
|
+
if (!NAME_PATTERN.test(name)) throw new ConfigError(`Invalid marketplace name: "${name}"`, { hint: "Names must start with a letter or digit and contain only letters, digits, \".\", \"_\", or \"-\"." });
|
|
969
|
+
if (RESERVED_MARKETPLACE_NAMES.has(name)) throw new ConfigError(`Marketplace name "${name}" is reserved`, { hint: `Choose another name — ${[...RESERVED_MARKETPLACE_NAMES].join(", ")} collide with skill-spec protocols.` });
|
|
970
|
+
let rhs = entry.slice(eqIdx + 1).trim();
|
|
971
|
+
let format = DEFAULT_MARKETPLACE_FORMAT;
|
|
972
|
+
const formatMatch = rhs.match(FORMAT_PREFIX_PATTERN);
|
|
973
|
+
if (formatMatch) {
|
|
974
|
+
const token = formatMatch[1];
|
|
975
|
+
if (!MARKETPLACE_FORMATS.includes(token)) throw new ConfigError(`Unknown marketplace format "${token}" in "${redactUrl(entry)}"`, { hint: `Supported formats: ${MARKETPLACE_FORMATS.join(", ")}. Omit the prefix (e.g. "${name}=https://…") to default to "${DEFAULT_MARKETPLACE_FORMAT}".` });
|
|
976
|
+
format = token;
|
|
977
|
+
rhs = rhs.slice(formatMatch[0].length);
|
|
978
|
+
}
|
|
979
|
+
let parsed;
|
|
980
|
+
try {
|
|
981
|
+
parsed = new URL(normalizeGitUrl(rhs));
|
|
982
|
+
} catch {
|
|
983
|
+
throw new ConfigError(`Invalid marketplace URL for "${name}": "${redactUrl(rhs)}"`, { hint: "Expected a git URL like https://host/group/tools.git or git+ssh://git@host/group/tools.git. scp-style (git@host:group/repo.git) is not supported — use the ssh:// form." });
|
|
984
|
+
}
|
|
985
|
+
const ref = parsed.hash ? parsed.hash.slice(1) : "";
|
|
986
|
+
parsed.hash = "";
|
|
987
|
+
return {
|
|
988
|
+
name,
|
|
989
|
+
format,
|
|
990
|
+
url: parsed.toString(),
|
|
991
|
+
ref
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Build a name→ref registry from parsed marketplace declarations, rejecting
|
|
996
|
+
* duplicate names (a later entry silently shadowing an earlier one is a config
|
|
997
|
+
* bug worth surfacing).
|
|
998
|
+
*/
|
|
999
|
+
function buildMarketplaceRegistry(refs) {
|
|
1000
|
+
const registry = /* @__PURE__ */ new Map();
|
|
1001
|
+
for (const ref of refs) {
|
|
1002
|
+
if (registry.has(ref.name)) throw new ConfigError(`Duplicate marketplace name "${ref.name}"`, { hint: "Each marketplace must be declared once. Remove the duplicate declaration." });
|
|
1003
|
+
registry.set(ref.name, ref);
|
|
1004
|
+
}
|
|
1005
|
+
return registry;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Resolve a `<marketplace>:<plugin>/<skill>` reference to a loaded {@link Skill}.
|
|
1009
|
+
*
|
|
1010
|
+
* Clones the marketplace repo (reusing the shared on-disk clone cache), then
|
|
1011
|
+
* dispatches to the format-specific resolver. Throws a `ConfigError` with a
|
|
1012
|
+
* redacted, actionable hint when the marketplace, plugin, or skill cannot be
|
|
1013
|
+
* resolved — the caller treats that as a skip-and-warn, not a fatal error.
|
|
1014
|
+
*/
|
|
1015
|
+
async function loadMarketplaceSkill(spec, registry, options = {}) {
|
|
1016
|
+
const ref = `${spec.marketplace}:${spec.plugin}/${spec.skill}`;
|
|
1017
|
+
const mp = registry.get(spec.marketplace);
|
|
1018
|
+
if (!mp) throw new ConfigError(`Cannot load skill: "${ref}"`, { hint: `No marketplace named "${spec.marketplace}" is registered. Declare it with CODE_REVIEW_MARKETPLACES or --marketplace.` });
|
|
1019
|
+
let repoDir;
|
|
1020
|
+
try {
|
|
1021
|
+
repoDir = await cloneGitRepo(mp.url, mp.ref, {
|
|
1022
|
+
cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
|
|
1023
|
+
refresh: options.refresh ?? false
|
|
1024
|
+
});
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
const atRef = mp.ref ? ` at ref "${mp.ref}"` : "";
|
|
1027
|
+
throw new ConfigError(`Cannot load marketplace "${mp.name}"`, {
|
|
1028
|
+
cause: error,
|
|
1029
|
+
hint: `Failed to clone "${redactUrl(mp.url)}"${atRef}. Check the URL, the ref, and your git credentials. For private GitLab, prefer git+ssh://git@host/group/project.git.`
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
switch (mp.format) {
|
|
1033
|
+
case "anthropic": return resolveAnthropicSkill(repoDir, mp, spec);
|
|
1034
|
+
default: {
|
|
1035
|
+
const exhaustive = mp.format;
|
|
1036
|
+
throw new ConfigError(`Unsupported marketplace format: "${String(exhaustive)}"`, { hint: `Supported formats: ${MARKETPLACE_FORMATS.join(", ")}.` });
|
|
1037
|
+
}
|
|
377
1038
|
}
|
|
378
|
-
}
|
|
1039
|
+
}
|
|
1040
|
+
/** Resolve a skill inside a Claude Code (`anthropic`) plugin marketplace. */
|
|
1041
|
+
async function resolveAnthropicSkill(repoDir, mp, spec) {
|
|
1042
|
+
const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
|
|
1043
|
+
const realRoot = await realpath(repoDir);
|
|
1044
|
+
const manifestRaw = await readTextInside(realRoot, join(repoDir, ".claude-plugin", "marketplace.json"), ref);
|
|
1045
|
+
if (manifestRaw === null) throw new ConfigError(`Marketplace "${mp.name}" is not a valid Anthropic plugin marketplace`, { hint: `Expected a readable .claude-plugin/marketplace.json at "${redactUrl(mp.url)}"${mp.ref ? ` (ref "${mp.ref}")` : ""}.` });
|
|
1046
|
+
let manifest;
|
|
1047
|
+
try {
|
|
1048
|
+
manifest = JSON.parse(manifestRaw);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
throw new ConfigError(`Marketplace "${mp.name}" is not a valid Anthropic plugin marketplace`, {
|
|
1051
|
+
cause: error,
|
|
1052
|
+
hint: `.claude-plugin/marketplace.json at "${redactUrl(mp.url)}" is not valid JSON.`
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
|
|
1056
|
+
const entry = plugins.find((p) => p && p.name === spec.plugin);
|
|
1057
|
+
if (!entry) {
|
|
1058
|
+
const available = plugins.map((p) => typeof p?.name === "string" ? p.name : null).filter((n) => Boolean(n));
|
|
1059
|
+
throw new ConfigError(`Plugin "${spec.plugin}" not found in marketplace "${mp.name}"`, { hint: available.length ? `Available plugins: ${available.join(", ")}.` : "The marketplace lists no plugins." });
|
|
1060
|
+
}
|
|
1061
|
+
const pluginDir = resolvePluginDir(repoDir, manifest, entry.source, mp, spec);
|
|
1062
|
+
const realPluginDir = await resolveInside(realRoot, pluginDir, ref);
|
|
1063
|
+
const bases = computeSkillBases(repoDir, pluginDir, entry, entry.strict !== false && realPluginDir ? await readPluginManifest(realRoot, realPluginDir, ref) : null, ref);
|
|
1064
|
+
const skill = await findSkillInBases(realRoot, bases, pluginDir, spec.skill, ref);
|
|
1065
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${ref}"`, { hint: `No skill "${spec.skill}" found in plugin "${spec.plugin}". Looked under the default skills/ directory${bases.length > 1 ? " and the plugin's custom \"skills\" paths" : ""}. Check the skill name against the marketplace.` });
|
|
1066
|
+
return skill;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Read a plugin's optional `.claude-plugin/plugin.json`. A missing file returns
|
|
1070
|
+
* `null` (the manifest is optional), but a malformed one throws a `ConfigError`
|
|
1071
|
+
* rather than being silently ignored — otherwise a typo would quietly drop the
|
|
1072
|
+
* plugin's declared `skills` paths.
|
|
1073
|
+
*/
|
|
1074
|
+
async function readPluginManifest(realRoot, pluginDir, ref) {
|
|
1075
|
+
const raw = await readTextInside(realRoot, join(pluginDir, ".claude-plugin", "plugin.json"), ref);
|
|
1076
|
+
if (raw === null) return null;
|
|
1077
|
+
try {
|
|
1078
|
+
return JSON.parse(raw);
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
throw new ConfigError(`Malformed plugin.json for "${ref}"`, {
|
|
1081
|
+
cause: error,
|
|
1082
|
+
hint: "The plugin's .claude-plugin/plugin.json is not valid JSON. Fix it in the marketplace repository."
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
/** Normalize a `skills`/`commands` manifest field (string | string[]) to a path list. */
|
|
1087
|
+
function toPathArray(value) {
|
|
1088
|
+
return (typeof value === "string" ? [value] : Array.isArray(value) ? value : []).filter((v) => typeof v === "string").map((v) => v.trim()).filter(Boolean);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Compute the directories to search for a named skill, per the plugin schema:
|
|
1092
|
+
* the default `<plugin>/skills/` scan plus any directories declared in the
|
|
1093
|
+
* `skills` field of the marketplace entry and (unless `strict: false`) the
|
|
1094
|
+
* plugin's own `plugin.json`. When the plugin source resolves to the marketplace
|
|
1095
|
+
* root and the entry lists specific `skills` subdirectories, those replace the
|
|
1096
|
+
* default scan rather than adding to it.
|
|
1097
|
+
*/
|
|
1098
|
+
function computeSkillBases(repoDir, pluginDir, entry, pluginManifest, ref) {
|
|
1099
|
+
const strict = entry.strict !== false;
|
|
1100
|
+
const entrySkills = toPathArray(entry.skills);
|
|
1101
|
+
const custom = (strict ? [...entrySkills, ...toPathArray(pluginManifest?.skills)] : entrySkills).map((p) => resolveUnderPlugin(repoDir, pluginDir, p, ref));
|
|
1102
|
+
const bases = resolve(pluginDir) === resolve(repoDir) && entrySkills.length > 0 ? custom : [join(pluginDir, "skills"), ...custom];
|
|
1103
|
+
return [...new Set(bases)];
|
|
1104
|
+
}
|
|
1105
|
+
/** Resolve a plugin-relative `skills` path to an absolute dir, guarding escapes. */
|
|
1106
|
+
function resolveUnderPlugin(repoDir, pluginDir, rel, ref) {
|
|
1107
|
+
let p = rel.trim();
|
|
1108
|
+
if (p === "." || p === "./") p = "";
|
|
1109
|
+
else if (p.startsWith("./")) p = p.slice(2);
|
|
1110
|
+
const dir = join(pluginDir, p);
|
|
1111
|
+
ensureInside(repoDir, dir, ref);
|
|
1112
|
+
return dir;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Find `skillName` across the candidate base directories. Each base is either a
|
|
1116
|
+
* container of skill sub-directories (`<base>/<skill>/SKILL.md`) or a single
|
|
1117
|
+
* skill directory (`<base>/SKILL.md`, matched by its frontmatter `name`). Falls
|
|
1118
|
+
* back to a single `SKILL.md` at the plugin root (single-skill plugins).
|
|
1119
|
+
*
|
|
1120
|
+
* Every candidate is verified with {@link loadSkillIfInside} to resolve through
|
|
1121
|
+
* symlinks and reject any directory that escapes the cloned marketplace before a
|
|
1122
|
+
* file is read.
|
|
1123
|
+
*/
|
|
1124
|
+
async function findSkillInBases(realRoot, bases, pluginDir, skillName, ref) {
|
|
1125
|
+
for (const base of bases) {
|
|
1126
|
+
const container = await loadSkillIfInside(realRoot, join(base, skillName), ref);
|
|
1127
|
+
if (container) return container;
|
|
1128
|
+
const single = await loadSkillIfInside(realRoot, base, ref);
|
|
1129
|
+
if (single && single.name === skillName) return single;
|
|
1130
|
+
}
|
|
1131
|
+
const root = await loadSkillIfInside(realRoot, pluginDir, ref);
|
|
1132
|
+
return root && root.name === skillName ? root : null;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Resolve `dir` through symlinks and confirm it stays within `realRoot`. Returns
|
|
1136
|
+
* the real path, or `null` when the directory does not exist (nothing to read).
|
|
1137
|
+
* A directory that resolves — via `..` or a symlink — outside the marketplace
|
|
1138
|
+
* throws a `ConfigError`, so a malicious manifest cannot reach outside the clone.
|
|
1139
|
+
*/
|
|
1140
|
+
async function resolveInside(realRoot, dir, ref) {
|
|
1141
|
+
let real;
|
|
1142
|
+
try {
|
|
1143
|
+
real = await realpath(dir);
|
|
1144
|
+
} catch {
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
assertInside(realRoot, real, ref);
|
|
1148
|
+
return real;
|
|
1149
|
+
}
|
|
1150
|
+
/** Throw if `real` (an already-resolved real path) is not within `realRoot`. */
|
|
1151
|
+
function assertInside(realRoot, real, ref) {
|
|
1152
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) throw new ConfigError(`Refusing to load "${ref}": path escapes the marketplace repository`, { hint: "A plugin \"source\", manifest, or skill path resolved (via \"..\" or a symlink) outside the cloned marketplace." });
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Read a file only if its real path (following any symlink on the file itself,
|
|
1156
|
+
* not just its parent directory) stays within `realRoot`. Returns `null` when
|
|
1157
|
+
* the file does not exist; throws a `ConfigError` when it resolves outside the
|
|
1158
|
+
* marketplace, so a symlinked `SKILL.md` / manifest cannot exfiltrate an outside
|
|
1159
|
+
* file's contents.
|
|
1160
|
+
*/
|
|
1161
|
+
async function readTextInside(realRoot, filePath, ref) {
|
|
1162
|
+
let real;
|
|
1163
|
+
try {
|
|
1164
|
+
real = await realpath(filePath);
|
|
1165
|
+
} catch {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
assertInside(realRoot, real, ref);
|
|
1169
|
+
return readFile(real, "utf8");
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Load a skill from `dir` only if both the directory AND its `SKILL.md` file
|
|
1173
|
+
* resolve within `realRoot`. Guarding the file (not just the directory) closes
|
|
1174
|
+
* the case where a legitimate in-repo dir holds a `SKILL.md` symlinked outside.
|
|
1175
|
+
*/
|
|
1176
|
+
async function loadSkillIfInside(realRoot, dir, ref) {
|
|
1177
|
+
const real = await resolveInside(realRoot, dir, ref);
|
|
1178
|
+
if (!real) return null;
|
|
1179
|
+
if (await readTextInside(realRoot, join(real, "SKILL.md"), ref) === null) return null;
|
|
1180
|
+
return loadSkillFromDir(real, "marketplace");
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Resolve a plugin entry's `source` to an absolute directory inside the cloned
|
|
1184
|
+
* marketplace. Honors `metadata.pluginRoot` for bare (non-`./`) sources, per the
|
|
1185
|
+
* marketplace schema. Remote sources (github/url/git-subdir/npm) are rejected —
|
|
1186
|
+
* we only resolve plugins that live in the marketplace repository itself.
|
|
1187
|
+
*/
|
|
1188
|
+
function resolvePluginDir(repoDir, manifest, source, mp, spec) {
|
|
1189
|
+
const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
|
|
1190
|
+
if (typeof source !== "string" || !source.trim()) throw new ConfigError(`Plugin "${spec.plugin}" in marketplace "${mp.name}" has no local source`, { hint: "Only plugins whose \"source\" is a relative path within the marketplace repo are supported. Remote plugin sources (github/url/git-subdir/npm) are not fetched." });
|
|
1191
|
+
let rel = source.trim();
|
|
1192
|
+
if (rel === "." || rel === "./") rel = "";
|
|
1193
|
+
else if (rel.startsWith("./")) rel = rel.slice(2);
|
|
1194
|
+
else {
|
|
1195
|
+
const pluginRoot = typeof manifest.metadata?.pluginRoot === "string" ? manifest.metadata.pluginRoot.trim() : "";
|
|
1196
|
+
const root = pluginRoot.startsWith("./") ? pluginRoot.slice(2) : pluginRoot;
|
|
1197
|
+
rel = root ? `${root.replace(/\/+$/, "")}/${rel}` : rel;
|
|
1198
|
+
}
|
|
1199
|
+
const dir = join(repoDir, rel);
|
|
1200
|
+
ensureInside(repoDir, dir, ref);
|
|
1201
|
+
return dir;
|
|
1202
|
+
}
|
|
1203
|
+
/** Guard against `..`/symlink escapes: `target` must stay within `root`. */
|
|
1204
|
+
function ensureInside(root, target, ref) {
|
|
1205
|
+
const rootResolved = resolve(root);
|
|
1206
|
+
const targetResolved = resolve(target);
|
|
1207
|
+
if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + sep)) throw new ConfigError(`Refusing to load "${ref}": path escapes the marketplace repository`, { hint: "A plugin \"source\" or skill path resolved outside the cloned marketplace. Check the marketplace manifest." });
|
|
1208
|
+
}
|
|
379
1209
|
//#endregion
|
|
380
1210
|
//#region src/fingerprints.ts
|
|
381
1211
|
/**
|
|
@@ -560,7 +1390,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
|
|
|
560
1390
|
return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
|
|
561
1391
|
}
|
|
562
1392
|
function buildReviewedCommitFooter(commitSha) {
|
|
563
|
-
return `Reviewed by ${PRODUCT_LINK} v0.9.
|
|
1393
|
+
return `Reviewed by ${PRODUCT_LINK} v0.9.5 for commit ${commitSha}.`;
|
|
564
1394
|
}
|
|
565
1395
|
function extractReviewedCommitSha(body) {
|
|
566
1396
|
return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
|
|
@@ -896,6 +1726,7 @@ var RESERVED_ENV_SUFFIXES = [
|
|
|
896
1726
|
"FORCE_REVIEW",
|
|
897
1727
|
"VERBOSE",
|
|
898
1728
|
"SKILLS",
|
|
1729
|
+
"MARKETPLACES",
|
|
899
1730
|
"REFRESH_SKILLS",
|
|
900
1731
|
"THINKING_LEVEL"
|
|
901
1732
|
];
|
|
@@ -964,7 +1795,7 @@ var BOOLEAN_FLAGS = new Set([
|
|
|
964
1795
|
"help",
|
|
965
1796
|
"version"
|
|
966
1797
|
]);
|
|
967
|
-
var MULTI_FLAGS = new Set(["skill"]);
|
|
1798
|
+
var MULTI_FLAGS = new Set(["skill", "marketplace"]);
|
|
968
1799
|
function parseArgs(argv) {
|
|
969
1800
|
const args = {};
|
|
970
1801
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -1084,6 +1915,16 @@ function resolveSkills(args, env) {
|
|
|
1084
1915
|
return [];
|
|
1085
1916
|
}
|
|
1086
1917
|
/**
|
|
1918
|
+
* Resolve marketplace declarations from `--marketplace` (repeatable, preferred)
|
|
1919
|
+
* or the comma-separated `CODE_REVIEW_MARKETPLACES`. Each entry is
|
|
1920
|
+
* `<name>=<[format:]url[#ref]>`; parsing throws a `ConfigError` on malformed
|
|
1921
|
+
* entries, reserved names, or unknown formats (fail fast on misconfiguration).
|
|
1922
|
+
*/
|
|
1923
|
+
function resolveMarketplaces(args, env) {
|
|
1924
|
+
const argMarketplace = args.marketplace;
|
|
1925
|
+
return (Array.isArray(argMarketplace) ? argMarketplace : typeof argMarketplace === "string" && argMarketplace.length > 0 ? [argMarketplace] : (env.CODE_REVIEW_MARKETPLACES ?? "").split(",")).map((entry) => entry.trim()).filter(Boolean).map((entry) => parseMarketplaceEntry(entry));
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1087
1928
|
* Resolve the model pool from `--model-pool` (preferred) or
|
|
1088
1929
|
* `CODE_REVIEW_MODEL_POOL`. Both are comma-separated `provider/modelId` lists.
|
|
1089
1930
|
* Entries are trimmed and empty entries dropped. Returns `[]` when unset, which
|
|
@@ -1248,6 +2089,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
1248
2089
|
verbose: toBoolean(args.verbose) || toBoolean(env.CODE_REVIEW_VERBOSE),
|
|
1249
2090
|
cwd: String(args.cwd ?? process.cwd()),
|
|
1250
2091
|
skills: resolveSkills(args, env),
|
|
2092
|
+
marketplaces: resolveMarketplaces(args, env),
|
|
1251
2093
|
refreshGitSkills: toBoolean(env.CODE_REVIEW_REFRESH_SKILLS)
|
|
1252
2094
|
};
|
|
1253
2095
|
}
|
|
@@ -1372,199 +2214,37 @@ function createDiagnosticContext(phase, config, runId, overrides = {}) {
|
|
|
1372
2214
|
};
|
|
1373
2215
|
}
|
|
1374
2216
|
async function traceDiagnostic(channel, context, operation, secretValues = []) {
|
|
1375
|
-
const started = performance.now();
|
|
1376
|
-
return channel.tracePromise(async () => {
|
|
1377
|
-
try {
|
|
1378
|
-
return await operation(context);
|
|
1379
|
-
} catch (error) {
|
|
1380
|
-
context.errorInfo = toDiagnosticError(error, secretValues);
|
|
1381
|
-
throw error;
|
|
1382
|
-
} finally {
|
|
1383
|
-
context.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1384
|
-
context.durationMs = Number((performance.now() - started).toFixed(3));
|
|
1385
|
-
}
|
|
1386
|
-
}, context);
|
|
1387
|
-
}
|
|
1388
|
-
function traceDiagnosticPhase(phase, config, runId, operation, overrides = {}) {
|
|
1389
|
-
const context = createDiagnosticContext(phase, config, runId, overrides);
|
|
1390
|
-
return traceDiagnostic(tracingChannel(`${DIAGNOSTIC_CHANNEL_PREFIX}:${phase}`), context, operation, collectSecrets(config));
|
|
1391
|
-
}
|
|
1392
|
-
function toDiagnosticError(error, secretValues = []) {
|
|
1393
|
-
if (error instanceof Error) {
|
|
1394
|
-
const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
1395
|
-
const timeout = "timeout" in error && error.timeout === true ? true : void 0;
|
|
1396
|
-
const status = "status" in error && typeof error.status === "number" ? error.status : void 0;
|
|
1397
|
-
return {
|
|
1398
|
-
name: error.name,
|
|
1399
|
-
message: scrubSecrets(error.message, secretValues),
|
|
1400
|
-
code,
|
|
1401
|
-
timeout,
|
|
1402
|
-
status
|
|
1403
|
-
};
|
|
1404
|
-
}
|
|
1405
|
-
return { message: scrubSecrets(String(error), secretValues) };
|
|
1406
|
-
}
|
|
1407
|
-
//#endregion
|
|
1408
|
-
//#region src/git.ts
|
|
1409
|
-
var exec$1 = promisify(execFile);
|
|
1410
|
-
var DEFAULT_DIFF_CONTEXT = 20;
|
|
1411
|
-
var DEFAULT_CODEQUALITY_ARTIFACTS = [
|
|
1412
|
-
"gl-code-quality-report.json",
|
|
1413
|
-
"codequality.json",
|
|
1414
|
-
"codeclimate.json",
|
|
1415
|
-
"code-quality-report.json"
|
|
1416
|
-
];
|
|
1417
|
-
function gitErrorMessage(error) {
|
|
1418
|
-
const err = error;
|
|
1419
|
-
return [
|
|
1420
|
-
err.message,
|
|
1421
|
-
err.stderr,
|
|
1422
|
-
err.stdout
|
|
1423
|
-
].filter(Boolean).join("\n").trim();
|
|
1424
|
-
}
|
|
1425
|
-
async function git$1(args, options = {}) {
|
|
1426
|
-
try {
|
|
1427
|
-
const { stdout } = await exec$1("git", args, {
|
|
1428
|
-
cwd: options.cwd,
|
|
1429
|
-
maxBuffer: 50 * 1024 * 1024
|
|
1430
|
-
});
|
|
1431
|
-
return stdout;
|
|
1432
|
-
} catch (error) {
|
|
1433
|
-
throw new GitError(`git ${args.join(" ")} failed.`, {
|
|
1434
|
-
cause: error,
|
|
1435
|
-
hint: gitErrorMessage(error)
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
function remoteRef(remote, branch) {
|
|
1440
|
-
return `refs/remotes/${remote}/${branch}`;
|
|
1441
|
-
}
|
|
1442
|
-
function getMergeDiffArguments(targetBranch, options = {}) {
|
|
1443
|
-
const remote = options.remote ?? "origin";
|
|
1444
|
-
const context = options.context ?? DEFAULT_DIFF_CONTEXT;
|
|
1445
|
-
return [
|
|
1446
|
-
`${remoteRef(remote, targetBranch)}...HEAD`,
|
|
1447
|
-
`--unified=${context}`,
|
|
1448
|
-
"--"
|
|
1449
|
-
];
|
|
1450
|
-
}
|
|
1451
|
-
/**
|
|
1452
|
-
* Full git argv for the merge diff. `-c core.quotepath=false` keeps non-ASCII
|
|
1453
|
-
* paths literal instead of git's default octal-escaped + double-quoted form
|
|
1454
|
-
* (`"a/caf\303\251.ts"`), which the comment-position parser cannot match —
|
|
1455
|
-
* leaving such comments with invalid one-sided positions that 500 on
|
|
1456
|
-
* `bulk_publish`. Diffing literally also gives the reviewer readable paths.
|
|
1457
|
-
*/
|
|
1458
|
-
function getMergeDiffCommand(targetBranch, options = {}) {
|
|
1459
|
-
return [
|
|
1460
|
-
"-c",
|
|
1461
|
-
"core.quotepath=false",
|
|
1462
|
-
"diff",
|
|
1463
|
-
...getMergeDiffArguments(targetBranch, options)
|
|
1464
|
-
];
|
|
1465
|
-
}
|
|
1466
|
-
async function fetchBranch(remote, branch, options) {
|
|
1467
|
-
await git$1([
|
|
1468
|
-
"fetch",
|
|
1469
|
-
"--no-tags",
|
|
1470
|
-
remote,
|
|
1471
|
-
`+refs/heads/${branch}:${remoteRef(remote, branch)}`
|
|
1472
|
-
], options);
|
|
1473
|
-
}
|
|
1474
|
-
async function isTracked(path, options) {
|
|
1475
|
-
try {
|
|
1476
|
-
await git$1([
|
|
1477
|
-
"ls-files",
|
|
1478
|
-
"--error-unmatch",
|
|
1479
|
-
"--",
|
|
1480
|
-
path
|
|
1481
|
-
], options);
|
|
1482
|
-
return true;
|
|
1483
|
-
} catch {
|
|
1484
|
-
return false;
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_ARTIFACTS, options = {}) {
|
|
1488
|
-
const removed = [];
|
|
1489
|
-
for (const path of paths) {
|
|
1490
|
-
if (await isTracked(path, options)) continue;
|
|
1491
|
-
try {
|
|
1492
|
-
await unlink(options.cwd ? join(options.cwd, path) : path);
|
|
1493
|
-
removed.push(path);
|
|
1494
|
-
} catch (error) {
|
|
1495
|
-
if (error.code !== "ENOENT") throw error;
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
return removed;
|
|
1499
|
-
}
|
|
1500
|
-
async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
|
|
1501
|
-
const remote = options.remote ?? "origin";
|
|
1502
|
-
await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
|
|
1503
|
-
await git$1([
|
|
1504
|
-
"fetch",
|
|
1505
|
-
"--unshallow",
|
|
1506
|
-
"--no-tags",
|
|
1507
|
-
remote
|
|
1508
|
-
], options).catch(() => void 0);
|
|
1509
|
-
const fetchErrors = [];
|
|
1510
|
-
for (const branch of [targetBranch, sourceBranch]) try {
|
|
1511
|
-
await fetchBranch(remote, branch, options);
|
|
1512
|
-
} catch (error) {
|
|
1513
|
-
fetchErrors.push(`${branch}: ${gitErrorMessage(error)}`);
|
|
1514
|
-
}
|
|
1515
|
-
if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
|
|
1516
|
-
try {
|
|
1517
|
-
await git$1([
|
|
1518
|
-
"merge-base",
|
|
1519
|
-
remoteRef(remote, targetBranch),
|
|
1520
|
-
"HEAD"
|
|
1521
|
-
], options);
|
|
1522
|
-
} catch (error) {
|
|
1523
|
-
const fetchDetail = fetchErrors.length > 0 ? `\nFetch warnings:\n${fetchErrors.join("\n")}` : "";
|
|
1524
|
-
throw new GitError(`Unable to prepare Git history for MR review: merge-base ${remoteRef(remote, targetBranch)} HEAD failed.`, {
|
|
1525
|
-
cause: error,
|
|
1526
|
-
hint: `Set GIT_DEPTH: 0 or ensure ${remote}/${targetBranch} is fetchable.${fetchDetail}\n${gitErrorMessage(error)}`
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
async function getMergeDiff(targetBranch, options = {}) {
|
|
1531
|
-
return git$1(getMergeDiffCommand(targetBranch, options), options);
|
|
1532
|
-
}
|
|
1533
|
-
function getMergeCommitLogArguments(targetBranch, options = {}) {
|
|
1534
|
-
return [
|
|
1535
|
-
`${remoteRef(options.remote ?? "origin", targetBranch)}...HEAD`,
|
|
1536
|
-
"--pretty=tformat:commit %h%nAuthor: %an%nDate: %as%n%n%s%n%n%b",
|
|
1537
|
-
"--reverse",
|
|
1538
|
-
"--no-merges"
|
|
1539
|
-
];
|
|
1540
|
-
}
|
|
1541
|
-
async function getMergeCommitLog(targetBranch, options = {}) {
|
|
1542
|
-
return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
|
|
1543
|
-
}
|
|
1544
|
-
/**
|
|
1545
|
-
* Summarize a unified diff into file/line counts for telemetry. Counts one file
|
|
1546
|
-
* per `diff --git` header and counts `+`/`-` lines only inside a hunk (after a
|
|
1547
|
-
* `@@` header), so the `--- a/file` / `+++ b/file` header lines are excluded and
|
|
1548
|
-
* a genuine content line whose text starts with `++`/`--` is still counted. Pure
|
|
1549
|
-
* and allocation-light so it can run on the full merge diff without an extra git
|
|
1550
|
-
* invocation.
|
|
1551
|
-
*/
|
|
1552
|
-
function summarizeDiff(diff) {
|
|
1553
|
-
let filesChanged = 0;
|
|
1554
|
-
let linesAdded = 0;
|
|
1555
|
-
let linesRemoved = 0;
|
|
1556
|
-
let inHunk = false;
|
|
1557
|
-
for (const line of diff.split("\n")) if (line.startsWith("diff --git ")) {
|
|
1558
|
-
filesChanged += 1;
|
|
1559
|
-
inHunk = false;
|
|
1560
|
-
} else if (line.startsWith("@@ ")) inHunk = true;
|
|
1561
|
-
else if (inHunk && line.startsWith("+")) linesAdded += 1;
|
|
1562
|
-
else if (inHunk && line.startsWith("-")) linesRemoved += 1;
|
|
1563
|
-
return {
|
|
1564
|
-
filesChanged,
|
|
1565
|
-
linesAdded,
|
|
1566
|
-
linesRemoved
|
|
1567
|
-
};
|
|
2217
|
+
const started = performance.now();
|
|
2218
|
+
return channel.tracePromise(async () => {
|
|
2219
|
+
try {
|
|
2220
|
+
return await operation(context);
|
|
2221
|
+
} catch (error) {
|
|
2222
|
+
context.errorInfo = toDiagnosticError(error, secretValues);
|
|
2223
|
+
throw error;
|
|
2224
|
+
} finally {
|
|
2225
|
+
context.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2226
|
+
context.durationMs = Number((performance.now() - started).toFixed(3));
|
|
2227
|
+
}
|
|
2228
|
+
}, context);
|
|
2229
|
+
}
|
|
2230
|
+
function traceDiagnosticPhase(phase, config, runId, operation, overrides = {}) {
|
|
2231
|
+
const context = createDiagnosticContext(phase, config, runId, overrides);
|
|
2232
|
+
return traceDiagnostic(tracingChannel(`${DIAGNOSTIC_CHANNEL_PREFIX}:${phase}`), context, operation, collectSecrets(config));
|
|
2233
|
+
}
|
|
2234
|
+
function toDiagnosticError(error, secretValues = []) {
|
|
2235
|
+
if (error instanceof Error) {
|
|
2236
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
2237
|
+
const timeout = "timeout" in error && error.timeout === true ? true : void 0;
|
|
2238
|
+
const status = "status" in error && typeof error.status === "number" ? error.status : void 0;
|
|
2239
|
+
return {
|
|
2240
|
+
name: error.name,
|
|
2241
|
+
message: scrubSecrets(error.message, secretValues),
|
|
2242
|
+
code,
|
|
2243
|
+
timeout,
|
|
2244
|
+
status
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
return { message: scrubSecrets(String(error), secretValues) };
|
|
1568
2248
|
}
|
|
1569
2249
|
//#endregion
|
|
1570
2250
|
//#region src/git-tool.ts
|
|
@@ -2920,437 +3600,102 @@ function parseReviewMarkdownWithWarnings(markdown) {
|
|
|
2920
3600
|
};
|
|
2921
3601
|
}
|
|
2922
3602
|
return {
|
|
2923
|
-
comments,
|
|
2924
|
-
summary,
|
|
2925
|
-
warnings,
|
|
2926
|
-
malformed
|
|
2927
|
-
};
|
|
2928
|
-
}
|
|
2929
|
-
function parseReviewMarkdown(markdown) {
|
|
2930
|
-
return parseReviewMarkdownWithWarnings(markdown).comments;
|
|
2931
|
-
}
|
|
2932
|
-
//#endregion
|
|
2933
|
-
//#region src/prior-threads.ts
|
|
2934
|
-
var FINGERPRINT_MARKER_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "i");
|
|
2935
|
-
/**
|
|
2936
|
-
* Returns true when the note body contains a code-review fingerprint marker
|
|
2937
|
-
* (current or legacy prefix).
|
|
2938
|
-
* Used to identify notes posted by the bot without needing a getCurrentUser() call.
|
|
2939
|
-
*/
|
|
2940
|
-
function isBotNote(note) {
|
|
2941
|
-
return FINGERPRINT_MARKER_RE.test(note.body ?? "");
|
|
2942
|
-
}
|
|
2943
|
-
/**
|
|
2944
|
-
* Parses the `+++ b/<path>` lines from a unified diff and returns the set of
|
|
2945
|
-
* new file paths. `/dev/null` (deleted files) is excluded.
|
|
2946
|
-
*/
|
|
2947
|
-
function extractChangedFiles(diff) {
|
|
2948
|
-
const files = /* @__PURE__ */ new Set();
|
|
2949
|
-
for (const line of diff.split("\n")) {
|
|
2950
|
-
const match = line.match(/^\+\+\+ b\/(.+)$/);
|
|
2951
|
-
if (match && match[1] !== "/dev/null") files.add(match[1]);
|
|
2952
|
-
}
|
|
2953
|
-
return files;
|
|
2954
|
-
}
|
|
2955
|
-
/**
|
|
2956
|
-
* Returns the line number for a discussion note's position.
|
|
2957
|
-
* Prefers the new-side line (`new_line`) then falls back to `old_line`.
|
|
2958
|
-
*/
|
|
2959
|
-
function positionLine$1(note) {
|
|
2960
|
-
return note.position?.new_line ?? note.position?.old_line ?? null;
|
|
2961
|
-
}
|
|
2962
|
-
/**
|
|
2963
|
-
* Returns the file path for a discussion note's position.
|
|
2964
|
-
* Prefers the new path then falls back to the old path.
|
|
2965
|
-
*/
|
|
2966
|
-
function positionFile$1(note) {
|
|
2967
|
-
return note.position?.new_path ?? note.position?.old_path ?? null;
|
|
2968
|
-
}
|
|
2969
|
-
/**
|
|
2970
|
-
* Extracts prior review threads from existing MR discussions that are relevant
|
|
2971
|
-
* to the current diff.
|
|
2972
|
-
*
|
|
2973
|
-
* A thread is included when:
|
|
2974
|
-
* - It contains at least one bot note (identified by fingerprint marker).
|
|
2975
|
-
* - It contains at least one non-system human reply after the bot note.
|
|
2976
|
-
* - The thread's file appears in `changedFiles`.
|
|
2977
|
-
*
|
|
2978
|
-
* Resolved threads are included but marked with `resolved: true` so the
|
|
2979
|
-
* reviewer can reference them without re-raising the concern.
|
|
2980
|
-
*/
|
|
2981
|
-
function extractPriorThreads(discussions, changedFiles) {
|
|
2982
|
-
const threads = [];
|
|
2983
|
-
for (const discussion of discussions) {
|
|
2984
|
-
const notes = discussion.notes ?? [];
|
|
2985
|
-
const botNoteIndex = notes.findIndex(isBotNote);
|
|
2986
|
-
if (botNoteIndex === -1) continue;
|
|
2987
|
-
const botNote = notes[botNoteIndex];
|
|
2988
|
-
const file = positionFile$1(botNote);
|
|
2989
|
-
if (!file || !changedFiles.has(file)) continue;
|
|
2990
|
-
const replies = notes.slice(botNoteIndex + 1).filter((n) => !n.system && (n.body?.trim() ?? "")).filter((n) => !isBotNote(n)).map((n) => n.body?.trim() ?? "");
|
|
2991
|
-
if (replies.length === 0) continue;
|
|
2992
|
-
const resolved = notes.some((n) => n.resolved === true);
|
|
2993
|
-
threads.push({
|
|
2994
|
-
file,
|
|
2995
|
-
line: positionLine$1(botNote),
|
|
2996
|
-
resolved,
|
|
2997
|
-
botComment: normalizeBody(botNote.body ?? ""),
|
|
2998
|
-
replies
|
|
2999
|
-
});
|
|
3000
|
-
}
|
|
3001
|
-
return threads;
|
|
3002
|
-
}
|
|
3003
|
-
/**
|
|
3004
|
-
* Renders a `<prior_review_feedback>` XML block from a list of prior threads.
|
|
3005
|
-
* Returns an empty string when `threads` is empty.
|
|
3006
|
-
*/
|
|
3007
|
-
function renderPriorThreadsBlock(threads) {
|
|
3008
|
-
if (threads.length === 0) return "";
|
|
3009
|
-
return `<prior_review_feedback>\n${threads.map((t) => {
|
|
3010
|
-
return ` <thread ${[
|
|
3011
|
-
`file="${t.file}"`,
|
|
3012
|
-
t.line !== null ? `line="${t.line}"` : null,
|
|
3013
|
-
`resolved="${t.resolved}"`
|
|
3014
|
-
].filter(Boolean).join(" ")}>\n${` <comment>${escapeXml(t.botComment)}</comment>`}\n${t.replies.map((r) => ` <reply>${escapeXml(r)}</reply>`).join("\n")}\n </thread>`;
|
|
3015
|
-
}).join("\n")}\n</prior_review_feedback>`;
|
|
3016
|
-
}
|
|
3017
|
-
function escapeXml(text) {
|
|
3018
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3019
|
-
}
|
|
3020
|
-
//#endregion
|
|
3021
|
-
//#region src/skills.ts
|
|
3022
|
-
var SKILL_DIRS = [".agents/skills", ".claude/skills"];
|
|
3023
|
-
var RESOURCE_DIRS = ["references"];
|
|
3024
|
-
function parseFrontmatter(content) {
|
|
3025
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
3026
|
-
if (!match) return null;
|
|
3027
|
-
let data;
|
|
3028
|
-
try {
|
|
3029
|
-
data = parse(match[1]);
|
|
3030
|
-
} catch {
|
|
3031
|
-
return null;
|
|
3032
|
-
}
|
|
3033
|
-
if (!data || typeof data !== "object") return null;
|
|
3034
|
-
const { name, description } = data;
|
|
3035
|
-
if (typeof name !== "string" || typeof description !== "string") return null;
|
|
3036
|
-
const trimmedName = name.trim();
|
|
3037
|
-
const trimmedDescription = description.trim();
|
|
3038
|
-
if (!trimmedName || !trimmedDescription) return null;
|
|
3039
|
-
return {
|
|
3040
|
-
name: trimmedName,
|
|
3041
|
-
description: trimmedDescription
|
|
3042
|
-
};
|
|
3043
|
-
}
|
|
3044
|
-
async function loadSkillFromDir(dirPath, source) {
|
|
3045
|
-
const skillMdPath = join(dirPath, "SKILL.md");
|
|
3046
|
-
let content;
|
|
3047
|
-
try {
|
|
3048
|
-
content = await readFile(skillMdPath, "utf8");
|
|
3049
|
-
} catch {
|
|
3050
|
-
return null;
|
|
3051
|
-
}
|
|
3052
|
-
const parsed = parseFrontmatter(content);
|
|
3053
|
-
if (!parsed) return null;
|
|
3054
|
-
const resourceDirs = RESOURCE_DIRS.filter((d) => existsSync(join(dirPath, d)));
|
|
3055
|
-
return {
|
|
3056
|
-
name: parsed.name,
|
|
3057
|
-
description: parsed.description,
|
|
3058
|
-
filePath: skillMdPath,
|
|
3059
|
-
rootDir: dirPath,
|
|
3060
|
-
resourceDirs,
|
|
3061
|
-
source
|
|
3062
|
-
};
|
|
3063
|
-
}
|
|
3064
|
-
function resolveBuiltinSkillsDir() {
|
|
3065
|
-
return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
3066
|
-
}
|
|
3067
|
-
async function loadBuiltinSkill(name) {
|
|
3068
|
-
return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
|
|
3069
|
-
}
|
|
3070
|
-
async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
|
|
3071
|
-
const dirs = [];
|
|
3072
|
-
let current = cwd;
|
|
3073
|
-
while (true) {
|
|
3074
|
-
dirs.unshift(current);
|
|
3075
|
-
if (current === gitRoot) break;
|
|
3076
|
-
const parent = dirname(current);
|
|
3077
|
-
if (parent === current) break;
|
|
3078
|
-
current = parent;
|
|
3079
|
-
}
|
|
3080
|
-
const found = /* @__PURE__ */ new Map();
|
|
3081
|
-
for (const dir of dirs) for (const skillDir of SKILL_DIRS) {
|
|
3082
|
-
const skillsPath = join(dir, skillDir);
|
|
3083
|
-
let entries;
|
|
3084
|
-
try {
|
|
3085
|
-
entries = await readdir(skillsPath);
|
|
3086
|
-
} catch {
|
|
3087
|
-
continue;
|
|
3088
|
-
}
|
|
3089
|
-
for (const entry of entries) {
|
|
3090
|
-
const entryPath = join(skillsPath, entry);
|
|
3091
|
-
const skill = await loadSkillFromDir(entryPath, "project");
|
|
3092
|
-
if (skill) found.set(skill.name, skill);
|
|
3093
|
-
else if (warn && existsSync(join(entryPath, "SKILL.md"))) warn(`Skill at ${entryPath} has a SKILL.md but is missing required frontmatter fields (name, description) — skill not loaded.`);
|
|
3094
|
-
}
|
|
3095
|
-
}
|
|
3096
|
-
return [...found.values()];
|
|
3097
|
-
}
|
|
3098
|
-
/**
|
|
3099
|
-
* Parse a skill spec string into a typed `SkillSpec` descriptor.
|
|
3100
|
-
*
|
|
3101
|
-
* Supported spec formats:
|
|
3102
|
-
*
|
|
3103
|
-
* | Input | Result |
|
|
3104
|
-
* |------------------------------------|---------------------------------------------------|
|
|
3105
|
-
* | `code-review` | `{ protocol: 'builtin', name: 'code-review' }` |
|
|
3106
|
-
* | `npm:my-skill` | `{ protocol: 'npm', packageName: 'my-skill', ... }`|
|
|
3107
|
-
* | `npm:@scope/pkg` | `{ protocol: 'npm', packageName: '@scope/pkg', ... }`|
|
|
3108
|
-
* | `npm:@scope/bundle/security` | `{ protocol: 'npm', packageName: '@scope/bundle', subpath: 'security' }`|
|
|
3109
|
-
* | `npm:bundle/security` | `{ protocol: 'npm', packageName: 'bundle', subpath: 'security' }`|
|
|
3110
|
-
* | `file:./path/to/skill` | `{ protocol: 'file', path: './path/to/skill' }` |
|
|
3111
|
-
* | `file:/absolute/path` | `{ protocol: 'file', path: '/absolute/path' }` |
|
|
3112
|
-
* | `git:https://host/org/s.git` | `{ protocol: 'git', url: 'https://host/org/s.git', ref: '', subpath: '' }` |
|
|
3113
|
-
* | `git:https://host/org/b.git#v1/sec`| `{ protocol: 'git', url: 'https://host/org/b.git', ref: 'v1', subpath: 'sec' }` |
|
|
3114
|
-
* | `git+ssh://git@host/org/s.git` | `{ protocol: 'git', url: 'ssh://git@host/org/s.git', ref: '', subpath: '' }` |
|
|
3115
|
-
*/
|
|
3116
|
-
function parseSkillSpec(spec) {
|
|
3117
|
-
if (spec.startsWith("file:")) return {
|
|
3118
|
-
protocol: "file",
|
|
3119
|
-
path: spec.slice(5)
|
|
3120
|
-
};
|
|
3121
|
-
if (spec.startsWith("npm:")) {
|
|
3122
|
-
const rest = spec.slice(4);
|
|
3123
|
-
if (rest.startsWith("@")) {
|
|
3124
|
-
const parts = rest.split("/");
|
|
3125
|
-
if (parts.length < 2) return {
|
|
3126
|
-
protocol: "npm",
|
|
3127
|
-
packageName: rest,
|
|
3128
|
-
subpath: ""
|
|
3129
|
-
};
|
|
3130
|
-
return {
|
|
3131
|
-
protocol: "npm",
|
|
3132
|
-
packageName: `${parts[0]}/${parts[1]}`,
|
|
3133
|
-
subpath: parts.slice(2).join("/")
|
|
3134
|
-
};
|
|
3135
|
-
}
|
|
3136
|
-
const slashIdx = rest.indexOf("/");
|
|
3137
|
-
if (slashIdx === -1) return {
|
|
3138
|
-
protocol: "npm",
|
|
3139
|
-
packageName: rest,
|
|
3140
|
-
subpath: ""
|
|
3141
|
-
};
|
|
3142
|
-
return {
|
|
3143
|
-
protocol: "npm",
|
|
3144
|
-
packageName: rest.slice(0, slashIdx),
|
|
3145
|
-
subpath: rest.slice(slashIdx + 1)
|
|
3146
|
-
};
|
|
3147
|
-
}
|
|
3148
|
-
if (spec.startsWith("git+") || spec.startsWith("git:")) return parseGitSpec(spec);
|
|
3149
|
-
return {
|
|
3150
|
-
protocol: "builtin",
|
|
3151
|
-
name: spec
|
|
3603
|
+
comments,
|
|
3604
|
+
summary,
|
|
3605
|
+
warnings,
|
|
3606
|
+
malformed
|
|
3152
3607
|
};
|
|
3153
3608
|
}
|
|
3609
|
+
function parseReviewMarkdown(markdown) {
|
|
3610
|
+
return parseReviewMarkdownWithWarnings(markdown).comments;
|
|
3611
|
+
}
|
|
3612
|
+
//#endregion
|
|
3613
|
+
//#region src/prior-threads.ts
|
|
3614
|
+
var FINGERPRINT_MARKER_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "i");
|
|
3154
3615
|
/**
|
|
3155
|
-
*
|
|
3156
|
-
*
|
|
3157
|
-
*
|
|
3158
|
-
* (e.g. `git:https://host/org/repo.git`).
|
|
3159
|
-
* - `git+<transport>://…` strips the leading `git+`, leaving a URL git
|
|
3160
|
-
* understands directly (`git+ssh://git@host/…` → `ssh://git@host/…`), matching
|
|
3161
|
-
* npm's `package.json` git-dependency convention.
|
|
3162
|
-
*
|
|
3163
|
-
* An optional `#<ref>[/<subpath>]` fragment pins the ref (tag, branch, or
|
|
3164
|
-
* commit) and, after the first `/`, points at a skill directory inside the repo.
|
|
3616
|
+
* Returns true when the note body contains a code-review fingerprint marker
|
|
3617
|
+
* (current or legacy prefix).
|
|
3618
|
+
* Used to identify notes posted by the bot without needing a getCurrentUser() call.
|
|
3165
3619
|
*/
|
|
3166
|
-
function
|
|
3167
|
-
|
|
3168
|
-
let parsed;
|
|
3169
|
-
try {
|
|
3170
|
-
parsed = new URL(raw);
|
|
3171
|
-
} catch {
|
|
3172
|
-
return {
|
|
3173
|
-
protocol: "git",
|
|
3174
|
-
url: raw,
|
|
3175
|
-
ref: "",
|
|
3176
|
-
subpath: ""
|
|
3177
|
-
};
|
|
3178
|
-
}
|
|
3179
|
-
const fragment = parsed.hash ? parsed.hash.slice(1) : "";
|
|
3180
|
-
parsed.hash = "";
|
|
3181
|
-
const url = parsed.toString();
|
|
3182
|
-
const slashIdx = fragment.indexOf("/");
|
|
3183
|
-
if (slashIdx === -1) return {
|
|
3184
|
-
protocol: "git",
|
|
3185
|
-
url,
|
|
3186
|
-
ref: fragment,
|
|
3187
|
-
subpath: ""
|
|
3188
|
-
};
|
|
3189
|
-
return {
|
|
3190
|
-
protocol: "git",
|
|
3191
|
-
url,
|
|
3192
|
-
ref: fragment.slice(0, slashIdx),
|
|
3193
|
-
subpath: fragment.slice(slashIdx + 1)
|
|
3194
|
-
};
|
|
3620
|
+
function isBotNote(note) {
|
|
3621
|
+
return FINGERPRINT_MARKER_RE.test(note.body ?? "");
|
|
3195
3622
|
}
|
|
3196
3623
|
/**
|
|
3197
|
-
*
|
|
3198
|
-
*
|
|
3199
|
-
* or `null` if the package / subpath cannot be found.
|
|
3624
|
+
* Parses the `+++ b/<path>` lines from a unified diff and returns the set of
|
|
3625
|
+
* new file paths. `/dev/null` (deleted files) is excluded.
|
|
3200
3626
|
*/
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
const
|
|
3205
|
-
if (
|
|
3206
|
-
const parent = dirname(current);
|
|
3207
|
-
if (parent === current) break;
|
|
3208
|
-
current = parent;
|
|
3627
|
+
function extractChangedFiles(diff) {
|
|
3628
|
+
const files = /* @__PURE__ */ new Set();
|
|
3629
|
+
for (const line of diff.split("\n")) {
|
|
3630
|
+
const match = line.match(/^\+\+\+ b\/(.+)$/);
|
|
3631
|
+
if (match && match[1] !== "/dev/null") files.add(match[1]);
|
|
3209
3632
|
}
|
|
3210
|
-
return
|
|
3211
|
-
}
|
|
3212
|
-
/** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
|
|
3213
|
-
function resolveSkillCacheDir() {
|
|
3214
|
-
return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
|
|
3633
|
+
return files;
|
|
3215
3634
|
}
|
|
3216
3635
|
/**
|
|
3217
|
-
*
|
|
3218
|
-
*
|
|
3636
|
+
* Returns the line number for a discussion note's position.
|
|
3637
|
+
* Prefers the new-side line (`new_line`) then falls back to `old_line`.
|
|
3219
3638
|
*/
|
|
3220
|
-
function
|
|
3221
|
-
return
|
|
3639
|
+
function positionLine$1(note) {
|
|
3640
|
+
return note.position?.new_line ?? note.position?.old_line ?? null;
|
|
3222
3641
|
}
|
|
3223
3642
|
/**
|
|
3224
|
-
*
|
|
3225
|
-
*
|
|
3226
|
-
* commit SHA all resolve through the same path; an empty `ref` fetches the
|
|
3227
|
-
* remote's default branch via `HEAD`.
|
|
3643
|
+
* Returns the file path for a discussion note's position.
|
|
3644
|
+
* Prefers the new path then falls back to the old path.
|
|
3228
3645
|
*/
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
"init",
|
|
3232
|
-
"--quiet",
|
|
3233
|
-
dir
|
|
3234
|
-
]);
|
|
3235
|
-
await git$1([
|
|
3236
|
-
"remote",
|
|
3237
|
-
"add",
|
|
3238
|
-
"origin",
|
|
3239
|
-
url
|
|
3240
|
-
], { cwd: dir });
|
|
3241
|
-
await git$1([
|
|
3242
|
-
"fetch",
|
|
3243
|
-
"--depth",
|
|
3244
|
-
"1",
|
|
3245
|
-
"--quiet",
|
|
3246
|
-
"origin",
|
|
3247
|
-
ref || "HEAD"
|
|
3248
|
-
], { cwd: dir });
|
|
3249
|
-
await git$1([
|
|
3250
|
-
"checkout",
|
|
3251
|
-
"--quiet",
|
|
3252
|
-
"FETCH_HEAD"
|
|
3253
|
-
], { cwd: dir });
|
|
3646
|
+
function positionFile$1(note) {
|
|
3647
|
+
return note.position?.new_path ?? note.position?.old_path ?? null;
|
|
3254
3648
|
}
|
|
3255
3649
|
/**
|
|
3256
|
-
*
|
|
3257
|
-
*
|
|
3258
|
-
*
|
|
3259
|
-
*
|
|
3650
|
+
* Extracts prior review threads from existing MR discussions that are relevant
|
|
3651
|
+
* to the current diff.
|
|
3652
|
+
*
|
|
3653
|
+
* A thread is included when:
|
|
3654
|
+
* - It contains at least one bot note (identified by fingerprint marker).
|
|
3655
|
+
* - It contains at least one non-system human reply after the bot note.
|
|
3656
|
+
* - The thread's file appears in `changedFiles`.
|
|
3657
|
+
*
|
|
3658
|
+
* Resolved threads are included but marked with `resolved: true` so the
|
|
3659
|
+
* reviewer can reference them without re-raising the concern.
|
|
3260
3660
|
*/
|
|
3261
|
-
|
|
3262
|
-
const
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
await gitShallowClone(url, ref, tmpDir);
|
|
3280
|
-
try {
|
|
3281
|
-
await rename(tmpDir, repoDir);
|
|
3282
|
-
} catch (error) {
|
|
3283
|
-
if (existsSync(join(repoDir, ".git"))) {
|
|
3284
|
-
await rm(tmpDir, {
|
|
3285
|
-
recursive: true,
|
|
3286
|
-
force: true
|
|
3287
|
-
});
|
|
3288
|
-
return repoDir;
|
|
3289
|
-
}
|
|
3290
|
-
throw error;
|
|
3291
|
-
}
|
|
3292
|
-
} catch (error) {
|
|
3293
|
-
await rm(tmpDir, {
|
|
3294
|
-
recursive: true,
|
|
3295
|
-
force: true
|
|
3661
|
+
function extractPriorThreads(discussions, changedFiles) {
|
|
3662
|
+
const threads = [];
|
|
3663
|
+
for (const discussion of discussions) {
|
|
3664
|
+
const notes = discussion.notes ?? [];
|
|
3665
|
+
const botNoteIndex = notes.findIndex(isBotNote);
|
|
3666
|
+
if (botNoteIndex === -1) continue;
|
|
3667
|
+
const botNote = notes[botNoteIndex];
|
|
3668
|
+
const file = positionFile$1(botNote);
|
|
3669
|
+
if (!file || !changedFiles.has(file)) continue;
|
|
3670
|
+
const replies = notes.slice(botNoteIndex + 1).filter((n) => !n.system && (n.body?.trim() ?? "")).filter((n) => !isBotNote(n)).map((n) => n.body?.trim() ?? "");
|
|
3671
|
+
if (replies.length === 0) continue;
|
|
3672
|
+
const resolved = notes.some((n) => n.resolved === true);
|
|
3673
|
+
threads.push({
|
|
3674
|
+
file,
|
|
3675
|
+
line: positionLine$1(botNote),
|
|
3676
|
+
resolved,
|
|
3677
|
+
botComment: normalizeBody(botNote.body ?? ""),
|
|
3678
|
+
replies
|
|
3296
3679
|
});
|
|
3297
|
-
throw error;
|
|
3298
3680
|
}
|
|
3299
|
-
return
|
|
3681
|
+
return threads;
|
|
3300
3682
|
}
|
|
3301
3683
|
/**
|
|
3302
|
-
*
|
|
3303
|
-
* `
|
|
3304
|
-
*
|
|
3305
|
-
* Resolution order:
|
|
3306
|
-
* 1. `builtin` — package-bundled `skills/<name>/`
|
|
3307
|
-
* 2. `npm:` — `node_modules/<packageName>[/subpath]` walked up from `cwd`
|
|
3308
|
-
* 3. `file:` — direct filesystem path (relative paths resolved from `cwd`)
|
|
3309
|
-
* 4. `git:` / `git+ssh:` — shallow clone at the pinned ref, cached on disk,
|
|
3310
|
-
* loading `SKILL.md` from the repo root or the `#<ref>/<subpath>` directory
|
|
3311
|
-
*
|
|
3312
|
-
* Throws a `ConfigError` if the spec cannot be resolved or the resolved
|
|
3313
|
-
* directory does not contain a valid `SKILL.md`.
|
|
3684
|
+
* Renders a `<prior_review_feedback>` XML block from a list of prior threads.
|
|
3685
|
+
* Returns an empty string when `threads` is empty.
|
|
3314
3686
|
*/
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
}
|
|
3328
|
-
const skill = await loadSkillFromDir(dir, "npm");
|
|
3329
|
-
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
|
|
3330
|
-
return skill;
|
|
3331
|
-
}
|
|
3332
|
-
if (parsed.protocol === "file") {
|
|
3333
|
-
const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
|
|
3334
|
-
const skill = await loadSkillFromDir(resolvedPath, "file");
|
|
3335
|
-
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No valid SKILL.md was found at "${resolvedPath}". Check that the path points to a skill directory.` });
|
|
3336
|
-
return skill;
|
|
3337
|
-
}
|
|
3338
|
-
let repoDir;
|
|
3339
|
-
try {
|
|
3340
|
-
repoDir = await cloneGitSkill(parsed.url, parsed.ref, {
|
|
3341
|
-
cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
|
|
3342
|
-
refresh: options.refresh ?? false
|
|
3343
|
-
});
|
|
3344
|
-
} catch (error) {
|
|
3345
|
-
const atRef = parsed.ref ? ` at ref "${parsed.ref}"` : "";
|
|
3346
|
-
throw new ConfigError(`Cannot load skill: "${spec}"`, {
|
|
3347
|
-
cause: error,
|
|
3348
|
-
hint: `Failed to clone "${parsed.url}"${atRef}. Check the URL, the ref, and your git credentials. For GitLab, prefer the SSH form: git+ssh://git@host/group/project.git`
|
|
3349
|
-
});
|
|
3350
|
-
}
|
|
3351
|
-
const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
|
|
3352
|
-
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: parsed.subpath ? `The cloned repository has no valid SKILL.md at subpath "${parsed.subpath}".` : "The cloned repository has no valid SKILL.md at its root. If the skill lives in a subdirectory, point at it with \"#<ref>/<subpath>\"." });
|
|
3353
|
-
return skill;
|
|
3687
|
+
function renderPriorThreadsBlock(threads) {
|
|
3688
|
+
if (threads.length === 0) return "";
|
|
3689
|
+
return `<prior_review_feedback>\n${threads.map((t) => {
|
|
3690
|
+
return ` <thread ${[
|
|
3691
|
+
`file="${t.file}"`,
|
|
3692
|
+
t.line !== null ? `line="${t.line}"` : null,
|
|
3693
|
+
`resolved="${t.resolved}"`
|
|
3694
|
+
].filter(Boolean).join(" ")}>\n${` <comment>${escapeXml(t.botComment)}</comment>`}\n${t.replies.map((r) => ` <reply>${escapeXml(r)}</reply>`).join("\n")}\n </thread>`;
|
|
3695
|
+
}).join("\n")}\n</prior_review_feedback>`;
|
|
3696
|
+
}
|
|
3697
|
+
function escapeXml(text) {
|
|
3698
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3354
3699
|
}
|
|
3355
3700
|
//#endregion
|
|
3356
3701
|
//#region src/skipped-retrieval.ts
|
|
@@ -3617,6 +3962,26 @@ var SEVERITY_RULE = {
|
|
|
3617
3962
|
CRITICAL: "- Only report CRITICAL issues — skip WARN and INFO"
|
|
3618
3963
|
};
|
|
3619
3964
|
var exec = promisify(execFile);
|
|
3965
|
+
/**
|
|
3966
|
+
* Stream function for the reviewer agent.
|
|
3967
|
+
*
|
|
3968
|
+
* pi-ai >=0.82 requires an explicit stream function (earlier versions built one
|
|
3969
|
+
* internally from model + getApiKey); `streamSimple` is the drop-in. Critically,
|
|
3970
|
+
* 0.83 also moved cloudflare-ai-gateway base-URL substitution — the
|
|
3971
|
+
* `{CLOUDFLARE_ACCOUNT_ID}` / `{CLOUDFLARE_GATEWAY_ID}` placeholders — from a
|
|
3972
|
+
* direct `process.env` read to an explicit `env` on the stream options. Without
|
|
3973
|
+
* threading `env` through, the gateway URL keeps its literal placeholders and
|
|
3974
|
+
* every request fails with Cloudflare 401 2035 ("Invalid request path"). Passing
|
|
3975
|
+
* `env` is harmless for providers whose base URL has no placeholders.
|
|
3976
|
+
*
|
|
3977
|
+
* `stream` is injectable so the env threading can be unit-tested without a live call.
|
|
3978
|
+
*/
|
|
3979
|
+
function createReviewStreamFn(stream = streamSimple) {
|
|
3980
|
+
return (model, context, options) => stream(model, context, {
|
|
3981
|
+
...options,
|
|
3982
|
+
env: process.env
|
|
3983
|
+
});
|
|
3984
|
+
}
|
|
3620
3985
|
function defaultCreateAgent(params) {
|
|
3621
3986
|
return new Agent({
|
|
3622
3987
|
initialState: {
|
|
@@ -3625,7 +3990,8 @@ function defaultCreateAgent(params) {
|
|
|
3625
3990
|
tools: params.tools,
|
|
3626
3991
|
thinkingLevel: params.thinkingLevel
|
|
3627
3992
|
},
|
|
3628
|
-
getApiKey: params.getApiKey
|
|
3993
|
+
getApiKey: params.getApiKey,
|
|
3994
|
+
streamFn: createReviewStreamFn()
|
|
3629
3995
|
});
|
|
3630
3996
|
}
|
|
3631
3997
|
async function findGitRoot(cwd) {
|
|
@@ -3689,10 +4055,14 @@ async function loadReviewContext(cwd, skillNames = [], warn, options = {}) {
|
|
|
3689
4055
|
walkUpContextFiles(cwd, REVIEW_RULE_FILES, gitRoot),
|
|
3690
4056
|
loadAutoDiscoveredSkills(cwd, gitRoot, warn)
|
|
3691
4057
|
]);
|
|
4058
|
+
const registry = buildMarketplaceRegistry(options.marketplaces ?? []);
|
|
4059
|
+
const knownMarketplaces = new Set(registry.keys());
|
|
3692
4060
|
const skills = [...discovered];
|
|
3693
4061
|
const discoveredNames = new Set(discovered.map((s) => s.name));
|
|
3694
4062
|
const named = await Promise.all(skillNames.filter((n) => !discoveredNames.has(n)).map(async (n) => {
|
|
3695
4063
|
try {
|
|
4064
|
+
const spec = parseSkillSpec(n, knownMarketplaces);
|
|
4065
|
+
if (spec.protocol === "marketplace") return await loadMarketplaceSkill(spec, registry, { refresh: options.refreshGitSkills });
|
|
3696
4066
|
return await loadNamedSkill(n, cwd, { refresh: options.refreshGitSkills });
|
|
3697
4067
|
} catch (error) {
|
|
3698
4068
|
warn?.(`Skipping skill "${n}": ${formatError(error)}`);
|
|
@@ -4099,7 +4469,7 @@ function resolveModel(modelString, baseUrl, maxTokens) {
|
|
|
4099
4469
|
const { provider, modelId } = splitModel(modelString);
|
|
4100
4470
|
if (provider === void 0 || modelId === void 0) throw new ReviewerError(`Invalid model format "${modelString}". Expected "provider/modelId" (e.g. "anthropic/claude-sonnet-4-5").`);
|
|
4101
4471
|
if (provider === "ollama") return buildOllamaModel(modelId, baseUrl || "http://localhost:11434/v1", maxTokens);
|
|
4102
|
-
const model =
|
|
4472
|
+
const model = getBuiltinModel(provider, modelId);
|
|
4103
4473
|
if (!model) throw new ReviewerError(`Unknown model "${modelString}".`, { hint: `Check that "${provider}" is a valid provider and "${modelId}" is a registered model ID.` });
|
|
4104
4474
|
if (baseUrl || maxTokens > 0) return {
|
|
4105
4475
|
...model,
|
|
@@ -4329,7 +4699,10 @@ async function runReview(config, options) {
|
|
|
4329
4699
|
retrieved: retrievableSkipped.length > 0
|
|
4330
4700
|
};
|
|
4331
4701
|
}
|
|
4332
|
-
const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), {
|
|
4702
|
+
const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), {
|
|
4703
|
+
refreshGitSkills: config.refreshGitSkills,
|
|
4704
|
+
marketplaces: config.marketplaces
|
|
4705
|
+
});
|
|
4333
4706
|
const systemPrompt = buildJSONSystemPrompt(context, minSeverity);
|
|
4334
4707
|
const userPrompt = buildUserPrompt(promptDiff, promptSkippedFiles, options.commitLog, options.priorThreads, options.intent, promptCoverage, retrievableSkipped, diskMode, commitsMode ? { sinceRef: options.sinceRef } : void 0);
|
|
4335
4708
|
const skillNames = context.skills.map((s) => s.name);
|
|
@@ -5265,7 +5638,7 @@ async function loadDefaultRuntime() {
|
|
|
5265
5638
|
const [sdkNode, resources, semconv] = modules;
|
|
5266
5639
|
const serviceResource = resources.resourceFromAttributes({
|
|
5267
5640
|
[semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
|
|
5268
|
-
[semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.
|
|
5641
|
+
[semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.5"
|
|
5269
5642
|
});
|
|
5270
5643
|
applyOtelExporterDefaults(process.env);
|
|
5271
5644
|
const sdk = new sdkNode.NodeSDK({ resource: resources.defaultResource().merge(serviceResource) });
|
|
@@ -5737,7 +6110,7 @@ function boldCommentTitle(body) {
|
|
|
5737
6110
|
*/
|
|
5738
6111
|
function buildCommentBody(body, commitSha, confidence) {
|
|
5739
6112
|
const confidenceLine = `_Confidence: ${confidence}._`;
|
|
5740
|
-
const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.
|
|
6113
|
+
const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.5 for commit ${commitSha}.</sub>`;
|
|
5741
6114
|
return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
|
|
5742
6115
|
}
|
|
5743
6116
|
function buildPayload(comment, body, refs, resolved) {
|
|
@@ -6844,10 +7217,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
6844
7217
|
return;
|
|
6845
7218
|
}
|
|
6846
7219
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
6847
|
-
console.log("0.9.
|
|
7220
|
+
console.log("0.9.5");
|
|
6848
7221
|
return;
|
|
6849
7222
|
}
|
|
6850
|
-
process.stderr.write(`[code-review] @weareikko/code-review v0.9.
|
|
7223
|
+
process.stderr.write(`[code-review] @weareikko/code-review v0.9.5\n`);
|
|
6851
7224
|
assertNodeVersion();
|
|
6852
7225
|
applyCodeReviewEnvPrefix();
|
|
6853
7226
|
applyDefaultCacheRetention();
|
|
@@ -6868,6 +7241,6 @@ if (isDirectRun()) main().catch((error) => {
|
|
|
6868
7241
|
process.exitCode = 1;
|
|
6869
7242
|
});
|
|
6870
7243
|
//#endregion
|
|
6871
|
-
export {
|
|
7244
|
+
export { resolveNpmSkillDir as $, SUMMARY_MARKER as A, findExistingSummaryNoteId as B, normalizeSeverity as C, SUMMARY_HISTORY_ENTRY_START as D, SUMMARY_HISTORY_ENTRY_END as E, buildSummaryHistoryEntries as F, extractDiffHunkContext as G, stripSummaryMarker as H, extractReviewedCommitSha as I, normalizeBody as J, extractExistingFingerprints as K, extractSummaryHistoryEntries as L, buildReviewedCommitFooter as M, buildSizeNoticeBlock as N, SUMMARY_HISTORY_LIMIT as O, buildSummaryBody as P, parseSkillSpec as Q, findExistingReviewedCommitSha as R, traceDiagnosticPhase as S, SUMMARY_HISTORY_END as T, upsertSummaryNote as U, stripSummaryHistory as V, appendFingerprintMarkers as W, gitSkillCacheKey as X, sha256 as Y, loadNamedSkill as Z, DIAGNOSTIC_CHANNEL_PREFIX as _, main as a, diagnosticChannels as b, buildGeneratedComments as c, startOtelBridge as d, resolveSkillCacheDir as et, filterDiff as f, DIAGNOSTIC_CHANNEL_NAMES as g, parseReviewMarkdownWithWarnings as h, formatUsageLine as i, buildArchivedSummaryEntry as j, SUMMARY_HISTORY_START as k, buildPayload as l, parseReviewMarkdown as m, formatPerModelUsage as n, run as o, runReview as p, fingerprints as q, formatSkillsFooter as r, withHttpStamping as s, countPostedBySeverity as t, isOtelEnabled as u, createDiagnosticContext as v, toGitLabReviewSeverity as w, traceDiagnostic as x, createDiagnosticRunId as y, findExistingSummaryNote as z };
|
|
6872
7245
|
|
|
6873
|
-
//# sourceMappingURL=cli-
|
|
7246
|
+
//# sourceMappingURL=cli-tIo5jQvJ.js.map
|