pi-ast-sgrep 2.0.2 → 2.2.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 +16 -19
- package/dist/code-mode.d.ts +1 -1
- package/dist/code-mode.js +1 -1
- package/dist/codemode/connector.d.ts +18 -3
- package/dist/codemode/connector.js +85 -31
- package/dist/codemode/dispatch.d.ts +13 -1
- package/dist/codemode/dispatch.js +87 -24
- package/dist/codemode/guest-api.d.ts +16 -0
- package/dist/codemode/guest-api.js +194 -0
- package/dist/codemode/guest-worker.mjs +287 -0
- package/dist/codemode/index.d.ts +4 -3
- package/dist/codemode/index.js +4 -3
- package/dist/codemode/native.d.ts +1 -1
- package/dist/codemode/native.js +1 -1
- package/dist/codemode/runner.d.ts +13 -9
- package/dist/codemode/runner.js +411 -213
- package/dist/codemode/session-pool.d.ts +6 -1
- package/dist/codemode/session-pool.js +125 -32
- package/dist/codemode/types.d.ts +42 -2
- package/dist/codemode/types.js +40 -15
- package/dist/codemode/worker.d.ts +1 -1
- package/dist/codemode/worker.js +25 -2
- package/dist/host/commands.d.ts +6 -0
- package/dist/host/commands.js +49 -0
- package/dist/host/results.d.ts +123 -0
- package/dist/host/results.js +126 -0
- package/dist/host/tools.d.ts +28 -0
- package/dist/host/tools.js +802 -0
- package/dist/index.d.ts +7 -34
- package/dist/index.js +5 -543
- package/dist/runtime/config.d.ts +36 -0
- package/dist/runtime/config.js +98 -0
- package/dist/runtime/freshness.d.ts +43 -0
- package/dist/runtime/freshness.js +446 -0
- package/dist/runtime/index-health.d.ts +16 -0
- package/dist/runtime/index-health.js +111 -0
- package/dist/runtime/runtime.d.ts +48 -0
- package/dist/runtime/runtime.js +265 -0
- package/dist/runtime/sqlite.d.ts +15 -0
- package/dist/runtime/sqlite.js +63 -0
- package/dist/runtime/types.d.ts +55 -0
- package/dist/runtime/types.js +25 -0
- package/dist/ui/card.d.ts +66 -0
- package/dist/ui/card.js +375 -0
- package/dist/ui/present.d.ts +89 -0
- package/dist/ui/present.js +391 -0
- package/package.json +8 -7
- package/dist/codemode/sandbox-worker.d.ts +0 -1
- package/dist/codemode/sandbox-worker.js +0 -204
- package/dist/present.d.ts +0 -70
- package/dist/present.js +0 -260
- package/dist/runtime.d.ts +0 -137
- package/dist/runtime.js +0 -799
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index-file health: format probing, completion classification,
|
|
3
|
+
* rebuild-failure reporting, containment helper. Imports types + sqlite.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { INDEX_FORMAT_VERSION, RuntimeError } from "./types.js";
|
|
8
|
+
import { openIndexDatabase } from "./sqlite.js";
|
|
9
|
+
export function pathContained(parent, child) {
|
|
10
|
+
const rel = relative(parent, child);
|
|
11
|
+
const first = rel.split(/[\\/]/u, 1)[0];
|
|
12
|
+
return rel === "" || (!isAbsolute(rel) && first !== "..");
|
|
13
|
+
}
|
|
14
|
+
export function record(value) {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function indexHealth(status) {
|
|
18
|
+
const index = record(status.index);
|
|
19
|
+
const state = typeof index?.status === "string" ? index.status :
|
|
20
|
+
typeof status.index_status === "string" ? status.index_status : undefined;
|
|
21
|
+
if (state === "incompatible" || index?.compatible === false || status.index_compatible === false)
|
|
22
|
+
return "incompatible";
|
|
23
|
+
if (state === "missing" || index?.exists === false || status.indexed === false)
|
|
24
|
+
return "missing";
|
|
25
|
+
if (state === "ready" || state === "current" || index?.exists === true || status.indexed === true)
|
|
26
|
+
return "ready";
|
|
27
|
+
if (typeof status.index_path === "string" && typeof status.file_count === "number") {
|
|
28
|
+
// A present-but-empty index answers nothing: report it as unindexed so the
|
|
29
|
+
// caller indexes instead of reading zero rows as a legitimate no-match.
|
|
30
|
+
return status.file_count > 0 ? "ready" : "missing";
|
|
31
|
+
}
|
|
32
|
+
throw new RuntimeError("INDEX_STATUS_UNKNOWN", "ast-sgrep status did not report index freshness", { index: status.index, index_status: status.index_status });
|
|
33
|
+
}
|
|
34
|
+
export function incompatibleStatusFailure(cause) {
|
|
35
|
+
// RuntimeError (CLI envelope) or a plain sticky/NAPI error — both carry the
|
|
36
|
+
// native version-window message as text. The class gate is intentionally
|
|
37
|
+
// dropped: this classifier only feeds the rebuild decision.
|
|
38
|
+
const details = cause instanceof RuntimeError ? cause.details : undefined;
|
|
39
|
+
const text = `${cause instanceof Error ? cause.message : String(cause)} ${JSON.stringify(details ?? {})}`;
|
|
40
|
+
return /incompatib|unsupported.{0,24}schema|schema.{0,24}(version|mismatch)|(newer|older) than supported/i.test(text);
|
|
41
|
+
}
|
|
42
|
+
export function indexCompletion(response, requireWalkErrors) {
|
|
43
|
+
const stats = record(response.stats) ?? response;
|
|
44
|
+
const failed = stats.files_failed;
|
|
45
|
+
const walkErrors = stats.walk_errors;
|
|
46
|
+
if (!Number.isSafeInteger(failed) || failed < 0
|
|
47
|
+
|| (requireWalkErrors ? typeof walkErrors !== "boolean" : walkErrors !== undefined && typeof walkErrors !== "boolean")) {
|
|
48
|
+
throw new RuntimeError("INDEX_RESPONSE_INVALID", "ast-sgrep index response omitted valid completion status", { filesFailed: failed, walkErrors, requireWalkErrors });
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
failed: failed,
|
|
52
|
+
walkErrors: walkErrors === true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function indexPathFor(root, env) {
|
|
56
|
+
const configured = env.ASGREP_INDEX_PATH;
|
|
57
|
+
if (!configured)
|
|
58
|
+
return join(root, ".asgrep", "index.db");
|
|
59
|
+
const resolved = resolve(root, configured);
|
|
60
|
+
return extname(resolved) === ".db" ? resolved : join(resolved, "index.db");
|
|
61
|
+
}
|
|
62
|
+
export function indexQuarantines(indexPath) {
|
|
63
|
+
const quarantinePrefix = `${basename(indexPath)}.corrupt`;
|
|
64
|
+
try {
|
|
65
|
+
return readdirSync(dirname(indexPath), { withFileTypes: true })
|
|
66
|
+
.filter((entry) => entry.isFile() && (entry.name === quarantinePrefix || entry.name.startsWith(`${quarantinePrefix}.`)))
|
|
67
|
+
.map((entry) => join(dirname(indexPath), entry.name))
|
|
68
|
+
.sort();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Classify a rebuild failure and identify recovery copies made by this attempt. */
|
|
75
|
+
export function throwIndexRebuildFailed(cause, indexPath, quarantinesBefore) {
|
|
76
|
+
const newQuarantines = indexQuarantines(indexPath).filter((path) => !quarantinesBefore.has(path));
|
|
77
|
+
const recoveryPaths = [
|
|
78
|
+
...newQuarantines,
|
|
79
|
+
...(existsSync(indexPath) ? [indexPath] : []),
|
|
80
|
+
];
|
|
81
|
+
const causeText = cause instanceof Error ? cause.message : String(cause);
|
|
82
|
+
throw new RuntimeError("INDEX_REBUILD_FAILED", "Incompatible index rebuild failed: " + causeText + "; the prior index remains recoverable", {
|
|
83
|
+
indexPath,
|
|
84
|
+
recoveryPath: recoveryPaths[0] ?? indexPath,
|
|
85
|
+
recoveryPaths,
|
|
86
|
+
priorIndexPreserved: recoveryPaths.length > 0,
|
|
87
|
+
expectedIndexFormat: INDEX_FORMAT_VERSION,
|
|
88
|
+
cause: causeText,
|
|
89
|
+
repair: "run /asgrep-reindex, or remove the project's .asgrep directory and run /asgrep-index",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Read the on-disk index format marker. The binary is the authority on what it can read. */
|
|
93
|
+
export function inspectIndexFile(path) {
|
|
94
|
+
if (!existsSync(path))
|
|
95
|
+
return "missing";
|
|
96
|
+
let database;
|
|
97
|
+
try {
|
|
98
|
+
database = openIndexDatabase(path, { readOnly: true });
|
|
99
|
+
const row = database.prepare("PRAGMA user_version").get();
|
|
100
|
+
const version = Number(Object.values(row ?? {})[0]);
|
|
101
|
+
if (!Number.isSafeInteger(version) || version <= 0)
|
|
102
|
+
return "incompatible";
|
|
103
|
+
return version;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return "incompatible";
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
database?.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolveBinary } from "ast-sgrep";
|
|
2
|
+
import { type MachineEnvelope, type PiExec, type RunOptions, type RuntimeContext } from "./types.js";
|
|
3
|
+
import { resolveConfig, type ConfigSources } from "./config.js";
|
|
4
|
+
import { type IndexHealth } from "./index-health.js";
|
|
5
|
+
export { CONFIG_SCHEMA_VERSION, DEFAULT_FRESHNESS_WAIT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, INDEX_FORMAT_VERSION, MACHINE_SCHEMA_VERSION, RUNTIME_VERSION, RuntimeError, } from "./types.js";
|
|
6
|
+
export type { ExecOptions, ExecResult, MachineEnvelope, PiExec, RunOptions, RuntimeContext, } from "./types.js";
|
|
7
|
+
export { migrateConfig, resolveConfig, rollbackConfig } from "./config.js";
|
|
8
|
+
export type { ConfigSources, LegacyRuntimeConfig, RuntimeConfig, RuntimeConfigInput, } from "./config.js";
|
|
9
|
+
export { FreshnessCoordinator, type FreshnessCoordinatorOptions, type FreshnessRuntime, type FreshnessWatchFactory, } from "./freshness.js";
|
|
10
|
+
export type { IndexHealth } from "./index-health.js";
|
|
11
|
+
export declare function resolveRuntimeRoot(projectCwd: string, requestedRoot?: string, allowOutsideProject?: boolean): Promise<string>;
|
|
12
|
+
type BinaryResolver = typeof resolveBinary;
|
|
13
|
+
export interface RuntimeDependencies {
|
|
14
|
+
resolveBinary?: BinaryResolver;
|
|
15
|
+
}
|
|
16
|
+
export declare class AstSgrepRuntime {
|
|
17
|
+
#private;
|
|
18
|
+
private readonly pi;
|
|
19
|
+
readonly watchExternalChanges = true;
|
|
20
|
+
readonly config: ReturnType<typeof resolveConfig>;
|
|
21
|
+
constructor(pi: PiExec, sources?: ConfigSources, dependencies?: RuntimeDependencies);
|
|
22
|
+
resolveRoot(context: RuntimeContext): Promise<string>;
|
|
23
|
+
resolveIndexPath(root: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Index format check. The configured binary is the sole authority on its own
|
|
26
|
+
* schema window (exact-match: it refuses both older and newer), so the local
|
|
27
|
+
* probe is only a pre-filter:
|
|
28
|
+
* missing/unreadable -> cheap no-spawn health answers;
|
|
29
|
+
* version == INDEX_FORMAT_VERSION (this release's shipped format) -> ready;
|
|
30
|
+
* otherwise -> consult the binary's declared index_schema_version once
|
|
31
|
+
* (cached), because a configured ASGREP_BIN/dev build may be newer than the
|
|
32
|
+
* shipped constant. Index newer than the binary -> INDEX_VERSION_TOO_NEW
|
|
33
|
+
* (never modified); older -> "incompatible" and rebuild migrates in place.
|
|
34
|
+
*/
|
|
35
|
+
inspectIndexCompatibility(context: RuntimeContext): Promise<IndexHealth>;
|
|
36
|
+
private supportedIndexFormat;
|
|
37
|
+
rebuildIncompatibleIndex(context: RuntimeContext, options?: RunOptions): Promise<MachineEnvelope>;
|
|
38
|
+
run(args: readonly string[], context: RuntimeContext, options?: RunOptions): Promise<MachineEnvelope>;
|
|
39
|
+
/** Absolute path to the native binary (for sticky serve / stdin batch spawn). */
|
|
40
|
+
resolveBinaryPath(options?: {
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
}): string;
|
|
43
|
+
/** Merged process env for native Code Mode workers. */
|
|
44
|
+
nativeEnv(options?: {
|
|
45
|
+
env?: NodeJS.ProcessEnv;
|
|
46
|
+
}): NodeJS.ProcessEnv;
|
|
47
|
+
checkCompatibility(context: RuntimeContext, options?: RunOptions): Promise<MachineEnvelope>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { constants, accessSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { resolveBinary } from "ast-sgrep";
|
|
5
|
+
import { CONFIG_SCHEMA_VERSION, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, INDEX_FORMAT_VERSION, MACHINE_SCHEMA_VERSION, RUNTIME_VERSION, RuntimeError, RESOLVED_ROOT, } from "./types.js";
|
|
6
|
+
import { finitePositive, migrateConfig, resolveConfig, rollbackConfig, } from "./config.js";
|
|
7
|
+
import { indexCompletion, indexPathFor, indexQuarantines, inspectIndexFile, pathContained, record, throwIndexRebuildFailed, } from "./index-health.js";
|
|
8
|
+
// The public surface of ./runtime is a contract (package.json exports +
|
|
9
|
+
// tests): re-export the symbols that moved to their own modules.
|
|
10
|
+
export { CONFIG_SCHEMA_VERSION, DEFAULT_FRESHNESS_WAIT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, INDEX_FORMAT_VERSION, MACHINE_SCHEMA_VERSION, RUNTIME_VERSION, RuntimeError, } from "./types.js";
|
|
11
|
+
export { migrateConfig, resolveConfig, rollbackConfig } from "./config.js";
|
|
12
|
+
export { FreshnessCoordinator, } from "./freshness.js";
|
|
13
|
+
export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutsideProject = false) {
|
|
14
|
+
let project;
|
|
15
|
+
let candidate;
|
|
16
|
+
try {
|
|
17
|
+
project = await realpath(resolve(projectCwd));
|
|
18
|
+
candidate = await realpath(resolve(project, requestedRoot ?? "."));
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
throw new RuntimeError("INVALID_ROOT", "Project or requested root does not exist", { projectCwd, requestedRoot, cause: cause instanceof Error ? cause.message : String(cause) });
|
|
22
|
+
}
|
|
23
|
+
if (!allowOutsideProject && !pathContained(project, candidate)) {
|
|
24
|
+
throw new RuntimeError("ROOT_OUTSIDE_PROJECT", "Requested root resolves outside the project", { project, requestedRoot, resolvedRoot: candidate });
|
|
25
|
+
}
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
function getBinary(config, env, resolver) {
|
|
29
|
+
let binary;
|
|
30
|
+
try {
|
|
31
|
+
const options = config.binaryPath ? { binaryPath: config.binaryPath, env } : { env };
|
|
32
|
+
binary = resolver(options);
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
36
|
+
if (config.binaryPath) {
|
|
37
|
+
throw new RuntimeError("BINARY_NOT_FOUND", `Configured ast-sgrep binary is unavailable: ${config.binaryPath}`, { binaryPath: config.binaryPath, cause: message });
|
|
38
|
+
}
|
|
39
|
+
throw new RuntimeError("BINARY_RESOLUTION_FAILED", "Unable to resolve an ast-sgrep binary for this platform", { cause: message });
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
accessSync(binary, constants.X_OK);
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
throw new RuntimeError("BINARY_NOT_EXECUTABLE", `ast-sgrep binary is not executable: ${binary}`, { binaryPath: binary, cause: cause instanceof Error ? cause.message : String(cause) });
|
|
46
|
+
}
|
|
47
|
+
return binary;
|
|
48
|
+
}
|
|
49
|
+
function byteLength(value) { return Buffer.byteLength(value, "utf8"); }
|
|
50
|
+
/** Present-field version identity checks. Pass `requireIdentity` for version --json. */
|
|
51
|
+
function assertVersionTriple(envelope, requireIdentity = false) {
|
|
52
|
+
// Compound guards (same short-circuit as nested if): check only when required or field present.
|
|
53
|
+
if ((requireIdentity || envelope.version !== undefined) && envelope.version !== RUNTIME_VERSION) {
|
|
54
|
+
throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: envelope.version });
|
|
55
|
+
}
|
|
56
|
+
if ((requireIdentity || envelope.machine_schema_version !== undefined) && envelope.machine_schema_version !== MACHINE_SCHEMA_VERSION) {
|
|
57
|
+
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep binary reports an incompatible machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: envelope.machine_schema_version });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Nonzero CLI exit: prefer structured failed envelope (OPERATIONAL_ERROR), else PROCESS_FAILED.
|
|
62
|
+
* Always throws — error-path extract so parseEnvelope keeps success-path protocol field checks.
|
|
63
|
+
*/
|
|
64
|
+
function throwNonzeroProcessFailure(result, code) {
|
|
65
|
+
try {
|
|
66
|
+
const value = record(JSON.parse(result.stdout));
|
|
67
|
+
// Wire-valid ok:false asgrep envelope → structured operational failure (not PROCESS_FAILED).
|
|
68
|
+
if (value && value.tool === "asgrep" && value.schema_version === MACHINE_SCHEMA_VERSION && value.ok === false) {
|
|
69
|
+
const failure = record(value.error);
|
|
70
|
+
const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
|
|
71
|
+
throw new RuntimeError("OPERATIONAL_ERROR", message, { command: value.command, error: failure, exitCode: code });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (cause) {
|
|
75
|
+
if (cause instanceof RuntimeError)
|
|
76
|
+
throw cause;
|
|
77
|
+
}
|
|
78
|
+
throw new RuntimeError("PROCESS_FAILED", `ast-sgrep exited with code ${code}`, {
|
|
79
|
+
exitCode: code,
|
|
80
|
+
signal: result.signal ?? undefined,
|
|
81
|
+
stderr: result.stderr.slice(0, 1024),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Map exec failures (abort / timeout / generic) to RuntimeError. Re-throws RuntimeError as-is. */
|
|
85
|
+
function rethrowExecFailure(cause, options, timeout) {
|
|
86
|
+
if (cause instanceof RuntimeError)
|
|
87
|
+
throw cause;
|
|
88
|
+
if (options.signal?.aborted || (cause instanceof Error && cause.name === "AbortError")) {
|
|
89
|
+
throw new RuntimeError("CANCELLED", "ast-sgrep execution was cancelled");
|
|
90
|
+
}
|
|
91
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
92
|
+
if (/timeout|timed out/i.test(message)) {
|
|
93
|
+
throw new RuntimeError("TIMEOUT", `ast-sgrep exceeded ${timeout}ms`, { timeoutMs: timeout });
|
|
94
|
+
}
|
|
95
|
+
throw new RuntimeError("EXEC_FAILED", "Unable to execute ast-sgrep", { cause: message });
|
|
96
|
+
}
|
|
97
|
+
function parseEnvelope(result, limit) {
|
|
98
|
+
const stdoutBytes = byteLength(result.stdout);
|
|
99
|
+
const stderrBytes = byteLength(result.stderr);
|
|
100
|
+
// Byte lengths are non-negative: sum > limit covers either-side overflow and combined cap.
|
|
101
|
+
if (stdoutBytes + stderrBytes > limit) {
|
|
102
|
+
throw new RuntimeError("OUTPUT_LIMIT", "ast-sgrep output exceeded the configured limit", { limit, stdoutBytes, stderrBytes });
|
|
103
|
+
}
|
|
104
|
+
const code = result.exitCode ?? result.code ?? 0;
|
|
105
|
+
if (code !== 0) {
|
|
106
|
+
throwNonzeroProcessFailure(result, code);
|
|
107
|
+
}
|
|
108
|
+
let value;
|
|
109
|
+
try {
|
|
110
|
+
value = JSON.parse(result.stdout);
|
|
111
|
+
}
|
|
112
|
+
catch (cause) {
|
|
113
|
+
throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned malformed JSON", { cause: cause instanceof Error ? cause.message : String(cause) });
|
|
114
|
+
}
|
|
115
|
+
const envelope = record(value);
|
|
116
|
+
if (!envelope)
|
|
117
|
+
throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned a non-object JSON payload");
|
|
118
|
+
// Protocol field varieties (Ashby Keep) — sequential wire-contract checks stay here.
|
|
119
|
+
if (envelope.tool !== "asgrep")
|
|
120
|
+
throw new RuntimeError("TOOL_MISMATCH", "Response is not from ast-sgrep", { actual: envelope.tool });
|
|
121
|
+
if (envelope.schema_version !== MACHINE_SCHEMA_VERSION)
|
|
122
|
+
throw new RuntimeError("PROTOCOL_MISMATCH", "Unsupported ast-sgrep machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: envelope.schema_version });
|
|
123
|
+
if (typeof envelope.ok !== "boolean")
|
|
124
|
+
throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep response is missing boolean ok");
|
|
125
|
+
if (!envelope.ok) {
|
|
126
|
+
// Preserve pre-extract failure shape: plain object check (arrays allowed as error bag).
|
|
127
|
+
const failure = envelope.error && typeof envelope.error === "object" ? envelope.error : undefined;
|
|
128
|
+
const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
|
|
129
|
+
throw new RuntimeError("OPERATIONAL_ERROR", message, { command: envelope.command, error: failure });
|
|
130
|
+
}
|
|
131
|
+
assertVersionTriple(envelope);
|
|
132
|
+
return envelope;
|
|
133
|
+
}
|
|
134
|
+
export class AstSgrepRuntime {
|
|
135
|
+
pi;
|
|
136
|
+
watchExternalChanges = true;
|
|
137
|
+
config;
|
|
138
|
+
#resolver;
|
|
139
|
+
#environment;
|
|
140
|
+
constructor(pi, sources = {}, dependencies = {}) {
|
|
141
|
+
this.pi = pi;
|
|
142
|
+
this.#environment = sources.environment ?? process.env;
|
|
143
|
+
this.config = resolveConfig({ ...sources, environment: this.#environment });
|
|
144
|
+
this.#resolver = dependencies.resolveBinary ?? resolveBinary;
|
|
145
|
+
}
|
|
146
|
+
async resolveRoot(context) {
|
|
147
|
+
return context[RESOLVED_ROOT]
|
|
148
|
+
? resolveRuntimeRoot(context.cwd)
|
|
149
|
+
: resolveRuntimeRoot(context.cwd, this.config.root, this.config.allowOutsideProject);
|
|
150
|
+
}
|
|
151
|
+
resolveIndexPath(root) {
|
|
152
|
+
return indexPathFor(root, { ...this.#environment, ...this.config.env });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Index format check. The configured binary is the sole authority on its own
|
|
156
|
+
* schema window (exact-match: it refuses both older and newer), so the local
|
|
157
|
+
* probe is only a pre-filter:
|
|
158
|
+
* missing/unreadable -> cheap no-spawn health answers;
|
|
159
|
+
* version == INDEX_FORMAT_VERSION (this release's shipped format) -> ready;
|
|
160
|
+
* otherwise -> consult the binary's declared index_schema_version once
|
|
161
|
+
* (cached), because a configured ASGREP_BIN/dev build may be newer than the
|
|
162
|
+
* shipped constant. Index newer than the binary -> INDEX_VERSION_TOO_NEW
|
|
163
|
+
* (never modified); older -> "incompatible" and rebuild migrates in place.
|
|
164
|
+
*/
|
|
165
|
+
async inspectIndexCompatibility(context) {
|
|
166
|
+
const root = await this.resolveRoot(context);
|
|
167
|
+
const indexPath = indexPathFor(root, { ...this.#environment, ...this.config.env });
|
|
168
|
+
const version = inspectIndexFile(indexPath);
|
|
169
|
+
if (version === "missing" || version === "incompatible")
|
|
170
|
+
return version;
|
|
171
|
+
if (version === INDEX_FORMAT_VERSION)
|
|
172
|
+
return "ready";
|
|
173
|
+
const supported = await this.supportedIndexFormat(context);
|
|
174
|
+
if (version === supported)
|
|
175
|
+
return "ready";
|
|
176
|
+
if (version > supported) {
|
|
177
|
+
throw new RuntimeError("INDEX_VERSION_TOO_NEW", "Index schema is newer than the configured ast-sgrep binary", {
|
|
178
|
+
actual: version,
|
|
179
|
+
supported,
|
|
180
|
+
rollbackSafe: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return "incompatible";
|
|
184
|
+
}
|
|
185
|
+
/** The configured binary's declared index schema, or this release's shipped floor. */
|
|
186
|
+
#indexFormatProbe;
|
|
187
|
+
supportedIndexFormat(context) {
|
|
188
|
+
if (!this.#indexFormatProbe) {
|
|
189
|
+
const probe = this.run(["version", "--json"], context)
|
|
190
|
+
.then((envelope) => {
|
|
191
|
+
const declared = envelope.index_schema_version;
|
|
192
|
+
return typeof declared === "number" && Number.isSafeInteger(declared) && declared > 0
|
|
193
|
+
? declared
|
|
194
|
+
: INDEX_FORMAT_VERSION;
|
|
195
|
+
});
|
|
196
|
+
this.#indexFormatProbe = probe;
|
|
197
|
+
// A failed probe (binary missing/exec error) must not be cached forever.
|
|
198
|
+
probe.catch(() => {
|
|
199
|
+
if (this.#indexFormatProbe === probe)
|
|
200
|
+
this.#indexFormatProbe = undefined;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return this.#indexFormatProbe;
|
|
204
|
+
}
|
|
205
|
+
async rebuildIncompatibleIndex(context, options = {}) {
|
|
206
|
+
const root = await this.resolveRoot(context);
|
|
207
|
+
const env = { ...this.#environment, ...this.config.env, ...options.env };
|
|
208
|
+
const indexPath = indexPathFor(root, env);
|
|
209
|
+
const quarantinesBefore = new Set(indexQuarantines(indexPath));
|
|
210
|
+
try {
|
|
211
|
+
// Core reindex prepares files before opening one bulk transaction and
|
|
212
|
+
// commits rewrites plus stale-row pruning together. Keeping the same DB
|
|
213
|
+
// inode avoids stale warm NAPI sessions and removes rename crash windows.
|
|
214
|
+
const response = await this.run(["reindex", ".", "--json"], { cwd: root }, options);
|
|
215
|
+
const { failed, walkErrors } = indexCompletion(response, true);
|
|
216
|
+
if (failed > 0 || walkErrors) {
|
|
217
|
+
throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", "ast-sgrep did not complete the incompatible-index rebuild", { failed, walkErrors, force: true });
|
|
218
|
+
}
|
|
219
|
+
if ((await this.inspectIndexCompatibility(context)) !== "ready") {
|
|
220
|
+
throw new RuntimeError("INDEX_REBUILD_INVALID", "Rebuilt index has an incompatible format", { expected: INDEX_FORMAT_VERSION });
|
|
221
|
+
}
|
|
222
|
+
return response;
|
|
223
|
+
}
|
|
224
|
+
catch (cause) {
|
|
225
|
+
throwIndexRebuildFailed(cause, indexPath, quarantinesBefore);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async run(args, context, options = {}) {
|
|
229
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string"))
|
|
230
|
+
throw new RuntimeError("INVALID_ARGUMENTS", "Arguments must be a string array");
|
|
231
|
+
if (options.signal?.aborted)
|
|
232
|
+
throw new RuntimeError("CANCELLED", "ast-sgrep execution was cancelled");
|
|
233
|
+
const root = await this.resolveRoot(context);
|
|
234
|
+
const timeout = finitePositive(options.timeoutMs, this.config.timeoutMs, "timeoutMs");
|
|
235
|
+
const env = this.#mergedEnv(options.env);
|
|
236
|
+
const binary = getBinary(this.config, env, this.#resolver);
|
|
237
|
+
try {
|
|
238
|
+
const execOptions = { cwd: root, env, timeout };
|
|
239
|
+
if (options.signal)
|
|
240
|
+
execOptions.signal = options.signal;
|
|
241
|
+
const result = await this.pi.exec(binary, Object.freeze([...args]), execOptions);
|
|
242
|
+
return parseEnvelope(result, this.config.maxOutputBytes);
|
|
243
|
+
}
|
|
244
|
+
catch (cause) {
|
|
245
|
+
rethrowExecFailure(cause, options, timeout);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** environment < config.env < options.env < NO_COLOR — the merge every path shares. */
|
|
249
|
+
#mergedEnv(extra) {
|
|
250
|
+
return { ...this.#environment, ...this.config.env, ...extra, NO_COLOR: "1" };
|
|
251
|
+
}
|
|
252
|
+
/** Absolute path to the native binary (for sticky serve / stdin batch spawn). */
|
|
253
|
+
resolveBinaryPath(options = {}) {
|
|
254
|
+
return getBinary(this.config, this.#mergedEnv(options.env), this.#resolver);
|
|
255
|
+
}
|
|
256
|
+
/** Merged process env for native Code Mode workers. */
|
|
257
|
+
nativeEnv(options = {}) {
|
|
258
|
+
return this.#mergedEnv(options.env);
|
|
259
|
+
}
|
|
260
|
+
async checkCompatibility(context, options = {}) {
|
|
261
|
+
const value = await this.run(["version", "--json"], context, options);
|
|
262
|
+
assertVersionTriple(value, true);
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SqliteBackend = "node" | "bun";
|
|
2
|
+
export interface IndexStatement {
|
|
3
|
+
get(...params: unknown[]): unknown;
|
|
4
|
+
run(...params: unknown[]): unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface IndexDatabase {
|
|
7
|
+
prepare(sql: string): IndexStatement;
|
|
8
|
+
exec(sql: string): unknown;
|
|
9
|
+
close(): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function sqliteBackend(): SqliteBackend;
|
|
12
|
+
/** Open the index DB with Node `node:sqlite` or Bun `bun:sqlite`. */
|
|
13
|
+
export declare function openIndexDatabase(path: string, options?: {
|
|
14
|
+
readOnly?: boolean;
|
|
15
|
+
}): IndexDatabase;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
let cached;
|
|
3
|
+
function bunVersion() {
|
|
4
|
+
return process.versions.bun;
|
|
5
|
+
}
|
|
6
|
+
function loadModule(specifier) {
|
|
7
|
+
return createRequire(import.meta.url)(specifier);
|
|
8
|
+
}
|
|
9
|
+
function loadBackend() {
|
|
10
|
+
if (cached)
|
|
11
|
+
return cached;
|
|
12
|
+
if (bunVersion() !== undefined) {
|
|
13
|
+
cached = { backend: "bun", Ctor: requireCtor(loadModule("bun:sqlite"), "Database") };
|
|
14
|
+
return cached;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
cached = { backend: "node", Ctor: requireCtor(loadModule("node:sqlite"), "DatabaseSync") };
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
catch (nodeError) {
|
|
21
|
+
try {
|
|
22
|
+
cached = { backend: "bun", Ctor: requireCtor(loadModule("bun:sqlite"), "Database") };
|
|
23
|
+
return cached;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error("No SQLite backend available (node:sqlite and bun:sqlite both failed)", {
|
|
27
|
+
cause: nodeError,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function requireCtor(mod, name) {
|
|
33
|
+
const Ctor = mod[name];
|
|
34
|
+
if (typeof Ctor !== "function") {
|
|
35
|
+
throw new Error(`SQLite module is missing ${name}`);
|
|
36
|
+
}
|
|
37
|
+
return Ctor;
|
|
38
|
+
}
|
|
39
|
+
export function sqliteBackend() {
|
|
40
|
+
return loadBackend().backend;
|
|
41
|
+
}
|
|
42
|
+
/** Open the index DB with Node `node:sqlite` or Bun `bun:sqlite`. */
|
|
43
|
+
export function openIndexDatabase(path, options = {}) {
|
|
44
|
+
const { backend, Ctor } = loadBackend();
|
|
45
|
+
const readOnly = options.readOnly === true;
|
|
46
|
+
const database = backend === "bun"
|
|
47
|
+
? new Ctor(path, { readonly: readOnly, create: !readOnly })
|
|
48
|
+
: new Ctor(path, { readOnly });
|
|
49
|
+
return {
|
|
50
|
+
prepare(sql) {
|
|
51
|
+
const statement = database.prepare?.(sql) ?? database.query?.(sql);
|
|
52
|
+
if (!statement)
|
|
53
|
+
throw new Error("SQLite statement API is unavailable");
|
|
54
|
+
return statement;
|
|
55
|
+
},
|
|
56
|
+
exec(sql) {
|
|
57
|
+
return database.exec(sql);
|
|
58
|
+
},
|
|
59
|
+
close() {
|
|
60
|
+
database.close();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared leaf module: version constants, wire types, RuntimeError.
|
|
3
|
+
* Imports nothing from sibling modules — any file may depend on it.
|
|
4
|
+
*/
|
|
5
|
+
export declare const RUNTIME_VERSION = "2.1.0";
|
|
6
|
+
export declare const MACHINE_SCHEMA_VERSION = "1.0.0";
|
|
7
|
+
export declare const CONFIG_SCHEMA_VERSION: 1;
|
|
8
|
+
/** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
|
|
9
|
+
export declare const INDEX_FORMAT_VERSION: 16;
|
|
10
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
11
|
+
export declare const DEFAULT_MAX_OUTPUT_BYTES: number;
|
|
12
|
+
export declare const DEFAULT_REFRESH_INTERVAL_MS = 30000;
|
|
13
|
+
/** Max one caller waits on a shared index refresh before serving stale. */
|
|
14
|
+
export declare const DEFAULT_FRESHNESS_WAIT_MS = 10000;
|
|
15
|
+
export interface RuntimeContext {
|
|
16
|
+
cwd: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const RESOLVED_ROOT: unique symbol;
|
|
19
|
+
export type InternalRuntimeContext = RuntimeContext & {
|
|
20
|
+
[RESOLVED_ROOT]?: true;
|
|
21
|
+
};
|
|
22
|
+
export interface RunOptions {
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
env?: Readonly<Record<string, string>>;
|
|
26
|
+
}
|
|
27
|
+
export interface ExecOptions {
|
|
28
|
+
cwd: string;
|
|
29
|
+
env: NodeJS.ProcessEnv;
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
timeout?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface ExecResult {
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
code?: number | null;
|
|
37
|
+
exitCode?: number | null;
|
|
38
|
+
signal?: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface PiExec {
|
|
41
|
+
exec(command: string, args: readonly string[], options: ExecOptions): Promise<ExecResult>;
|
|
42
|
+
}
|
|
43
|
+
export interface MachineEnvelope {
|
|
44
|
+
tool: "asgrep";
|
|
45
|
+
schema_version: string;
|
|
46
|
+
ok: boolean;
|
|
47
|
+
version?: string;
|
|
48
|
+
machine_schema_version?: string;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
export declare class RuntimeError extends Error {
|
|
52
|
+
readonly code: string;
|
|
53
|
+
readonly details: Readonly<Record<string, unknown>>;
|
|
54
|
+
constructor(code: string, message: string, details?: Readonly<Record<string, unknown>>);
|
|
55
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared leaf module: version constants, wire types, RuntimeError.
|
|
3
|
+
* Imports nothing from sibling modules — any file may depend on it.
|
|
4
|
+
*/
|
|
5
|
+
export const RUNTIME_VERSION = "2.1.0";
|
|
6
|
+
export const MACHINE_SCHEMA_VERSION = "1.0.0";
|
|
7
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
8
|
+
/** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
|
|
9
|
+
export const INDEX_FORMAT_VERSION = 16;
|
|
10
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
11
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
export const DEFAULT_REFRESH_INTERVAL_MS = 30_000;
|
|
13
|
+
/** Max one caller waits on a shared index refresh before serving stale. */
|
|
14
|
+
export const DEFAULT_FRESHNESS_WAIT_MS = 10_000;
|
|
15
|
+
export const RESOLVED_ROOT = Symbol("resolvedRoot");
|
|
16
|
+
export class RuntimeError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
details;
|
|
19
|
+
constructor(code, message, details = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.details = details;
|
|
23
|
+
this.name = "AstSgrepRuntimeError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Pi TUI result card — supernova-style: the call slot is empty; one card
|
|
2
|
+
* owns the whole lifecycle (running → ops ledger → result → error). Rows are
|
|
3
|
+
* fixed-column and theme-painted; nothing here writes to the model channel. */
|
|
4
|
+
import { type HitLike, type PresentTheme } from "./present.js";
|
|
5
|
+
/** renderCall component that paints nothing — the result card owns display. */
|
|
6
|
+
export declare const EMPTY_CALL: {
|
|
7
|
+
render: () => string[];
|
|
8
|
+
invalidate(): void;
|
|
9
|
+
};
|
|
10
|
+
export type CardModel = {
|
|
11
|
+
command: string;
|
|
12
|
+
title: Array<string | null | undefined>;
|
|
13
|
+
hits?: HitLike[];
|
|
14
|
+
/** Applied edit diffs: path + line + removed/added line arrays. */
|
|
15
|
+
edits?: Array<{
|
|
16
|
+
path?: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
removed?: string[];
|
|
19
|
+
added?: string[];
|
|
20
|
+
truncated?: boolean;
|
|
21
|
+
}>;
|
|
22
|
+
ops?: Array<{
|
|
23
|
+
tool: string;
|
|
24
|
+
target: string;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
ms: number;
|
|
27
|
+
}>;
|
|
28
|
+
resultLines?: string[];
|
|
29
|
+
/** Warnings that qualify the answer (stale index, unindexed repo). */
|
|
30
|
+
notes?: string[];
|
|
31
|
+
error?: string;
|
|
32
|
+
running?: boolean;
|
|
33
|
+
expanded?: boolean;
|
|
34
|
+
};
|
|
35
|
+
export declare class AsgrepCard {
|
|
36
|
+
theme: PresentTheme | undefined;
|
|
37
|
+
model: CardModel | undefined;
|
|
38
|
+
cache: {
|
|
39
|
+
width: number;
|
|
40
|
+
lines: string[];
|
|
41
|
+
} | undefined;
|
|
42
|
+
set(theme: PresentTheme | undefined, model: CardModel): void;
|
|
43
|
+
invalidate(): void;
|
|
44
|
+
render(width?: number): string[];
|
|
45
|
+
}
|
|
46
|
+
type ResultLike = {
|
|
47
|
+
isError?: boolean;
|
|
48
|
+
content?: Array<{
|
|
49
|
+
type: string;
|
|
50
|
+
text?: string;
|
|
51
|
+
}>;
|
|
52
|
+
details?: unknown;
|
|
53
|
+
};
|
|
54
|
+
type RenderOptions = {
|
|
55
|
+
expanded?: boolean;
|
|
56
|
+
isPartial?: boolean;
|
|
57
|
+
};
|
|
58
|
+
type RenderContext = {
|
|
59
|
+
lastComponent?: unknown;
|
|
60
|
+
args?: object;
|
|
61
|
+
};
|
|
62
|
+
/** Build the card model from the tool result's details payload. */
|
|
63
|
+
export declare function cardModel(result: ResultLike, options: RenderOptions, callArgs?: Record<string, unknown>): CardModel;
|
|
64
|
+
/** renderResult entrypoint: bind one card per result slot, feed it details. */
|
|
65
|
+
export declare function renderAsgrepResult(result: ResultLike, options: RenderOptions, theme: PresentTheme, context?: RenderContext): AsgrepCard;
|
|
66
|
+
export {};
|