kaniscope 0.26.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/README.md +75 -0
- package/bin/kaniscope.js +29 -0
- package/binary.js +90 -0
- package/index.d.ts +70 -0
- package/index.js +225 -0
- package/package.json +41 -0
- package/types.d.ts +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# kaniscope
|
|
2
|
+
|
|
3
|
+
AI pull-request reviewer: fetches a PR's diff, reviews it, and posts line-anchored inline comments plus a summary. Works with **GitHub**, **GitLab** and **Bitbucket**.
|
|
4
|
+
|
|
5
|
+
This package ships a native binary — there is no Rust toolchain to install, no compile step, and no postinstall download. The binary lives in a small per-platform package that npm picks by `os`/`cpu`, so you download one, not five.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install kaniscope
|
|
9
|
+
npx kaniscope --provider github --repo me/app --pr 12 --dry-run
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## As a library
|
|
13
|
+
|
|
14
|
+
Build a bot in TypeScript and drive the engine from it:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { review } from "kaniscope";
|
|
18
|
+
|
|
19
|
+
const out = await review({
|
|
20
|
+
provider: "github",
|
|
21
|
+
repo: "me/app",
|
|
22
|
+
pr: 12,
|
|
23
|
+
dryRun: true,
|
|
24
|
+
env: { OPENROUTER_API_KEY: key, GH_TOKEN: token, MODEL: "anthropic/claude-sonnet-5" },
|
|
25
|
+
onLog: (line) => console.error(line),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
console.log(out.recommendation, `${out.findings} finding(s)`);
|
|
29
|
+
for (const f of out.findingsDetail ?? []) {
|
|
30
|
+
console.log(`${f.severity} ${f.file}:${f.line ?? "-"} — ${f.body}`);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`review()` runs the binary and parses its JSON. It rejects when the engine exits non-zero, with `exitCode`, `signal` and the full `stderr` on the error.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Everything beyond the flags above — the model, the API key, provider tokens, file globs, confidence floors, bot identity — is read from the environment, exactly as it is for the Rust library. Pass overrides in `env` (merged over `process.env`, unless `inheritEnv: false`).
|
|
39
|
+
|
|
40
|
+
The essentials: `OPENROUTER_API_KEY`, plus `GH_TOKEN` / `GITLAB_TOKEN` / Bitbucket credentials for the provider you use. See the [engine README](https://github.com/nhatvu148/pr-review-core#injecting-identity-and-prompt) for the full list, and `.prbot.toml` for per-repo settings.
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
| Export | What it does |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `review(options)` | Review a PR, or a local diff with `{ local: true }`. |
|
|
47
|
+
| `schema()` | The JSON Schema of a review result. No key, no network. |
|
|
48
|
+
| `version()` | The engine version this package's binary was built from. |
|
|
49
|
+
| `binaryPath()` | Absolute path to the bundled binary. |
|
|
50
|
+
|
|
51
|
+
`RunReviewOutput`, `Finding`, `InlineComment` and `Usage` are exported as types. They are **generated** from the binary's own `--schema`, so they cannot drift from what the engine actually emits.
|
|
52
|
+
|
|
53
|
+
## Reviewing without a PR
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
kaniscope --local --base main # the working tree against a ref
|
|
57
|
+
git diff --staged | kaniscope --local # any diff, from anywhere
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Through the API, `base` picks the ref and `diff` is the stdin form:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
await review({ local: true, base: "main" });
|
|
64
|
+
await review({ local: true, diff: await readFile("change.patch", "utf8") });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Same reviewer, same prompts, same anchoring — it just has nowhere to post.
|
|
68
|
+
|
|
69
|
+
## Why a subprocess and not native bindings
|
|
70
|
+
|
|
71
|
+
The engine takes five scalars, reads its configuration from the environment, and returns one JSON document after minutes of network and git work. A native addon would buy nothing a pipe does not already give, and would cost an ABI-pinned build per Node release plus a second declaration of every wire type. `uv` and `ruff` reach this ecosystem the same way.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT OR Apache-2.0. Source: [nhatvu148/pr-review-core](https://github.com/nhatvu148/pr-review-core).
|
package/bin/kaniscope.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// The CLI entry point: hand this process straight to the native binary.
|
|
5
|
+
//
|
|
6
|
+
// `spawnSync` with stdio inherited rather than `execFileSync`, so stdin still
|
|
7
|
+
// pipes (`git diff | kaniscope --local`) and the child's exit code is this
|
|
8
|
+
// process's exit code — a review that fails must not exit 0 through a wrapper.
|
|
9
|
+
|
|
10
|
+
const { spawnSync } = require("node:child_process");
|
|
11
|
+
const { binaryPath } = require("../binary.js");
|
|
12
|
+
|
|
13
|
+
let bin;
|
|
14
|
+
try {
|
|
15
|
+
bin = binaryPath();
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error(err.message);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" });
|
|
22
|
+
|
|
23
|
+
// A child killed by a signal has a null status. Reporting that as 0 would tell a
|
|
24
|
+
// CI job that a review which was OOM-killed had passed.
|
|
25
|
+
if (result.error) {
|
|
26
|
+
console.error(`kaniscope: could not run ${bin}: ${result.error.message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/binary.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Where the native `kaniscope` binary is, for this host.
|
|
4
|
+
//
|
|
5
|
+
// The binary is not in this package. It ships in one small per-platform package
|
|
6
|
+
// each declaring `os`/`cpu`, listed here as `optionalDependencies` — so npm
|
|
7
|
+
// installs exactly the one matching the host and skips the rest, and a Windows
|
|
8
|
+
// developer never downloads a Linux binary. This is esbuild's arrangement, and it
|
|
9
|
+
// is the reason this package works without a postinstall script that downloads
|
|
10
|
+
// from the network (blocked in a lot of CI, and a supply-chain surface besides).
|
|
11
|
+
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
|
|
14
|
+
/** npm's `process.platform`-`process.arch` pair, as it appears in the package name. */
|
|
15
|
+
function platformKey() {
|
|
16
|
+
return `${process.platform}-${process.arch}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SUPPORTED = new Set([
|
|
20
|
+
"darwin-arm64",
|
|
21
|
+
"darwin-x64",
|
|
22
|
+
"linux-arm64",
|
|
23
|
+
"linux-x64",
|
|
24
|
+
"win32-x64",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Absolute path to the `kaniscope` executable.
|
|
29
|
+
*
|
|
30
|
+
* Throws with the actual reason rather than letting a spawn fail later with
|
|
31
|
+
* ENOENT: by far the most common cause is an install with
|
|
32
|
+
* `--no-optional`/`--omit=optional`, which silently skips every platform package
|
|
33
|
+
* and leaves this one intact but inert. That produces a package that is present
|
|
34
|
+
* and unusable, and "ENOENT: kaniscope" points at nothing.
|
|
35
|
+
*/
|
|
36
|
+
function binaryPath() {
|
|
37
|
+
// An explicit override wins, always, and is therefore checked FIRST — before
|
|
38
|
+
// the supported-platform test below. The unsupported-platform error tells you
|
|
39
|
+
// to build from source and set this variable, so reading it only after that
|
|
40
|
+
// check makes the advertised escape hatch unreachable on precisely the
|
|
41
|
+
// platforms it exists for. It is also how you point at a locally built binary
|
|
42
|
+
// or an air-gapped vendored copy without editing inside node_modules.
|
|
43
|
+
const override = process.env.KANISCOPE_BINARY_PATH;
|
|
44
|
+
if (override) return override;
|
|
45
|
+
|
|
46
|
+
const key = platformKey();
|
|
47
|
+
if (!SUPPORTED.has(key)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`kaniscope has no prebuilt binary for ${key}. Supported: ${[...SUPPORTED].join(", ")}. ` +
|
|
50
|
+
`Build from source with \`cargo install pr-review-core --features cli\`, and set ` +
|
|
51
|
+
`KANISCOPE_BINARY_PATH to the result.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Scoped, matching what the generator publishes — see the note there on npm's
|
|
56
|
+
// spam detection. The wrapper itself stays unscoped.
|
|
57
|
+
const pkg = `@nhatvu148/kaniscope-${key}`;
|
|
58
|
+
const exe = process.platform === "win32" ? "kaniscope.exe" : "kaniscope";
|
|
59
|
+
|
|
60
|
+
// Resolve the package's manifest, not the binary: `bin/` is not an export, and
|
|
61
|
+
// `require.resolve` on a bare non-JS path fails under some resolvers.
|
|
62
|
+
//
|
|
63
|
+
// Two search roots, in this order. Normally the first is the only one that
|
|
64
|
+
// matters — a real install puts this package and its platform package side by
|
|
65
|
+
// side in one `node_modules`, and pnpm's layout resolves the same way. The
|
|
66
|
+
// second covers the case where this package is a SYMLINK: `npm link`, a
|
|
67
|
+
// `file:` dependency, or a monorepo workspace. Resolution then starts from the
|
|
68
|
+
// link target — outside the tree that holds the binary — and the first attempt
|
|
69
|
+
// fails on a perfectly good install. Version skew is not a risk in the
|
|
70
|
+
// fallback, because the wrapper pins its platform packages to one exact
|
|
71
|
+
// version rather than a range.
|
|
72
|
+
for (const from of [null, process.cwd()]) {
|
|
73
|
+
try {
|
|
74
|
+
const manifest = from
|
|
75
|
+
? require.resolve(`${pkg}/package.json`, { paths: [from] })
|
|
76
|
+
: require.resolve(`${pkg}/package.json`);
|
|
77
|
+
return path.join(path.dirname(manifest), "bin", exe);
|
|
78
|
+
} catch {
|
|
79
|
+
// Try the next root; the throw below reports the failure once.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(
|
|
84
|
+
`kaniscope could not find its native binary (package ${pkg} is not installed). ` +
|
|
85
|
+
`If you installed with --no-optional or --omit=optional, reinstall without it: ` +
|
|
86
|
+
`the binary ships in an optional per-platform package.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { binaryPath, platformKey };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { RunReviewOutput } from "./types";
|
|
2
|
+
|
|
3
|
+
export type { RunReviewOutput, Finding, InlineComment, Usage } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Options common to every call: how the binary is found and run. */
|
|
6
|
+
export interface SpawnOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Environment overrides, merged over `process.env` (see {@link inheritEnv}).
|
|
9
|
+
* This is where the engine's configuration lives — `OPENROUTER_API_KEY`,
|
|
10
|
+
* `GH_TOKEN`, `MODEL`, `BOT_NAME`, globs, confidence floors — exactly as it
|
|
11
|
+
* does for a Rust consumer.
|
|
12
|
+
*/
|
|
13
|
+
env?: Record<string, string | undefined>;
|
|
14
|
+
/** Set `false` to pass only {@link env}, ignoring `process.env`. Default `true`. */
|
|
15
|
+
inheritEnv?: boolean;
|
|
16
|
+
/** Kill the review after this many milliseconds and reject. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Called once per line the engine logs to stderr, as it happens. */
|
|
19
|
+
onLog?: (line: string) => void;
|
|
20
|
+
/** Override the binary to run. Defaults to the bundled per-platform one. */
|
|
21
|
+
binary?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReviewOptions extends SpawnOptions {
|
|
25
|
+
/** `github` | `gitlab` | `bitbucket`. Required unless {@link local}. */
|
|
26
|
+
provider?: string;
|
|
27
|
+
/** `owner/repo` (GitHub, GitLab) or `workspace/repo` (Bitbucket). */
|
|
28
|
+
repo?: string;
|
|
29
|
+
/** PR / MR number. */
|
|
30
|
+
pr?: number;
|
|
31
|
+
/** Produce the review but post nothing. */
|
|
32
|
+
dryRun?: boolean;
|
|
33
|
+
/** Review a diff instead of a PR. Nothing is posted; there is nowhere to post. */
|
|
34
|
+
local?: boolean;
|
|
35
|
+
/** With {@link local}: diff the working tree against this ref. */
|
|
36
|
+
base?: string;
|
|
37
|
+
/** With {@link local}: the checkout to read files from. Defaults to cwd. */
|
|
38
|
+
repoRoot?: string;
|
|
39
|
+
/** With {@link local}: what to call this change. Defaults to the branch name. */
|
|
40
|
+
label?: string;
|
|
41
|
+
/** Also write the result as JSON to this path, atomically. */
|
|
42
|
+
jsonOut?: string;
|
|
43
|
+
/**
|
|
44
|
+
* A unified diff to feed the engine on stdin — the way to use
|
|
45
|
+
* `{ local: true }` without a {@link base}, matching
|
|
46
|
+
* `git diff --staged | kaniscope --local`.
|
|
47
|
+
*
|
|
48
|
+
* Without it the child's stdin is closed rather than inherited: a server has
|
|
49
|
+
* no diff on its own stdin, so inheriting would block on a read that never
|
|
50
|
+
* returns.
|
|
51
|
+
*/
|
|
52
|
+
diff?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Review a pull request, or a local diff with `{ local: true }`.
|
|
57
|
+
*
|
|
58
|
+
* Rejects when the engine exits non-zero; the error carries `exitCode`,
|
|
59
|
+
* `signal` and the full `stderr`.
|
|
60
|
+
*/
|
|
61
|
+
export function review(options?: ReviewOptions): Promise<RunReviewOutput>;
|
|
62
|
+
|
|
63
|
+
/** The JSON Schema of a {@link review} result. Needs no key and no network. */
|
|
64
|
+
export function schema(options?: SpawnOptions): Promise<Record<string, unknown>>;
|
|
65
|
+
|
|
66
|
+
/** The engine version this package's binary was built from. */
|
|
67
|
+
export function version(options?: SpawnOptions): Promise<string>;
|
|
68
|
+
|
|
69
|
+
/** Absolute path to the bundled native binary. Throws if none matches the host. */
|
|
70
|
+
export function binaryPath(): string;
|
package/index.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The programmatic API: what a bot written in TypeScript actually calls.
|
|
4
|
+
//
|
|
5
|
+
// Under it is a subprocess, and that is the point rather than a compromise. The
|
|
6
|
+
// engine's entry point takes five scalars, reads the rest of its configuration
|
|
7
|
+
// from the environment, and returns one JSON-serializable struct after minutes of
|
|
8
|
+
// network and git work. Binding that across an FFI boundary would buy nothing a
|
|
9
|
+
// pipe does not already give, and would cost an ABI-pinned build matrix, a
|
|
10
|
+
// tokio<->libuv bridge, and a second declaration of every wire type that can
|
|
11
|
+
// drift from the first. The types below are generated from the binary's own
|
|
12
|
+
// `--schema`, so they cannot.
|
|
13
|
+
|
|
14
|
+
const { spawn } = require("node:child_process");
|
|
15
|
+
const { StringDecoder } = require("node:string_decoder");
|
|
16
|
+
const { binaryPath } = require("./binary.js");
|
|
17
|
+
|
|
18
|
+
/** Flags that take a value, mapped from the camelCase option name. */
|
|
19
|
+
const VALUE_FLAGS = {
|
|
20
|
+
provider: "--provider",
|
|
21
|
+
repo: "--repo",
|
|
22
|
+
pr: "--pr",
|
|
23
|
+
base: "--base",
|
|
24
|
+
repoRoot: "--repo-root",
|
|
25
|
+
label: "--label",
|
|
26
|
+
jsonOut: "--json-out",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Boolean flags, mapped from the camelCase option name. */
|
|
30
|
+
const BOOL_FLAGS = {
|
|
31
|
+
local: "--local",
|
|
32
|
+
dryRun: "--dry-run",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Turn an options object into an argv for the binary.
|
|
37
|
+
*
|
|
38
|
+
* Unknown keys are rejected rather than dropped. A silently ignored `dry_run`
|
|
39
|
+
* (snake_case, the natural typo when porting from the Python client) would post a
|
|
40
|
+
* live review to someone's PR while the caller believed it was a dry run — the
|
|
41
|
+
* one mistake in this API with consequences that cannot be undone.
|
|
42
|
+
*/
|
|
43
|
+
function buildArgs(options) {
|
|
44
|
+
const known = new Set([...Object.keys(VALUE_FLAGS), ...Object.keys(BOOL_FLAGS), "env", "inheritEnv", "timeoutMs", "onLog", "binary", "diff"]);
|
|
45
|
+
for (const key of Object.keys(options)) {
|
|
46
|
+
if (!known.has(key)) {
|
|
47
|
+
throw new TypeError(`kaniscope: unknown option ${JSON.stringify(key)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const args = ["--json"];
|
|
52
|
+
for (const [key, flag] of Object.entries(VALUE_FLAGS)) {
|
|
53
|
+
const value = options[key];
|
|
54
|
+
if (value === undefined || value === null) continue;
|
|
55
|
+
args.push(flag, String(value));
|
|
56
|
+
}
|
|
57
|
+
for (const [key, flag] of Object.entries(BOOL_FLAGS)) {
|
|
58
|
+
if (options[key]) args.push(flag);
|
|
59
|
+
}
|
|
60
|
+
return args;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run the binary and return its stdout, stderr and exit code.
|
|
65
|
+
*
|
|
66
|
+
* Accumulates rather than using a buffered helper on purpose: `execFile` caps
|
|
67
|
+
* stdout at 1 MB by default, and a review of a large PR — every finding, every
|
|
68
|
+
* rendered inline comment body — goes past that. The failure mode there is a
|
|
69
|
+
* truncated JSON parse error on exactly the big PRs you most wanted reviewed.
|
|
70
|
+
*/
|
|
71
|
+
function run(bin, args, options) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
// stdin is `pipe` only when a diff was supplied. The CLI's most general door
|
|
74
|
+
// is `git diff | kaniscope --local`, and with stdin hard-wired to "ignore"
|
|
75
|
+
// that mode was unreachable through this API: the child saw EOF immediately
|
|
76
|
+
// and bailed with "empty diff". Inheriting the parent's stdin instead — what
|
|
77
|
+
// the Python client used to do by omission — is worse, because a server has
|
|
78
|
+
// no diff on stdin and would block or read junk. Explicit both ways.
|
|
79
|
+
const wantsStdin = options.diff !== undefined && options.diff !== null;
|
|
80
|
+
const child = spawn(bin, args, {
|
|
81
|
+
env: options.inheritEnv === false ? { ...options.env } : { ...process.env, ...options.env },
|
|
82
|
+
stdio: [wantsStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (wantsStdin) {
|
|
86
|
+
// A child that exits before reading it all (bad flags, missing key) makes
|
|
87
|
+
// this write fail with EPIPE. That is the child's error to report, not a
|
|
88
|
+
// crash in the parent, so swallow it and let the exit code speak.
|
|
89
|
+
child.stdin.on("error", () => {});
|
|
90
|
+
child.stdin.end(options.diff);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const stdout = [];
|
|
94
|
+
const stderr = [];
|
|
95
|
+
child.stdout.on("data", (c) => stdout.push(c));
|
|
96
|
+
|
|
97
|
+
// The engine logs progress here (tool calls, warnings). A caller that wants
|
|
98
|
+
// to surface them live gets them line by line; otherwise they are still
|
|
99
|
+
// kept, because they are what makes a failure diagnosable.
|
|
100
|
+
//
|
|
101
|
+
// `carry` holds the incomplete tail of the last chunk. A `data` event is a
|
|
102
|
+
// chunk of a byte stream, NOT a line: a log line straddling two events was
|
|
103
|
+
// being delivered as two fragments, which breaks the "called once per line"
|
|
104
|
+
// contract in index.d.ts and, worse, hands a caller half a message to log.
|
|
105
|
+
// The StringDecoder is the same bug one level down — it holds a partial
|
|
106
|
+
// UTF-8 sequence split across chunks instead of turning it into U+FFFD.
|
|
107
|
+
const decoder = new StringDecoder("utf8");
|
|
108
|
+
let carry = "";
|
|
109
|
+
const emitLines = (text, flush) => {
|
|
110
|
+
if (!options.onLog) return;
|
|
111
|
+
carry += text;
|
|
112
|
+
const lines = carry.split("\n");
|
|
113
|
+
carry = lines.pop(); // the incomplete tail; more bytes may still be coming
|
|
114
|
+
for (const line of lines) if (line.trim()) options.onLog(line);
|
|
115
|
+
// At EOF nothing more is coming, so the tail is a whole line after all —
|
|
116
|
+
// which is the common case, since the last log line has no trailing \n.
|
|
117
|
+
if (flush && carry.trim()) {
|
|
118
|
+
options.onLog(carry);
|
|
119
|
+
carry = "";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
child.stderr.on("data", (c) => {
|
|
124
|
+
stderr.push(c);
|
|
125
|
+
emitLines(decoder.write(c), false);
|
|
126
|
+
});
|
|
127
|
+
child.stderr.on("end", () => emitLines(decoder.end(), true));
|
|
128
|
+
|
|
129
|
+
let timer;
|
|
130
|
+
if (options.timeoutMs) {
|
|
131
|
+
timer = setTimeout(() => {
|
|
132
|
+
child.kill("SIGKILL");
|
|
133
|
+
// Carry the same diagnostics as every other rejection from this file.
|
|
134
|
+
// A review that ran the full timeout usually printed the reason it was
|
|
135
|
+
// stuck, and dropping stderr here threw away the only evidence of it —
|
|
136
|
+
// on the one failure path where there is no exit code to look at either.
|
|
137
|
+
// `timedOut` because "was it killed by the timeout, or did it die on its
|
|
138
|
+
// own?" is otherwise only answerable by matching on the message string.
|
|
139
|
+
const err = new Error(`kaniscope: timed out after ${options.timeoutMs}ms`);
|
|
140
|
+
err.timedOut = true;
|
|
141
|
+
err.exitCode = null;
|
|
142
|
+
err.signal = "SIGKILL";
|
|
143
|
+
err.stderr = Buffer.concat(stderr).toString();
|
|
144
|
+
reject(err);
|
|
145
|
+
}, options.timeoutMs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
child.on("error", (err) => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
reject(new Error(`kaniscope: could not run ${bin}: ${err.message}`));
|
|
151
|
+
});
|
|
152
|
+
child.on("close", (code, signal) => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
resolve({
|
|
155
|
+
code,
|
|
156
|
+
signal,
|
|
157
|
+
stdout: Buffer.concat(stdout).toString(),
|
|
158
|
+
stderr: Buffer.concat(stderr).toString(),
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Trim stderr for an error message — the tail is where the cause is. */
|
|
165
|
+
function tail(text, lines = 20) {
|
|
166
|
+
const all = text.trimEnd().split("\n");
|
|
167
|
+
return all.slice(-lines).join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Review a pull request, or a local diff with `{ local: true }`.
|
|
172
|
+
*
|
|
173
|
+
* Configuration beyond these flags — the API key, the model, globs, confidence
|
|
174
|
+
* floors, bot identity — comes from the environment, exactly as it does for a
|
|
175
|
+
* Rust consumer. Pass overrides in `env`; they are merged over `process.env`.
|
|
176
|
+
*
|
|
177
|
+
* @param {import("./index").ReviewOptions} options
|
|
178
|
+
* @returns {Promise<import("./types").RunReviewOutput>}
|
|
179
|
+
*/
|
|
180
|
+
async function review(options = {}) {
|
|
181
|
+
const args = buildArgs(options);
|
|
182
|
+
const bin = options.binary || binaryPath();
|
|
183
|
+
const result = await run(bin, args, options);
|
|
184
|
+
|
|
185
|
+
if (result.code !== 0) {
|
|
186
|
+
const how = result.signal ? `killed by ${result.signal}` : `exited ${result.code}`;
|
|
187
|
+
const err = new Error(`kaniscope ${how}\n${tail(result.stderr)}`);
|
|
188
|
+
err.exitCode = result.code;
|
|
189
|
+
err.signal = result.signal;
|
|
190
|
+
err.stderr = result.stderr;
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(result.stdout);
|
|
196
|
+
} catch (cause) {
|
|
197
|
+
// Distinguish "the review failed" from "the wrapper and the binary disagree
|
|
198
|
+
// about the protocol". They need different fixes, and a bare SyntaxError
|
|
199
|
+
// pointing at position 0 reads as neither.
|
|
200
|
+
const err = new Error(
|
|
201
|
+
`kaniscope exited 0 but stdout was not JSON — is KANISCOPE_BINARY_PATH ` +
|
|
202
|
+
`pointing at a different program?\n${tail(result.stdout, 5)}`
|
|
203
|
+
);
|
|
204
|
+
err.cause = cause;
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The JSON Schema of a {@link review} result. Needs no key and no network. */
|
|
210
|
+
async function schema(options = {}) {
|
|
211
|
+
const bin = options.binary || binaryPath();
|
|
212
|
+
const result = await run(bin, ["--schema"], options);
|
|
213
|
+
if (result.code !== 0) throw new Error(`kaniscope --schema failed\n${tail(result.stderr)}`);
|
|
214
|
+
return JSON.parse(result.stdout);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The engine version this package's binary was built from. */
|
|
218
|
+
async function version(options = {}) {
|
|
219
|
+
const bin = options.binary || binaryPath();
|
|
220
|
+
const result = await run(bin, ["--version"], options);
|
|
221
|
+
if (result.code !== 0) throw new Error(`kaniscope --version failed\n${tail(result.stderr)}`);
|
|
222
|
+
return result.stdout.trim().replace(/^kaniscope\s+/, "");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { review, schema, version, binaryPath };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kaniscope",
|
|
3
|
+
"version": "0.26.0",
|
|
4
|
+
"description": "AI pull-request reviewer — line-anchored inline comments plus a summary, for GitHub, GitLab and Bitbucket. Ships a native binary; no Rust toolchain required.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"code-review",
|
|
7
|
+
"pull-request",
|
|
8
|
+
"ai",
|
|
9
|
+
"github",
|
|
10
|
+
"gitlab",
|
|
11
|
+
"bitbucket"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT OR Apache-2.0",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/nhatvu148/pr-review-core.git"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"types": "index.d.ts",
|
|
23
|
+
"bin": {
|
|
24
|
+
"kaniscope": "bin/kaniscope.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"index.d.ts",
|
|
29
|
+
"types.d.ts",
|
|
30
|
+
"binary.js",
|
|
31
|
+
"bin/kaniscope.js",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"@nhatvu148/kaniscope-darwin-arm64": "0.26.0",
|
|
36
|
+
"@nhatvu148/kaniscope-darwin-x64": "0.26.0",
|
|
37
|
+
"@nhatvu148/kaniscope-linux-arm64": "0.26.0",
|
|
38
|
+
"@nhatvu148/kaniscope-linux-x64": "0.26.0",
|
|
39
|
+
"@nhatvu148/kaniscope-win32-x64": "0.26.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// GENERATED by packaging/generate-types.mjs — do not edit.
|
|
2
|
+
// Regenerate with: node packaging/generate-types.mjs
|
|
3
|
+
|
|
4
|
+
/** Result of one review run (serialized as the HTTP/CLI response). */
|
|
5
|
+
export interface RunReviewOutput {
|
|
6
|
+
commentUrl?: string | null;
|
|
7
|
+
findings: number;
|
|
8
|
+
/** The post-processed findings that were posted (after self-critique, confidence floor, sort, and cap). */
|
|
9
|
+
findingsDetail?: Finding[];
|
|
10
|
+
/** The inline comments exactly as they were built — path, line, and the rendered body, committable suggestion block and all. */
|
|
11
|
+
inlineDetail?: InlineComment[];
|
|
12
|
+
inlinePosted: number;
|
|
13
|
+
model: string;
|
|
14
|
+
posted: boolean;
|
|
15
|
+
pr: number;
|
|
16
|
+
provider: string;
|
|
17
|
+
recommendation: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
summaryMarkdown: string;
|
|
20
|
+
usage?: Usage | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One review finding from the model. */
|
|
24
|
+
export interface Finding {
|
|
25
|
+
body: string;
|
|
26
|
+
/** Model's confidence (0–100) that this is a real, actionable issue a senior reviewer would flag. */
|
|
27
|
+
confidence?: number | null;
|
|
28
|
+
file?: string;
|
|
29
|
+
line?: number | null;
|
|
30
|
+
severity?: string;
|
|
31
|
+
/** Replacement text for the anchored line, ready to render as a committable suggestion — or `None` whenever the fix is not expressible as an exact line replacement, which is most of the time. */
|
|
32
|
+
suggestion?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One inline comment anchored to a file + line on the new side of the diff. */
|
|
36
|
+
export interface InlineComment {
|
|
37
|
+
body: string;
|
|
38
|
+
line: number;
|
|
39
|
+
path: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Token accounting echoed back by OpenRouter. */
|
|
43
|
+
export interface Usage {
|
|
44
|
+
completion_tokens?: number | null;
|
|
45
|
+
prompt_tokens?: number | null;
|
|
46
|
+
total_tokens?: number | null;
|
|
47
|
+
}
|
|
48
|
+
|