@cruxy/cli 0.22.1 → 0.23.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/dist/approval/classify.js +18 -0
- package/dist/approval/prompt.js +11 -0
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +2 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +59 -10
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/session-factory.d.ts +4 -3
- package/dist/cli/session-factory.js +98 -12
- package/dist/errors/constructors.d.ts +28 -0
- package/dist/errors/constructors.js +59 -0
- package/dist/errors/types.d.ts +20 -0
- package/dist/errors/types.js +26 -0
- package/dist/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- package/dist/render/diff.js +42 -5
- package/dist/subagent/orchestrator.d.ts +15 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/testing/run-tests-tool.js +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +122 -9
- package/dist/tools/file/paths.js +165 -10
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +8 -1
- package/dist/tools/types.d.ts +63 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- package/package.json +1 -1
|
@@ -2,7 +2,8 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { glob } from "tinyglobby";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
5
|
+
import { confineToRoot } from "../../workspace/index.js";
|
|
6
|
+
import { contextWorkspace, isEscapingPattern, labelPath, resolveToolPath, } from "./paths.js";
|
|
6
7
|
/** Default cap on returned match lines; beyond it we report the overflow. */
|
|
7
8
|
const DEFAULT_MAX_RESULTS = 100;
|
|
8
9
|
/** Trim point for a single matched line so long lines don't flood the output. */
|
|
@@ -32,15 +33,31 @@ const parameters = z.object({
|
|
|
32
33
|
.positive()
|
|
33
34
|
.optional()
|
|
34
35
|
.describe(`Maximum number of matching lines to return (default ${DEFAULT_MAX_RESULTS}).`),
|
|
36
|
+
root: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("In a multi-repo session, restrict the search to a single declared root by name. Omit to search every root (a relative `path` is applied under each) and label each match with its root."),
|
|
35
40
|
});
|
|
41
|
+
/** True when `p`'s first segment names a declared root (a `‹root›/rest` selector). */
|
|
42
|
+
function firstSegmentIsRoot(ws, p) {
|
|
43
|
+
const seg = p.split(/[/\\]/, 1)[0];
|
|
44
|
+
return seg.length > 0 && seg !== p && ws.tryRootByName(seg) !== undefined;
|
|
45
|
+
}
|
|
36
46
|
/**
|
|
37
|
-
* Search file *contents* for a regex within the
|
|
47
|
+
* Search file *contents* for a regex within the workspace. Read-only — no
|
|
38
48
|
* approval — so the model should prefer this over shelling out to grep/rg via
|
|
39
49
|
* run_command (which is platform-dependent and routes through the approval gate).
|
|
40
50
|
*
|
|
41
51
|
* Files are enumerated with the same glob mechanism as the `glob` tool (so
|
|
42
52
|
* node_modules and .git are always ignored), binary files are skipped, and the
|
|
43
|
-
* search is bounded by the
|
|
53
|
+
* search is bounded by the same root boundary as every file tool.
|
|
54
|
+
*
|
|
55
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root and no root-
|
|
56
|
+
* selecting argument, the search FANS every root — a bare relative `path` is
|
|
57
|
+
* applied under each root (⚖︎JC-6) — and labels each match `‹root› ▸ path`. A
|
|
58
|
+
* `root` argument, or an absolute / `‹root›/…`-prefixed `path`, scopes it to that
|
|
59
|
+
* one root. Each walk starts at exactly one confined root directory, so a match
|
|
60
|
+
* can only come from the root its label names.
|
|
44
61
|
*/
|
|
45
62
|
export const grepFilesTool = {
|
|
46
63
|
name: "grep_files",
|
|
@@ -55,59 +72,117 @@ export const grepFilesTool = {
|
|
|
55
72
|
catch (err) {
|
|
56
73
|
return { ok: false, error: `invalid regex: ${err.message}` };
|
|
57
74
|
}
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
// The `glob` filter is not a resolvable path, so — like the glob tool — it is
|
|
76
|
+
// confined by rejecting a pattern that could walk outside the scope (G2).
|
|
77
|
+
if (input.glob && isEscapingPattern(input.glob)) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: "glob must be relative to the search scope (no '..' or absolute paths)",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const ws = contextWorkspace(ctx);
|
|
84
|
+
let targets;
|
|
60
85
|
try {
|
|
61
|
-
|
|
86
|
+
if (input.root || !ws.isMultiRoot) {
|
|
87
|
+
// Explicit named root, or a single-root session: confine the path within
|
|
88
|
+
// that one root exactly as the pre-C.26 tool did (byte-identical output).
|
|
89
|
+
const { root, abs } = await resolveToolPath(ctx, {
|
|
90
|
+
root: input.root,
|
|
91
|
+
path: input.path ?? ".",
|
|
92
|
+
});
|
|
93
|
+
targets = [{ root, scope: abs }];
|
|
94
|
+
}
|
|
95
|
+
else if (input.path &&
|
|
96
|
+
(path.isAbsolute(input.path) || firstSegmentIsRoot(ws, input.path))) {
|
|
97
|
+
// A `path` that itself points into / names one root selects that root.
|
|
98
|
+
const { root, abs } = await resolveToolPath(ctx, { path: input.path });
|
|
99
|
+
targets = [{ root, scope: abs }];
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Fan: apply the bare-relative `path` (or none) under EACH root (⚖︎JC-6).
|
|
103
|
+
targets = await Promise.all(ws.roots().map(async (root) => ({
|
|
104
|
+
root,
|
|
105
|
+
scope: input.path
|
|
106
|
+
? await confineToRoot(root.absPath, input.path)
|
|
107
|
+
: root.absPath,
|
|
108
|
+
})));
|
|
109
|
+
}
|
|
62
110
|
}
|
|
63
111
|
catch (err) {
|
|
64
112
|
return { ok: false, error: err.message };
|
|
65
113
|
}
|
|
66
114
|
const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS;
|
|
67
115
|
try {
|
|
68
|
-
const
|
|
69
|
-
cwd: scope,
|
|
70
|
-
ignore: DEFAULT_IGNORE,
|
|
71
|
-
onlyFiles: true,
|
|
72
|
-
dot: false,
|
|
73
|
-
});
|
|
74
|
-
files.sort();
|
|
75
|
-
const lines = [];
|
|
116
|
+
const rows = []; // shown (capped) lines
|
|
76
117
|
let total = 0; // total matches found, including those past the cap
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
const totalByRoot = new Map();
|
|
119
|
+
for (const target of targets) {
|
|
120
|
+
const files = await glob(input.glob ?? "**/*", {
|
|
121
|
+
cwd: target.scope,
|
|
122
|
+
ignore: DEFAULT_IGNORE,
|
|
123
|
+
onlyFiles: true,
|
|
124
|
+
dot: false,
|
|
125
|
+
});
|
|
126
|
+
files.sort();
|
|
127
|
+
for (const rel of files) {
|
|
128
|
+
const abs = path.join(target.scope, rel);
|
|
129
|
+
const buf = await fs.readFile(abs);
|
|
130
|
+
if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
|
|
131
|
+
continue; // binary
|
|
132
|
+
const display = labelPath(target.root, path.relative(target.root.absPath, abs), ws.isMultiRoot);
|
|
133
|
+
const fileLines = buf.toString("utf8").split("\n");
|
|
134
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
135
|
+
if (!regex.test(fileLines[i]))
|
|
136
|
+
continue;
|
|
137
|
+
total++;
|
|
138
|
+
totalByRoot.set(target.root.name, (totalByRoot.get(target.root.name) ?? 0) + 1);
|
|
139
|
+
if (rows.length < maxResults) {
|
|
140
|
+
let text = fileLines[i].trim();
|
|
141
|
+
if (text.length > MAX_LINE_LENGTH) {
|
|
142
|
+
text = `${text.slice(0, MAX_LINE_LENGTH)}…`;
|
|
143
|
+
}
|
|
144
|
+
rows.push({
|
|
145
|
+
root: target.root.name,
|
|
146
|
+
line: `${display}:${i + 1}: ${text}`,
|
|
147
|
+
});
|
|
92
148
|
}
|
|
93
|
-
lines.push(`${display}:${i + 1}: ${text}`);
|
|
94
149
|
}
|
|
95
150
|
}
|
|
96
151
|
}
|
|
97
152
|
if (total === 0) {
|
|
98
153
|
return { ok: true, output: "(no matches)" };
|
|
99
154
|
}
|
|
100
|
-
const omitted = total -
|
|
101
|
-
const body =
|
|
102
|
-
|
|
103
|
-
ok: true,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
155
|
+
const omitted = total - rows.length;
|
|
156
|
+
const body = rows.map((r) => r.line).join("\n");
|
|
157
|
+
if (omitted === 0) {
|
|
158
|
+
return { ok: true, output: body };
|
|
159
|
+
}
|
|
160
|
+
const footer = ws.isMultiRoot
|
|
161
|
+
? grepTruncationFooter(omitted, rows, totalByRoot)
|
|
162
|
+
: `[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`;
|
|
163
|
+
return { ok: true, output: `${body}\n\n${footer}` };
|
|
108
164
|
}
|
|
109
165
|
catch (err) {
|
|
110
166
|
return { ok: false, error: err.message };
|
|
111
167
|
}
|
|
112
168
|
},
|
|
113
169
|
};
|
|
170
|
+
/**
|
|
171
|
+
* Truncation footer for a fanned grep: the overall overflow count plus a per-root
|
|
172
|
+
* note for every root whose matches the cap dropped — so a root cut off by the cap
|
|
173
|
+
* is named, never silently read as having no matches.
|
|
174
|
+
*/
|
|
175
|
+
function grepTruncationFooter(omitted, shownRows, totalByRoot) {
|
|
176
|
+
const shownByRoot = new Map();
|
|
177
|
+
for (const r of shownRows) {
|
|
178
|
+
shownByRoot.set(r.root, (shownByRoot.get(r.root) ?? 0) + 1);
|
|
179
|
+
}
|
|
180
|
+
const lines = [`[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`];
|
|
181
|
+
for (const [name, count] of totalByRoot) {
|
|
182
|
+
const dropped = count - (shownByRoot.get(name) ?? 0);
|
|
183
|
+
if (dropped > 0) {
|
|
184
|
+
lines.push(`— ${name}: ${dropped} more omitted`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return lines.join("\n");
|
|
188
|
+
}
|
|
@@ -1,19 +1,132 @@
|
|
|
1
|
-
import { PathEscapeError } from "../../workspace/index.js";
|
|
1
|
+
import { PathEscapeError, type DeclaredRoot, type RootRef, type Workspace } from "../../workspace/index.js";
|
|
2
2
|
import type { ToolContext } from "../types.js";
|
|
3
3
|
/**
|
|
4
|
-
* Path confinement for file tools. The confinement kernel
|
|
5
|
-
* `src/workspace`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Path confinement for file tools (C.26). The confinement kernel itself lives in
|
|
5
|
+
* `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
|
|
6
|
+
* that every path-taking tool calls instead of touching the filesystem directly.
|
|
7
|
+
* Keeping ONE resolver here is what makes Funnel A ("a path tool cannot reach fs
|
|
8
|
+
* outside the selected root") a structural property rather than a per-tool habit.
|
|
9
9
|
*/
|
|
10
10
|
export { PathEscapeError };
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The workspace this context acts in. Runtime always sets `ctx.workspace`; the
|
|
13
|
+
* fallback to a trivial single-root workspace over `ctx.cwd` exists only for the
|
|
14
|
+
* many test contexts (and any not-yet-migrated caller) that don't set one. The
|
|
15
|
+
* fallback is byte-identical to the legacy single-root behaviour by construction:
|
|
16
|
+
* its one root's `absPath` is `path.resolve(ctx.cwd)`, the same root the old
|
|
17
|
+
* `resolveInRoot` confined to.
|
|
18
|
+
*/
|
|
19
|
+
export declare function contextWorkspace(ctx: ToolContext): Workspace;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a tool-supplied path against the workspace and prove it stays inside the
|
|
22
|
+
* one root it selects — the single funnel for every path-taking file tool.
|
|
23
|
+
*
|
|
24
|
+
* Single-root sessions short-circuit straight to `confineToRoot(primary, path)`,
|
|
25
|
+
* which is exactly the legacy `resolveInRoot` behaviour (same result, same
|
|
26
|
+
* error type + message for every case: relative, absolute-inside, absolute-outside,
|
|
27
|
+
* outward symlink). Multi-root sessions run the two-step select-then-confine:
|
|
28
|
+
* {@link selectRoot} commits to exactly one declared root FIRST, then
|
|
29
|
+
* {@link confineToRoot} validates against only that root — so a `../otherRoot/x`
|
|
30
|
+
* that lands in a sibling is refused as an ordinary `PATH_ESCAPE` (R2), never
|
|
31
|
+
* rebound to the sibling.
|
|
32
|
+
*
|
|
33
|
+
* `deferNonPrimaryWrite` (set by the mutating file tools) refuses a write whose
|
|
34
|
+
* selected root is NOT the primary in a multi-root session, with
|
|
35
|
+
* `CRUXY_E_MULTIROOT_WRITE_DEFERRED` — UNLESS `ctx.checkpointsActive` is true.
|
|
36
|
+
* That flag is the C.26 step-3 coupling: it is set exactly when a per-root
|
|
37
|
+
* checkpoint gate is wired, which captures the write (snapshots its root) before
|
|
38
|
+
* it reaches disk — so the refusal lifts precisely when the write becomes
|
|
39
|
+
* rollback-able, never before (not step-wide, not lift-then-verify). With
|
|
40
|
+
* checkpoints disabled the flag is false, so a non-primary write is still refused
|
|
41
|
+
* rather than left un-restorable (⚖︎JC-γ: the opt-out covers the user's own cwd,
|
|
42
|
+
* not every declared sibling). It is a no-op in single-root (there is only the
|
|
43
|
+
* primary) and for reads (which never set it). The refusal is raised BEFORE any
|
|
44
|
+
* filesystem access.
|
|
45
|
+
*
|
|
46
|
+
* @throws {PathEscapeError} if the path escapes the selected root.
|
|
47
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS on bad selection,
|
|
48
|
+
* or CRUXY_E_MULTIROOT_WRITE_DEFERRED on a non-primary write while deferred.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveToolPath(ctx: ToolContext, ref: RootRef, opts?: {
|
|
51
|
+
requireExplicit?: boolean;
|
|
52
|
+
deferNonPrimaryWrite?: boolean;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
root: DeclaredRoot;
|
|
55
|
+
abs: string;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Legacy single-root entry point, retained as a thin shim over
|
|
59
|
+
* {@link resolveToolPath} so callers that pass only a bare path (e.g. the LSP
|
|
60
|
+
* tools) keep working and stay byte-identical. New path tools should call
|
|
61
|
+
* {@link resolveToolPath} so they can carry an explicit root selector.
|
|
15
62
|
*
|
|
16
63
|
* @returns the resolved absolute path (lexical, not realpath'd).
|
|
17
64
|
* @throws {PathEscapeError} if the path escapes the root.
|
|
18
65
|
*/
|
|
19
66
|
export declare function resolveInRoot(ctx: ToolContext, p: string): Promise<string>;
|
|
67
|
+
/**
|
|
68
|
+
* True when a glob pattern could walk outside its root — an absolute pattern or one
|
|
69
|
+
* containing a `..` segment. The ONE implementation of "does this pattern escape",
|
|
70
|
+
* shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
|
|
71
|
+
* (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
|
|
72
|
+
* *patterns* are not resolvable paths, so this predicate guards the walk instead.
|
|
73
|
+
*/
|
|
74
|
+
export declare function isEscapingPattern(pattern: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* The separator between a root's name and a path within it (`‹root› ▸ rel`) — one
|
|
77
|
+
* constant so every no-path tool that fans across roots renders the same label.
|
|
78
|
+
*/
|
|
79
|
+
export declare const ROOT_LABEL_SEP = " \u25B8 ";
|
|
80
|
+
/**
|
|
81
|
+
* Prefix a root-relative path with its root name — but ONLY in a genuine
|
|
82
|
+
* multi-root session. In single-root the label is dropped so output stays
|
|
83
|
+
* byte-identical with the pre-C.26 tools. The `root`/`rel` pair always comes from
|
|
84
|
+
* the same fan iteration that produced the bytes, so a rendered label can never
|
|
85
|
+
* point at a different root than the one that was walked (the honesty pin).
|
|
86
|
+
*/
|
|
87
|
+
export declare function labelPath(root: DeclaredRoot, rel: string, isMultiRoot: boolean): string;
|
|
88
|
+
/** As {@link labelPath} but from a bare root name (for hits that carry only a name). */
|
|
89
|
+
export declare function labelName(rootName: string, rel: string): string;
|
|
90
|
+
/**
|
|
91
|
+
* The Funnel-B selector for **no-path** tools (`glob`, `grep_files`, `list_files`,
|
|
92
|
+
* `git_status`, `search_codebase`) — the counterpart to {@link resolveToolPath}.
|
|
93
|
+
* A no-path tool has no path to confine; its boundary is *which root's `absPath`*
|
|
94
|
+
* it threads as the walk-start / index-key / spawn-cwd. This returns that root
|
|
95
|
+
* set, and the tool commits to exactly one root per read by iterating it:
|
|
96
|
+
*
|
|
97
|
+
* - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
|
|
98
|
+
* - single-root session → the primary root (so output is byte-identical);
|
|
99
|
+
* - multi-root, unscoped → **fan** every declared root.
|
|
100
|
+
*
|
|
101
|
+
* The fan is N independent single-root reads (one `root.absPath` each), never one
|
|
102
|
+
* walk over a union — so each read is Funnel-B-confined and the cross-root merge
|
|
103
|
+
* only ever happens on already-attributed *results*, never on the filesystem.
|
|
104
|
+
* Reads leave `requireExplicit` false (that gate is for mutations, ⚖︎#3).
|
|
105
|
+
*
|
|
106
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if a named root is not declared.
|
|
107
|
+
*/
|
|
108
|
+
export declare function resolveReadRoots(ctx: ToolContext, ref?: {
|
|
109
|
+
root?: string;
|
|
110
|
+
}): readonly DeclaredRoot[];
|
|
111
|
+
/**
|
|
112
|
+
* The Funnel-B selector for a **no-path, whole-root MUTATION** — today
|
|
113
|
+
* `create_pull_request` (and the future commit/branch/push tools). Unlike
|
|
114
|
+
* {@link resolveReadRoots} it commits to exactly ONE root and, in a multi-root
|
|
115
|
+
* session, refuses to default (⚖︎#11, JC-1 take B):
|
|
116
|
+
*
|
|
117
|
+
* - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
|
|
118
|
+
* - single-root session → the primary root (byte-identical to pre-C.26 — a
|
|
119
|
+
* single root is unambiguous, so no `root` is required);
|
|
120
|
+
* - multi-root, unscoped → refuse `ROOT_AMBIGUOUS` (a defaulted PR is the
|
|
121
|
+
* "opened the wrong repo" accident; ambiguity only exists when N > 1).
|
|
122
|
+
*
|
|
123
|
+
* `what` names the action in the refusal message (e.g. `"a pull request"`). This
|
|
124
|
+
* throws BEFORE the caller runs any git, so a no-root multi-root call performs
|
|
125
|
+
* zero git spawns.
|
|
126
|
+
*
|
|
127
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN (unknown name) / CRUXY_E_ROOT_AMBIGUOUS
|
|
128
|
+
* (multi-root, no root).
|
|
129
|
+
*/
|
|
130
|
+
export declare function resolveMutationRoot(ctx: ToolContext, ref: {
|
|
131
|
+
root?: string;
|
|
132
|
+
}, what: string): DeclaredRoot;
|
package/dist/tools/file/paths.js
CHANGED
|
@@ -1,20 +1,175 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { multirootWriteDeferred, rootAmbiguous } from "../../errors/index.js";
|
|
3
|
+
import { confineToRoot, PathEscapeError, selectRoot, singleRootWorkspace, } from "../../workspace/index.js";
|
|
2
4
|
/**
|
|
3
|
-
* Path confinement for file tools. The confinement kernel
|
|
4
|
-
* `src/workspace`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Path confinement for file tools (C.26). The confinement kernel itself lives in
|
|
6
|
+
* `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
|
|
7
|
+
* that every path-taking tool calls instead of touching the filesystem directly.
|
|
8
|
+
* Keeping ONE resolver here is what makes Funnel A ("a path tool cannot reach fs
|
|
9
|
+
* outside the selected root") a structural property rather than a per-tool habit.
|
|
8
10
|
*/
|
|
9
11
|
export { PathEscapeError };
|
|
10
12
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* The workspace this context acts in. Runtime always sets `ctx.workspace`; the
|
|
14
|
+
* fallback to a trivial single-root workspace over `ctx.cwd` exists only for the
|
|
15
|
+
* many test contexts (and any not-yet-migrated caller) that don't set one. The
|
|
16
|
+
* fallback is byte-identical to the legacy single-root behaviour by construction:
|
|
17
|
+
* its one root's `absPath` is `path.resolve(ctx.cwd)`, the same root the old
|
|
18
|
+
* `resolveInRoot` confined to.
|
|
19
|
+
*/
|
|
20
|
+
export function contextWorkspace(ctx) {
|
|
21
|
+
return ctx.workspace ?? singleRootWorkspace(ctx.cwd);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a tool-supplied path against the workspace and prove it stays inside the
|
|
25
|
+
* one root it selects — the single funnel for every path-taking file tool.
|
|
26
|
+
*
|
|
27
|
+
* Single-root sessions short-circuit straight to `confineToRoot(primary, path)`,
|
|
28
|
+
* which is exactly the legacy `resolveInRoot` behaviour (same result, same
|
|
29
|
+
* error type + message for every case: relative, absolute-inside, absolute-outside,
|
|
30
|
+
* outward symlink). Multi-root sessions run the two-step select-then-confine:
|
|
31
|
+
* {@link selectRoot} commits to exactly one declared root FIRST, then
|
|
32
|
+
* {@link confineToRoot} validates against only that root — so a `../otherRoot/x`
|
|
33
|
+
* that lands in a sibling is refused as an ordinary `PATH_ESCAPE` (R2), never
|
|
34
|
+
* rebound to the sibling.
|
|
35
|
+
*
|
|
36
|
+
* `deferNonPrimaryWrite` (set by the mutating file tools) refuses a write whose
|
|
37
|
+
* selected root is NOT the primary in a multi-root session, with
|
|
38
|
+
* `CRUXY_E_MULTIROOT_WRITE_DEFERRED` — UNLESS `ctx.checkpointsActive` is true.
|
|
39
|
+
* That flag is the C.26 step-3 coupling: it is set exactly when a per-root
|
|
40
|
+
* checkpoint gate is wired, which captures the write (snapshots its root) before
|
|
41
|
+
* it reaches disk — so the refusal lifts precisely when the write becomes
|
|
42
|
+
* rollback-able, never before (not step-wide, not lift-then-verify). With
|
|
43
|
+
* checkpoints disabled the flag is false, so a non-primary write is still refused
|
|
44
|
+
* rather than left un-restorable (⚖︎JC-γ: the opt-out covers the user's own cwd,
|
|
45
|
+
* not every declared sibling). It is a no-op in single-root (there is only the
|
|
46
|
+
* primary) and for reads (which never set it). The refusal is raised BEFORE any
|
|
47
|
+
* filesystem access.
|
|
48
|
+
*
|
|
49
|
+
* @throws {PathEscapeError} if the path escapes the selected root.
|
|
50
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS on bad selection,
|
|
51
|
+
* or CRUXY_E_MULTIROOT_WRITE_DEFERRED on a non-primary write while deferred.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveToolPath(ctx, ref, opts = {}) {
|
|
54
|
+
const ws = contextWorkspace(ctx);
|
|
55
|
+
// Single-root: byte-identical with the legacy single-root funnel. Skipping
|
|
56
|
+
// selectRoot here also means a bare path segment that happens to equal the sole
|
|
57
|
+
// root's name is never mis-stripped as a root prefix. (A single-root session has
|
|
58
|
+
// only the primary, so the non-primary-write guard can never fire here.)
|
|
59
|
+
if (!ws.isMultiRoot) {
|
|
60
|
+
const root = ws.primary();
|
|
61
|
+
return { root, abs: await confineToRoot(root.absPath, ref.path) };
|
|
62
|
+
}
|
|
63
|
+
const selected = selectRoot(ws, ref, opts);
|
|
64
|
+
if (opts.deferNonPrimaryWrite &&
|
|
65
|
+
selected.root.name !== ws.primary().name &&
|
|
66
|
+
!ctx.checkpointsActive) {
|
|
67
|
+
// Refuse BEFORE confineToRoot touches the filesystem — no partial write. The
|
|
68
|
+
// lift is conditioned on the per-root checkpoint gate being wired
|
|
69
|
+
// (ctx.checkpointsActive): permitting the write and capturing it are the same
|
|
70
|
+
// decision, so a permitted non-primary write is always checkpointed.
|
|
71
|
+
throw multirootWriteDeferred(selected.root.name, ws.primary().name);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
root: selected.root,
|
|
75
|
+
abs: await confineToRoot(selected.root.absPath, selected.relPath),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Legacy single-root entry point, retained as a thin shim over
|
|
80
|
+
* {@link resolveToolPath} so callers that pass only a bare path (e.g. the LSP
|
|
81
|
+
* tools) keep working and stay byte-identical. New path tools should call
|
|
82
|
+
* {@link resolveToolPath} so they can carry an explicit root selector.
|
|
14
83
|
*
|
|
15
84
|
* @returns the resolved absolute path (lexical, not realpath'd).
|
|
16
85
|
* @throws {PathEscapeError} if the path escapes the root.
|
|
17
86
|
*/
|
|
18
87
|
export async function resolveInRoot(ctx, p) {
|
|
19
|
-
return
|
|
88
|
+
return (await resolveToolPath(ctx, { path: p })).abs;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* True when a glob pattern could walk outside its root — an absolute pattern or one
|
|
92
|
+
* containing a `..` segment. The ONE implementation of "does this pattern escape",
|
|
93
|
+
* shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
|
|
94
|
+
* (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
|
|
95
|
+
* *patterns* are not resolvable paths, so this predicate guards the walk instead.
|
|
96
|
+
*/
|
|
97
|
+
export function isEscapingPattern(pattern) {
|
|
98
|
+
return path.isAbsolute(pattern) || pattern.split(/[/\\]/).includes("..");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The separator between a root's name and a path within it (`‹root› ▸ rel`) — one
|
|
102
|
+
* constant so every no-path tool that fans across roots renders the same label.
|
|
103
|
+
*/
|
|
104
|
+
export const ROOT_LABEL_SEP = " ▸ ";
|
|
105
|
+
/**
|
|
106
|
+
* Prefix a root-relative path with its root name — but ONLY in a genuine
|
|
107
|
+
* multi-root session. In single-root the label is dropped so output stays
|
|
108
|
+
* byte-identical with the pre-C.26 tools. The `root`/`rel` pair always comes from
|
|
109
|
+
* the same fan iteration that produced the bytes, so a rendered label can never
|
|
110
|
+
* point at a different root than the one that was walked (the honesty pin).
|
|
111
|
+
*/
|
|
112
|
+
export function labelPath(root, rel, isMultiRoot) {
|
|
113
|
+
return isMultiRoot ? labelName(root.name, rel) : rel;
|
|
114
|
+
}
|
|
115
|
+
/** As {@link labelPath} but from a bare root name (for hits that carry only a name). */
|
|
116
|
+
export function labelName(rootName, rel) {
|
|
117
|
+
return `${rootName}${ROOT_LABEL_SEP}${rel}`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The Funnel-B selector for **no-path** tools (`glob`, `grep_files`, `list_files`,
|
|
121
|
+
* `git_status`, `search_codebase`) — the counterpart to {@link resolveToolPath}.
|
|
122
|
+
* A no-path tool has no path to confine; its boundary is *which root's `absPath`*
|
|
123
|
+
* it threads as the walk-start / index-key / spawn-cwd. This returns that root
|
|
124
|
+
* set, and the tool commits to exactly one root per read by iterating it:
|
|
125
|
+
*
|
|
126
|
+
* - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
|
|
127
|
+
* - single-root session → the primary root (so output is byte-identical);
|
|
128
|
+
* - multi-root, unscoped → **fan** every declared root.
|
|
129
|
+
*
|
|
130
|
+
* The fan is N independent single-root reads (one `root.absPath` each), never one
|
|
131
|
+
* walk over a union — so each read is Funnel-B-confined and the cross-root merge
|
|
132
|
+
* only ever happens on already-attributed *results*, never on the filesystem.
|
|
133
|
+
* Reads leave `requireExplicit` false (that gate is for mutations, ⚖︎#3).
|
|
134
|
+
*
|
|
135
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if a named root is not declared.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveReadRoots(ctx, ref = {}) {
|
|
138
|
+
const ws = contextWorkspace(ctx);
|
|
139
|
+
if (ref.root !== undefined) {
|
|
140
|
+
return [ws.rootByName(ref.root)];
|
|
141
|
+
}
|
|
142
|
+
if (!ws.isMultiRoot) {
|
|
143
|
+
return [ws.primary()];
|
|
144
|
+
}
|
|
145
|
+
return ws.roots();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The Funnel-B selector for a **no-path, whole-root MUTATION** — today
|
|
149
|
+
* `create_pull_request` (and the future commit/branch/push tools). Unlike
|
|
150
|
+
* {@link resolveReadRoots} it commits to exactly ONE root and, in a multi-root
|
|
151
|
+
* session, refuses to default (⚖︎#11, JC-1 take B):
|
|
152
|
+
*
|
|
153
|
+
* - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
|
|
154
|
+
* - single-root session → the primary root (byte-identical to pre-C.26 — a
|
|
155
|
+
* single root is unambiguous, so no `root` is required);
|
|
156
|
+
* - multi-root, unscoped → refuse `ROOT_AMBIGUOUS` (a defaulted PR is the
|
|
157
|
+
* "opened the wrong repo" accident; ambiguity only exists when N > 1).
|
|
158
|
+
*
|
|
159
|
+
* `what` names the action in the refusal message (e.g. `"a pull request"`). This
|
|
160
|
+
* throws BEFORE the caller runs any git, so a no-root multi-root call performs
|
|
161
|
+
* zero git spawns.
|
|
162
|
+
*
|
|
163
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN (unknown name) / CRUXY_E_ROOT_AMBIGUOUS
|
|
164
|
+
* (multi-root, no root).
|
|
165
|
+
*/
|
|
166
|
+
export function resolveMutationRoot(ctx, ref, what) {
|
|
167
|
+
const ws = contextWorkspace(ctx);
|
|
168
|
+
if (ref.root !== undefined) {
|
|
169
|
+
return ws.rootByName(ref.root);
|
|
170
|
+
}
|
|
171
|
+
if (!ws.isMultiRoot) {
|
|
172
|
+
return ws.primary();
|
|
173
|
+
}
|
|
174
|
+
throw rootAmbiguous(what, ws.roots().map((r) => r.name));
|
|
20
175
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveToolPath } from "./paths.js";
|
|
4
4
|
/** Files larger than this are truncated rather than dumped in full. */
|
|
5
5
|
const MAX_BYTES = 256 * 1024;
|
|
6
6
|
/**
|
|
@@ -16,7 +16,7 @@ export const readFileTool = {
|
|
|
16
16
|
}),
|
|
17
17
|
async execute(input, ctx) {
|
|
18
18
|
try {
|
|
19
|
-
const abs = await
|
|
19
|
+
const { abs } = await resolveToolPath(ctx, { path: input.path });
|
|
20
20
|
const stat = await fs.stat(abs);
|
|
21
21
|
if (stat.isDirectory()) {
|
|
22
22
|
return {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveToolPath } from "./paths.js";
|
|
5
5
|
/** How many leading lines of new content the approval preview shows. */
|
|
6
6
|
const PREVIEW_LINES = 20;
|
|
7
7
|
/**
|
|
@@ -20,7 +20,7 @@ export const writeFileTool = {
|
|
|
20
20
|
async execute(input, ctx) {
|
|
21
21
|
let abs;
|
|
22
22
|
try {
|
|
23
|
-
abs = await
|
|
23
|
+
({ abs } = await resolveToolPath(ctx, { path: input.path }, { deferNonPrimaryWrite: true }));
|
|
24
24
|
}
|
|
25
25
|
catch (err) {
|
|
26
26
|
return { ok: false, error: err.message };
|
|
@@ -4,5 +4,12 @@ import type { Tool } from "./types.js";
|
|
|
4
4
|
* Report the current git branch and working-tree status (`git status
|
|
5
5
|
* --porcelain`) for the project root. Read-only — no approval, like read_file
|
|
6
6
|
* and glob.
|
|
7
|
+
*
|
|
8
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
|
|
9
|
+
* root — each `getGitStatus(root.absPath)` spawns git with that root as its cwd,
|
|
10
|
+
* so a section reports exactly the working tree its label names. A non-git root
|
|
11
|
+
* is NAMED (not fatal), and a `root` argument scopes to one root.
|
|
7
12
|
*/
|
|
8
|
-
export declare const gitStatusTool: Tool<z.ZodObject<
|
|
13
|
+
export declare const gitStatusTool: Tool<z.ZodObject<{
|
|
14
|
+
root: z.ZodOptional<z.ZodString>;
|
|
15
|
+
}>>;
|
package/dist/tools/git-status.js
CHANGED
|
@@ -1,26 +1,58 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getGitStatus } from "../utils/git.js";
|
|
3
|
+
import { contextWorkspace, resolveReadRoots } from "./file/paths.js";
|
|
3
4
|
/**
|
|
4
5
|
* Report the current git branch and working-tree status (`git status
|
|
5
6
|
* --porcelain`) for the project root. Read-only — no approval, like read_file
|
|
6
7
|
* and glob.
|
|
8
|
+
*
|
|
9
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
|
|
10
|
+
* root — each `getGitStatus(root.absPath)` spawns git with that root as its cwd,
|
|
11
|
+
* so a section reports exactly the working tree its label names. A non-git root
|
|
12
|
+
* is NAMED (not fatal), and a `root` argument scopes to one root.
|
|
7
13
|
*/
|
|
8
14
|
export const gitStatusTool = {
|
|
9
15
|
name: "git_status",
|
|
10
16
|
description: "Show the current git branch and the working-tree status (porcelain format: ' M file' modified, '?? file' untracked, etc.). Read-only and requires no approval.",
|
|
11
|
-
parameters: z.object({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
parameters: z.object({
|
|
18
|
+
root: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("In a multi-repo session, restrict the report to a single declared root by name. Omit to report every root."),
|
|
22
|
+
}),
|
|
23
|
+
async execute(input, ctx) {
|
|
24
|
+
const ws = contextWorkspace(ctx);
|
|
25
|
+
let roots;
|
|
26
|
+
try {
|
|
27
|
+
roots = resolveReadRoots(ctx, { root: input.root });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return { ok: false, error: err.message };
|
|
31
|
+
}
|
|
32
|
+
// Single-root: byte-identical with the pre-C.26 tool.
|
|
33
|
+
if (!ws.isMultiRoot) {
|
|
34
|
+
const info = getGitStatus(roots[0].absPath);
|
|
35
|
+
if (info === null) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: "not a git repository (or git is unavailable)",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const body = info.status.trim();
|
|
15
42
|
return {
|
|
16
|
-
ok:
|
|
17
|
-
|
|
43
|
+
ok: true,
|
|
44
|
+
output: `branch ${info.branch}\n${body === "" ? "working tree clean" : body}`,
|
|
18
45
|
};
|
|
19
46
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
// Multi-root: one section per root; a non-git root is named, never fatal.
|
|
48
|
+
const sections = roots.map((root) => {
|
|
49
|
+
const info = getGitStatus(root.absPath);
|
|
50
|
+
if (info === null) {
|
|
51
|
+
return `${root.name}: not a git repository`;
|
|
52
|
+
}
|
|
53
|
+
const body = info.status.trim();
|
|
54
|
+
return `${root.name}: branch ${info.branch}\n${body === "" ? "working tree clean" : body}`;
|
|
55
|
+
});
|
|
56
|
+
return { ok: true, output: sections.join("\n\n") };
|
|
25
57
|
},
|
|
26
58
|
};
|