@illuminis/comprism 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +281 -0
- package/out/agent/command.d.ts +86 -0
- package/out/agent/command.js +259 -0
- package/out/agent/render.d.ts +97 -0
- package/out/agent/render.js +255 -0
- package/out/agent/session.d.ts +175 -0
- package/out/agent/session.js +573 -0
- package/out/commands/ask.d.ts +1 -0
- package/out/commands/ask.js +146 -0
- package/out/commands/codemap.d.ts +2 -0
- package/out/commands/codemap.js +151 -0
- package/out/commands/commands-thin.d.ts +39 -0
- package/out/commands/commands-thin.js +182 -0
- package/out/commands/install.d.ts +163 -0
- package/out/commands/install.js +543 -0
- package/out/commands/keys.d.ts +55 -0
- package/out/commands/keys.js +344 -0
- package/out/commands/login.d.ts +9 -0
- package/out/commands/login.js +384 -0
- package/out/commands/repl.d.ts +1 -0
- package/out/commands/repl.js +752 -0
- package/out/commands/settings.d.ts +21 -0
- package/out/commands/settings.js +244 -0
- package/out/commands/welcome.d.ts +1 -0
- package/out/commands/welcome.js +196 -0
- package/out/executor/documents.d.ts +40 -0
- package/out/executor/documents.js +170 -0
- package/out/executor/files.d.ts +2 -0
- package/out/executor/files.js +360 -0
- package/out/executor/git.d.ts +48 -0
- package/out/executor/git.js +132 -0
- package/out/executor/hooks.d.ts +67 -0
- package/out/executor/hooks.js +247 -0
- package/out/executor/index.d.ts +29 -0
- package/out/executor/index.js +221 -0
- package/out/executor/notebook.d.ts +2 -0
- package/out/executor/notebook.js +147 -0
- package/out/executor/paths.d.ts +15 -0
- package/out/executor/paths.js +126 -0
- package/out/executor/shell.d.ts +41 -0
- package/out/executor/shell.js +336 -0
- package/out/graph/build.d.ts +45 -0
- package/out/graph/build.js +91 -0
- package/out/graph/facts.d.ts +47 -0
- package/out/graph/facts.js +12 -0
- package/out/graph/files.d.ts +45 -0
- package/out/graph/files.js +207 -0
- package/out/graph/read-locales.d.ts +29 -0
- package/out/graph/read-locales.js +246 -0
- package/out/graph/read-python.d.ts +11 -0
- package/out/graph/read-python.js +115 -0
- package/out/graph/read-typescript.d.ts +16 -0
- package/out/graph/read-typescript.js +292 -0
- package/out/graph/sync.d.ts +66 -0
- package/out/graph/sync.js +242 -0
- package/out/lib/attach.d.ts +62 -0
- package/out/lib/attach.js +228 -0
- package/out/lib/config.d.ts +93 -0
- package/out/lib/config.js +198 -0
- package/out/lib/connection.d.ts +73 -0
- package/out/lib/connection.js +188 -0
- package/out/lib/gateway.d.ts +239 -0
- package/out/lib/gateway.js +171 -0
- package/out/lib/prompt.d.ts +34 -0
- package/out/lib/prompt.js +108 -0
- package/out/lib/types.d.ts +417 -0
- package/out/lib/types.js +21 -0
- package/out/lib/ui.d.ts +114 -0
- package/out/lib/ui.js +265 -0
- package/out/lib/version.d.ts +24 -0
- package/out/lib/version.js +27 -0
- package/out/lib/voice.d.ts +50 -0
- package/out/lib/voice.js +218 -0
- package/out/postinstall.d.ts +2 -0
- package/out/postinstall.js +92 -0
- package/out/thin.d.ts +2 -0
- package/out/thin.js +259 -0
- package/package.json +101 -0
- package/scripts/read_python.py +270 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.OutsideWorkspace = void 0;
|
|
37
|
+
exports.isSecret = isSecret;
|
|
38
|
+
exports.resolveInside = resolveInside;
|
|
39
|
+
/**
|
|
40
|
+
* The folder boundary, on a real filesystem.
|
|
41
|
+
*
|
|
42
|
+
* Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md §8, register
|
|
43
|
+
* items F1 (the folder is a boundary) and F2 (shortcuts do not defeat it).
|
|
44
|
+
*
|
|
45
|
+
* The browser executor gets this property free: it walks directory handles, and
|
|
46
|
+
* a handle it was never given simply cannot be reached. A real filesystem has no
|
|
47
|
+
* such protection, so here it has to be enforced, and enforced in the one order
|
|
48
|
+
* that works.
|
|
49
|
+
*
|
|
50
|
+
* **Resolve first, then compare.** Checking the string somebody sent and then
|
|
51
|
+
* resolving it is the standard way out: `a/../../etc/passwd` passes a check for
|
|
52
|
+
* ".." at the start and becomes `/etc/passwd` afterwards. And on a real
|
|
53
|
+
* filesystem there is a second door the browser does not have, which is a
|
|
54
|
+
* symbolic link inside the project pointing outside it. `realpath` follows it;
|
|
55
|
+
* a string comparison does not.
|
|
56
|
+
*
|
|
57
|
+
* The server checks this too. Two checks is not belt and braces: the server
|
|
58
|
+
* cannot see the filesystem, so it can only reason about the path it was given,
|
|
59
|
+
* and this can see the filesystem but runs on a machine we do not control.
|
|
60
|
+
* Neither alone is the boundary.
|
|
61
|
+
*/
|
|
62
|
+
const fs = __importStar(require("fs"));
|
|
63
|
+
const path = __importStar(require("path"));
|
|
64
|
+
class OutsideWorkspace extends Error {
|
|
65
|
+
attempted;
|
|
66
|
+
constructor(attempted) {
|
|
67
|
+
super(`Refused: ${attempted} is outside this project. The agent works only ` +
|
|
68
|
+
`inside the folder that was opened.`);
|
|
69
|
+
this.attempted = attempted;
|
|
70
|
+
this.name = "OutsideWorkspace";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
exports.OutsideWorkspace = OutsideWorkspace;
|
|
74
|
+
/** Files never read, whatever the mode. Mirrors the server's own list. */
|
|
75
|
+
const SECRET = /^(\.env(\..*)?|.*\.pem|.*\.key|.*\.p12|.*\.pfx|id_rsa.*|id_ed25519.*|.*\.keystore|credentials|\.npmrc|\.netrc|\.git-credentials)$/i;
|
|
76
|
+
function isSecret(p) {
|
|
77
|
+
return SECRET.test(path.basename(p));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* An absolute path inside the workspace, or a refusal.
|
|
81
|
+
*
|
|
82
|
+
* `mustExist` is false when the caller is about to CREATE the thing. A file that
|
|
83
|
+
* does not exist yet cannot be resolved, so the check is applied to the deepest
|
|
84
|
+
* parent that does exist: creating `src/new/deep/file.ts` is inside the project
|
|
85
|
+
* exactly when `src` is, and demanding the leaf exist first would make it
|
|
86
|
+
* impossible to write a new file at all.
|
|
87
|
+
*/
|
|
88
|
+
function resolveInside(root, rel, mustExist = false) {
|
|
89
|
+
const realRoot = fs.realpathSync(root);
|
|
90
|
+
const asked = String(rel ?? "");
|
|
91
|
+
// An ABSOLUTE path is resolved as written, and refused if it lands outside.
|
|
92
|
+
// It used to have its leading slash stripped and be treated as relative to
|
|
93
|
+
// the project, which was safe and quietly wrong: asking to write
|
|
94
|
+
// `/tmp/notes.txt` created `<project>/tmp/notes.txt` and reported the path the
|
|
95
|
+
// caller gave. Nothing escaped, but the confirmation named a file that did not
|
|
96
|
+
// exist, so the next read failed and the model spent steps on a mystery. An
|
|
97
|
+
// absolute path inside the project still works, which is the only case anybody
|
|
98
|
+
// actually means.
|
|
99
|
+
const joined = path.isAbsolute(asked)
|
|
100
|
+
? path.resolve(asked)
|
|
101
|
+
: path.resolve(realRoot, asked);
|
|
102
|
+
let probe = joined;
|
|
103
|
+
if (!mustExist) {
|
|
104
|
+
// Walk up to the deepest part that exists, so a path we are about to create
|
|
105
|
+
// is still checked against where it would actually land.
|
|
106
|
+
while (!fs.existsSync(probe) && path.dirname(probe) !== probe) {
|
|
107
|
+
probe = path.dirname(probe);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let real;
|
|
111
|
+
try {
|
|
112
|
+
real = fs.realpathSync(probe);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new OutsideWorkspace(rel);
|
|
116
|
+
}
|
|
117
|
+
// The comparison, with a separator on the end. Without it `/work/project-two`
|
|
118
|
+
// passes as being inside `/work/project`, because one string starts with the
|
|
119
|
+
// other.
|
|
120
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
|
|
121
|
+
throw new OutsideWorkspace(rel);
|
|
122
|
+
}
|
|
123
|
+
// Return the requested path, not the probe: the caller wants to write to the
|
|
124
|
+
// file it named, and the probe may be an ancestor of it.
|
|
125
|
+
return mustExist ? real : joined;
|
|
126
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface RunResult {
|
|
2
|
+
content: string;
|
|
3
|
+
isError?: boolean;
|
|
4
|
+
summary?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function safeEnvironment(from?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
7
|
+
export interface ShellOptions {
|
|
8
|
+
root: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
onOutput?: (chunk: string) => void;
|
|
12
|
+
/** Extra variables for this one command.
|
|
13
|
+
*
|
|
14
|
+
* Used by hooks, so a project's own script can read which file the agent
|
|
15
|
+
* just touched without the agent having to rewrite the script's arguments.
|
|
16
|
+
* Still filtered by `safeEnvironment`, so a hook cannot be handed a
|
|
17
|
+
* credential this way either. */
|
|
18
|
+
extraEnv?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
/** Run a command and wait for it.
|
|
21
|
+
*
|
|
22
|
+
* Never throws for an ordinary failure. A command that exits non-zero is a
|
|
23
|
+
* RESULT: it is usually the most useful thing that can happen, because it is
|
|
24
|
+
* how the agent learns what is wrong.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runCommand(command: string, opts: ShellOptions): Promise<RunResult>;
|
|
27
|
+
export declare function startBackground(command: string, opts: ShellOptions): RunResult;
|
|
28
|
+
export declare function readOutput(id: string): RunResult;
|
|
29
|
+
export declare function stopProcess(id: string): RunResult;
|
|
30
|
+
/** Everything still running, stopped. Called when a session ends, so a job that
|
|
31
|
+
* started a dev server does not leave it holding a port after the person has
|
|
32
|
+
* closed the terminal. */
|
|
33
|
+
export declare function stopEverything(): void;
|
|
34
|
+
export declare function guessTestCommand(root: string): string | null;
|
|
35
|
+
export declare function runTests(opts: ShellOptions & {
|
|
36
|
+
command?: string;
|
|
37
|
+
path?: string;
|
|
38
|
+
}): Promise<RunResult & {
|
|
39
|
+
passed?: boolean;
|
|
40
|
+
command?: string;
|
|
41
|
+
}>;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.safeEnvironment = safeEnvironment;
|
|
37
|
+
exports.runCommand = runCommand;
|
|
38
|
+
exports.startBackground = startBackground;
|
|
39
|
+
exports.readOutput = readOutput;
|
|
40
|
+
exports.stopProcess = stopProcess;
|
|
41
|
+
exports.stopEverything = stopEverything;
|
|
42
|
+
exports.guessTestCommand = guessTestCommand;
|
|
43
|
+
exports.runTests = runTests;
|
|
44
|
+
/**
|
|
45
|
+
* Running things on a real machine.
|
|
46
|
+
*
|
|
47
|
+
* Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md, register
|
|
48
|
+
* items D17 (run a command), D18 (run the tests), D19 to D21 (long running
|
|
49
|
+
* processes), D22 (install a dependency) and F12 (a restricted environment).
|
|
50
|
+
*
|
|
51
|
+
* ## Why the tests are their own action
|
|
52
|
+
*
|
|
53
|
+
* `run_tests` could be a shell command and deliberately is not. Its result is
|
|
54
|
+
* the grading signal the whole product is built on: it is how a job knows it is
|
|
55
|
+
* finished, and how the routing engine eventually learns whether a cheaper
|
|
56
|
+
* model actually finished the work. A result buried inside the output of a
|
|
57
|
+
* generic command is a result nobody can count.
|
|
58
|
+
*
|
|
59
|
+
* ## Why the environment is trimmed
|
|
60
|
+
*
|
|
61
|
+
* A command inherits the environment of whatever started it, and on a
|
|
62
|
+
* developer's machine that includes cloud credentials, deployment tokens and
|
|
63
|
+
* signing keys. None of that is needed to run a test suite, and all of it is
|
|
64
|
+
* available to anything the command chooses to run. The agent is not the risk
|
|
65
|
+
* here; a package in the project's own dependency tree is.
|
|
66
|
+
*/
|
|
67
|
+
const child_process_1 = require("child_process");
|
|
68
|
+
const fs = __importStar(require("fs"));
|
|
69
|
+
const path = __importStar(require("path"));
|
|
70
|
+
const paths_1 = require("./paths");
|
|
71
|
+
/** What a command may print back into the conversation. Beyond this it is cut,
|
|
72
|
+
* and the cut is stated: a model that believes it saw the whole output, and
|
|
73
|
+
* did not, draws a confident conclusion from half of it. */
|
|
74
|
+
const MAX_OUTPUT_CHARS = 30_000;
|
|
75
|
+
/** Environment variables never passed to a command.
|
|
76
|
+
*
|
|
77
|
+
* Matched by pattern rather than listed by name, because the list is
|
|
78
|
+
* unknowable: every cloud provider, CI system and package registry invents its
|
|
79
|
+
* own, and a list of the ones we happened to think of is a list that is out of
|
|
80
|
+
* date the day it is written. */
|
|
81
|
+
const SECRET_ENV = /(^|_)(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|CREDENTIALS|AUTH|PRIVATE|SESSION|COOKIE)(_|$)/i;
|
|
82
|
+
/** Kept regardless, because a command that cannot find its own tools is a
|
|
83
|
+
* command that fails for a reason nobody can diagnose. */
|
|
84
|
+
const ALWAYS_KEEP = new Set([
|
|
85
|
+
"PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "TERM", "TMPDIR", "TZ",
|
|
86
|
+
"NODE_ENV", "PWD", "PYTHONPATH", "VIRTUAL_ENV", "NVM_DIR", "JAVA_HOME",
|
|
87
|
+
"GOPATH", "GOROOT", "CARGO_HOME", "RUSTUP_HOME",
|
|
88
|
+
]);
|
|
89
|
+
function safeEnvironment(from = process.env) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [k, v] of Object.entries(from)) {
|
|
92
|
+
if (ALWAYS_KEEP.has(k) || !SECRET_ENV.test(k))
|
|
93
|
+
out[k] = v;
|
|
94
|
+
}
|
|
95
|
+
// Told, not hidden. A command that behaves differently under the agent should
|
|
96
|
+
// be able to say so, and a person debugging one should be able to see why.
|
|
97
|
+
out.ILLUMINIS_AGENT = "1";
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function clip(text) {
|
|
101
|
+
if (text.length <= MAX_OUTPUT_CHARS)
|
|
102
|
+
return text;
|
|
103
|
+
const cut = text.length - MAX_OUTPUT_CHARS;
|
|
104
|
+
return (`${text.slice(0, MAX_OUTPUT_CHARS)}\n\n[... ${cut.toLocaleString()} more ` +
|
|
105
|
+
`characters. Narrow the command, or write the output to a file and read part of it.]`);
|
|
106
|
+
}
|
|
107
|
+
/** Run a command and wait for it.
|
|
108
|
+
*
|
|
109
|
+
* Never throws for an ordinary failure. A command that exits non-zero is a
|
|
110
|
+
* RESULT: it is usually the most useful thing that can happen, because it is
|
|
111
|
+
* how the agent learns what is wrong.
|
|
112
|
+
*/
|
|
113
|
+
function runCommand(command, opts) {
|
|
114
|
+
const cwd = opts.cwd ? (0, paths_1.resolveInside)(opts.root, opts.cwd, true) : fs.realpathSync(opts.root);
|
|
115
|
+
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
let out = "";
|
|
118
|
+
let done = false;
|
|
119
|
+
// Through a shell on purpose: pipes, redirection and `&&` are how people
|
|
120
|
+
// actually write commands, and an agent told it cannot use them writes three
|
|
121
|
+
// commands where a person would write one.
|
|
122
|
+
//
|
|
123
|
+
// Running a command IS this function. It is the agent's shell tool, and a
|
|
124
|
+
// shell tool that cannot reach a shell is not a tool. What stands between a
|
|
125
|
+
// command and the machine is the approval list every command is matched
|
|
126
|
+
// against before it reaches here, plus the working directory being resolved
|
|
127
|
+
// inside the workspace root. Removing the shell would not add safety; it
|
|
128
|
+
// would move the same commands somewhere with fewer checks in front of them.
|
|
129
|
+
// The marker has to be the LAST line before the call, not the first line of
|
|
130
|
+
// the explanation: semgrep only reads the line immediately above.
|
|
131
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
132
|
+
const child = (0, child_process_1.spawn)(command, {
|
|
133
|
+
cwd, shell: true,
|
|
134
|
+
env: { ...safeEnvironment(), ...(opts.extraEnv ?? {}) },
|
|
135
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
136
|
+
});
|
|
137
|
+
const collect = (chunk) => {
|
|
138
|
+
const text = chunk.toString();
|
|
139
|
+
out += text;
|
|
140
|
+
opts.onOutput?.(text);
|
|
141
|
+
};
|
|
142
|
+
child.stdout?.on("data", collect);
|
|
143
|
+
child.stderr?.on("data", collect);
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
if (done)
|
|
146
|
+
return;
|
|
147
|
+
done = true;
|
|
148
|
+
// The whole process group, not just the shell. Killing the shell leaves
|
|
149
|
+
// whatever it started running, which is how a stuck job leaves a server
|
|
150
|
+
// holding a port after everybody has gone home.
|
|
151
|
+
try {
|
|
152
|
+
process.kill(-child.pid, "SIGKILL");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
child.kill("SIGKILL");
|
|
156
|
+
}
|
|
157
|
+
resolve({
|
|
158
|
+
isError: true,
|
|
159
|
+
content: clip(out) +
|
|
160
|
+
`\n\n[Stopped after ${Math.round(timeoutMs / 1000)}s. If this command ` +
|
|
161
|
+
`waits for input it will never finish; run it in a way that does not.]`,
|
|
162
|
+
summary: `timed out after ${Math.round(timeoutMs / 1000)}s`,
|
|
163
|
+
});
|
|
164
|
+
}, timeoutMs);
|
|
165
|
+
child.on("error", (err) => {
|
|
166
|
+
if (done)
|
|
167
|
+
return;
|
|
168
|
+
done = true;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
resolve({ isError: true, content: `Could not run it: ${err.message}` });
|
|
171
|
+
});
|
|
172
|
+
child.on("close", (code) => {
|
|
173
|
+
if (done)
|
|
174
|
+
return;
|
|
175
|
+
done = true;
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
const body = clip(out).trim() || "(no output)";
|
|
178
|
+
resolve({
|
|
179
|
+
// Non-zero is not an error in the sense that matters here. It is
|
|
180
|
+
// information, and it is usually the most useful information available.
|
|
181
|
+
isError: false,
|
|
182
|
+
content: `exit ${code}\n\n${body}`,
|
|
183
|
+
summary: code === 0 ? "exit 0" : `exit ${code}`,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const running = new Map();
|
|
189
|
+
let counter = 0;
|
|
190
|
+
function startBackground(command, opts) {
|
|
191
|
+
const cwd = opts.cwd ? (0, paths_1.resolveInside)(opts.root, opts.cwd, true) : fs.realpathSync(opts.root);
|
|
192
|
+
const id = `p${++counter}`;
|
|
193
|
+
// The background half of the same shell tool, with the same approval list and
|
|
194
|
+
// the same workspace root in front of it. See `runCommand` above.
|
|
195
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
196
|
+
const child = (0, child_process_1.spawn)(command, {
|
|
197
|
+
cwd, shell: true, env: safeEnvironment(), stdio: ["ignore", "pipe", "pipe"],
|
|
198
|
+
});
|
|
199
|
+
const entry = { child, buffer: "", read: 0, command };
|
|
200
|
+
const collect = (chunk) => {
|
|
201
|
+
entry.buffer += chunk.toString();
|
|
202
|
+
// Bounded. A watcher left running for an hour would otherwise hold its whole
|
|
203
|
+
// output in memory, and nobody is going to read the first hour of it.
|
|
204
|
+
if (entry.buffer.length > MAX_OUTPUT_CHARS * 4) {
|
|
205
|
+
entry.buffer = entry.buffer.slice(-MAX_OUTPUT_CHARS * 2);
|
|
206
|
+
entry.read = 0;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
child.stdout?.on("data", collect);
|
|
210
|
+
child.stderr?.on("data", collect);
|
|
211
|
+
running.set(id, entry);
|
|
212
|
+
return {
|
|
213
|
+
content: `Started as ${id}: ${command}\nUse read_output with ${id} to see what it prints, and stop_process to end it.`,
|
|
214
|
+
summary: `started ${id}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function readOutput(id) {
|
|
218
|
+
const entry = running.get(id);
|
|
219
|
+
if (!entry)
|
|
220
|
+
return { isError: true, content: `No process called ${id}.` };
|
|
221
|
+
const fresh = entry.buffer.slice(entry.read);
|
|
222
|
+
entry.read = entry.buffer.length;
|
|
223
|
+
const alive = entry.child.exitCode === null && !entry.child.killed;
|
|
224
|
+
return {
|
|
225
|
+
content: (fresh.trim() || "(nothing new)") +
|
|
226
|
+
`\n\n[${id} is ${alive ? "still running" : `finished, exit ${entry.child.exitCode}`}.]`,
|
|
227
|
+
summary: alive ? `${id} running` : `${id} exited`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function stopProcess(id) {
|
|
231
|
+
const entry = running.get(id);
|
|
232
|
+
if (!entry)
|
|
233
|
+
return { isError: true, content: `No process called ${id}.` };
|
|
234
|
+
try {
|
|
235
|
+
process.kill(-entry.child.pid, "SIGTERM");
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
entry.child.kill("SIGTERM");
|
|
239
|
+
}
|
|
240
|
+
running.delete(id);
|
|
241
|
+
return { content: `Stopped ${id}.`, summary: `stopped ${id}` };
|
|
242
|
+
}
|
|
243
|
+
/** Everything still running, stopped. Called when a session ends, so a job that
|
|
244
|
+
* started a dev server does not leave it holding a port after the person has
|
|
245
|
+
* closed the terminal. */
|
|
246
|
+
function stopEverything() {
|
|
247
|
+
for (const id of Array.from(running.keys()))
|
|
248
|
+
stopProcess(id);
|
|
249
|
+
}
|
|
250
|
+
// ── the tests ───────────────────────────────────────────────────────────────
|
|
251
|
+
/** How this project runs its tests, worked out from what is in it.
|
|
252
|
+
*
|
|
253
|
+
* Ordered by how specific the evidence is. A `test` script in package.json is
|
|
254
|
+
* a statement by the project's own authors; the presence of a pytest.ini is an
|
|
255
|
+
* inference. Guessing wrong is cheap here because the result says what it ran.
|
|
256
|
+
*/
|
|
257
|
+
/** Whether this folder holds Python tests, by their conventional names.
|
|
258
|
+
*
|
|
259
|
+
* `test_x.py` and `x_test.py` are the two names pytest collects by default, so
|
|
260
|
+
* a folder containing either is a folder pytest can run. Deliberately shallow:
|
|
261
|
+
* one directory read, no walk, because this is a guess made before every test
|
|
262
|
+
* run and a recursive scan of somebody's repository is not.
|
|
263
|
+
*/
|
|
264
|
+
function looksLikePython(root) {
|
|
265
|
+
try {
|
|
266
|
+
return fs.readdirSync(root).some((name) => /^test_.*\.py$/.test(name) || /.*_test\.py$/.test(name));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// An unreadable project directory is a different problem, and it will be
|
|
270
|
+
// reported by whatever tries to read a file next.
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function guessTestCommand(root) {
|
|
275
|
+
const has = (f) => fs.existsSync(path.join(root, f));
|
|
276
|
+
const pkgPath = path.join(root, "package.json");
|
|
277
|
+
if (has("package.json")) {
|
|
278
|
+
try {
|
|
279
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
280
|
+
if (pkg.scripts?.test) {
|
|
281
|
+
return has("pnpm-lock.yaml") ? "pnpm test"
|
|
282
|
+
: has("yarn.lock") ? "yarn test" : "npm test";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch { /* a malformed package.json is not our problem to report here */ }
|
|
286
|
+
}
|
|
287
|
+
if (has("pytest.ini") || has("pyproject.toml") || has("tests") || has("setup.cfg")) {
|
|
288
|
+
return "python -m pytest -q";
|
|
289
|
+
}
|
|
290
|
+
// A project identified by its TEST FILES rather than by a configuration file.
|
|
291
|
+
// A plain folder of `test_*.py` with no `pyproject.toml` is an entirely normal
|
|
292
|
+
// Python project and was not recognized, so `run_tests` refused and the model
|
|
293
|
+
// had to work out the command itself. It did, and it cost a step and an
|
|
294
|
+
// approval on a job that was otherwise clean. Found by watching a real run in
|
|
295
|
+
// the desktop app.
|
|
296
|
+
//
|
|
297
|
+
// One shallow directory read, and only when nothing above matched.
|
|
298
|
+
if (looksLikePython(root))
|
|
299
|
+
return "python -m pytest -q";
|
|
300
|
+
if (has("Cargo.toml"))
|
|
301
|
+
return "cargo test";
|
|
302
|
+
if (has("go.mod"))
|
|
303
|
+
return "go test ./...";
|
|
304
|
+
if (has("Gemfile"))
|
|
305
|
+
return "bundle exec rspec";
|
|
306
|
+
if (has("pom.xml"))
|
|
307
|
+
return "mvn -q test";
|
|
308
|
+
if (has("build.gradle") || has("build.gradle.kts"))
|
|
309
|
+
return "gradle test";
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
async function runTests(opts) {
|
|
313
|
+
const root = fs.realpathSync(opts.root);
|
|
314
|
+
let command = opts.command?.trim() || guessTestCommand(root) || "";
|
|
315
|
+
if (!command) {
|
|
316
|
+
return {
|
|
317
|
+
isError: true,
|
|
318
|
+
content: "I could not work out how this project runs its tests. Tell me the " +
|
|
319
|
+
"command and I will use it from now on.",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (opts.path)
|
|
323
|
+
command = `${command} ${(0, paths_1.resolveInside)(root, opts.path, false)}`;
|
|
324
|
+
const result = await runCommand(command, { ...opts, timeoutMs: opts.timeoutMs ?? 600_000 });
|
|
325
|
+
const passed = /^exit 0\b/.test(result.content);
|
|
326
|
+
return {
|
|
327
|
+
...result,
|
|
328
|
+
passed,
|
|
329
|
+
command,
|
|
330
|
+
// The verdict first, in words, before the output. This result is read by a
|
|
331
|
+
// model deciding whether it has finished, and burying "exit 1" under two
|
|
332
|
+
// hundred lines of test output is how a job declares success on a red suite.
|
|
333
|
+
content: `${passed ? "TESTS PASSED" : "TESTS FAILED"} (${command})\n\n${result.content}`,
|
|
334
|
+
summary: `${passed ? "tests passed" : "tests failed"}: ${command}`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a folder of source files into code map facts, on the machine that holds
|
|
3
|
+
* them.
|
|
4
|
+
*
|
|
5
|
+
* Reading is the only part of the map that happens here. The joining, the
|
|
6
|
+
* storing and the answering all happen in the service, because a client that
|
|
7
|
+
* joined the map would be a client holding an opinion, and because a map that
|
|
8
|
+
* lived on one laptop could not be read by the same person's editor an hour
|
|
9
|
+
* later.
|
|
10
|
+
*/
|
|
11
|
+
import type { FactSet } from "./facts";
|
|
12
|
+
import { IndexedFile } from "./files";
|
|
13
|
+
export interface ReadResult {
|
|
14
|
+
/** One entry per file successfully read. */
|
|
15
|
+
facts: Record<string, FactSet>;
|
|
16
|
+
failures: {
|
|
17
|
+
file: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}[];
|
|
20
|
+
/** Files present in the project that no reader on this machine can read. */
|
|
21
|
+
unread: {
|
|
22
|
+
python: number;
|
|
23
|
+
typescript: number;
|
|
24
|
+
};
|
|
25
|
+
seconds: number;
|
|
26
|
+
}
|
|
27
|
+
/** What this machine can read at all, said plainly and once. */
|
|
28
|
+
export declare function readers(): {
|
|
29
|
+
python: boolean;
|
|
30
|
+
typescript: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** Read the named files. Everything when `rels` is absent. */
|
|
33
|
+
export declare function readFacts(root: string, rels?: string[], knownNames?: string[]): ReadResult;
|
|
34
|
+
export interface ProjectState {
|
|
35
|
+
files: IndexedFile[];
|
|
36
|
+
stamps: Record<string, string>;
|
|
37
|
+
fingerprint: {
|
|
38
|
+
hash: string;
|
|
39
|
+
files: number;
|
|
40
|
+
};
|
|
41
|
+
commit: string;
|
|
42
|
+
}
|
|
43
|
+
/** What the project looks like right now: which files, and one short string
|
|
44
|
+
* that changes the moment any of them does. Costs about ten milliseconds. */
|
|
45
|
+
export declare function projectState(root: string): ProjectState;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readers = readers;
|
|
4
|
+
exports.readFacts = readFacts;
|
|
5
|
+
exports.projectState = projectState;
|
|
6
|
+
const files_1 = require("./files");
|
|
7
|
+
const read_locales_1 = require("./read-locales");
|
|
8
|
+
const read_python_1 = require("./read-python");
|
|
9
|
+
const read_typescript_1 = require("./read-typescript");
|
|
10
|
+
/** What this machine can read at all, said plainly and once. */
|
|
11
|
+
function readers() {
|
|
12
|
+
return {
|
|
13
|
+
python: Boolean(require("./read-python").findPython()),
|
|
14
|
+
typescript: Boolean((0, read_typescript_1.loadCompiler)()),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Drop the calls that could never resolve to anything in this project.
|
|
19
|
+
*
|
|
20
|
+
* Two thirds of the raw facts read from a real 1,816 file project were calls to
|
|
21
|
+
* names the project does not define: `len`, `push`, `map`, and every library
|
|
22
|
+
* function anybody imported. The service discards every one of them when it
|
|
23
|
+
* joins the map, so sending and storing them is pure waste, measured at 17.8
|
|
24
|
+
* megabytes of 26.6.
|
|
25
|
+
*
|
|
26
|
+
* `known` is what the project already defines, which the service sends back
|
|
27
|
+
* with the file stamps, plus whatever the files just read define themselves.
|
|
28
|
+
* Nothing is dropped that the service would have kept: the join applies the same
|
|
29
|
+
* rule, only later. Correctness never depends on this, only size.
|
|
30
|
+
*/
|
|
31
|
+
function prune(facts, known) {
|
|
32
|
+
const droppable = new Set(["calls", "references"]);
|
|
33
|
+
for (const rel of Object.keys(facts)) {
|
|
34
|
+
const entry = facts[rel];
|
|
35
|
+
if (!entry)
|
|
36
|
+
continue;
|
|
37
|
+
entry.links = entry.links.filter((link) => {
|
|
38
|
+
if (!droppable.has(link.kind))
|
|
39
|
+
return true;
|
|
40
|
+
const leaf = String(link.to_name ?? "").split(".").pop() ?? "";
|
|
41
|
+
return known.has(leaf);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Read the named files. Everything when `rels` is absent. */
|
|
46
|
+
function readFacts(root, rels, knownNames) {
|
|
47
|
+
const started = Date.now();
|
|
48
|
+
const all = (0, files_1.indexedFiles)(root);
|
|
49
|
+
const wanted = rels ? new Set(rels) : null;
|
|
50
|
+
const chosen = wanted ? all.filter((f) => wanted.has(f.rel)) : all;
|
|
51
|
+
const py = chosen.filter((f) => f.lang === "python").map((f) => f.rel);
|
|
52
|
+
const ts = chosen.filter((f) => f.lang === "typescript").map((f) => f.rel);
|
|
53
|
+
const locales = chosen.filter((f) => f.lang === "translations").map((f) => f.rel);
|
|
54
|
+
const fromPython = (0, read_python_1.readPython)(root, py);
|
|
55
|
+
const fromTs = (0, read_typescript_1.readTypeScript)(root, ts);
|
|
56
|
+
// Translation files need no compiler and no interpreter, so they are always
|
|
57
|
+
// read: a machine missing Python still gets its labels mapped.
|
|
58
|
+
const fromLocales = (0, read_locales_1.readLocales)(root, locales);
|
|
59
|
+
const facts = { ...fromPython.facts, ...fromTs.facts, ...fromLocales.facts };
|
|
60
|
+
// Everything the files just read define, plus everything the service already
|
|
61
|
+
// knows this project defines. A name on neither list cannot resolve.
|
|
62
|
+
const known = new Set(knownNames ?? []);
|
|
63
|
+
for (const entry of Object.values(facts)) {
|
|
64
|
+
for (const object of entry.objects) {
|
|
65
|
+
if (object.kind === "file" || object.kind === "route" || object.kind === "table")
|
|
66
|
+
continue;
|
|
67
|
+
const leaf = String(object.name).split(".").pop();
|
|
68
|
+
if (leaf)
|
|
69
|
+
known.add(leaf);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
prune(facts, known);
|
|
73
|
+
return {
|
|
74
|
+
facts,
|
|
75
|
+
failures: [...fromPython.failures, ...fromTs.failures, ...fromLocales.failures],
|
|
76
|
+
unread: {
|
|
77
|
+
python: fromPython.available ? 0 : py.length,
|
|
78
|
+
typescript: fromTs.available ? 0 : ts.length,
|
|
79
|
+
},
|
|
80
|
+
seconds: Math.round((Date.now() - started) / 100) / 10,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** What the project looks like right now: which files, and one short string
|
|
84
|
+
* that changes the moment any of them does. Costs about ten milliseconds. */
|
|
85
|
+
function projectState(root) {
|
|
86
|
+
const files = (0, files_1.indexedFiles)(root);
|
|
87
|
+
const stamps = {};
|
|
88
|
+
for (const file of files)
|
|
89
|
+
stamps[file.rel] = (0, files_1.stampOf)(file);
|
|
90
|
+
return { files, stamps, fingerprint: (0, files_1.fingerprint)(files), commit: (0, files_1.headCommit)(root) };
|
|
91
|
+
}
|