@counterfact-labs/cli 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/bundle/client-release.json +1 -1
- package/dist/adapters/completion-lock.js +95 -0
- package/dist/adapters/completion.js +132 -17
- package/dist/adapters/entry-files.js +126 -0
- package/dist/adapters/entry-selection.js +45 -0
- package/dist/adapters/files.js +2 -1
- package/dist/adapters/results.js +10 -2
- package/dist/adapters/selection.js +104 -0
- package/dist/adapters/settings.js +29 -2
- package/dist/analysis-core/analysis/schemas.js +1 -1
- package/dist/analysis-core/chunks/2PZF54KH.js +10 -0
- package/dist/analysis-core/chunks/{OPMA32A6.js → 6RWEWBHK.js} +1 -1
- package/dist/analysis-core/chunks/{PECX3OXB.js → 7NEWZ74F.js} +1 -1
- package/dist/analysis-core/chunks/{6QY5BV5B.js → CGCJN73D.js} +1 -1
- package/dist/analysis-core/chunks/CWYVUGG4.js +1 -0
- package/dist/analysis-core/chunks/{3JWEFCTJ.js → FBTOOUPL.js} +1 -1
- package/dist/analysis-core/chunks/GOQVKWP3.js +32 -0
- package/dist/analysis-core/chunks/IHAVDX4L.js +66 -0
- package/dist/analysis-core/chunks/IVULXWQN.js +21 -0
- package/dist/analysis-core/chunks/K5FUABWW.js +1 -0
- package/dist/analysis-core/chunks/KGQYR62R.js +7 -0
- package/dist/analysis-core/chunks/PY5M22CL.js +1 -0
- package/dist/analysis-core/chunks/{YDBO7P4X.js → RNTMH4MU.js} +1 -1
- package/dist/analysis-core/chunks/RUVMFAST.js +1 -0
- package/dist/analysis-core/chunks/S27VIQMZ.js +1 -0
- package/dist/analysis-core/chunks/{5DGPLXYB.js → SLJ6CW7W.js} +1 -1
- package/dist/analysis-core/chunks/{STI4JE7L.js → UEORNVQB.js} +1 -1
- package/dist/analysis-core/chunks/{X7WZAA7S.js → WYLIV2P2.js} +1 -1
- package/dist/analysis-core/contracts/analysis-selection.js +1 -0
- package/dist/analysis-core/contracts/module-resolution.js +1 -0
- package/dist/analysis-core/contracts/relay-analysis.js +1 -1
- package/dist/analysis-core/contracts/requests.js +1 -1
- package/dist/analysis-core/contracts/selection-paths.js +1 -0
- package/dist/analysis-core/conventions/analysis-fit.js +1 -1
- package/dist/analysis-core/conventions/file-admission.js +1 -1
- package/dist/analysis-core/import/calls/modules.js +1 -0
- package/dist/analysis-core/import/outside-loader-sources.js +1 -1
- package/dist/analysis-core/index.js +3 -3
- package/dist/analysis-core/requests/local-plan.js +1 -1
- package/dist/analysis-core/requests/registry.js +1 -1
- package/dist/analysis-core/requests/template-version.js +1 -1
- package/dist/analysis-core/requests/tools.js +1 -1
- package/dist/analysis-core/settings.js +1 -1
- package/dist/analysis-core/stages/engine-entry.js +1 -1
- package/dist/cli/analysis-selection.js +91 -0
- package/dist/cli/commands/analyze.js +31 -6
- package/dist/cli/completion-delivery.js +63 -0
- package/dist/cli/completion-recovery.js +68 -14
- package/dist/cli/context.js +2 -1
- package/dist/cli/main.js +21 -9
- package/dist/cli/mcp.js +70 -11
- package/dist/cli/prior-completion.js +35 -0
- package/dist/cli/relay-check.js +163 -97
- package/dist/cli/relay-failure.js +32 -0
- package/dist/cli/relay-preparation.js +24 -11
- package/dist/cli/results-upload.js +131 -0
- package/dist/cli/scope-choices.js +57 -0
- package/dist/contract/cli-refusals.js +8 -0
- package/dist/contract/cli-version.js +1 -1
- package/dist/contract/console.js +7 -2
- package/dist/contract/scope-choice.js +78 -0
- package/dist/contract/selection-metadata.js +9 -0
- package/dist/contract/selection-offer.js +43 -0
- package/dist/gatekeeper/console.js +36 -25
- package/package.json +2 -2
- package/dist/analysis-core/chunks/377MLK4M.js +0 -21
- package/dist/analysis-core/chunks/DLPVYWHZ.js +0 -1
- package/dist/analysis-core/chunks/FOKDE64Y.js +0 -1
- package/dist/analysis-core/chunks/KTNA2KTN.js +0 -7
- package/dist/analysis-core/chunks/NIOS7U6C.js +0 -41
- package/dist/analysis-core/chunks/QFY6A3MS.js +0 -66
- package/dist/analysis-core/chunks/SVMEWGD3.js +0 -1
- package/dist/analysis-core/chunks/ZTOVBAG7.js +0 -1
package/README.md
CHANGED
|
@@ -95,6 +95,26 @@ With `CF_EMAIL_SIGN_IN=0`, login help, arguments and the full MCP tool/status co
|
|
|
95
95
|
`confirm` argument are removed. Each requested analysis runs once without a price prompt. Prices belong on the website
|
|
96
96
|
and top-up page; an uncovered balance refuses with a top-up link.
|
|
97
97
|
|
|
98
|
+
CLI 0.2.7 enables file selection and smaller-scope choices by default for the
|
|
99
|
+
terminal and MCP server. `CF_FILE_SCOPE=0` restores the earlier folder-only
|
|
100
|
+
behavior; `CF_SCOPE_CHOICES=0` disables smaller-scope offers and scope retry
|
|
101
|
+
arguments. Shared core and sealed relay defaults are unchanged. Use
|
|
102
|
+
`counterfact-labs analyze ./agents/room.ts` to select a file within the launch
|
|
103
|
+
folder. The same file path works with `analyze_agent`. File paths must stay inside
|
|
104
|
+
the launch folder before and after resolving symlinks. Terminal folder analysis
|
|
105
|
+
still accepts folders outside the launch folder. MCP folder analysis keeps its
|
|
106
|
+
project path guard; `CF_MCP_PATH_GUARD=0` disables that guard.
|
|
107
|
+
`CF_LOCAL_IMPORT_RESOLUTION=0` also keeps folder analysis available by default;
|
|
108
|
+
explicitly combining it with `CF_FILE_SCOPE=1` retains the file-mode refusal.
|
|
109
|
+
Missing or unreadable paths follow the earlier folder route, including names that look like files and symlink loops.
|
|
110
|
+
MCP reports `folder_unavailable`, or passes the path to the runner when its guard is off.
|
|
111
|
+
An over-limit selection returns included and omitted files for the user to
|
|
112
|
+
choose. The coding agent must ask before narrowing coverage. Terminal offers
|
|
113
|
+
exit with status 3 and save a private scope JSON file for `--scope <json-file>`;
|
|
114
|
+
MCP returns a successful `choose_scope` response. Each retry uses the saved
|
|
115
|
+
snapshot and the ordinary admission checks. File reports live under the file's
|
|
116
|
+
parent at `.counterfact/files/<entry-path-hash>/` and include selection metadata.
|
|
117
|
+
|
|
98
118
|
The archive also ships `bundle/client-release.json` with its public Ed25519 verification key and local/relay versions,
|
|
99
119
|
and the gatekeeper's public production trust policy. Production trust has no environment override. With exactly
|
|
100
120
|
`CF_RELAY_DEV=1`, development startup accepts separate issuer trust, a bundle and public release pins through
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"verificationKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbBab1PYdmBKXxCvoJ47toJhQvxPevo9nlZnb0zbDWoc=\n-----END PUBLIC KEY-----\n",
|
|
3
3
|
"relayVersion": "relay-1.0.1",
|
|
4
|
-
"localVersion": "0.2.
|
|
4
|
+
"localVersion": "0.2.7",
|
|
5
5
|
"relayUrl": "https://relay.counterfactlabs.com:8443",
|
|
6
6
|
"projectContext": "checks"
|
|
7
7
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readdir, rename, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { FOLDER_BUSY_MESSAGE } from "../contract/cli-refusals.js";
|
|
6
|
+
export class CompletionBusyError extends Error {
|
|
7
|
+
reason = "folder_busy";
|
|
8
|
+
constructor() { super(FOLDER_BUSY_MESSAGE); }
|
|
9
|
+
}
|
|
10
|
+
const staleMs = 60_000;
|
|
11
|
+
const missing = (error) => error.code === "ENOENT";
|
|
12
|
+
async function retireStaleLock(lock) {
|
|
13
|
+
try {
|
|
14
|
+
const info = await lstat(lock);
|
|
15
|
+
if (!info.isDirectory() || Date.now() - info.mtimeMs < staleMs)
|
|
16
|
+
return;
|
|
17
|
+
const owners = await readdir(lock);
|
|
18
|
+
const current = await lstat(lock);
|
|
19
|
+
if (current.ino !== info.ino || current.dev !== info.dev || current.mtimeMs !== info.mtimeMs)
|
|
20
|
+
return;
|
|
21
|
+
// Unique owner names prevent a stale observer from removing a newly acquired lease.
|
|
22
|
+
for (const owner of owners)
|
|
23
|
+
await unlink(join(lock, owner)).catch(error => { if (!missing(error))
|
|
24
|
+
throw error; });
|
|
25
|
+
await rmdir(lock).catch(error => { if (!missing(error) && error.code !== "ENOTEMPTY")
|
|
26
|
+
throw error; });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (!missing(error))
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function retireStaleTemporaryLocks(lock) {
|
|
34
|
+
const prefix = `${basename(lock)}.`;
|
|
35
|
+
for (const name of await readdir(dirname(lock))) {
|
|
36
|
+
if (!name.startsWith(prefix) || !/^[0-9a-f-]{36}\.tmp$/.test(name.slice(prefix.length)))
|
|
37
|
+
continue;
|
|
38
|
+
await retireStaleLock(join(dirname(lock), name));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Acquire before paid work. Renewal keeps long analyses live; crashed owners expire after a minute. */
|
|
42
|
+
export async function acquireCompletionLock(path, onLost = () => { }) {
|
|
43
|
+
const lock = `${path}.lock`, temporary = `${lock}.${randomUUID()}.tmp`;
|
|
44
|
+
await retireStaleTemporaryLocks(lock);
|
|
45
|
+
let owner = randomUUID();
|
|
46
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
47
|
+
let acquired = false;
|
|
48
|
+
try {
|
|
49
|
+
await writeFile(join(temporary, owner), "", { flag: "wx", mode: 0o600 });
|
|
50
|
+
const deadline = Date.now() + 500;
|
|
51
|
+
for (;;) {
|
|
52
|
+
try {
|
|
53
|
+
await rename(temporary, lock);
|
|
54
|
+
acquired = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error.code ?? ""))
|
|
59
|
+
throw error;
|
|
60
|
+
await retireStaleLock(lock);
|
|
61
|
+
if (Date.now() >= deadline)
|
|
62
|
+
throw new CompletionBusyError();
|
|
63
|
+
await sleep(25);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let renewal;
|
|
67
|
+
const heartbeat = setInterval(() => {
|
|
68
|
+
if (renewal)
|
|
69
|
+
return;
|
|
70
|
+
const next = randomUUID();
|
|
71
|
+
// A stale observer can remove only the name it saw, so renewal survives its cleanup.
|
|
72
|
+
renewal = rename(join(lock, owner), join(lock, next)).then(() => { owner = next; })
|
|
73
|
+
.catch(() => { clearInterval(heartbeat); onLost(); }).finally(() => { renewal = undefined; });
|
|
74
|
+
}, 20_000);
|
|
75
|
+
heartbeat.unref();
|
|
76
|
+
let released = false;
|
|
77
|
+
return async () => {
|
|
78
|
+
if (released)
|
|
79
|
+
return;
|
|
80
|
+
released = true;
|
|
81
|
+
clearInterval(heartbeat);
|
|
82
|
+
await renewal;
|
|
83
|
+
await unlink(join(lock, owner)).catch(error => { if (!missing(error))
|
|
84
|
+
throw error; });
|
|
85
|
+
await rmdir(lock).catch(error => { if (!missing(error) && error.code !== "ENOTEMPTY")
|
|
86
|
+
throw error; });
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const directory = acquired ? lock : temporary;
|
|
91
|
+
await unlink(join(directory, owner)).catch(() => { });
|
|
92
|
+
await rmdir(directory).catch(() => { });
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { lstat, open, readdir, realpath } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
@@ -10,11 +10,19 @@ import { standaloneGitFailure } from "./repository-root.js";
|
|
|
10
10
|
import { runGit } from "./git.js";
|
|
11
11
|
import { resolveFolderPath } from "./folder-path.js";
|
|
12
12
|
import { matchesSnapshotProvenance } from "./snapshot.js";
|
|
13
|
+
import { acquireCompletionLock } from "./completion-lock.js";
|
|
13
14
|
const record = z.strictObject({ version: z.literal(1), consoleOrigin: z.url().optional(), uploaded: z.boolean().optional(),
|
|
14
|
-
finished: z.boolean().optional(),
|
|
15
|
+
finished: z.boolean().optional(), completedAt: z.number().finite().nonnegative().optional(),
|
|
16
|
+
absoluteExpiresAt: z.number().finite().nonnegative().optional(), payload: localFindingsPayloadSchema });
|
|
17
|
+
const selectionIdentity = z.strictObject({ consoleOrigin: z.url(), selectionKey: z.string().min(1).max(16384) });
|
|
18
|
+
const selectedRecord = record.extend({ version: z.literal(2), consoleOrigin: z.url(), selectionKey: selectionIdentity.shape.selectionKey });
|
|
19
|
+
const records = z.discriminatedUnion("version", [record, selectedRecord]);
|
|
20
|
+
const retiredRecord = z.strictObject({ version: z.literal(1), finished: z.literal(true) });
|
|
21
|
+
const selectedRetiredRecord = retiredRecord.extend({ version: z.literal(2), ...selectionIdentity.shape });
|
|
22
|
+
const storedRecord = z.union([records, retiredRecord, selectedRetiredRecord]);
|
|
15
23
|
const maxBytes = LOCAL_FINDINGS_BODY_LIMIT + 1024;
|
|
16
24
|
/** Recovery belongs to this checkout and origin, and survives edits and new commits. */
|
|
17
|
-
export async function completionPath(folder) {
|
|
25
|
+
export async function completionPath(folder, identity) {
|
|
18
26
|
const root = await resolveFolderPath(folder);
|
|
19
27
|
let repository = null;
|
|
20
28
|
let common;
|
|
@@ -37,21 +45,60 @@ export async function completionPath(folder) {
|
|
|
37
45
|
}
|
|
38
46
|
repository = [await realpath(common), origin];
|
|
39
47
|
}
|
|
40
|
-
const
|
|
48
|
+
const coordinates = identity ? [root, repository, selectionIdentity.parse(identity)] : [root, repository];
|
|
49
|
+
const key = createHash("sha256").update(JSON.stringify(coordinates)).digest("hex");
|
|
41
50
|
const home = await realpath(os.homedir());
|
|
42
51
|
return { home, path: join(home, ".config/counterfact-labs/completions", `${key}.json`) };
|
|
43
52
|
}
|
|
44
|
-
export
|
|
45
|
-
|
|
53
|
+
export function matchesCompletionSetup(saved, setup) {
|
|
54
|
+
return saved.consoleOrigin === setup.consoleOrigin && (setup.projectId === undefined || saved.payload.projectId === setup.projectId);
|
|
55
|
+
}
|
|
56
|
+
function setupSlot(path, consoleOrigin, projectId) {
|
|
57
|
+
const key = createHash("sha256").update(JSON.stringify([consoleOrigin, projectId])).digest("hex");
|
|
58
|
+
return path.replace(/\.json$/, `.${key}.json`);
|
|
59
|
+
}
|
|
60
|
+
export async function saveCompletion(folder, payload, consoleOrigin, options = {}) {
|
|
61
|
+
const { uploaded = false, finished = false, retainUnsaved, identity, ...timing } = options;
|
|
62
|
+
if (identity && consoleOrigin !== identity.consoleOrigin)
|
|
63
|
+
throw new Error(INVALID_COMPLETION_MESSAGE);
|
|
64
|
+
const value = identity ? selectedRecord.parse({ version: 2, payload, ...selectionIdentity.parse(identity), ...timing, uploaded, finished })
|
|
65
|
+
: record.parse({ version: 1, payload, consoleOrigin, ...timing, uploaded, finished });
|
|
46
66
|
const bytes = JSON.stringify(value);
|
|
47
67
|
if (Buffer.byteLength(bytes) > maxBytes)
|
|
48
68
|
throw new Error("Invalid completion record");
|
|
49
|
-
const { home, path } = await completionPath(folder);
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
const { home, path } = await completionPath(folder, identity);
|
|
70
|
+
let destination = path;
|
|
71
|
+
if (retainUnsaved) {
|
|
72
|
+
const separate = setupSlot(path, consoleOrigin, payload.projectId);
|
|
73
|
+
// A setup keeps its own slot once created, so later saves cannot split its pending work.
|
|
74
|
+
try {
|
|
75
|
+
await outputPaths(home, [basename(separate)], dirname(path));
|
|
76
|
+
await lstat(separate);
|
|
77
|
+
destination = separate;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code !== "ENOENT")
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (destination === path) {
|
|
84
|
+
try {
|
|
85
|
+
await outputPaths(home, [basename(path)], dirname(path));
|
|
86
|
+
const { value: prior } = await readCompletion(path, identity);
|
|
87
|
+
if ("payload" in prior && !prior.uploaded && !prior.finished
|
|
88
|
+
&& !matchesCompletionSetup(prior, { consoleOrigin, projectId: payload.projectId }))
|
|
89
|
+
destination = separate;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code !== "ENOENT")
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await writeOutputFiles(home, { [basename(destination)]: bytes }, dirname(path));
|
|
98
|
+
return destination;
|
|
52
99
|
}
|
|
53
100
|
const INVALID_COMPLETION_MESSAGE = "The saved completion record is invalid. Keep the local report and choose a new output directory.";
|
|
54
|
-
async function readCompletion(path) {
|
|
101
|
+
async function readCompletion(path, identity) {
|
|
55
102
|
let file;
|
|
56
103
|
try {
|
|
57
104
|
file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
@@ -62,17 +109,24 @@ async function readCompletion(path) {
|
|
|
62
109
|
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
|
63
110
|
if (bytesRead > maxBytes)
|
|
64
111
|
throw new Error();
|
|
65
|
-
|
|
112
|
+
const value = storedRecord.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytesRead))));
|
|
113
|
+
if (identity ? value.version !== 2 || value.consoleOrigin !== identity.consoleOrigin || value.selectionKey !== identity.selectionKey
|
|
114
|
+
: value.version !== 1)
|
|
115
|
+
throw new Error(INVALID_COMPLETION_MESSAGE);
|
|
116
|
+
// File times can carry a sub-millisecond fraction; completion times are whole milliseconds everywhere else.
|
|
117
|
+
return { value, modifiedAt: Math.round(info.mtimeMs) };
|
|
66
118
|
}
|
|
67
119
|
finally {
|
|
68
120
|
await file?.close();
|
|
69
121
|
}
|
|
70
122
|
}
|
|
71
|
-
export async function loadCompletion(folder) {
|
|
123
|
+
export async function loadCompletion(folder, options = {}) {
|
|
124
|
+
const { identity } = options;
|
|
72
125
|
try {
|
|
73
|
-
const { home, path } = await completionPath(folder);
|
|
126
|
+
const { home, path } = await completionPath(folder, identity);
|
|
74
127
|
await outputPaths(home, [basename(path)], dirname(path));
|
|
75
|
-
|
|
128
|
+
const { value, modifiedAt } = await readCompletion(path, identity);
|
|
129
|
+
return "payload" in value ? options.retainUnsaved ? { ...value, completedAt: value.completedAt ?? modifiedAt } : value : null;
|
|
76
130
|
}
|
|
77
131
|
catch (error) {
|
|
78
132
|
if (error instanceof OutputPathError)
|
|
@@ -81,6 +135,8 @@ export async function loadCompletion(folder) {
|
|
|
81
135
|
throw new Error(INVALID_COMPLETION_MESSAGE);
|
|
82
136
|
}
|
|
83
137
|
}
|
|
138
|
+
if (identity)
|
|
139
|
+
return null;
|
|
84
140
|
// Repository-owned records cannot supply recovery data, even with matching provenance.
|
|
85
141
|
let legacy;
|
|
86
142
|
try {
|
|
@@ -97,7 +153,10 @@ export async function loadCompletion(folder) {
|
|
|
97
153
|
throw error;
|
|
98
154
|
}
|
|
99
155
|
const [path] = await outputPaths(folder, ["completion.json"]);
|
|
100
|
-
|
|
156
|
+
const { value, modifiedAt } = await readCompletion(path);
|
|
157
|
+
if (!("payload" in value))
|
|
158
|
+
return null;
|
|
159
|
+
legacy = options.retainUnsaved ? { ...value, completedAt: value.completedAt ?? modifiedAt } : value;
|
|
101
160
|
}
|
|
102
161
|
catch (error) {
|
|
103
162
|
if (error.code === "ENOENT")
|
|
@@ -109,7 +168,63 @@ export async function loadCompletion(folder) {
|
|
|
109
168
|
return null;
|
|
110
169
|
if (!await matchesSnapshotProvenance(folder, legacy.payload.agent))
|
|
111
170
|
throw new Error(INVALID_COMPLETION_MESSAGE);
|
|
112
|
-
//
|
|
113
|
-
await saveCompletion(folder, legacy.payload, legacy.consoleOrigin, legacy.uploaded, true
|
|
171
|
+
// Imported results follow the same retention policy as newly completed work.
|
|
172
|
+
await saveCompletion(folder, legacy.payload, legacy.consoleOrigin, { uploaded: legacy.uploaded, finished: !options.retainUnsaved || legacy.finished === true,
|
|
173
|
+
completedAt: legacy.completedAt, absoluteExpiresAt: legacy.absoluteExpiresAt });
|
|
114
174
|
return legacy;
|
|
115
175
|
}
|
|
176
|
+
/** Setup slots share the folder lock, but each pending payload keeps its original file. */
|
|
177
|
+
export async function loadCompletions(folder, options = {}) {
|
|
178
|
+
const primary = await loadCompletion(folder, options);
|
|
179
|
+
const { home, path } = await completionPath(folder, options.identity);
|
|
180
|
+
const records = primary ? [{ path, record: primary }] : [];
|
|
181
|
+
if (!options.retainUnsaved)
|
|
182
|
+
return records;
|
|
183
|
+
let names;
|
|
184
|
+
try {
|
|
185
|
+
names = await readdir(dirname(path));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error.code === "ENOENT")
|
|
189
|
+
return records;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
const prefix = basename(path, ".json") + ".";
|
|
193
|
+
const ownSlot = options.projectId === undefined ? undefined : setupSlot(path, options.consoleOrigin, options.projectId);
|
|
194
|
+
for (const name of names.sort()) {
|
|
195
|
+
if (!name.startsWith(prefix) || !/^[a-f0-9]{64}\.json$/.test(name.slice(prefix.length)))
|
|
196
|
+
continue;
|
|
197
|
+
const slot = join(dirname(path), name);
|
|
198
|
+
try {
|
|
199
|
+
await outputPaths(home, [name], dirname(path));
|
|
200
|
+
const { value, modifiedAt } = await readCompletion(slot, options.identity);
|
|
201
|
+
if ("payload" in value)
|
|
202
|
+
records.push({ path: slot, record: { ...value, completedAt: value.completedAt ?? modifiedAt } });
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
// An unreadable sibling must not prevent another setup from saving its results.
|
|
206
|
+
if (slot === ownSlot)
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return records;
|
|
211
|
+
}
|
|
212
|
+
/** A retired copy is named <record>.<64 hex digest>.json; checked without building a pattern from the path. */
|
|
213
|
+
function isRetiredCopyName(record, name) {
|
|
214
|
+
const digest = name.startsWith(`${record}.`) && name.endsWith(".json") ? name.slice(record.length + 1, -".json".length) : "";
|
|
215
|
+
return /^[a-f0-9]{64}$/.test(digest);
|
|
216
|
+
}
|
|
217
|
+
/** The empty marker also prevents a retired legacy copy from being imported again. */
|
|
218
|
+
export async function discardCompletion(folder, recordPath, identity) {
|
|
219
|
+
const { home, path } = await completionPath(folder, identity);
|
|
220
|
+
const destination = recordPath ?? path;
|
|
221
|
+
if (dirname(destination) !== dirname(path) || destination !== path && !isRetiredCopyName(basename(path, ".json"), basename(destination)))
|
|
222
|
+
throw new OutputPathError();
|
|
223
|
+
const value = identity ? selectedRetiredRecord.parse({ version: 2, finished: true, ...identity }) : { version: 1, finished: true };
|
|
224
|
+
await writeOutputFiles(home, { [basename(destination)]: JSON.stringify(value) }, dirname(path));
|
|
225
|
+
}
|
|
226
|
+
export async function lockCompletion(folder, onLost, identity) {
|
|
227
|
+
const { home, path } = await completionPath(folder, identity);
|
|
228
|
+
await outputPaths(home, [basename(path)], dirname(path), true);
|
|
229
|
+
return acquireCompletionLock(path, onLost);
|
|
230
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { ENTRY_LIMITS } from "../analysis-core/index.js";
|
|
3
|
+
import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { admitFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
|
|
6
|
+
import { moduleResolutionLimits } from "../analysis-core/import/calls/modules.js";
|
|
7
|
+
import { createGitIgnore } from "./git-ignore.js";
|
|
8
|
+
import { readTargetMatches, searchBoundary } from "./repository-root.js";
|
|
9
|
+
/** Inventory and bounded reads supply filesystem facts; core decides which files belong. */
|
|
10
|
+
export async function createEntryFiles(settings, root, admission = {}, signal, entryPath) {
|
|
11
|
+
signal?.throwIfAborted();
|
|
12
|
+
const safety = { ...settings, CF_LOCAL_SYMLINK_GUARD: "1", CF_RESPECT_GITIGNORE: "1", CF_SECRET_SCAN: "1" };
|
|
13
|
+
const ignored = createGitIgnore(safety, root), gitIgnored = new Set();
|
|
14
|
+
const boundary = (await searchBoundary(root))?.base ?? null;
|
|
15
|
+
const snapshot = Object.create(null);
|
|
16
|
+
const entries = [], directories = new Set();
|
|
17
|
+
let visited = 0, inventoryIncomplete = 0;
|
|
18
|
+
const localTarget = async (path) => {
|
|
19
|
+
signal?.throwIfAborted();
|
|
20
|
+
const lexical = join(root, path), target = await realpath(lexical);
|
|
21
|
+
const facts = await ignored([lexical, target]);
|
|
22
|
+
if (facts?.has(lexical))
|
|
23
|
+
gitIgnored.add(path);
|
|
24
|
+
for (const ignoredPath of facts ?? [])
|
|
25
|
+
gitIgnored.add(ignoredPath);
|
|
26
|
+
const excludedTargets = await resolveLocalExclusions(admission, async (excluded) => realpath(join(root, excluded)).catch(() => null));
|
|
27
|
+
return { root, path: target, ...(facts === undefined ? {} : { parent: await realpath(dirname(lexical)) }), excludedTargets };
|
|
28
|
+
};
|
|
29
|
+
const allowed = async (path, directory = false) => {
|
|
30
|
+
const target = await localTarget(path);
|
|
31
|
+
return admitFile(safety, { path: directory ? `${path}/` : path, directory }, { ...admission, purpose: "entry", route: "local-folder", localTarget: target, gitIgnored }).admitted
|
|
32
|
+
&& await readTargetMatches(target.path, boundary) ? target : null;
|
|
33
|
+
};
|
|
34
|
+
async function walk(folder, prefix) {
|
|
35
|
+
if (directories.has(folder))
|
|
36
|
+
return;
|
|
37
|
+
directories.add(folder);
|
|
38
|
+
for await (const item of await opendir(folder)) {
|
|
39
|
+
signal?.throwIfAborted();
|
|
40
|
+
if (++visited > moduleResolutionLimits.inventoryEntries) {
|
|
41
|
+
inventoryIncomplete = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const path = prefix ? `${prefix}/${item.name}` : item.name;
|
|
45
|
+
try {
|
|
46
|
+
if (item.isDirectory() || item.isSymbolicLink()) {
|
|
47
|
+
const target = await allowed(path, true);
|
|
48
|
+
if (target && (await stat(target.path)).isDirectory()) {
|
|
49
|
+
// A Git marker starts another checkout, which is outside this inventory's search boundary.
|
|
50
|
+
if (await lstat(join(target.path, ".git")).then(() => true, error => error.code !== "ENOENT"))
|
|
51
|
+
continue;
|
|
52
|
+
await walk(target.path, path);
|
|
53
|
+
if (inventoryIncomplete)
|
|
54
|
+
return;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const info = await stat(join(root, path));
|
|
59
|
+
if (!info.isFile())
|
|
60
|
+
continue;
|
|
61
|
+
// Denied metadata stays visible so an unreadable config cannot look absent.
|
|
62
|
+
await localTarget(path);
|
|
63
|
+
entries.push({ path, size: info.size, ref: path });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
signal?.throwIfAborted();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await walk(root, "");
|
|
71
|
+
if (entryPath && !entries.some(entry => entry.path === entryPath)) {
|
|
72
|
+
const info = await stat(join(root, entryPath)).catch(() => null);
|
|
73
|
+
if (info?.isFile())
|
|
74
|
+
entries.push({ path: entryPath, size: info.size, ref: entryPath });
|
|
75
|
+
}
|
|
76
|
+
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
77
|
+
return { entries, gitIgnored, inventoryIncomplete, snapshot, localTarget,
|
|
78
|
+
async read(entry) {
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
let handle;
|
|
81
|
+
try {
|
|
82
|
+
const target = await allowed(entry.path);
|
|
83
|
+
if (!target)
|
|
84
|
+
return null;
|
|
85
|
+
if (snapshot[entry.path])
|
|
86
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(snapshot[entry.path]);
|
|
87
|
+
const capacity = entry.readBytes ?? entry.size + 1;
|
|
88
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > Math.max(ENTRY_LIMITS.bytes, moduleResolutionLimits.settingsBytes) + 1)
|
|
89
|
+
return null;
|
|
90
|
+
handle = await open(target.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
91
|
+
const before = await handle.stat({ bigint: true });
|
|
92
|
+
if (!before.isFile() || before.size !== BigInt(entry.size))
|
|
93
|
+
return null;
|
|
94
|
+
const bytes = Buffer.alloc(capacity);
|
|
95
|
+
let size = 0;
|
|
96
|
+
while (size < capacity) {
|
|
97
|
+
signal?.throwIfAborted();
|
|
98
|
+
const { bytesRead } = await handle.read(bytes, size, Math.min(65_536, capacity - size), null);
|
|
99
|
+
if (!bytesRead)
|
|
100
|
+
break;
|
|
101
|
+
size += bytesRead;
|
|
102
|
+
}
|
|
103
|
+
const after = await handle.stat({ bigint: true });
|
|
104
|
+
if (size !== entry.size || size === capacity || before.ino !== after.ino || before.dev !== after.dev
|
|
105
|
+
|| before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
|
|
106
|
+
return null;
|
|
107
|
+
const current = await allowed(entry.path);
|
|
108
|
+
if (!current || current.path !== target.path)
|
|
109
|
+
return null;
|
|
110
|
+
const captured = bytes.subarray(0, size);
|
|
111
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(captured);
|
|
112
|
+
if (!admitFile(safety, { path: entry.path, text }, { ...admission, purpose: "entry", route: "local-folder", localTarget: current, gitIgnored }).admitted)
|
|
113
|
+
return null;
|
|
114
|
+
snapshot[entry.path] = captured;
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
signal?.throwIfAborted();
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await handle?.close();
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { discoverLocalSources, fileScopeEnabled, readEntrySelection } from "../analysis-core/index.js";
|
|
2
|
+
import { measureAnalysisSize, assessAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
|
|
3
|
+
import { FileReadRefusal } from "../analysis-core/conventions/file-admission.js";
|
|
4
|
+
import { localRepoKeyFor } from "../analysis-core/github/repo-key.js";
|
|
5
|
+
import { cliRefusals } from "../contract/cli-refusals.js";
|
|
6
|
+
import { INVALID_SELECTION_MESSAGE, INVALID_SCOPE_MESSAGE, SINGLE_FILE_TOO_LARGE_MESSAGE, SCOPE_PROPOSAL_CONFLICT_MESSAGE, STALE_SCOPE_MESSAGE } from "../contract/scope-choice.js";
|
|
7
|
+
import { createEntryFiles } from "./entry-files.js";
|
|
8
|
+
/** The CLI consumes the core membership answer and captures only its retained files. */
|
|
9
|
+
export const entrySelectionProvider = async (settings, request, options) => {
|
|
10
|
+
if (!fileScopeEnabled(settings) || request.kind !== "file")
|
|
11
|
+
throw new Error(cliRefusals.release_unavailable);
|
|
12
|
+
const port = await createEntryFiles(settings, request.launchRoot, options.admission, options.signal, request.entry).catch(() => {
|
|
13
|
+
options.signal?.throwIfAborted();
|
|
14
|
+
throw new Error(INVALID_SELECTION_MESSAGE);
|
|
15
|
+
});
|
|
16
|
+
const input = { ...port, request: { ...request, scope: undefined }, admission: options.admission };
|
|
17
|
+
const full = await readEntrySelection(settings, input);
|
|
18
|
+
const refusal = (reason) => {
|
|
19
|
+
if (reason === "over-limit")
|
|
20
|
+
throw new Error(SINGLE_FILE_TOO_LARGE_MESSAGE);
|
|
21
|
+
if (reason === "stale-snapshot")
|
|
22
|
+
throw new Error(STALE_SCOPE_MESSAGE);
|
|
23
|
+
if (reason === "invalid-scope")
|
|
24
|
+
throw new Error(options.proposal ? SCOPE_PROPOSAL_CONFLICT_MESSAGE : INVALID_SCOPE_MESSAGE);
|
|
25
|
+
if (reason === "excluded" || reason === "hidden" || reason === "secret" || reason === "secret-content")
|
|
26
|
+
throw new FileReadRefusal(reason, request.entry);
|
|
27
|
+
throw new Error(INVALID_SELECTION_MESSAGE);
|
|
28
|
+
};
|
|
29
|
+
if (full.kind !== "ready")
|
|
30
|
+
return refusal(full.reason);
|
|
31
|
+
// Replay the same admitted captures through the owner to validate a narrower scope.
|
|
32
|
+
const selected = request.scope || options.proposal ? await readEntrySelection(settings, { ...input, request, proposal: options.proposal }) : full;
|
|
33
|
+
if (selected.kind !== "ready")
|
|
34
|
+
return refusal(selected.reason);
|
|
35
|
+
const { files } = selected;
|
|
36
|
+
const entries = selected.selection.files.map(({ path, size }) => ({ path, size, ref: path }));
|
|
37
|
+
const selection = { selected: entries, refused: [], totalBytes: entries.reduce((sum, file) => sum + file.size, 0) };
|
|
38
|
+
const repoKey = localRepoKeyFor(request.launchRoot);
|
|
39
|
+
const discovered = await discoverLocalSources(settings, { repoKey, entries, selection, membership: selected.selection, admission: options.admission,
|
|
40
|
+
read: async (entry) => files[entry.path] });
|
|
41
|
+
const analysisSize = measureAnalysisSize(settings, entries, options.admission);
|
|
42
|
+
return { membership: full.selection, source: { ...discovered, repoKey, root: ".", sourceBase: request.launchRoot, files, selection,
|
|
43
|
+
snapshot: Object.fromEntries(entries.map(({ path }) => [path, port.snapshot[path]])), analysisSize,
|
|
44
|
+
...(settings.CF_REFUSE_TOO_LARGE === "0" ? {} : { analysisFit: assessAnalysisSize(analysisSize) }) } };
|
|
45
|
+
};
|
package/dist/adapters/files.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { moduleResolutionLimits } from "../analysis-core/import/calls/modules.js";
|
|
1
2
|
import { opendir, realpath, stat, readFile } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, posix } from "node:path";
|
|
3
4
|
import { admitFile, FileReadRefusal, fileRefusalWarning, localSymlinkGuardEnabled, respectGitignore, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
|
|
@@ -10,7 +11,7 @@ import { createGitIgnore } from "./git-ignore.js";
|
|
|
10
11
|
import { createIgnoredFileCollector } from "./ignored-files.js";
|
|
11
12
|
import { resolveFolderPath } from "./folder-path.js";
|
|
12
13
|
import { createRepositoryFiles } from "./repository-files.js";
|
|
13
|
-
export const FOLDER_ENTRY_LIMIT =
|
|
14
|
+
export const FOLDER_ENTRY_LIMIT = moduleResolutionLimits.inventoryEntries;
|
|
14
15
|
export const FOLDER_ENTRY_LIMIT_MESSAGE = "The folder has too many entries to analyze. Choose a smaller folder.";
|
|
15
16
|
export async function readLocalFolder(settings, folder, admission = {}, options = {}) {
|
|
16
17
|
const root = await resolveFolderPath(folder);
|
package/dist/adapters/results.js
CHANGED
|
@@ -2,12 +2,20 @@ import { formatSummary } from "../cli/summary.js";
|
|
|
2
2
|
import { ignoreDefaultOutput } from "./gitignore-output.js";
|
|
3
3
|
import { writeOutputFiles } from "./output-paths.js";
|
|
4
4
|
import { projectPublicReport } from "../contract/public-report.js";
|
|
5
|
+
import { INCLUDED_FILES_CAPTION, INCOMPLETE_IMPORTS_MESSAGE, SELECTION_COVERAGE_DESCRIPTION } from "../contract/scope-choice.js";
|
|
5
6
|
export async function saveLocalResults(folder, report, refusedFiles, outputDir, options) {
|
|
6
7
|
const publicReport = projectPublicReport(report, refusedFiles);
|
|
8
|
+
const scope = options.scope;
|
|
9
|
+
const incomplete = options.selection && !options.selection.completeness.complete
|
|
10
|
+
? [INCOMPLETE_IMPORTS_MESSAGE, "", INCLUDED_FILES_CAPTION, ...options.includedPaths.map(path => ` ${path}`), "", ""].join("\n") : "";
|
|
11
|
+
const coverage = scope && scope.imports !== "all" ? SELECTION_COVERAGE_DESCRIPTION
|
|
12
|
+
.replace("{entry}", () => scope.entry).replace("{count}", String(Array.isArray(scope.imports) ? scope.imports.length : 0)) + "\n\n" : "";
|
|
7
13
|
const [findingsPath, summaryPath] = await writeOutputFiles(folder, {
|
|
8
14
|
"findings.json": `${JSON.stringify(publicReport, null, 2)}\n`,
|
|
9
|
-
"summary.md": formatSummary(publicReport, options),
|
|
15
|
+
"summary.md": incomplete + coverage + formatSummary(publicReport, options),
|
|
16
|
+
...(options.selection ? { "selection.json": JSON.stringify({ schemaVersion: 1, selection: options.selection,
|
|
17
|
+
...(scope ? { scope } : {}) }, null, 2) + "\n" } : {}),
|
|
10
18
|
}, outputDir);
|
|
11
|
-
const ignoreAdded = await ignoreDefaultOutput(folder, outputDir, options);
|
|
19
|
+
const ignoreAdded = await ignoreDefaultOutput(folder, options.defaultFileOutput ? undefined : outputDir, options);
|
|
12
20
|
return { findingsPath, summaryPath, ignoreAdded };
|
|
13
21
|
}
|