mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
Add shell command explainer (#75004)
Summary: - The PR adds an internal Tree-sitter-backed shell command explainer under `src/infra`, parser runtime/tests, dependency/build-policy updates, an index export, and a changelog entry. - Reproducibility: not applicable. this is a feature PR rather than a bug report. For the prior PR blocker, source inspection shows byte-to-string span conversion and focused Unicode span coverage on the exact head. Automerge notes: - Ran the ClawSweeper repair loop before final review. - Included post-review commit in the final squash: Repair shell command explainer automerge blockers - Included post-review commit in the final squash: fix(clawsweeper): address review for automerge-openclaw-openclaw-7500… Validation: - ClawSweeper review passed for head47577579e9. - Required merge gates passed before the squash merge. Prepared head SHA:47577579e9Review: https://github.com/openclaw/openclaw/pull/75004#issuecomment-4351322592 Co-authored-by: Jesse Merhi <jessejmerhi@gmail.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.
|
||||
- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists.
|
||||
- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.
|
||||
- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const rootEntries = [
|
||||
"src/entry.ts!",
|
||||
"src/cli/daemon-cli.ts!",
|
||||
"src/infra/warning-filter.ts!",
|
||||
"src/infra/command-explainer/index.ts!",
|
||||
bundledPluginFile("telegram", "src/audit.ts", "!"),
|
||||
bundledPluginFile("telegram", "src/token.ts", "!"),
|
||||
"src/hooks/bundled/*/handler.ts!",
|
||||
@@ -139,6 +140,7 @@ const config = {
|
||||
"@openclaw/*",
|
||||
"playwright-core",
|
||||
"sqlite-vec",
|
||||
"tree-sitter-bash",
|
||||
...rootBundledPluginRuntimeDependencies,
|
||||
],
|
||||
project: [
|
||||
|
||||
@@ -1700,10 +1700,12 @@
|
||||
"sqlite-vec": "0.1.9",
|
||||
"tar": "7.5.13",
|
||||
"tokenjuice": "0.7.0",
|
||||
"tree-sitter-bash": "^0.25.1",
|
||||
"tslog": "^4.10.2",
|
||||
"typebox": "1.1.37",
|
||||
"undici": "8.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
"web-tree-sitter": "^0.26.8",
|
||||
"ws": "^8.20.0",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.4.1"
|
||||
@@ -1784,7 +1786,8 @@
|
||||
"sharp"
|
||||
],
|
||||
"ignoredBuiltDependencies": [
|
||||
"koffi"
|
||||
"koffi",
|
||||
"tree-sitter-bash"
|
||||
],
|
||||
"packageExtensions": {
|
||||
"@mariozechner/pi-coding-agent": {
|
||||
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -193,6 +193,9 @@ importers:
|
||||
tokenjuice:
|
||||
specifier: 0.7.0
|
||||
version: 0.7.0
|
||||
tree-sitter-bash:
|
||||
specifier: ^0.25.1
|
||||
version: 0.25.1
|
||||
tslog:
|
||||
specifier: ^4.10.2
|
||||
version: 4.10.2
|
||||
@@ -205,6 +208,9 @@ importers:
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
web-tree-sitter:
|
||||
specifier: ^0.26.8
|
||||
version: 0.26.8
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
@@ -6379,6 +6385,10 @@ packages:
|
||||
resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
node-downloader-helper@2.1.11:
|
||||
resolution: {integrity: sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ==}
|
||||
engines: {node: '>=14.18'}
|
||||
@@ -7388,6 +7398,14 @@ packages:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
|
||||
tree-sitter-bash@0.25.1:
|
||||
resolution: {integrity: sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==}
|
||||
peerDependencies:
|
||||
tree-sitter: ^0.25.0
|
||||
peerDependenciesMeta:
|
||||
tree-sitter:
|
||||
optional: true
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
|
||||
@@ -7665,6 +7683,9 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
web-tree-sitter@0.26.8:
|
||||
resolution: {integrity: sha512-4sUwi7ZyOrIk5KLgYLkc2A/F0LFMQnBhfb+2Cdl7ik4ePJ6JD+fk4ofI2sA5eGawBKBaK4Vntt7Ww5KcEsay4A==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -13555,8 +13576,9 @@ snapshots:
|
||||
|
||||
netmask@2.1.1: {}
|
||||
|
||||
node-addon-api@8.7.0:
|
||||
optional: true
|
||||
node-addon-api@8.7.0: {}
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-downloader-helper@2.1.11: {}
|
||||
|
||||
@@ -14755,6 +14777,11 @@ snapshots:
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
tree-sitter-bash@0.25.1:
|
||||
dependencies:
|
||||
node-addon-api: 8.7.0
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
trough@2.2.0: {}
|
||||
@@ -14976,6 +15003,8 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
web-tree-sitter@0.26.8: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
@@ -48,3 +48,4 @@ onlyBuiltDependencies:
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- koffi
|
||||
- tree-sitter-bash
|
||||
|
||||
718
src/infra/command-explainer/extract.test.ts
Normal file
718
src/infra/command-explainer/extract.test.ts
Normal file
@@ -0,0 +1,718 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Node as TreeSitterNode, Parser, Tree } from "web-tree-sitter";
|
||||
import { explainShellCommand } from "./extract.js";
|
||||
import {
|
||||
getBashParserForCommandExplanation,
|
||||
parseBashForCommandExplanation,
|
||||
resolvePackageFileForCommandExplanation,
|
||||
setBashParserLoaderForCommandExplanationForTest,
|
||||
} from "./tree-sitter-runtime.js";
|
||||
|
||||
let parserLoaderOverridden = false;
|
||||
|
||||
function setParserLoaderForTest(loader: () => Promise<Parser>): void {
|
||||
parserLoaderOverridden = true;
|
||||
setBashParserLoaderForCommandExplanationForTest(loader);
|
||||
}
|
||||
|
||||
type FakeNodeInit = {
|
||||
type: string;
|
||||
text: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
startPosition: TreeSitterNode["startPosition"];
|
||||
endPosition: TreeSitterNode["endPosition"];
|
||||
namedChildren?: TreeSitterNode[];
|
||||
fieldChildren?: Record<string, TreeSitterNode>;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
function fakeNode(init: FakeNodeInit): TreeSitterNode {
|
||||
const named = init.namedChildren ?? [];
|
||||
const children = named;
|
||||
return {
|
||||
type: init.type,
|
||||
text: init.text,
|
||||
startIndex: init.startIndex,
|
||||
endIndex: init.endIndex,
|
||||
startPosition: init.startPosition,
|
||||
endPosition: init.endPosition,
|
||||
childCount: children.length,
|
||||
namedChildCount: named.length,
|
||||
hasError: init.hasError ?? false,
|
||||
child(index: number): TreeSitterNode | null {
|
||||
return children[index] ?? null;
|
||||
},
|
||||
namedChild(index: number): TreeSitterNode | null {
|
||||
return named[index] ?? null;
|
||||
},
|
||||
childForFieldName(name: string): TreeSitterNode | null {
|
||||
return init.fieldChildren?.[name] ?? null;
|
||||
},
|
||||
} as unknown as TreeSitterNode;
|
||||
}
|
||||
|
||||
function createByteIndexedUnicodeCommandTree(source: string): Tree {
|
||||
const firstCommand = "echo café";
|
||||
const separator = " && ";
|
||||
const secondCommand = "echo ok";
|
||||
const firstCommandEnd = Buffer.byteLength(firstCommand, "utf8");
|
||||
const secondCommandStart = Buffer.byteLength(firstCommand + separator, "utf8");
|
||||
const sourceEnd = Buffer.byteLength(source, "utf8");
|
||||
|
||||
const firstName = fakeNode({
|
||||
type: "command_name",
|
||||
text: "echo",
|
||||
startIndex: 0,
|
||||
endIndex: 4,
|
||||
startPosition: { row: 0, column: 0 },
|
||||
endPosition: { row: 0, column: 4 },
|
||||
});
|
||||
const firstArgument = fakeNode({
|
||||
type: "word",
|
||||
text: "café",
|
||||
startIndex: 5,
|
||||
endIndex: firstCommandEnd,
|
||||
startPosition: { row: 0, column: 5 },
|
||||
endPosition: { row: 0, column: firstCommandEnd },
|
||||
});
|
||||
const first = fakeNode({
|
||||
type: "command",
|
||||
text: firstCommand,
|
||||
startIndex: 0,
|
||||
endIndex: firstCommandEnd,
|
||||
startPosition: { row: 0, column: 0 },
|
||||
endPosition: { row: 0, column: firstCommandEnd },
|
||||
namedChildren: [firstName, firstArgument],
|
||||
fieldChildren: { name: firstName },
|
||||
});
|
||||
|
||||
const secondName = fakeNode({
|
||||
type: "command_name",
|
||||
text: "echo",
|
||||
startIndex: secondCommandStart,
|
||||
endIndex: secondCommandStart + 4,
|
||||
startPosition: { row: 0, column: secondCommandStart },
|
||||
endPosition: { row: 0, column: secondCommandStart + 4 },
|
||||
});
|
||||
const secondArgument = fakeNode({
|
||||
type: "word",
|
||||
text: "ok",
|
||||
startIndex: secondCommandStart + 5,
|
||||
endIndex: sourceEnd,
|
||||
startPosition: { row: 0, column: secondCommandStart + 5 },
|
||||
endPosition: { row: 0, column: sourceEnd },
|
||||
});
|
||||
const second = fakeNode({
|
||||
type: "command",
|
||||
text: secondCommand,
|
||||
startIndex: secondCommandStart,
|
||||
endIndex: sourceEnd,
|
||||
startPosition: { row: 0, column: secondCommandStart },
|
||||
endPosition: { row: 0, column: sourceEnd },
|
||||
namedChildren: [secondName, secondArgument],
|
||||
fieldChildren: { name: secondName },
|
||||
});
|
||||
|
||||
return {
|
||||
rootNode: fakeNode({
|
||||
type: "program",
|
||||
text: source,
|
||||
startIndex: 0,
|
||||
endIndex: sourceEnd,
|
||||
startPosition: { row: 0, column: 0 },
|
||||
endPosition: { row: 0, column: sourceEnd },
|
||||
namedChildren: [first, second],
|
||||
}),
|
||||
delete: vi.fn(),
|
||||
} as unknown as Tree;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (parserLoaderOverridden) {
|
||||
setBashParserLoaderForCommandExplanationForTest();
|
||||
parserLoaderOverridden = false;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("command explainer tree-sitter runtime", () => {
|
||||
it("loads tree-sitter bash and parses a simple command", async () => {
|
||||
const tree = await parseBashForCommandExplanation("ls | grep stuff");
|
||||
|
||||
try {
|
||||
expect(tree.rootNode.type).toBe("program");
|
||||
expect(tree.rootNode.toString()).toContain("pipeline");
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized parser input before parsing", async () => {
|
||||
await expect(parseBashForCommandExplanation("x".repeat(128 * 1024 + 1))).rejects.toThrow(
|
||||
"Shell command is too large to explain",
|
||||
);
|
||||
});
|
||||
|
||||
it("retries parser initialization after a loader rejection", async () => {
|
||||
const parser = {} as Parser;
|
||||
let calls = 0;
|
||||
setParserLoaderForTest(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("transient parser load failure");
|
||||
}
|
||||
return parser;
|
||||
});
|
||||
|
||||
await expect(getBashParserForCommandExplanation()).rejects.toThrow(
|
||||
"transient parser load failure",
|
||||
);
|
||||
await expect(getBashParserForCommandExplanation()).resolves.toBe(parser);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("reports missing parser packages and wasm files with explainer context", () => {
|
||||
expect(() =>
|
||||
resolvePackageFileForCommandExplanation(
|
||||
"definitely-missing-openclaw-parser-package",
|
||||
"parser.wasm",
|
||||
),
|
||||
).toThrow("Unable to resolve definitely-missing-openclaw-parser-package");
|
||||
|
||||
expect(() =>
|
||||
resolvePackageFileForCommandExplanation("web-tree-sitter", "missing-openclaw-parser.wasm"),
|
||||
).toThrow("Unable to locate missing-openclaw-parser.wasm in web-tree-sitter");
|
||||
});
|
||||
|
||||
it("reports parser progress cancellation as a timeout", async () => {
|
||||
const reset = vi.fn();
|
||||
const parser = {
|
||||
parse: (
|
||||
_source: string,
|
||||
_oldTree: unknown,
|
||||
options?: { progressCallback?: (state: unknown) => boolean },
|
||||
) => {
|
||||
options?.progressCallback?.({ currentOffset: 0, hasError: false });
|
||||
return null;
|
||||
},
|
||||
reset,
|
||||
} as unknown as Parser;
|
||||
vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValue(501);
|
||||
setParserLoaderForTest(async () => parser);
|
||||
|
||||
await expect(parseBashForCommandExplanation("echo hi")).rejects.toThrow(
|
||||
"tree-sitter-bash timed out after 500ms while parsing shell command",
|
||||
);
|
||||
expect(reset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps parser byte offsets to JavaScript string spans for Unicode source", async () => {
|
||||
const source = "echo café && echo ok";
|
||||
const parser = {
|
||||
parse: vi.fn(() => createByteIndexedUnicodeCommandTree(source)),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
setParserLoaderForTest(async () => parser as unknown as Parser);
|
||||
|
||||
const explanation = await explainShellCommand(source);
|
||||
|
||||
expect(explanation.topLevelCommands).toEqual([
|
||||
expect.objectContaining({
|
||||
executable: "echo",
|
||||
argv: ["echo", "café"],
|
||||
span: expect.objectContaining({ startIndex: 0, endIndex: 9 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
executable: "echo",
|
||||
argv: ["echo", "ok"],
|
||||
span: expect.objectContaining({ startIndex: 13, endIndex: 20 }),
|
||||
}),
|
||||
]);
|
||||
for (const command of explanation.topLevelCommands) {
|
||||
expect(source.slice(command.span.startIndex, command.span.endIndex)).toBe(command.text);
|
||||
expect(command.span.endPosition.column).toBe(command.span.endIndex);
|
||||
}
|
||||
});
|
||||
|
||||
it("explains a pipeline with python inline eval", async () => {
|
||||
const explanation = await explainShellCommand('ls | grep "stuff" | python -c \'print("hi")\'');
|
||||
|
||||
expect(explanation.ok).toBe(true);
|
||||
expect(explanation.shapes).toContain("pipeline");
|
||||
expect(explanation.topLevelCommands.map((step) => step.executable)).toEqual([
|
||||
"ls",
|
||||
"grep",
|
||||
"python",
|
||||
]);
|
||||
expect(explanation.topLevelCommands[2]?.argv).toEqual(["python", "-c", 'print("hi")']);
|
||||
expect(explanation.nestedCommands).toEqual([]);
|
||||
expect(explanation.topLevelCommands[2]?.span).toEqual(
|
||||
expect.objectContaining({ startIndex: expect.any(Number), endIndex: expect.any(Number) }),
|
||||
);
|
||||
expect(explanation.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "inline-eval",
|
||||
command: "python",
|
||||
flag: "-c",
|
||||
text: "python -c 'print(\"hi\")'",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("separates command substitution in an argument", async () => {
|
||||
const explanation = await explainShellCommand("echo $(whoami)");
|
||||
|
||||
expect(explanation.topLevelCommands.map((step) => step.executable)).toEqual(["echo"]);
|
||||
expect(explanation.nestedCommands).toEqual([
|
||||
expect.objectContaining({ context: "command-substitution", executable: "whoami" }),
|
||||
]);
|
||||
expect(explanation.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "command-substitution", text: "$(whoami)" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks command substitution in executable position as dynamic", async () => {
|
||||
const explanation = await explainShellCommand("$(whoami) --help");
|
||||
|
||||
expect(explanation.topLevelCommands).toEqual([]);
|
||||
expect(explanation.nestedCommands).toEqual([
|
||||
expect.objectContaining({ context: "command-substitution", executable: "whoami" }),
|
||||
]);
|
||||
expect(explanation.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "dynamic-executable", text: "$(whoami)" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("separates process substitution commands", async () => {
|
||||
const explanation = await explainShellCommand("diff <(ls a) <(ls b)");
|
||||
|
||||
expect(explanation.topLevelCommands.map((step) => step.executable)).toEqual(["diff"]);
|
||||
expect(explanation.nestedCommands.map((step) => `${step.context}:${step.executable}`)).toEqual([
|
||||
"process-substitution:ls",
|
||||
"process-substitution:ls",
|
||||
]);
|
||||
expect(explanation.risks.map((risk) => risk.kind)).toContain("process-substitution");
|
||||
});
|
||||
|
||||
it("detects AND OR and sequence shapes", async () => {
|
||||
const explanation = await explainShellCommand("pnpm test && pnpm build || echo failed; pwd");
|
||||
|
||||
expect(explanation.shapes).toEqual(expect.arrayContaining(["and", "or", "sequence"]));
|
||||
expect(explanation.topLevelCommands.map((step) => step.executable)).toEqual([
|
||||
"pnpm",
|
||||
"pnpm",
|
||||
"echo",
|
||||
"pwd",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects newline sequences and background commands", async () => {
|
||||
const newlineSequence = await explainShellCommand("echo a\necho b");
|
||||
expect(newlineSequence.shapes).toContain("sequence");
|
||||
expect(newlineSequence.topLevelCommands.map((step) => step.executable)).toEqual([
|
||||
"echo",
|
||||
"echo",
|
||||
]);
|
||||
|
||||
const background = await explainShellCommand("echo a & echo b");
|
||||
expect(background.shapes).toEqual(expect.arrayContaining(["background", "sequence"]));
|
||||
expect(background.topLevelCommands.map((step) => step.executable)).toEqual(["echo", "echo"]);
|
||||
});
|
||||
|
||||
it("detects conditionals", async () => {
|
||||
const explanation = await explainShellCommand(
|
||||
"if test -f package.json; then pnpm test; else echo missing; fi",
|
||||
);
|
||||
|
||||
expect(explanation.shapes).toContain("if");
|
||||
expect(explanation.topLevelCommands.map((step) => step.executable)).toEqual([
|
||||
"test",
|
||||
"pnpm",
|
||||
"echo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects declaration and test command forms", async () => {
|
||||
const declaration = await explainShellCommand("export A=$(whoami)");
|
||||
|
||||
expect(declaration.topLevelCommands).toEqual([
|
||||
expect.objectContaining({ executable: "export", argv: ["export", "A=$(whoami)"] }),
|
||||
]);
|
||||
expect(declaration.nestedCommands).toEqual([
|
||||
expect.objectContaining({ context: "command-substitution", executable: "whoami" }),
|
||||
]);
|
||||
|
||||
const testCommand = await explainShellCommand("[ -f package.json ]");
|
||||
expect(testCommand.topLevelCommands).toEqual([
|
||||
expect.objectContaining({ executable: "[", argv: ["[", "-f", "package.json"] }),
|
||||
]);
|
||||
|
||||
const doubleBracket = await explainShellCommand("[[ -f package.json ]]");
|
||||
expect(doubleBracket.topLevelCommands).toEqual([
|
||||
expect.objectContaining({ executable: "[[", argv: ["[[", "-f", "package.json"] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects shell wrappers", async () => {
|
||||
const explanation = await explainShellCommand('bash -lc "echo hi | wc -c"');
|
||||
|
||||
expect(explanation.topLevelCommands.map((step) => step.executable)).toEqual(["bash"]);
|
||||
expect(explanation.nestedCommands).toEqual([
|
||||
expect.objectContaining({ context: "wrapper-payload", executable: "echo" }),
|
||||
expect.objectContaining({ context: "wrapper-payload", executable: "wc" }),
|
||||
]);
|
||||
const [wrappedEcho, wrappedWc] = explanation.nestedCommands;
|
||||
expect(explanation.source.slice(wrappedEcho?.span.startIndex, wrappedEcho?.span.endIndex)).toBe(
|
||||
"echo hi",
|
||||
);
|
||||
expect(explanation.source.slice(wrappedWc?.span.startIndex, wrappedWc?.span.endIndex)).toBe(
|
||||
"wc -c",
|
||||
);
|
||||
expect(explanation.shapes).toContain("pipeline");
|
||||
expect(explanation.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "bash",
|
||||
flag: "-lc",
|
||||
payload: "echo hi | wc -c",
|
||||
text: 'bash -lc "echo hi | wc -c"',
|
||||
}),
|
||||
);
|
||||
|
||||
const combinedFlags = await explainShellCommand('bash -euxc "echo hi"');
|
||||
expect(combinedFlags.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "bash",
|
||||
flag: "-euxc",
|
||||
payload: "echo hi",
|
||||
}),
|
||||
);
|
||||
|
||||
const combinedInline = await explainShellCommand('bash -c"echo hi"');
|
||||
expect(combinedInline.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "bash",
|
||||
payload: "echo hi",
|
||||
}),
|
||||
);
|
||||
|
||||
const powershell = await explainShellCommand('pwsh -Command "Get-ChildItem"');
|
||||
expect(powershell.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "pwsh",
|
||||
flag: "-Command",
|
||||
payload: "Get-ChildItem",
|
||||
}),
|
||||
);
|
||||
|
||||
const powershellWithOptions = await explainShellCommand(
|
||||
"pwsh -ExecutionPolicy Bypass -Command Get-ChildItem",
|
||||
);
|
||||
expect(powershellWithOptions.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "pwsh",
|
||||
flag: "-Command",
|
||||
payload: "Get-ChildItem",
|
||||
}),
|
||||
);
|
||||
|
||||
const dynamicPayload = await explainShellCommand('bash -lc "$CMD"');
|
||||
expect(dynamicPayload.nestedCommands).toEqual([]);
|
||||
expect(dynamicPayload.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "bash",
|
||||
flag: "-lc",
|
||||
payload: "$CMD",
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidPayload = await explainShellCommand("bash -lc 'echo &&'");
|
||||
expect(invalidPayload.ok).toBe(false);
|
||||
expect(invalidPayload.risks).toContainEqual(expect.objectContaining({ kind: "syntax-error" }));
|
||||
|
||||
const powershellPipeline = await explainShellCommand(
|
||||
'pwsh -Command "Get-ChildItem | Select Name"',
|
||||
);
|
||||
expect(powershellPipeline.nestedCommands).toEqual([]);
|
||||
expect(powershellPipeline.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "pwsh",
|
||||
flag: "-Command",
|
||||
payload: "Get-ChildItem | Select Name",
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [command, carrier] of [
|
||||
["time bash -lc 'id'", "time"],
|
||||
["nice bash -lc 'id'", "nice"],
|
||||
["timeout 1 bash -lc 'id'", "timeout"],
|
||||
["caffeinate -d -w 42 bash -lc 'id'", "caffeinate"],
|
||||
] as const) {
|
||||
const wrapped = await explainShellCommand(command);
|
||||
expect(wrapped.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper-through-carrier",
|
||||
command: carrier,
|
||||
}),
|
||||
);
|
||||
expect(wrapped.nestedCommands).toContainEqual(
|
||||
expect.objectContaining({ context: "wrapper-payload", executable: "id" }),
|
||||
);
|
||||
const wrappedId = wrapped.nestedCommands.find((step) => step.executable === "id");
|
||||
expect(wrapped.source.slice(wrappedId?.span.startIndex, wrappedId?.span.endIndex)).toBe("id");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps decoded shell-wrapper payload spans back to original source escapes", async () => {
|
||||
const explanation = await explainShellCommand('bash -lc "printf \\"hi\\" | wc -c"');
|
||||
|
||||
const wrappedPrintf = explanation.nestedCommands.find((step) => step.executable === "printf");
|
||||
const wrappedWc = explanation.nestedCommands.find((step) => step.executable === "wc");
|
||||
|
||||
expect(wrappedPrintf).toEqual(
|
||||
expect.objectContaining({
|
||||
context: "wrapper-payload",
|
||||
text: 'printf "hi"',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
explanation.source.slice(wrappedPrintf?.span.startIndex, wrappedPrintf?.span.endIndex),
|
||||
).toBe('printf \\"hi\\"');
|
||||
expect(explanation.source.slice(wrappedWc?.span.startIndex, wrappedWc?.span.endIndex)).toBe(
|
||||
"wc -c",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes static shell words before classifying commands", async () => {
|
||||
const quotedCommand = await explainShellCommand("e'c'ho a\\ b \"c d\"");
|
||||
expect(quotedCommand.topLevelCommands).toEqual([
|
||||
expect.objectContaining({ executable: "echo", argv: ["echo", "a b", "c d"] }),
|
||||
]);
|
||||
|
||||
const ansiCString = await explainShellCommand("$'ec\\x68o' hi");
|
||||
expect(ansiCString.topLevelCommands).toEqual([
|
||||
expect.objectContaining({ executable: "echo", argv: ["echo", "hi"] }),
|
||||
]);
|
||||
|
||||
const wrappedShell = await explainShellCommand("b'a'sh -lc 'echo hi'");
|
||||
expect(wrappedShell.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "shell-wrapper",
|
||||
executable: "bash",
|
||||
flag: "-lc",
|
||||
payload: "echo hi",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not normalize dynamic executable names into trusted commands", async () => {
|
||||
const dynamicPrefix = await explainShellCommand("e${CMD}ho hi");
|
||||
expect(dynamicPrefix.topLevelCommands).toEqual([]);
|
||||
expect(dynamicPrefix.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "dynamic-executable", text: "e${CMD}ho" }),
|
||||
);
|
||||
|
||||
const dynamicQuoted = await explainShellCommand('"${CMD}" hi');
|
||||
expect(dynamicQuoted.topLevelCommands).toEqual([]);
|
||||
expect(dynamicQuoted.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "dynamic-executable", text: '"${CMD}"' }),
|
||||
);
|
||||
|
||||
const dynamicGlob = await explainShellCommand("./ec* hi");
|
||||
expect(dynamicGlob.topLevelCommands).toEqual([]);
|
||||
expect(dynamicGlob.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "dynamic-executable", text: "./ec*" }),
|
||||
);
|
||||
|
||||
const dynamicBraceExpansion = await explainShellCommand("./{echo,printf} hi");
|
||||
expect(dynamicBraceExpansion.topLevelCommands).toEqual([]);
|
||||
expect(dynamicBraceExpansion.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "dynamic-executable", text: "./{echo,printf}" }),
|
||||
);
|
||||
|
||||
const dynamicArgument = await explainShellCommand("echo ./ec*");
|
||||
expect(dynamicArgument.topLevelCommands).toEqual([
|
||||
expect.objectContaining({ executable: "echo", argv: ["echo", "./ec*"] }),
|
||||
]);
|
||||
expect(dynamicArgument.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "dynamic-argument",
|
||||
command: "echo",
|
||||
argumentIndex: 1,
|
||||
text: "./ec*",
|
||||
}),
|
||||
);
|
||||
|
||||
const dynamicShellFlag = await explainShellCommand("bash $FLAGS id");
|
||||
expect(dynamicShellFlag.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "dynamic-argument",
|
||||
command: "bash",
|
||||
argumentIndex: 1,
|
||||
text: "$FLAGS",
|
||||
}),
|
||||
);
|
||||
|
||||
const lineContinuation = await explainShellCommand("ec\\\nho hi");
|
||||
expect(lineContinuation.topLevelCommands).toEqual([]);
|
||||
expect(lineContinuation.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "line-continuation" }),
|
||||
);
|
||||
expect(lineContinuation.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "dynamic-executable" }),
|
||||
);
|
||||
|
||||
const continuedArgument = await explainShellCommand("pnpm test \\\n --filter foo");
|
||||
expect(continuedArgument.topLevelCommands).toEqual([
|
||||
expect.objectContaining({
|
||||
executable: "pnpm",
|
||||
argv: ["pnpm", "test", "--filter", "foo"],
|
||||
}),
|
||||
]);
|
||||
expect(continuedArgument.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "line-continuation" }),
|
||||
);
|
||||
|
||||
const invalidObfuscation = await explainShellCommand("e'c'h'o hi");
|
||||
expect(invalidObfuscation.ok).toBe(false);
|
||||
expect(invalidObfuscation.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "syntax-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects command carriers", async () => {
|
||||
const find = await explainShellCommand('find . -name "*.ts" -exec grep -n TODO {} +');
|
||||
expect(find.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "command-carrier", command: "find", flag: "-exec" }),
|
||||
);
|
||||
|
||||
const xargs = await explainShellCommand('printf "%s\\n" a b | xargs -I{} sh -c "echo {}"');
|
||||
expect(xargs.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "command-carrier", command: "xargs" }),
|
||||
);
|
||||
|
||||
const envSplitString = await explainShellCommand("env -S 'sh -c \"id\"'");
|
||||
expect(envSplitString.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "command-carrier", command: "env", flag: "-S" }),
|
||||
);
|
||||
|
||||
for (const command of [
|
||||
'env python -c "print(1)"',
|
||||
'sudo python -c "print(1)"',
|
||||
'command python -c "print(1)"',
|
||||
]) {
|
||||
const explanation = await explainShellCommand(command);
|
||||
expect(explanation.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "inline-eval",
|
||||
command: "python",
|
||||
flag: "-c",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects eval, source, aliases, and carrier shell wrappers", async () => {
|
||||
const evalCommand = await explainShellCommand('eval "$OPENCLAW_CMD"');
|
||||
expect(evalCommand.risks).toContainEqual(expect.objectContaining({ kind: "eval" }));
|
||||
|
||||
const builtinEval = await explainShellCommand("builtin eval 'echo hi'");
|
||||
expect(builtinEval.risks).toContainEqual(expect.objectContaining({ kind: "eval" }));
|
||||
|
||||
const sourceCommand = await explainShellCommand(". ./some-script.sh");
|
||||
expect(sourceCommand.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "source", command: "." }),
|
||||
);
|
||||
|
||||
const aliasCommand = await explainShellCommand("alias ll='ls -l'");
|
||||
expect(aliasCommand.risks).toContainEqual(expect.objectContaining({ kind: "alias" }));
|
||||
|
||||
const sudoShell = await explainShellCommand('sudo sh -c "id && whoami"');
|
||||
expect(sudoShell.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "shell-wrapper-through-carrier", command: "sudo" }),
|
||||
);
|
||||
|
||||
const commandShell = await explainShellCommand("command bash -lc 'id && whoami'");
|
||||
expect(commandShell.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "shell-wrapper-through-carrier", command: "command" }),
|
||||
);
|
||||
|
||||
const sudoCombinedFlags = await explainShellCommand('sudo bash -euxc "id && whoami"');
|
||||
expect(sudoCombinedFlags.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "shell-wrapper-through-carrier", command: "sudo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats function bodies as nested command context", async () => {
|
||||
const explanation = await explainShellCommand("ls() { echo hi; }; ls /tmp");
|
||||
|
||||
expect(explanation.topLevelCommands).toEqual([
|
||||
expect.objectContaining({ context: "top-level", executable: "ls", argv: ["ls", "/tmp"] }),
|
||||
]);
|
||||
expect(explanation.nestedCommands).toEqual([
|
||||
expect.objectContaining({ context: "function-definition", executable: "echo" }),
|
||||
]);
|
||||
expect(explanation.risks).toContainEqual(
|
||||
expect.objectContaining({ kind: "function-definition", name: "ls" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat literal operator text as command shapes", async () => {
|
||||
const quotedSemicolon = await explainShellCommand('echo ";"');
|
||||
expect(quotedSemicolon.shapes).not.toContain("sequence");
|
||||
|
||||
const heredoc = await explainShellCommand("cat <<EOF\n;\nEOF");
|
||||
expect(heredoc.shapes).not.toContain("sequence");
|
||||
});
|
||||
|
||||
it("marks redirects heredocs and here-strings as risks", async () => {
|
||||
const redirect = await explainShellCommand("echo hi > out.txt");
|
||||
const redirectRisks = redirect.risks.filter((risk) => risk.kind === "redirect");
|
||||
expect(redirectRisks).toEqual([expect.objectContaining({ text: "> out.txt" })]);
|
||||
|
||||
const heredoc = await explainShellCommand("cat <<EOF\nhello\nEOF");
|
||||
expect(heredoc.risks).toContainEqual(expect.objectContaining({ kind: "heredoc" }));
|
||||
|
||||
const hereString = await explainShellCommand('cat <<< "hello"');
|
||||
expect(hereString.risks).toContainEqual(expect.objectContaining({ kind: "here-string" }));
|
||||
});
|
||||
|
||||
it("reports syntax errors with source spans", async () => {
|
||||
const explanation = await explainShellCommand("echo 'unterminated");
|
||||
|
||||
expect(explanation.ok).toBe(false);
|
||||
expect(explanation.risks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "syntax-error",
|
||||
span: expect.objectContaining({
|
||||
startIndex: expect.any(Number),
|
||||
endIndex: expect.any(Number),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses and extracts a repeated approval-sized corpus without parser state leakage", async () => {
|
||||
const corpus = [
|
||||
'ls | grep "stuff" | python -c \'print("hi")\'',
|
||||
"echo $(whoami)",
|
||||
"diff <(ls a) <(ls b)",
|
||||
'find . -name "*.ts" -exec grep -n TODO {} +',
|
||||
'bash -lc "echo hi | wc -c"',
|
||||
];
|
||||
const iterations = 3;
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
for (const command of corpus) {
|
||||
const explanation = await explainShellCommand(command);
|
||||
expect(explanation.risks.length + explanation.topLevelCommands.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
1196
src/infra/command-explainer/extract.ts
Normal file
1196
src/infra/command-explainer/extract.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
src/infra/command-explainer/index.ts
Normal file
9
src/infra/command-explainer/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { explainShellCommand } from "./extract.js";
|
||||
export type {
|
||||
CommandContext,
|
||||
CommandExplanation,
|
||||
CommandRisk,
|
||||
CommandShape,
|
||||
CommandStep,
|
||||
SourceSpan,
|
||||
} from "./types.js";
|
||||
107
src/infra/command-explainer/tree-sitter-runtime.ts
Normal file
107
src/infra/command-explainer/tree-sitter-runtime.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import * as TreeSitter from "web-tree-sitter";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let parserPromise: Promise<TreeSitter.Parser> | null = null;
|
||||
let parserLoader: () => Promise<TreeSitter.Parser> = loadParser;
|
||||
const MAX_COMMAND_EXPLANATION_SOURCE_CHARS = 128 * 1024;
|
||||
const MAX_COMMAND_EXPLANATION_PARSE_MS = 500;
|
||||
|
||||
export function resolvePackageFileForCommandExplanation(
|
||||
packageName: string,
|
||||
fileName: string,
|
||||
): string {
|
||||
let packageEntry: string;
|
||||
try {
|
||||
packageEntry = require.resolve(packageName);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to resolve ${packageName} while loading the shell command explainer parser`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
let directory = path.dirname(packageEntry);
|
||||
const searched: string[] = [];
|
||||
for (let depth = 0; depth < 5; depth += 1) {
|
||||
const candidate = path.join(directory, fileName);
|
||||
searched.push(candidate);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) {
|
||||
break;
|
||||
}
|
||||
directory = parent;
|
||||
}
|
||||
throw new Error(
|
||||
`Unable to locate ${fileName} in ${packageName} while loading the shell command explainer parser; searched ${searched.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWebTreeSitterFile(fileName: string): string {
|
||||
return resolvePackageFileForCommandExplanation("web-tree-sitter", fileName);
|
||||
}
|
||||
|
||||
function resolveBashWasmPath(): string {
|
||||
return resolvePackageFileForCommandExplanation("tree-sitter-bash", "tree-sitter-bash.wasm");
|
||||
}
|
||||
|
||||
async function loadParser(): Promise<TreeSitter.Parser> {
|
||||
await TreeSitter.Parser.init({
|
||||
locateFile: resolveWebTreeSitterFile,
|
||||
});
|
||||
const language = await TreeSitter.Language.load(resolveBashWasmPath());
|
||||
const parser = new TreeSitter.Parser();
|
||||
parser.setLanguage(language);
|
||||
return parser;
|
||||
}
|
||||
|
||||
export function getBashParserForCommandExplanation(): Promise<TreeSitter.Parser> {
|
||||
parserPromise ??= parserLoader().catch((error: unknown) => {
|
||||
parserPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return parserPromise;
|
||||
}
|
||||
|
||||
export function setBashParserLoaderForCommandExplanationForTest(
|
||||
loader?: () => Promise<TreeSitter.Parser>,
|
||||
): void {
|
||||
parserPromise = null;
|
||||
parserLoader = loader ?? loadParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level parser access for tests and parser diagnostics.
|
||||
* Callers own the returned Tree and must call tree.delete().
|
||||
* Prefer explainShellCommand for normal command-explainer use.
|
||||
*/
|
||||
export async function parseBashForCommandExplanation(source: string): Promise<TreeSitter.Tree> {
|
||||
if (source.length > MAX_COMMAND_EXPLANATION_SOURCE_CHARS) {
|
||||
throw new Error("Shell command is too large to explain");
|
||||
}
|
||||
const parser = await getBashParserForCommandExplanation();
|
||||
const deadlineMs = performance.now() + MAX_COMMAND_EXPLANATION_PARSE_MS;
|
||||
let timedOut = false;
|
||||
const tree = parser.parse(source, null, {
|
||||
progressCallback: () => {
|
||||
timedOut = performance.now() > deadlineMs;
|
||||
return timedOut;
|
||||
},
|
||||
});
|
||||
if (!tree) {
|
||||
parser.reset();
|
||||
if (timedOut) {
|
||||
throw new Error(
|
||||
`tree-sitter-bash timed out after ${MAX_COMMAND_EXPLANATION_PARSE_MS}ms while parsing shell command`,
|
||||
);
|
||||
}
|
||||
throw new Error("tree-sitter-bash returned no parse tree");
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
75
src/infra/command-explainer/types.ts
Normal file
75
src/infra/command-explainer/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export type CommandContext =
|
||||
| "top-level"
|
||||
| "command-substitution"
|
||||
| "process-substitution"
|
||||
| "function-definition"
|
||||
| "wrapper-payload";
|
||||
|
||||
export type CommandShape =
|
||||
| "pipeline"
|
||||
| "and"
|
||||
| "or"
|
||||
| "sequence"
|
||||
| "if"
|
||||
| "for"
|
||||
| "while"
|
||||
| "case"
|
||||
| "subshell"
|
||||
| "group"
|
||||
| "background";
|
||||
|
||||
export type SourceSpan = {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
startPosition: { row: number; column: number };
|
||||
endPosition: { row: number; column: number };
|
||||
};
|
||||
|
||||
export type CommandStep = {
|
||||
context: CommandContext;
|
||||
executable: string;
|
||||
argv: string[];
|
||||
text: string;
|
||||
span: SourceSpan;
|
||||
};
|
||||
|
||||
export type CommandRisk =
|
||||
| { kind: "inline-eval"; command: string; flag: string; text: string; span: SourceSpan }
|
||||
| {
|
||||
kind: "shell-wrapper";
|
||||
executable: string;
|
||||
flag: string;
|
||||
payload: string;
|
||||
text: string;
|
||||
span: SourceSpan;
|
||||
}
|
||||
| { kind: "shell-wrapper-through-carrier"; command: string; text: string; span: SourceSpan }
|
||||
| { kind: "command-carrier"; command: string; flag?: string; text: string; span: SourceSpan }
|
||||
| { kind: "command-substitution"; text: string; span: SourceSpan }
|
||||
| { kind: "process-substitution"; text: string; span: SourceSpan }
|
||||
| { kind: "dynamic-executable"; text: string; span: SourceSpan }
|
||||
| {
|
||||
kind: "dynamic-argument";
|
||||
command: string;
|
||||
argumentIndex: number;
|
||||
text: string;
|
||||
span: SourceSpan;
|
||||
}
|
||||
| { kind: "eval"; text: string; span: SourceSpan }
|
||||
| { kind: "source"; command: string; text: string; span: SourceSpan }
|
||||
| { kind: "alias"; text: string; span: SourceSpan }
|
||||
| { kind: "function-definition"; name: string; text: string; span: SourceSpan }
|
||||
| { kind: "line-continuation"; text: string; span: SourceSpan }
|
||||
| { kind: "heredoc"; text: string; span: SourceSpan }
|
||||
| { kind: "here-string"; text: string; span: SourceSpan }
|
||||
| { kind: "redirect"; text: string; span: SourceSpan }
|
||||
| { kind: "syntax-error"; text: string; span: SourceSpan };
|
||||
|
||||
export type CommandExplanation = {
|
||||
ok: boolean;
|
||||
source: string;
|
||||
shapes: CommandShape[];
|
||||
topLevelCommands: CommandStep[];
|
||||
nestedCommands: CommandStep[];
|
||||
risks: CommandRisk[];
|
||||
};
|
||||
Reference in New Issue
Block a user