@counterfact-labs/cli 0.2.5 → 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.
Files changed (82) hide show
  1. package/README.md +20 -0
  2. package/bundle/client-release.json +1 -1
  3. package/dist/adapters/completion-lock.js +95 -0
  4. package/dist/adapters/completion.js +132 -17
  5. package/dist/adapters/entry-files.js +126 -0
  6. package/dist/adapters/entry-selection.js +45 -0
  7. package/dist/adapters/files.js +2 -1
  8. package/dist/adapters/results.js +10 -2
  9. package/dist/adapters/selection.js +104 -0
  10. package/dist/adapters/settings.js +29 -2
  11. package/dist/analysis-core/analysis/schemas.js +1 -1
  12. package/dist/analysis-core/chunks/2PZF54KH.js +10 -0
  13. package/dist/analysis-core/chunks/{OPMA32A6.js → 6RWEWBHK.js} +1 -1
  14. package/dist/analysis-core/chunks/{PECX3OXB.js → 7NEWZ74F.js} +1 -1
  15. package/dist/analysis-core/chunks/{6QY5BV5B.js → CGCJN73D.js} +1 -1
  16. package/dist/analysis-core/chunks/CWYVUGG4.js +1 -0
  17. package/dist/analysis-core/chunks/{3JWEFCTJ.js → FBTOOUPL.js} +1 -1
  18. package/dist/analysis-core/chunks/GOQVKWP3.js +32 -0
  19. package/dist/analysis-core/chunks/IHAVDX4L.js +66 -0
  20. package/dist/analysis-core/chunks/IVULXWQN.js +21 -0
  21. package/dist/analysis-core/chunks/K5FUABWW.js +1 -0
  22. package/dist/analysis-core/chunks/KGQYR62R.js +7 -0
  23. package/dist/analysis-core/chunks/PY5M22CL.js +1 -0
  24. package/dist/analysis-core/chunks/{YDBO7P4X.js → RNTMH4MU.js} +1 -1
  25. package/dist/analysis-core/chunks/RUVMFAST.js +1 -0
  26. package/dist/analysis-core/chunks/S27VIQMZ.js +1 -0
  27. package/dist/analysis-core/chunks/{5DGPLXYB.js → SLJ6CW7W.js} +1 -1
  28. package/dist/analysis-core/chunks/{STI4JE7L.js → UEORNVQB.js} +1 -1
  29. package/dist/analysis-core/chunks/{X7WZAA7S.js → WYLIV2P2.js} +1 -1
  30. package/dist/analysis-core/contracts/analysis-selection.js +1 -0
  31. package/dist/analysis-core/contracts/module-resolution.js +1 -0
  32. package/dist/analysis-core/contracts/relay-analysis.js +1 -1
  33. package/dist/analysis-core/contracts/requests.js +1 -1
  34. package/dist/analysis-core/contracts/selection-paths.js +1 -0
  35. package/dist/analysis-core/conventions/analysis-fit.js +1 -1
  36. package/dist/analysis-core/conventions/file-admission.js +1 -1
  37. package/dist/analysis-core/import/calls/modules.js +1 -0
  38. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  39. package/dist/analysis-core/index.js +3 -3
  40. package/dist/analysis-core/requests/local-plan.js +1 -1
  41. package/dist/analysis-core/requests/registry.js +1 -1
  42. package/dist/analysis-core/requests/template-version.js +1 -1
  43. package/dist/analysis-core/requests/tools.js +1 -1
  44. package/dist/analysis-core/settings.js +1 -1
  45. package/dist/analysis-core/stages/engine-entry.js +1 -1
  46. package/dist/cli/agent-folder.js +3 -3
  47. package/dist/cli/analysis-selection.js +91 -0
  48. package/dist/cli/commands/analyze.js +34 -8
  49. package/dist/cli/commands/balance.js +6 -3
  50. package/dist/cli/completion-delivery.js +63 -0
  51. package/dist/cli/completion-recovery.js +68 -14
  52. package/dist/cli/context.js +4 -1
  53. package/dist/cli/main.js +21 -9
  54. package/dist/cli/mcp-failure.js +8 -0
  55. package/dist/cli/mcp.js +130 -30
  56. package/dist/cli/prior-completion.js +35 -0
  57. package/dist/cli/relay-check.js +163 -97
  58. package/dist/cli/relay-failure.js +32 -0
  59. package/dist/cli/relay-preparation.js +24 -11
  60. package/dist/cli/results-upload.js +131 -0
  61. package/dist/cli/scope-choices.js +57 -0
  62. package/dist/cli/sign-in.js +61 -2
  63. package/dist/contract/cli-api-messages.js +2 -0
  64. package/dist/contract/cli-refusals.js +74 -1
  65. package/dist/contract/cli-version.js +1 -1
  66. package/dist/contract/console.js +7 -2
  67. package/dist/contract/email-sign-in-messages.js +4 -0
  68. package/dist/contract/scope-choice.js +78 -0
  69. package/dist/contract/selection-metadata.js +9 -0
  70. package/dist/contract/selection-offer.js +43 -0
  71. package/dist/gatekeeper/cli-api.js +16 -7
  72. package/dist/gatekeeper/connection-exchange.js +1 -0
  73. package/dist/gatekeeper/console.js +61 -36
  74. package/package.json +2 -2
  75. package/dist/analysis-core/chunks/377MLK4M.js +0 -21
  76. package/dist/analysis-core/chunks/DLPVYWHZ.js +0 -1
  77. package/dist/analysis-core/chunks/FOKDE64Y.js +0 -1
  78. package/dist/analysis-core/chunks/KTNA2KTN.js +0 -7
  79. package/dist/analysis-core/chunks/NIOS7U6C.js +0 -41
  80. package/dist/analysis-core/chunks/QFY6A3MS.js +0 -66
  81. package/dist/analysis-core/chunks/SVMEWGD3.js +0 -1
  82. 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.5",
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(), payload: localFindingsPayloadSchema });
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 key = createHash("sha256").update(JSON.stringify([root, repository])).digest("hex");
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 async function saveCompletion(folder, payload, consoleOrigin, uploaded = false, finished = false) {
45
- const value = record.parse({ version: 1, payload, consoleOrigin, uploaded, finished });
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
- await writeOutputFiles(home, { [basename(path)]: bytes }, dirname(path));
51
- return path;
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
- return record.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytesRead))));
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
- return await readCompletion(path);
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
- legacy = await readCompletion(path);
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
- // Attempt imported recovery once automatically; uncertain legacy data must not block later analyses.
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
+ };
@@ -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 = 10_000;
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);
@@ -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
  }