@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.
Files changed (74) 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/analysis-selection.js +91 -0
  47. package/dist/cli/commands/analyze.js +31 -6
  48. package/dist/cli/completion-delivery.js +63 -0
  49. package/dist/cli/completion-recovery.js +68 -14
  50. package/dist/cli/context.js +2 -1
  51. package/dist/cli/main.js +21 -9
  52. package/dist/cli/mcp.js +70 -11
  53. package/dist/cli/prior-completion.js +35 -0
  54. package/dist/cli/relay-check.js +163 -97
  55. package/dist/cli/relay-failure.js +32 -0
  56. package/dist/cli/relay-preparation.js +24 -11
  57. package/dist/cli/results-upload.js +131 -0
  58. package/dist/cli/scope-choices.js +57 -0
  59. package/dist/contract/cli-refusals.js +8 -0
  60. package/dist/contract/cli-version.js +1 -1
  61. package/dist/contract/console.js +7 -2
  62. package/dist/contract/scope-choice.js +78 -0
  63. package/dist/contract/selection-metadata.js +9 -0
  64. package/dist/contract/selection-offer.js +43 -0
  65. package/dist/gatekeeper/console.js +36 -25
  66. package/package.json +2 -2
  67. package/dist/analysis-core/chunks/377MLK4M.js +0 -21
  68. package/dist/analysis-core/chunks/DLPVYWHZ.js +0 -1
  69. package/dist/analysis-core/chunks/FOKDE64Y.js +0 -1
  70. package/dist/analysis-core/chunks/KTNA2KTN.js +0 -7
  71. package/dist/analysis-core/chunks/NIOS7U6C.js +0 -41
  72. package/dist/analysis-core/chunks/QFY6A3MS.js +0 -66
  73. package/dist/analysis-core/chunks/SVMEWGD3.js +0 -1
  74. package/dist/analysis-core/chunks/ZTOVBAG7.js +0 -1
@@ -0,0 +1,57 @@
1
+ import { readdir, readFile, unlink } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { outputPaths, writeOutputFiles } from "../adapters/output-paths.js";
4
+ import { analysisScopeSchema, chooseScopeResultSchema, CHOOSE_SCOPE_MESSAGE, INCLUDED_FILES_CAPTION, OMITTED_IMPORTS_CAPTION, SCOPE_CHOICE_INSTRUCTION, INCOMPLETE_IMPORTS_MESSAGE, IMPORT_DISCOVERY_LIMIT_MESSAGE, INVALID_SCOPE_MESSAGE, SCOPE_RETRY_TEMPLATE } from "../contract/scope-choice.js";
5
+ import { scopeChoiceRefusal } from "../contract/selection-offer.js";
6
+ import { scopeChoicesEnabled } from "../adapters/settings.js";
7
+ export async function loadAnalysisScope(options, cwd, settings) {
8
+ if (options.scope === undefined && options.scopeFile === undefined)
9
+ return undefined;
10
+ if (!scopeChoicesEnabled(settings))
11
+ throw new Error(INVALID_SCOPE_MESSAGE);
12
+ try {
13
+ const value = options.scopeFile === undefined ? options.scope : JSON.parse(await readFile(resolve(cwd, options.scopeFile), "utf8"));
14
+ return analysisScopeSchema.parse(value);
15
+ }
16
+ catch {
17
+ throw new Error(INVALID_SCOPE_MESSAGE);
18
+ }
19
+ }
20
+ export function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'`; }
21
+ /** MCP and terminal output share the same file coverage; only the terminal adds retry commands. */
22
+ export function scopeChoiceText(value, retryPaths = {}) {
23
+ const result = chooseScopeResultSchema.parse(value);
24
+ const refusal = scopeChoiceRefusal(result);
25
+ if (refusal)
26
+ return refusal + "\n";
27
+ const lines = [CHOOSE_SCOPE_MESSAGE];
28
+ if (!result.coverageComplete && result.refusedEdges.length > 0)
29
+ lines.push(INCOMPLETE_IMPORTS_MESSAGE);
30
+ if (!result.coverageComplete && result.frontierCount > 0)
31
+ lines.push(IMPORT_DISCOVERY_LIMIT_MESSAGE);
32
+ result.choices.forEach((choice, index) => {
33
+ lines.push(`${index + 1}. ${choice.label}`, choice.description, INCLUDED_FILES_CAPTION, ...choice.includes.map(path => ` ${path}`));
34
+ if (choice.omitted.length > 0)
35
+ lines.push(OMITTED_IMPORTS_CAPTION, ...choice.omitted.map(path => ` ${path}`));
36
+ if (choice.fits !== false && retryPaths[index]) {
37
+ const entry = choice.scope.entry.startsWith("-") ? `./${choice.scope.entry}` : choice.scope.entry;
38
+ lines.push(SCOPE_RETRY_TEMPLATE.replace("{quoted-entry}", () => shellQuote(entry))
39
+ .replace("{quoted-scope-file}", () => shellQuote(retryPaths[index])));
40
+ }
41
+ });
42
+ lines.push(SCOPE_CHOICE_INSTRUCTION);
43
+ return lines.join("\n") + "\n";
44
+ }
45
+ export async function renderScopeChoices(value, anchor) {
46
+ const result = chooseScopeResultSchema.parse(value);
47
+ // Validate the output directory and old entries before removing only generated scope files.
48
+ const [probe] = await outputPaths(anchor.outputAnchor, ["scope-1.json"], anchor.outputDir, true);
49
+ const old = (await readdir(dirname(probe))).filter(name => /^scope-.*\.json$/.test(name));
50
+ await outputPaths(anchor.outputAnchor, old, anchor.outputDir);
51
+ for (const name of old)
52
+ await unlink(join(dirname(probe), name));
53
+ const runnable = result.choices.flatMap((choice, index) => choice.fits === false ? [] : [{ choice, index }]);
54
+ const files = Object.fromEntries(runnable.map(({ choice, index }) => [`scope-${index + 1}.json`, JSON.stringify(choice.scope, null, 2) + "\n"]));
55
+ const paths = await writeOutputFiles(anchor.outputAnchor, files, anchor.outputDir);
56
+ return scopeChoiceText(result, Object.fromEntries(runnable.map(({ index }, position) => [index, paths[position]])));
57
+ }
@@ -4,6 +4,12 @@ import { emailSignInMessages } from "./email-sign-in-messages.js";
4
4
  export const START_FAILURE_MESSAGE = "This couldn't start. Try again.";
5
5
  export const RESERVATION_EXPIRED_MESSAGE = "This reservation expired. Start a new analysis.";
6
6
  export const FINISH_FAILURE_MESSAGE = "The analysis couldn't finish. Try again.";
7
+ export const NOT_ALLOWED_MESSAGE = "Your account can't run analyses. Ask Counterfact for access.";
8
+ export const SIGN_IN_EXPIRED_MESSAGE = "Your sign-in has expired. Run counterfact-labs login, then try again.";
9
+ export const FOLDER_BUSY_MESSAGE = "Another analysis is running on this folder. Try again when it finishes.";
10
+ export const CHECK_CONFLICT_MESSAGE = "Saved results from an earlier run don't match this analysis. Start a new analysis.";
11
+ export const UNSAVED_RESULTS_MESSAGE = "The analysis ended before its results were saved. Start a new analysis.";
12
+ export const PENDING_RESULTS_MESSAGE = "Your results are kept on this computer. Run the same command on the same folder again to save them at no extra charge.";
7
13
  export const RESULTS_TOO_LARGE_MESSAGE = "The results were too large to save. Try again.";
8
14
  export const COMMITTED_FILES_MESSAGE = "Counterfact analyzes the committed version of the agent's files. Commit your changes, then try again.";
9
15
  const unsupportedVersion = "This version of Counterfact can't run analyses. Update it: npm install -g @counterfact-labs/cli@latest.";
@@ -70,6 +76,8 @@ const reasonValues = [
70
76
  ...Object.keys(localRefusals),
71
77
  ...Object.values(otherReasons), ...Object.values(messageReasons),
72
78
  "console_unreachable", "invalid_console_url", "relay_unreachable", "results_not_saved",
79
+ "results_saved_report_failed", "results_pending", "results_discarded",
80
+ "folder_busy", "completion_settings_mismatch",
73
81
  ];
74
82
  const ownedReasons = new Set(reasonValues);
75
83
  /** Only fixed reasons may cross the MCP boundary; diagnostics can contain customer data. */
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.2.6";
1
+ export const CLI_VERSION = "0.2.7";
@@ -5,6 +5,7 @@ import { sourceNotesSchema } from "../analysis-core/analysis/source-notes/types.
5
5
  import { parseAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
6
6
  import { canonicalUuidSchema as uuid, matchingFindingNumbers, numberedFindingsSchema } from "./findings.js";
7
7
  import { RESULT_CHECK_LIMITS, validIntakeText } from "../analysis-core/contracts/relay-analysis.js";
8
+ import { selectionStartIdentitySchema } from "./selection-metadata.js";
8
9
  export const id = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/);
9
10
  const money = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
10
11
  const count = (max) => z.number().int().positive().max(max);
@@ -13,13 +14,15 @@ export const LOCAL_START_EXECUTION_HEADER = { name: "x-counterfact-execution", v
13
14
  export const LOCAL_START_BODY_LIMIT = 2 * 1024 * 1024;
14
15
  export const ANALYSIS_SIZE_HEADER = "x-counterfact-analysis-size";
15
16
  export const START_ATTEMPT_HEADER = "x-counterfact-start-attempt";
17
+ export const SELECTION_IDENTITY_HEADER = "x-counterfact-selection";
16
18
  export const ANALYSIS_SIZE_HEADER_LIMIT = 256;
17
19
  /** Additive headers let older consoles keep accepting the unchanged, strict start body. */
18
20
  export function serializeLocalStartMetadata(metadata) {
19
21
  const size = JSON.stringify(parseAnalysisSize(metadata.analysisSize));
20
22
  if (Buffer.byteLength(size, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
21
23
  throw new Error("Invalid analysis size header");
22
- return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt) };
24
+ return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt),
25
+ ...(metadata.selection === undefined ? {} : { [SELECTION_IDENTITY_HEADER]: JSON.stringify(selectionStartIdentitySchema.parse(metadata.selection)) }) };
23
26
  }
24
27
  /** Missing measurements preserve old clients; present measurements must be complete. */
25
28
  export function parseLocalStartMetadata(headers) {
@@ -28,7 +31,9 @@ export function parseLocalStartMetadata(headers) {
28
31
  return undefined;
29
32
  if (Buffer.byteLength(raw, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
30
33
  throw new Error("Invalid analysis size header");
31
- return { analysisSize: parseAnalysisSize(JSON.parse(raw)), startAttempt: uuid.parse(headers.get(START_ATTEMPT_HEADER)) };
34
+ const selection = headers.get(SELECTION_IDENTITY_HEADER);
35
+ return { analysisSize: parseAnalysisSize(JSON.parse(raw)), startAttempt: uuid.parse(headers.get(START_ATTEMPT_HEADER)),
36
+ ...(selection === null ? {} : { selection: selectionStartIdentitySchema.parse(JSON.parse(z.string().max(256).parse(selection))) }) };
32
37
  }
33
38
  const localStartPath = z.string().min(1).max(4096).refine(value => !value.includes("\\") && !value.includes("\0") && !value.startsWith("/")
34
39
  && value.split("/").every(part => part !== ".." && part !== "." && part !== ""));
@@ -0,0 +1,78 @@
1
+ import { z } from "zod";
2
+ import { ANALYSIS_SELECTION_LIMITS, provenScopeChoiceSchema } from "../analysis-core/contracts/analysis-selection.js";
3
+ import { refusedImportSchema } from "../analysis-core/contracts/module-resolution.js";
4
+ import { selectionPathSchema, snapshotIdSchema } from "../analysis-core/contracts/selection-paths.js";
5
+ import { FileReadRefusal } from "../analysis-core/conventions/file-admission.js";
6
+ export { analysisScopeSchema } from "../analysis-core/contracts/analysis-selection.js";
7
+ // Proposed copy stays here so wiring the CLI and MCP cannot create different promises about coverage.
8
+ export const CHOOSE_SCOPE_MESSAGE = "This analysis is too large to run at once. Choose what Counterfact should read.";
9
+ export const ENTRY_ONLY_LABEL = "Only the selected file";
10
+ export const ENTRY_ONLY_DESCRIPTION = "Read {entry}. Leave its imports out of this analysis.";
11
+ export const SELECTED_IMPORTS_LABEL = "The selected file and some imports";
12
+ export const SELECTED_IMPORTS_DESCRIPTION = "Read {entry} and the imports listed below. Leave the other imports out of this analysis.";
13
+ export const ENTRY_IMPORTS_LABEL = "One agent file and its imports";
14
+ export const ENTRY_IMPORTS_DESCRIPTION = "Read {entry} and its project imports inside the launch folder.";
15
+ export const SINGLE_FILE_TOO_LARGE_MESSAGE = "This file is too large to analyze on its own. Choose another agent file or split this file into smaller files.";
16
+ export const INVALID_SELECTION_MESSAGE = "Choose an agent file or folder inside the launch folder.";
17
+ export const INVALID_SCOPE_MESSAGE = "This scope could not be used. Choose one of the returned scopes and try again.";
18
+ export const STALE_SCOPE_MESSAGE = "The selected files changed. Run the analysis again to get updated scope choices.";
19
+ export const SCOPE_PROPOSAL_CONFLICT_MESSAGE = "The selected scope and proposed files do not match. Keep the selected file and only its listed imports.";
20
+ export const SELECTION_REFUSAL_MESSAGES = Object.freeze([
21
+ INVALID_SELECTION_MESSAGE, INVALID_SCOPE_MESSAGE, STALE_SCOPE_MESSAGE, SCOPE_PROPOSAL_CONFLICT_MESSAGE,
22
+ SINGLE_FILE_TOO_LARGE_MESSAGE,
23
+ ]);
24
+ export function isSelectionRefusal(error, fileMode) {
25
+ return error instanceof Error && (SELECTION_REFUSAL_MESSAGES.includes(error.message)
26
+ || fileMode && error instanceof FileReadRefusal);
27
+ }
28
+ export const INCLUDED_FILES_CAPTION = "Included files:";
29
+ export const OMITTED_IMPORTS_CAPTION = "Imports left out:";
30
+ export const SCOPE_CHOICE_INSTRUCTION = "Choose a scope, then run the analysis again with that scope.";
31
+ export const INCOMPLETE_IMPORTS_MESSAGE = "Some imports could not be resolved. This analysis covers only the files listed below.";
32
+ export const IMPORT_DISCOVERY_LIMIT_MESSAGE = "Counterfact reached the import discovery limit. The list of imports is incomplete.";
33
+ export const SELECTED_FILES_PROGRESS = "Reading the selected files and running analysis.\n";
34
+ export const SELECTED_PATH_FAILURE = "Analysis could not finish. Check the selected path, provider key and connection.";
35
+ export function progressLine(selection, surface = "source") {
36
+ return selection?.kind === "file" ? SELECTED_FILES_PROGRESS
37
+ : surface === "terminal" ? "Analyzing the agent…" : "Reading the folder and running analysis.\n";
38
+ }
39
+ export function failureLine(selection) {
40
+ return selection?.kind === "file" ? SELECTED_PATH_FAILURE : "Analysis could not finish. Check the folder, provider key and connection.";
41
+ }
42
+ export const SELECTION_COVERAGE_DESCRIPTION = "This analysis covers {entry} and {count} selected imports. Other imports were left out.";
43
+ export const SCOPE_RETRY_TEMPLATE = "Run again: counterfact-labs analyze {quoted-entry} --scope {quoted-scope-file}";
44
+ export const FILE_SCOPE_PUBLIC_USAGE = "Usage: counterfact-labs analyze [path] [--proposal <json-file>] [--scope <json-file>]\n counterfact-labs finding <id>\n counterfact-labs balance | login | logout | mcp\n";
45
+ export const FILE_SCOPE_SOURCE_USAGE = "Usage: npx tsx cli/check.ts <path> [--output <directory>] [--scope <json-file>]\n";
46
+ export const scopeChoiceSchema = provenScopeChoiceSchema.safeExtend({
47
+ label: z.enum([ENTRY_ONLY_LABEL, SELECTED_IMPORTS_LABEL, ENTRY_IMPORTS_LABEL]),
48
+ description: z.string().min(1).max(8192),
49
+ // Fewer files do not prove admission; only the ordinary run checks can set true.
50
+ fits: z.union([z.boolean(), z.literal("unknown")]),
51
+ }).refine(choice => {
52
+ const imports = choice.scope.imports;
53
+ const entryOnly = imports === "none" || Array.isArray(imports) && imports.length === 0;
54
+ const label = entryOnly ? ENTRY_ONLY_LABEL : imports === "all" ? ENTRY_IMPORTS_LABEL : SELECTED_IMPORTS_LABEL;
55
+ const description = entryOnly && choice.fits === false ? SINGLE_FILE_TOO_LARGE_MESSAGE
56
+ : (entryOnly ? ENTRY_ONLY_DESCRIPTION : imports === "all" ? ENTRY_IMPORTS_DESCRIPTION : SELECTED_IMPORTS_DESCRIPTION)
57
+ .replace("{entry}", () => choice.scope.entry);
58
+ return choice.label === label && choice.description === description;
59
+ });
60
+ export const chooseScopeResultSchema = z.strictObject({
61
+ kind: z.literal("choose_scope"), text: z.literal(CHOOSE_SCOPE_MESSAGE), snapshot: snapshotIdSchema,
62
+ entry: selectionPathSchema.nullable(), coverageComplete: z.boolean(),
63
+ frontierCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
64
+ choices: z.array(scopeChoiceSchema).min(1).max(ANALYSIS_SELECTION_LIMITS.choices),
65
+ imports: z.array(z.strictObject({ path: selectionPathSchema, role: z.literal("imported-helper"),
66
+ importedBy: z.array(selectionPathSchema).min(1).max(ANALYSIS_SELECTION_LIMITS.files)
67
+ .refine(paths => new Set(paths).size === paths.length) })).max(ANALYSIS_SELECTION_LIMITS.files),
68
+ refusedEdges: z.array(refusedImportSchema).max(ANALYSIS_SELECTION_LIMITS.edges),
69
+ }).refine(value => (!value.coverageComplete || value.frontierCount === 0 && value.refusedEdges.every(edge => edge.reason === "external"))
70
+ && new Set(value.choices.map(choice => choice.id)).size === value.choices.length
71
+ && new Set(value.imports.map(file => file.path)).size === value.imports.length
72
+ && value.choices.every(choice => choice.scope.snapshot === value.snapshot && (value.entry === null || choice.scope.entry === value.entry
73
+ && (choice.scope.imports !== "all" || choice.omitted.length === 0))));
74
+ export const chooseScopeMcpResultSchema = z.strictObject({
75
+ content: z.tuple([z.strictObject({ type: z.literal("text"), text: z.string().startsWith(CHOOSE_SCOPE_MESSAGE) })]),
76
+ isError: z.literal(false), structuredContent: chooseScopeResultSchema.safeExtend({ exitCode: z.literal(0) }),
77
+ });
78
+ export const SCOPE_CHOICE_EXIT_CODE = 3;
@@ -0,0 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ const digest = z.string().regex(/^[a-f0-9]{64}$/);
4
+ export const selectionStartIdentitySchema = z.strictObject({ version: z.literal(1), key: digest, snapshot: digest });
5
+ /** The console can distinguish selected runs without receiving local coordinates or snapshot contents. */
6
+ export function selectionStartIdentity(key, snapshot) {
7
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
8
+ return { version: 1, key: hash(key), snapshot: hash(snapshot) };
9
+ }
@@ -0,0 +1,43 @@
1
+ import { CHOOSE_SCOPE_MESSAGE, ENTRY_IMPORTS_DESCRIPTION, ENTRY_IMPORTS_LABEL, ENTRY_ONLY_DESCRIPTION, ENTRY_ONLY_LABEL, SELECTED_IMPORTS_DESCRIPTION, SELECTED_IMPORTS_LABEL, SINGLE_FILE_TOO_LARGE_MESSAGE } from "./scope-choice.js";
2
+ export class ScopeChoiceError extends Error {
3
+ choice;
4
+ constructor(choice) {
5
+ super(choice.text);
6
+ this.choice = choice;
7
+ }
8
+ }
9
+ /** A known nonfitting choice must never invite another paid attempt. */
10
+ export function scopeChoiceRefusal(choice) {
11
+ return choice.choices.every(option => option.fits === false) ? SINGLE_FILE_TOO_LARGE_MESSAGE : undefined;
12
+ }
13
+ /** Membership choices are proven by core; fewer files alone never prove that a run fits. */
14
+ export function selectionScopeChoices(membership, options = {}) {
15
+ const current = new Set(options.currentPaths ?? membership.files.map(file => file.path));
16
+ const oversized = new Set(options.oversizedPaths ?? []);
17
+ const seen = new Set();
18
+ const choices = membership.choices.flatMap(choice => {
19
+ const entryOnly = choice.scope.imports === "none" || Array.isArray(choice.scope.imports) && choice.scope.imports.length === 0;
20
+ const knownOversized = entryOnly && oversized.has(choice.scope.entry);
21
+ const identical = choice.includes.length === current.size && choice.includes.every(path => current.has(path));
22
+ const key = JSON.stringify([...choice.includes].sort());
23
+ if (seen.has(key) || choice.includes.some(path => !current.has(path)) || identical && !knownOversized)
24
+ return [];
25
+ seen.add(key);
26
+ const label = entryOnly ? ENTRY_ONLY_LABEL : choice.scope.imports === "all" ? ENTRY_IMPORTS_LABEL : SELECTED_IMPORTS_LABEL;
27
+ const description = knownOversized ? SINGLE_FILE_TOO_LARGE_MESSAGE
28
+ : (entryOnly ? ENTRY_ONLY_DESCRIPTION : choice.scope.imports === "all" ? ENTRY_IMPORTS_DESCRIPTION : SELECTED_IMPORTS_DESCRIPTION)
29
+ .replace("{entry}", () => choice.scope.entry);
30
+ return [{ ...choice, label, description, fits: knownOversized ? false : "unknown" }];
31
+ });
32
+ if (!choices.length)
33
+ return undefined;
34
+ const imports = membership.files.flatMap(file => {
35
+ if (file.role !== "imported-helper")
36
+ return [];
37
+ const importedBy = [...new Set(membership.edges.filter(edge => edge.path === file.path).map(edge => edge.from))];
38
+ return importedBy.length ? [{ path: file.path, role: "imported-helper", importedBy }] : [];
39
+ });
40
+ return { kind: "choose_scope", text: CHOOSE_SCOPE_MESSAGE, snapshot: membership.snapshot,
41
+ entry: membership.kind === "file" ? membership.entry : null, coverageComplete: membership.completeness.complete,
42
+ frontierCount: membership.completeness.frontierCount, choices, imports, refusedEdges: membership.refusedEdges };
43
+ }
@@ -2,7 +2,7 @@ import { consoleSchemeAllowed } from "../contract/console-url.js";
2
2
  import { request as httpRequest } from "node:http";
3
3
  import { request as httpsRequest } from "node:https";
4
4
  import { CLI_VERSION } from "../contract/cli-version.js";
5
- import { cliRefusals, FINISH_FAILURE_MESSAGE, RESERVATION_EXPIRED_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
5
+ import { cliRefusals, NOT_ALLOWED_MESSAGE, SIGN_IN_EXPIRED_MESSAGE, CHECK_CONFLICT_MESSAGE, UNSAVED_RESULTS_MESSAGE, FINISH_FAILURE_MESSAGE, RESERVATION_EXPIRED_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
6
6
  import { cliRelayReservationRequestSchema, consoleCreatedSchema, consoleIntakeResultSchema, consoleReservationSchema, consoleStatusSchema, LOCAL_FINDINGS_BODY_LIMIT, localFindingsPayloadSchema, LOCAL_START_BODY_LIMIT, LOCAL_START_EXECUTION_HEADER, localStartRequestSchema, localStartResponseSchema, serializeLocalStartMetadata } from "../contract/console.js";
7
7
  const RESPONSE_LIMIT = 64 * 1024;
8
8
  const RESERVATION_BODY_LIMIT = 64 * 1024;
@@ -11,19 +11,22 @@ export const consoleErrorMessages = {
11
11
  invalid_configuration: START_FAILURE_MESSAGE,
12
12
  invalid_request: FINISH_FAILURE_MESSAGE,
13
13
  unavailable: "The results couldn't be saved yet. Run the analysis on the same folder again to save them; you won't be charged again.",
14
- unauthorized: "Your sign-in has expired. Run counterfact-labs login, then try again.",
15
- not_allowed: "Your account can't run analyses. Ask Counterfact for access.",
14
+ unauthorized: SIGN_IN_EXPIRED_MESSAGE,
15
+ not_allowed: NOT_ALLOWED_MESSAGE,
16
16
  not_found: "Counterfact couldn't find this analysis. Start a new analysis.",
17
17
  rate_limited: "Counterfact is busy. Try again in a minute.",
18
- reservation_closed: "The analysis ended before its results were saved. Start a new analysis.",
18
+ reservation_closed: UNSAVED_RESULTS_MESSAGE,
19
19
  reservation_closing: "The results are still being saved. Run the analysis on the same folder again in a minute.",
20
- delivery_incomplete: "The analysis ended before its results were saved. Start a new analysis.",
21
- check_conflict: "Saved results from an earlier run don't match this analysis. Start a new analysis.",
20
+ delivery_incomplete: UNSAVED_RESULTS_MESSAGE,
21
+ check_conflict: CHECK_CONFLICT_MESSAGE,
22
22
  };
23
23
  export class ConsoleError extends Error {
24
24
  code;
25
25
  reason;
26
- constructor(code = "unavailable", context, localReason) {
26
+ localReason;
27
+ responseCode;
28
+ httpStatus;
29
+ constructor(code = "unavailable", context, options = {}) {
27
30
  let message = consoleErrorMessages[code];
28
31
  let reason = code === "unavailable" ? "results_not_saved" : code;
29
32
  if (context === "start" && (code === "unavailable" || code === "invalid_request" || code === "not_found")) {
@@ -39,7 +42,10 @@ export class ConsoleError extends Error {
39
42
  super(message);
40
43
  this.name = "ConsoleError";
41
44
  this.code = code;
42
- this.reason = localReason ?? reason;
45
+ this.reason = options.localReason ?? reason;
46
+ this.localReason = options.localReason;
47
+ this.responseCode = options.responseCode;
48
+ this.httpStatus = options.httpStatus;
43
49
  }
44
50
  }
45
51
  export const isConsoleToken = (token) => /^[\x21-\x7e]{1,4096}$/.test(token);
@@ -54,15 +60,15 @@ export class ConsoleClient {
54
60
  url = new URL(options.url);
55
61
  if (!consoleSchemeAllowed(url)
56
62
  || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
57
- throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
63
+ throw new ConsoleError("invalid_configuration", undefined, { localReason: "invalid_console_url" });
58
64
  }
59
65
  }
60
66
  catch {
61
- throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
67
+ throw new ConsoleError("invalid_configuration", undefined, { localReason: "invalid_console_url" });
62
68
  }
63
69
  const timeout = options.timeoutMs ?? 15_000;
64
70
  if (!isConsoleToken(options.token) || !Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000) {
65
- throw new ConsoleError("invalid_configuration", undefined, "request_failed");
71
+ throw new ConsoleError("invalid_configuration", undefined, { localReason: "request_failed" });
66
72
  }
67
73
  this.#url = url;
68
74
  this.#token = options.token;
@@ -93,8 +99,8 @@ export class ConsoleClient {
93
99
  fail(reservationId) {
94
100
  return this.#lifecycle({ action: "fail", reservationId }, consoleStatusSchema);
95
101
  }
96
- intake(input) {
97
- return this.#send("/api/cli/checks/local", input, localFindingsPayloadSchema, consoleIntakeResultSchema, LOCAL_FINDINGS_BODY_LIMIT, { statuses: [200, 201], context: "intake" });
102
+ intake(input, options = {}) {
103
+ return this.#send("/api/cli/checks/local", input, localFindingsPayloadSchema, consoleIntakeResultSchema, LOCAL_FINDINGS_BODY_LIMIT, { statuses: [200, 201], context: "intake", ...options });
98
104
  }
99
105
  #lifecycle(input, response) {
100
106
  // Starting work has no saved results to retry; recovery keeps its saving guidance.
@@ -120,7 +126,8 @@ export class ConsoleClient {
120
126
  throw new ConsoleError("invalid_request", options.context);
121
127
  }
122
128
  return new Promise((resolve, reject) => {
123
- let settled = false;
129
+ let settled = false, responseStatus;
130
+ const interrupted = () => new ConsoleError("unavailable", undefined, { httpStatus: responseStatus });
124
131
  const finish = (error, result) => {
125
132
  if (settled)
126
133
  return;
@@ -139,12 +146,16 @@ export class ConsoleClient {
139
146
  ...options.headers },
140
147
  }, response => {
141
148
  const status = response.statusCode ?? 0;
149
+ responseStatus = status;
150
+ const accepted = options.acceptAnySuccess ? status >= 200 && status < 300 : (options.statuses ?? [200]).includes(status);
151
+ // Keep delivery status separate from validated receipt details.
152
+ const responseError = (code) => new ConsoleError(typeof code === "string" && Object.hasOwn(consoleErrorMessages, code) ? code : "unavailable", undefined, { responseCode: typeof code === "string" ? code : undefined, httpStatus: status });
142
153
  const declared = response.headers["content-length"];
143
154
  const contentType = response.headers["content-type"] ?? "";
144
155
  if (status >= 300 && status < 400 || !/^application\/json(?:\s*;|$)/i.test(contentType)
145
156
  || response.headers["content-encoding"] && response.headers["content-encoding"] !== "identity"
146
157
  || declared && (!/^\d+$/.test(declared) || Number(declared) > RESPONSE_LIMIT)) {
147
- finish(new ConsoleError());
158
+ finish(responseError());
148
159
  response.destroy();
149
160
  return;
150
161
  }
@@ -153,37 +164,37 @@ export class ConsoleClient {
153
164
  response.on("data", (chunk) => {
154
165
  size += chunk.length;
155
166
  if (size > RESPONSE_LIMIT) {
156
- finish(new ConsoleError());
167
+ finish(responseError());
157
168
  response.destroy();
158
169
  }
159
170
  else
160
171
  chunks.push(chunk);
161
172
  });
162
- response.on("error", () => finish(new ConsoleError()));
163
- response.on("aborted", () => finish(new ConsoleError()));
173
+ response.on("error", () => finish(interrupted()));
174
+ response.on("aborted", () => finish(interrupted()));
164
175
  response.on("end", () => {
165
176
  try {
166
177
  const decoded = JSON.parse(Buffer.concat(chunks).toString("utf8"));
167
- if (!(options.statuses ?? [200]).includes(status)) {
178
+ if (!accepted) {
168
179
  const code = decoded && typeof decoded === "object" && "code" in decoded ? decoded.code : undefined;
169
- throw new ConsoleError(typeof code === "string" && Object.hasOwn(consoleErrorMessages, code) ? code : "unavailable");
180
+ throw responseError(code);
170
181
  }
171
182
  const result = responseSchema.parse(decoded);
172
183
  for (const [field, expected] of Object.entries(identities)) {
173
184
  if (result[field] !== expected)
174
- throw new ConsoleError();
185
+ throw responseError();
175
186
  }
176
187
  finish(undefined, result);
177
188
  }
178
189
  catch (error) {
179
- finish(error instanceof ConsoleError ? error : new ConsoleError());
190
+ finish(error instanceof ConsoleError ? error : responseError());
180
191
  }
181
192
  });
182
193
  });
183
194
  // Use one wall-clock deadline: an idle timeout alone lets a trickling response run forever.
184
- const timer = setTimeout(() => { finish(new ConsoleError()); request.destroy(); }, this.#timeoutMs);
185
- request.on("error", () => finish(new ConsoleError()));
195
+ const timer = setTimeout(() => { finish(interrupted()); request.destroy(); }, this.#timeoutMs);
196
+ request.on("error", () => finish(interrupted()));
186
197
  request.end(body);
187
- }).catch(error => { throw new ConsoleError(error instanceof ConsoleError ? error.code : "unavailable", options.context); });
198
+ }).catch(error => { throw new ConsoleError(error instanceof ConsoleError ? error.code : "unavailable", options.context, error instanceof ConsoleError ? { localReason: error.localReason, responseCode: error.responseCode, httpStatus: error.httpStatus } : {}); });
188
199
  }
189
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@counterfact-labs/cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "homepage": "https://counterfactlabs.com",
5
5
  "relaySource": "https://github.com/counterfact-labs/sealed-relay",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "bench:replay": "tsx bench/replay.ts",
21
21
  "bench:importer": "tsx bench/importer-coverage.ts",
22
22
  "typecheck": "tsc --noEmit",
23
- "test": "tsx --test ${TEST_SHARD:+--test-shard=$TEST_SHARD} --test-concurrency=4 --test-timeout=600000 \"core/**/*.test.ts\" \"adapters/**/*.test.ts\" \"cli/**/*.test.ts\" \"bench/**/*.test.ts\" \"bundle/**/*.test.ts\" \"contract/**/*.test.ts\" \"gatekeeper/**/*.test.ts\" \"relay/**/*.test.ts\" \"transport/**/*.test.ts\" \"packages/core/src/**/*.test.ts\" \"packages/core/*.test.ts\" \"packages/setup-pings/src/**/*.test.ts\" \"scripts/**/*.test.ts\"",
23
+ "test": "tsx --test ${TEST_SHARD:+--test-shard=$TEST_SHARD} --test-concurrency=4 --test-timeout=600000 \"core/**/*.test.ts\" \"adapters/**/*.test.ts\" \"cli/**/*.test.ts\" \"bench/**/*.test.ts\" \"bundle/**/*.test.ts\" \"contract/**/*.test.ts\" \"gatekeeper/**/*.test.ts\" \"relay/**/*.test.ts\" \"transport/**/*.test.ts\" \"packages/core/src/**/*.test.ts\" \"packages/core/*.test.ts\" \"packages/setup-pings/src/**/*.test.ts\" \"scripts/**/*.test.ts\" \".github/scripts/*.test.ts\"",
24
24
  "build:core": "npm run build --workspace @counterfact-labs/analysis-core",
25
25
  "build:setup-pings": "npm run build --workspace @counterfact-labs/setup-pings",
26
26
  "pretest": "npm run build:core && npm run build:setup-pings",