@counterfact-labs/cli 0.2.6 → 0.2.9

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 (157) hide show
  1. package/README.md +20 -0
  2. package/bundle/client-release.json +1 -1
  3. package/dist/adapters/analysis-results.js +87 -0
  4. package/dist/adapters/combined-results.js +27 -0
  5. package/dist/adapters/completion-lock.js +95 -0
  6. package/dist/adapters/completion.js +132 -17
  7. package/dist/adapters/entry-files.js +129 -0
  8. package/dist/adapters/entry-packing.js +161 -0
  9. package/dist/adapters/entry-selection.js +57 -0
  10. package/dist/adapters/files.js +111 -5
  11. package/dist/adapters/git.js +10 -1
  12. package/dist/adapters/relay-plan.js +5 -7
  13. package/dist/adapters/release.js +4 -1
  14. package/dist/adapters/results.js +18 -3
  15. package/dist/adapters/selection.js +114 -0
  16. package/dist/adapters/settings.js +62 -4
  17. package/dist/adapters/source-relay-plan.js +7 -0
  18. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -1
  19. package/dist/analysis-core/analysis/file-analysis-plan.js +1 -0
  20. package/dist/analysis-core/analysis/file-dependencies.js +1 -0
  21. package/dist/analysis-core/analysis/finding-activity-switch.js +1 -0
  22. package/dist/analysis-core/analysis/finding-identity.js +1 -0
  23. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
  24. package/dist/analysis-core/analysis/folder-analysis-plan.js +1 -0
  25. package/dist/analysis-core/analysis/passes/full-file-passes.js +1 -0
  26. package/dist/analysis-core/analysis/project-architecture.js +1 -0
  27. package/dist/analysis-core/analysis/schemas.js +1 -1
  28. package/dist/analysis-core/chunks/2AASSZRO.js +35 -0
  29. package/dist/analysis-core/chunks/2YREYGOG.js +1 -0
  30. package/dist/analysis-core/chunks/33HRXJ5Q.js +1 -0
  31. package/dist/analysis-core/chunks/33IGHOYT.js +1 -0
  32. package/dist/analysis-core/chunks/4PT5C4IT.js +7 -0
  33. package/dist/analysis-core/chunks/4RXRAB5S.js +1 -0
  34. package/dist/analysis-core/chunks/4UDQBDDE.js +1 -0
  35. package/dist/analysis-core/chunks/{OPMA32A6.js → 5B5UCWD3.js} +1 -1
  36. package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
  37. package/dist/analysis-core/chunks/CQQ4TTXE.js +1 -0
  38. package/dist/analysis-core/chunks/CTLHKFPH.js +19 -0
  39. package/dist/analysis-core/chunks/F54DOWKN.js +6 -0
  40. package/dist/analysis-core/chunks/FRXOQ2M6.js +3 -0
  41. package/dist/analysis-core/chunks/HCPO6QPU.js +1 -0
  42. package/dist/analysis-core/chunks/HHFDNY6V.js +14 -0
  43. package/dist/analysis-core/chunks/{3JWEFCTJ.js → HVQJKPQ4.js} +1 -1
  44. package/dist/analysis-core/chunks/INVTU73D.js +1 -0
  45. package/dist/analysis-core/chunks/JEWBPIQI.js +1 -0
  46. package/dist/analysis-core/chunks/JGK37AWN.js +1 -0
  47. package/dist/analysis-core/chunks/LFYTD7JF.js +1 -0
  48. package/dist/analysis-core/chunks/LMUXOKIM.js +1 -0
  49. package/dist/analysis-core/chunks/LOBIDUCA.js +1 -0
  50. package/dist/analysis-core/chunks/M5YWJY7P.js +1 -0
  51. package/dist/analysis-core/chunks/MGVIKA74.js +1 -0
  52. package/dist/analysis-core/chunks/MKYLK6DB.js +1 -0
  53. package/dist/analysis-core/chunks/MZOKZ3N6.js +1 -0
  54. package/dist/analysis-core/chunks/NXHVBJL4.js +1 -0
  55. package/dist/analysis-core/chunks/OKBK3JL3.js +1 -0
  56. package/dist/analysis-core/chunks/PBHHWFR6.js +1 -0
  57. package/dist/analysis-core/chunks/Q5WR6B2H.js +2 -0
  58. package/dist/analysis-core/chunks/QVXARKP7.js +1 -0
  59. package/dist/analysis-core/chunks/RQTNTIR4.js +1 -0
  60. package/dist/analysis-core/chunks/RUVMFAST.js +1 -0
  61. package/dist/analysis-core/chunks/S27VIQMZ.js +1 -0
  62. package/dist/analysis-core/chunks/{YDBO7P4X.js → S27Y2TP5.js} +1 -1
  63. package/dist/analysis-core/chunks/SJESFYBA.js +10 -0
  64. package/dist/analysis-core/chunks/SP6XFHNH.js +1 -0
  65. package/dist/analysis-core/chunks/VDUFPTDD.js +1 -0
  66. package/dist/analysis-core/chunks/W24SFYZM.js +67 -0
  67. package/dist/analysis-core/chunks/YTHF2E2S.js +1 -0
  68. package/dist/analysis-core/chunks/YWL52FVW.js +2 -0
  69. package/dist/analysis-core/contracts/analysis-selection.js +1 -0
  70. package/dist/analysis-core/contracts/analysis-size.js +1 -0
  71. package/dist/analysis-core/contracts/execution-request.js +1 -0
  72. package/dist/analysis-core/contracts/intake-finding.js +1 -0
  73. package/dist/analysis-core/contracts/model-call-identity.js +1 -0
  74. package/dist/analysis-core/contracts/module-resolution.js +1 -0
  75. package/dist/analysis-core/contracts/relay-analysis.js +1 -1
  76. package/dist/analysis-core/contracts/requests.js +1 -1
  77. package/dist/analysis-core/contracts/selection-paths.js +1 -0
  78. package/dist/analysis-core/conventions/analysis-fit.js +1 -1
  79. package/dist/analysis-core/conventions/file-admission.js +1 -1
  80. package/dist/analysis-core/conventions/read-limits.js +1 -0
  81. package/dist/analysis-core/import/calls/modules.js +1 -0
  82. package/dist/analysis-core/import/entry-selection.js +1 -0
  83. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  84. package/dist/analysis-core/index.js +3 -3
  85. package/dist/analysis-core/requests/answer-formats.js +1 -1
  86. package/dist/analysis-core/requests/local-plan.js +1 -1
  87. package/dist/analysis-core/requests/planned-limits.js +1 -0
  88. package/dist/analysis-core/requests/registry.js +1 -1
  89. package/dist/analysis-core/requests/template-version.js +1 -1
  90. package/dist/analysis-core/requests/tools.js +1 -1
  91. package/dist/analysis-core/settings.js +1 -1
  92. package/dist/analysis-core/stages/engine-entry.js +1 -1
  93. package/dist/bundle/seal/engine.js +1 -1
  94. package/dist/cli/analysis-selection.js +133 -0
  95. package/dist/cli/api.js +1 -1
  96. package/dist/cli/commands/analyze.js +82 -14
  97. package/dist/cli/commands/skipped.js +65 -0
  98. package/dist/cli/completion-delivery.js +63 -0
  99. package/dist/cli/completion-recovery.js +68 -14
  100. package/dist/cli/context.js +4 -2
  101. package/dist/cli/finding-display.js +102 -7
  102. package/dist/cli/finding-settings.js +3 -1
  103. package/dist/cli/intake-payload.js +26 -2
  104. package/dist/cli/main.js +37 -11
  105. package/dist/cli/mcp-failure.js +6 -5
  106. package/dist/cli/mcp.js +191 -26
  107. package/dist/cli/prior-completion.js +35 -0
  108. package/dist/cli/relay-check.js +260 -110
  109. package/dist/cli/relay-failure.js +32 -0
  110. package/dist/cli/relay-preparation.js +72 -21
  111. package/dist/cli/result-check.js +31 -12
  112. package/dist/cli/results-upload.js +131 -0
  113. package/dist/cli/saved-results.js +10 -0
  114. package/dist/cli/scope-choices.js +57 -0
  115. package/dist/cli/sign-in.js +2 -2
  116. package/dist/cli/skipped-files.js +22 -0
  117. package/dist/cli/summary.js +15 -1
  118. package/dist/contract/agent-choice.js +25 -2
  119. package/dist/contract/cli-api-messages.js +3 -0
  120. package/dist/contract/cli-refusals.js +39 -5
  121. package/dist/contract/cli-version.js +1 -1
  122. package/dist/contract/console-url.js +3 -0
  123. package/dist/contract/console.js +24 -40
  124. package/dist/contract/engine.js +1 -1
  125. package/dist/contract/messages.js +4 -3
  126. package/dist/contract/project-architecture.js +1 -0
  127. package/dist/contract/scope-choice.js +98 -0
  128. package/dist/contract/selection-metadata.js +9 -0
  129. package/dist/contract/selection-offer.js +40 -0
  130. package/dist/gatekeeper/cli-api.js +70 -4
  131. package/dist/gatekeeper/console.js +55 -28
  132. package/dist/gatekeeper/model.js +5 -23
  133. package/dist/gatekeeper/trust/policy.js +2 -1
  134. package/package.json +5 -3
  135. package/dist/analysis-core/analysis/source-notes/types.js +0 -1
  136. package/dist/analysis-core/chunks/2PSNOSJ2.js +0 -1
  137. package/dist/analysis-core/chunks/377MLK4M.js +0 -21
  138. package/dist/analysis-core/chunks/5DGPLXYB.js +0 -1
  139. package/dist/analysis-core/chunks/6B2HILSY.js +0 -1
  140. package/dist/analysis-core/chunks/6QY5BV5B.js +0 -14
  141. package/dist/analysis-core/chunks/6U2IR4XB.js +0 -1
  142. package/dist/analysis-core/chunks/DLPVYWHZ.js +0 -1
  143. package/dist/analysis-core/chunks/FOKDE64Y.js +0 -1
  144. package/dist/analysis-core/chunks/FSLWD2PE.js +0 -1
  145. package/dist/analysis-core/chunks/K2GNDX3W.js +0 -1
  146. package/dist/analysis-core/chunks/KTNA2KTN.js +0 -7
  147. package/dist/analysis-core/chunks/KWECQJ6X.js +0 -1
  148. package/dist/analysis-core/chunks/NIOS7U6C.js +0 -41
  149. package/dist/analysis-core/chunks/PECX3OXB.js +0 -1
  150. package/dist/analysis-core/chunks/PYKAERZZ.js +0 -1
  151. package/dist/analysis-core/chunks/QFY6A3MS.js +0 -66
  152. package/dist/analysis-core/chunks/SKVEBOGA.js +0 -1
  153. package/dist/analysis-core/chunks/STI4JE7L.js +0 -1
  154. package/dist/analysis-core/chunks/SVMEWGD3.js +0 -1
  155. package/dist/analysis-core/chunks/X7WZAA7S.js +0 -1
  156. package/dist/analysis-core/chunks/ZTOVBAG7.js +0 -1
  157. package/dist/analysis-core/data/finding-types.js +0 -1
@@ -1,10 +1,12 @@
1
1
  import { z } from "zod";
2
- import { findingAudienceSchema, fixDeltaSchema, fixFactsSchema } from "../analysis-core/data/finding-types.js";
3
- import { inspectFixSchema, otherSiteSchema, rawFindingSchema } from "../analysis-core/analysis/schemas.js";
4
- import { sourceNotesSchema } from "../analysis-core/analysis/source-notes/types.js";
5
- import { parseAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
2
+ import { parseAnalysisSize } from "../analysis-core/contracts/analysis-size.js";
6
3
  import { canonicalUuidSchema as uuid, matchingFindingNumbers, numberedFindingsSchema } from "./findings.js";
7
- import { RESULT_CHECK_LIMITS, validIntakeText } from "../analysis-core/contracts/relay-analysis.js";
4
+ import { publicReportSchema } from "../analysis-core/contracts/relay-analysis.js";
5
+ import { PLANNED_INPUT_BYTES } from "../analysis-core/requests/planned-limits.js";
6
+ import { projectArchitectureSchema } from "./project-architecture.js";
7
+ import { intakeFinding as localFindingSchema, nonblankText as text, RESULT_CHECK_LIMITS, validIntakeText } from "../analysis-core/contracts/intake-finding.js";
8
+ export { localFindingSchema };
9
+ import { selectionStartIdentitySchema } from "./selection-metadata.js";
8
10
  export const id = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/);
9
11
  const money = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
10
12
  const count = (max) => z.number().int().positive().max(max);
@@ -13,13 +15,17 @@ export const LOCAL_START_EXECUTION_HEADER = { name: "x-counterfact-execution", v
13
15
  export const LOCAL_START_BODY_LIMIT = 2 * 1024 * 1024;
14
16
  export const ANALYSIS_SIZE_HEADER = "x-counterfact-analysis-size";
15
17
  export const START_ATTEMPT_HEADER = "x-counterfact-start-attempt";
18
+ export const SELECTION_IDENTITY_HEADER = "x-counterfact-selection";
16
19
  export const ANALYSIS_SIZE_HEADER_LIMIT = 256;
20
+ export const ADD_TO_ANALYSIS_HEADER = "x-counterfact-add-to-analysis";
17
21
  /** Additive headers let older consoles keep accepting the unchanged, strict start body. */
18
22
  export function serializeLocalStartMetadata(metadata) {
19
23
  const size = JSON.stringify(parseAnalysisSize(metadata.analysisSize));
20
24
  if (Buffer.byteLength(size, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
21
25
  throw new Error("Invalid analysis size header");
22
- return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt) };
26
+ return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt),
27
+ ...(metadata.addToAnalysisId ? { [ADD_TO_ANALYSIS_HEADER]: uuid.parse(metadata.addToAnalysisId) } : {}),
28
+ ...(metadata.selection === undefined ? {} : { [SELECTION_IDENTITY_HEADER]: JSON.stringify(selectionStartIdentitySchema.parse(metadata.selection)) }) };
23
29
  }
24
30
  /** Missing measurements preserve old clients; present measurements must be complete. */
25
31
  export function parseLocalStartMetadata(headers) {
@@ -28,7 +34,10 @@ export function parseLocalStartMetadata(headers) {
28
34
  return undefined;
29
35
  if (Buffer.byteLength(raw, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
30
36
  throw new Error("Invalid analysis size header");
31
- return { analysisSize: parseAnalysisSize(JSON.parse(raw)), startAttempt: uuid.parse(headers.get(START_ATTEMPT_HEADER)) };
37
+ const selection = headers.get(SELECTION_IDENTITY_HEADER);
38
+ return { analysisSize: parseAnalysisSize(JSON.parse(raw)), startAttempt: uuid.parse(headers.get(START_ATTEMPT_HEADER)),
39
+ ...(headers.get(ADD_TO_ANALYSIS_HEADER) === null ? {} : { addToAnalysisId: uuid.parse(headers.get(ADD_TO_ANALYSIS_HEADER)) }),
40
+ ...(selection === null ? {} : { selection: selectionStartIdentitySchema.parse(JSON.parse(z.string().max(256).parse(selection))) }) };
32
41
  }
33
42
  const localStartPath = z.string().min(1).max(4096).refine(value => !value.includes("\\") && !value.includes("\0") && !value.startsWith("/")
34
43
  && value.split("/").every(part => part !== ".." && part !== "." && part !== ""));
@@ -42,7 +51,7 @@ export const localStartRequestSchema = z.strictObject({
42
51
  export const localStartResponseSchema = z.strictObject({ jobId: uuid, projectId: uuid });
43
52
  export const relayPlanSchema = z.strictObject({
44
53
  bundleVersion: id, bundleHash: z.string().regex(/^[a-f0-9]{64}$/), relayVersion: id,
45
- calls: z.array(z.strictObject({ routeId: id, model: id, calls: count(512), maxInputBytes: count(800_000),
54
+ calls: z.array(z.strictObject({ routeId: id, model: id, calls: count(512), maxInputBytes: count(PLANNED_INPUT_BYTES),
46
55
  maxInputTokens: count(200_000), maxOutputTokens: count(64_000) })).min(1).max(64)
47
56
  .refine(calls => new Set(calls.map(call => call.routeId)).size === calls.length && calls.reduce((sum, call) => sum + call.calls, 0) <= 512),
48
57
  });
@@ -84,44 +93,19 @@ export const consoleIntakeResultSchema = z.strictObject({
84
93
  export const LOCAL_FINDINGS_BODY_LIMIT = 2 * 1024 * 1024;
85
94
  export const LOCAL_FINDINGS_QUOTE_LIMIT = RESULT_CHECK_LIMITS.quoteBytes;
86
95
  export const LOCAL_FINDINGS_TEXT_LIMIT = RESULT_CHECK_LIMITS.textBytes;
87
- function shortFindingFields(value) {
88
- if (typeof value === "string")
89
- return Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_TEXT_LIMIT;
90
- return value === null || typeof value !== "object" || Object.values(value).every(shortFindingFields);
91
- }
92
- const quote = z.string().refine(value => Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_QUOTE_LIMIT);
93
- const text = z.string().min(1).refine(value => value.trim().length > 0);
94
- const delta = fixDeltaSchema.extend({
95
- nodes: z.array(fixDeltaSchema.shape.nodes.element.strict()),
96
- edges: z.array(fixDeltaSchema.shape.edges.element.strict()),
97
- }).strict();
98
- const fix = inspectFixSchema.extend({
99
- ...fixFactsSchema.shape,
100
- targetFiles: z.array(inspectFixSchema.shape.targetFiles.element.strict()),
101
- delta: delta.nullable(),
102
- }).strict();
103
- const notes = sourceNotesSchema.extend({
104
- sites: z.array(sourceNotesSchema.shape.sites.element.extend({ keyPhrase: quote }).strict()),
105
- counterEvidence: z.array(sourceNotesSchema.shape.counterEvidence.element.extend({ excerpt: quote }).strict()),
106
- }).strict();
107
- export const localFindingSchema = rawFindingSchema.extend({
108
- audience: findingAudienceSchema.optional(),
109
- summary: z.string().nullable(),
110
- category: rawFindingSchema.shape.category.removeCatch(),
111
- question: rawFindingSchema.shape.question.removeCatch().nullable(),
112
- siteRepo: text.nullable(),
113
- site: rawFindingSchema.shape.site.unwrap().strict().nullable(),
114
- excerpt: quote.nullable(),
115
- otherSites: z.array(otherSiteSchema.extend({ repo: text, excerpt: otherSiteSchema.shape.excerpt.pipe(quote) }).strict()),
116
- fixes: z.array(fix).min(1),
117
- sourceNotes: notes.nullable().optional(),
118
- }).strict().refine(shortFindingFields);
119
96
  const payload = z.strictObject({
120
97
  checkId: z.uuid(), projectId: z.uuid(), reservationId: uuid.optional(),
98
+ addToAnalysisId: uuid.optional(),
99
+ coverage: z.strictObject({ skippedFiles: z.array(z.strictObject({ path: localStartPath,
100
+ reason: publicReportSchema.shape.refusedFiles.element.shape.reason })),
101
+ readPaths: z.array(localStartPath) }).optional(),
102
+ findingAssessments: z.array(z.strictObject({ fingerprint: z.string().max(8192), support: z.enum(["complete", "incomplete"]),
103
+ confidence: z.enum(["sure", "not sure"]), weak: z.boolean(), reason: z.string().max(4096) })).optional(),
121
104
  agent: z.strictObject({
122
105
  repository: text.max(4096), commit: z.string().regex(/^(?:[a-f\d]{40}|[a-f\d]{64})$/i).nullable(),
123
106
  files: z.array(z.strictObject({ path: text.max(4096), hash: z.string().regex(/^[a-f\d]{64}$/i) })).min(1),
124
107
  }),
108
+ architecture: projectArchitectureSchema.optional(),
125
109
  findings: z.array(localFindingSchema), bundleVersion: text.max(128), relayVersion: text.max(128), counterfactLocalVersion: text.max(128),
126
110
  }).refine(validIntakeText);
127
111
  // Original JSON size is checked before a shared schema can trim text or apply defaults.
@@ -3,7 +3,7 @@ import { ANALYSIS_PROTOCOL, analysisCapabilities } from "../analysis-core/contra
3
3
  import { stableStringify } from "../analysis-core/llm/stable-json.js";
4
4
  import { workloadHardware } from "../gatekeeper/trust/schema.js";
5
5
  import { kmsKeyName } from "../transport/kms.js";
6
- export const ENGINE_ABI = 1;
6
+ export const ENGINE_ABI = 2;
7
7
  export const ENGINE_SPAWNER_CONTRACT = 1;
8
8
  const hash = z.string().regex(/^[0-9a-f]{64}$/);
9
9
  const version = z.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { analysisRelease } from "./engine.js";
3
- import { executionRequest } from "../analysis-core/contracts/requests.js";
3
+ import { executionRequest } from "../analysis-core/contracts/execution-request.js";
4
+ import { PLANNED_INPUT_BYTES } from "../analysis-core/requests/planned-limits.js";
4
5
  import { compactJwtPattern, hexChallenge } from "./attestation.js";
5
6
  export { PRODUCTION_EVIDENCE_VERSION, RELAY_CHANNEL_AUDIENCE, RELAY_PORT, RELAY_URL, RELAY_HEALTH_PATH, RELAY_HEALTH_URL, GOOGLE_ATTESTATION_ISSUER, MAX_ATTESTATION_BYTES, attestationChallenge, productionEvidence, evidenceBinding, googleAttestationClaims, observedAttestationClaims, probeAttestationClaims, attestationClaims } from "./attestation.js";
6
7
  /**
@@ -66,14 +67,14 @@ const outputCount = z.number().int().nonnegative().max(64_000);
66
67
  /** A reservation lookup establishes the owner without reserving spend or contacting the model provider. */
67
68
  export const reservationCheck = z.object({ action: z.literal("check"), ...billingScope }).strict();
68
69
  export const reservationIdentity = z.object({ clientId: z.string().min(1).max(128) }).strict();
69
- export const callCounts = z.object({ inputBytes: z.number().int().positive().max(800_000),
70
+ export const callCounts = z.object({ inputBytes: z.number().int().positive().max(PLANNED_INPUT_BYTES),
70
71
  inputTokens: inputCount.positive(), maxOutputTokens: outputCount.positive() }).strict();
71
72
  export const authorizationRequest = z.object({ action: z.literal("authorize"), ...billingScope,
72
73
  routeId: billingId, templateId: billingId, answerFormatId: billingId, model: billingId, ...callCounts.shape }).strict();
73
74
  export const callAuthorization = z.object({ attemptId: z.uuid(), model: billingId, role: z.enum(["required", "optional"]),
74
75
  /** Stable opaque owner supplied only by the authenticated authorization service. */
75
76
  clientId: reservationIdentity.shape.clientId,
76
- deadline: z.iso.datetime(), countLimits: z.object({ maxInputBytes: z.number().int().positive().max(800_000),
77
+ deadline: z.iso.datetime(), countLimits: z.object({ maxInputBytes: z.number().int().positive().max(PLANNED_INPUT_BYTES),
77
78
  maxInputTokens: inputCount.positive(), maxOutputTokens: outputCount.positive() }).strict() }).strict();
78
79
  export const providerUsage = z.object({ kind: z.enum(["actual", "estimated", "unpaid"]),
79
80
  outcome: z.enum(["success", "unknown", "retryable_failure", "terminal_provider_failure", "schema_refusal", "leak_refusal"]),
@@ -0,0 +1 @@
1
+ export { PROJECT_ARCHITECTURE_BYTES, projectArchitectureSchema, projectArchitecture } from "../analysis-core/analysis/project-architecture.js";
@@ -0,0 +1,98 @@
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
+ // Coverage wording stays here so the CLI and MCP cannot make 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 MULTI_ENTRY_CHOOSE_SCOPE_MESSAGE = "These agent files are too many or too large for one analysis. Choose what Counterfact should read.";
10
+ export const ENTRY_ONLY_LABEL = "Only the selected file";
11
+ export const ENTRY_ONLY_DESCRIPTION = "Read {entry}. Leave its imports out of this analysis.";
12
+ export const SELECTED_IMPORTS_LABEL = "The selected file and some imports";
13
+ export const SELECTED_IMPORTS_DESCRIPTION = "Read {entry} and the imports listed below. Leave the other imports out of this analysis.";
14
+ export const ENTRY_IMPORTS_LABEL = "One agent file and its imports";
15
+ export const ENTRY_IMPORTS_DESCRIPTION = "Read {entry} and its project imports inside the launch folder.";
16
+ export const ENTRY_GROUP_LABEL = "Several agent files and their imports";
17
+ export const ENTRY_GROUP_DESCRIPTION = "Read the agent files and their project imports listed below. Leave the other selected files out of this analysis.";
18
+ 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.";
19
+ export const INVALID_SELECTION_MESSAGE = "Choose an agent file or folder inside the launch folder.";
20
+ export const ANALYSIS_TOO_LARGE_MESSAGE = "This analysis is too large to run at once. Choose one agent file or a smaller folder, such as one agent's folder.";
21
+ /** Recovery may offer a file only when this run accepts file selections. */
22
+ export function selectionRefusalMessage(message, fileMode) {
23
+ if (!fileMode && message === INVALID_SELECTION_MESSAGE)
24
+ return "Choose a folder inside this project, or start Claude Code or Codex in the folder you want to analyze.";
25
+ if (!fileMode && message === ANALYSIS_TOO_LARGE_MESSAGE)
26
+ return "This analysis is too large to run at once. Choose a smaller folder, such as one agent's folder.";
27
+ return message;
28
+ }
29
+ export const INVALID_SCOPE_MESSAGE = "This scope could not be used. Choose one of the returned scopes and try again.";
30
+ export const STALE_SCOPE_MESSAGE = "The selected files changed. Run the analysis again to get updated scope choices.";
31
+ export const SCOPE_PROPOSAL_CONFLICT_MESSAGE = "The selected scope and proposed files do not match. Keep the selected file and only its listed imports.";
32
+ export const SELECTION_REFUSAL_MESSAGES = Object.freeze([
33
+ INVALID_SELECTION_MESSAGE, INVALID_SCOPE_MESSAGE, STALE_SCOPE_MESSAGE, SCOPE_PROPOSAL_CONFLICT_MESSAGE,
34
+ SINGLE_FILE_TOO_LARGE_MESSAGE,
35
+ ]);
36
+ export function isSelectionRefusal(error, fileMode) {
37
+ return error instanceof Error && (SELECTION_REFUSAL_MESSAGES.includes(error.message)
38
+ || fileMode && error instanceof FileReadRefusal);
39
+ }
40
+ export const INCLUDED_FILES_CAPTION = "Included files:";
41
+ export const OMITTED_IMPORTS_CAPTION = "Imports left out:";
42
+ export const OMITTED_FILES_CAPTION = "Files left out:";
43
+ export const SCOPE_CHOICE_INSTRUCTION = "Choose a scope, then run the analysis again with that scope.";
44
+ export const INCOMPLETE_IMPORTS_MESSAGE = "Some imports could not be resolved. This analysis covers only the files listed below.";
45
+ export const IMPORT_DISCOVERY_LIMIT_MESSAGE = "Counterfact reached the import discovery limit. The list of imports is incomplete.";
46
+ export const SELECTED_FILES_PROGRESS = "Reading the selected files and running analysis.\n";
47
+ export const SELECTED_PATH_FAILURE = "Analysis could not finish. Check the selected path, provider key and connection.";
48
+ export function progressLine(selection, surface = "source") {
49
+ return selection?.kind === "file" ? SELECTED_FILES_PROGRESS
50
+ : surface === "terminal" ? "Analyzing the agent…" : "Reading the folder and running analysis.\n";
51
+ }
52
+ export function failureLine(selection) {
53
+ return selection?.kind === "file" ? SELECTED_PATH_FAILURE : "Analysis could not finish. Check the folder, provider key and connection.";
54
+ }
55
+ export const SELECTION_COVERAGE_DESCRIPTION = "This analysis covers {entry} and {count} selected imports. Other imports were left out.";
56
+ export const SCOPE_RETRY_TEMPLATE = "Run again: counterfact-labs analyze {quoted-entry} --scope {quoted-scope-file}";
57
+ 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";
58
+ export const FILE_SCOPE_SOURCE_USAGE = "Usage: npx tsx cli/check.ts <path> [--output <directory>] [--scope <json-file>]\n";
59
+ export function scopeChoiceWording(choice, fits) {
60
+ const group = (choice.scope.entries ?? []).filter(entry => choice.includes.includes(entry)).length > 1;
61
+ const entryOnly = choice.scope.imports === "none" || Array.isArray(choice.scope.imports) && choice.scope.imports.length === 0;
62
+ const fullImports = choice.scope.imports === "all" || choice.coverage === "entry-imports";
63
+ return {
64
+ label: group ? ENTRY_GROUP_LABEL : entryOnly ? ENTRY_ONLY_LABEL : fullImports ? ENTRY_IMPORTS_LABEL : SELECTED_IMPORTS_LABEL,
65
+ description: entryOnly && fits === false ? SINGLE_FILE_TOO_LARGE_MESSAGE
66
+ : (group ? ENTRY_GROUP_DESCRIPTION : entryOnly ? ENTRY_ONLY_DESCRIPTION : fullImports ? ENTRY_IMPORTS_DESCRIPTION : SELECTED_IMPORTS_DESCRIPTION)
67
+ .replace("{entry}", () => choice.scope.entry),
68
+ };
69
+ }
70
+ export const scopeChoiceSchema = provenScopeChoiceSchema.safeExtend({
71
+ label: z.enum([ENTRY_ONLY_LABEL, SELECTED_IMPORTS_LABEL, ENTRY_IMPORTS_LABEL, ENTRY_GROUP_LABEL]),
72
+ description: z.string().min(1).max(8192),
73
+ // Local measurement can prove a group fits; ordinary run admission checks it again.
74
+ fits: z.union([z.boolean(), z.literal("unknown")]),
75
+ }).refine(choice => {
76
+ const { label, description } = scopeChoiceWording(choice, choice.fits);
77
+ return choice.label === label && choice.description === description;
78
+ });
79
+ export const chooseScopeResultSchema = z.strictObject({
80
+ kind: z.literal("choose_scope"), text: z.enum([CHOOSE_SCOPE_MESSAGE, MULTI_ENTRY_CHOOSE_SCOPE_MESSAGE]), snapshot: snapshotIdSchema,
81
+ entry: selectionPathSchema.nullable(), coverageComplete: z.boolean(),
82
+ frontierCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
83
+ choices: z.array(scopeChoiceSchema).min(1).max(ANALYSIS_SELECTION_LIMITS.choices),
84
+ imports: z.array(z.strictObject({ path: selectionPathSchema, role: z.literal("imported-helper"),
85
+ importedBy: z.array(selectionPathSchema).min(1).max(ANALYSIS_SELECTION_LIMITS.files)
86
+ .refine(paths => new Set(paths).size === paths.length) })).max(ANALYSIS_SELECTION_LIMITS.files),
87
+ refusedEdges: z.array(refusedImportSchema).max(ANALYSIS_SELECTION_LIMITS.edges),
88
+ }).refine(value => (!value.coverageComplete || value.frontierCount === 0 && value.refusedEdges.every(edge => edge.reason === "external"))
89
+ && new Set(value.choices.map(choice => choice.id)).size === value.choices.length
90
+ && new Set(value.imports.map(file => file.path)).size === value.imports.length
91
+ && value.choices.every(choice => choice.scope.snapshot === value.snapshot && (value.entry === null || choice.scope.entry === value.entry
92
+ && (choice.scope.imports !== "all" || choice.omitted.length === 0))));
93
+ export const chooseScopeMcpResultSchema = z.strictObject({
94
+ content: z.tuple([z.strictObject({ type: z.literal("text"), text: z.string()
95
+ .refine(text => [CHOOSE_SCOPE_MESSAGE, MULTI_ENTRY_CHOOSE_SCOPE_MESSAGE].some(message => text.startsWith(message))) })]),
96
+ isError: z.literal(false), structuredContent: chooseScopeResultSchema.safeExtend({ exitCode: z.literal(0) }),
97
+ });
98
+ 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,40 @@
1
+ import { CHOOSE_SCOPE_MESSAGE, MULTI_ENTRY_CHOOSE_SCOPE_MESSAGE, scopeChoiceWording, 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 && !options.allowCurrent)
24
+ return [];
25
+ seen.add(key);
26
+ const { label, description } = scopeChoiceWording(choice, knownOversized ? false : "unknown");
27
+ return [{ ...choice, label, description, fits: knownOversized ? false : "unknown" }];
28
+ });
29
+ if (!choices.length)
30
+ return undefined;
31
+ const imports = membership.files.flatMap(file => {
32
+ if (file.role !== "imported-helper")
33
+ return [];
34
+ const importedBy = [...new Set(membership.edges.filter(edge => edge.path === file.path).map(edge => edge.from))];
35
+ return importedBy.length ? [{ path: file.path, role: "imported-helper", importedBy }] : [];
36
+ });
37
+ return { kind: "choose_scope", text: membership.kind === "file" && membership.entries ? MULTI_ENTRY_CHOOSE_SCOPE_MESSAGE : CHOOSE_SCOPE_MESSAGE, snapshot: membership.snapshot,
38
+ entry: membership.kind === "file" && !membership.entries ? membership.entry : null, coverageComplete: membership.completeness.complete,
39
+ frontierCount: membership.completeness.frontierCount, choices, imports, refusedEdges: membership.refusedEdges };
40
+ }
@@ -1,7 +1,8 @@
1
- import { cliRefusals, refusalExitCode } from "../contract/cli-refusals.js";
1
+ import { cliRefusals, localFailure, refusalExitCode } from "../contract/cli-refusals.js";
2
2
  import { CLI_VERSION } from "../contract/cli-version.js";
3
3
  import { consoleSchemeAllowed } from "../contract/console-url.js";
4
4
  import { cliApiMessages } from "../contract/cli-api-messages.js";
5
+ import { selectionRefusalMessage } from "../contract/scope-choice.js";
5
6
  import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
6
7
  import { CLI_BODY_LIMIT } from "../cli/api-types.js";
7
8
  import { findingNumberParamsSchema } from "../contract/findings.js";
@@ -20,6 +21,9 @@ export class ApiError extends Error {
20
21
  this.reason = reason;
21
22
  }
22
23
  }
24
+ export class SavedSignInError extends ApiError {
25
+ constructor() { super(0, "invalid_request", cliApiMessages.unreadable_sign_in_terminal, 0, "invalid_request"); }
26
+ }
23
27
  const emailRoutes = {
24
28
  "email/start": { fields: ["email"], successStatus: 202, failures: { invalid_email: 400 }, jsonCooldown: true },
25
29
  "email/verify": { fields: ["email", "code", "clientName"], successStatus: 200,
@@ -27,6 +31,49 @@ const emailRoutes = {
27
31
  };
28
32
  // Longer cooldowns keep the server's delay but use the sentence without a duration.
29
33
  const MAX_DISPLAYED_EMAIL_COOLDOWN_MS = 24 * 60 * 60 * 1000;
34
+ const transportErrorCodes = new Set([
35
+ "ECONNREFUSED", "ECONNRESET", "ECONNABORTED", "EPIPE", "ENETUNREACH", "ENETDOWN", "EHOSTUNREACH", "EHOSTDOWN",
36
+ "ENETRESET", "EADDRNOTAVAIL", "EPROTO", "ENOTFOUND", "EAI_AGAIN", "EAI_FAIL", "ETIMEDOUT", "ETIMEOUT", "ESERVFAIL", "ENODATA",
37
+ // Certificate verification can fail without an SSL or TLS prefix.
38
+ "UNABLE_TO_GET_ISSUER_CERT", "UNABLE_TO_GET_CRL", "UNABLE_TO_DECRYPT_CERT_SIGNATURE",
39
+ "UNABLE_TO_DECRYPT_CRL_SIGNATURE", "UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY", "CERT_SIGNATURE_FAILURE",
40
+ "CRL_SIGNATURE_FAILURE", "CERT_NOT_YET_VALID", "CERT_HAS_EXPIRED", "CRL_NOT_YET_VALID", "CRL_HAS_EXPIRED",
41
+ "ERROR_IN_CERT_NOT_BEFORE_FIELD", "ERROR_IN_CERT_NOT_AFTER_FIELD", "ERROR_IN_CRL_LAST_UPDATE_FIELD",
42
+ "ERROR_IN_CRL_NEXT_UPDATE_FIELD", "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN",
43
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "CERT_CHAIN_TOO_LONG", "CERT_REVOKED",
44
+ "NO_ISSUER_PUBLIC_KEY", "PATH_LENGTH_EXCEEDED", "INVALID_PURPOSE", "CERT_UNTRUSTED", "CERT_REJECTED",
45
+ "SUBJECT_ISSUER_MISMATCH", "AKID_SKID_MISMATCH", "AKID_ISSUER_SERIAL_MISMATCH", "KEYUSAGE_NO_CERTSIGN",
46
+ "UNABLE_TO_GET_CRL_ISSUER", "UNHANDLED_CRITICAL_EXTENSION", "KEYUSAGE_NO_CRL_SIGN",
47
+ "UNHANDLED_CRITICAL_CRL_EXTENSION", "INVALID_NON_CA", "PROXY_PATH_LENGTH_EXCEEDED",
48
+ "KEYUSAGE_NO_DIGITAL_SIGNATURE", "PROXY_CERTIFICATES_NOT_ALLOWED", "INVALID_EXTENSION",
49
+ "INVALID_POLICY_EXTENSION", "NO_EXPLICIT_POLICY", "DIFFERENT_CRL_SCOPE", "UNSUPPORTED_EXTENSION_FEATURE",
50
+ "UNNESTED_RESOURCE", "PERMITTED_VIOLATION", "EXCLUDED_VIOLATION", "SUBTREE_MINMAX", "APPLICATION_VERIFICATION",
51
+ "UNSUPPORTED_CONSTRAINT_TYPE", "UNSUPPORTED_CONSTRAINT_SYNTAX", "UNSUPPORTED_NAME_SYNTAX",
52
+ "CRL_PATH_VALIDATION_ERROR", "PATH_LOOP", "SUITE_B_INVALID_VERSION", "SUITE_B_INVALID_ALGORITHM",
53
+ "SUITE_B_INVALID_CURVE", "SUITE_B_INVALID_SIGNATURE_ALGORITHM", "SUITE_B_LOS_NOT_ALLOWED",
54
+ "SUITE_B_CANNOT_SIGN_P_384_WITH_P_256", "HOSTNAME_MISMATCH", "EMAIL_MISMATCH", "IP_ADDRESS_MISMATCH",
55
+ "DANE_NO_MATCH", "EE_KEY_TOO_SMALL", "CA_KEY_TOO_SMALL", "CA_MD_TOO_WEAK", "NO_VALID_SCTS",
56
+ "PROXY_SUBJECT_NAME_VIOLATION", "OCSP_VERIFY_NEEDED", "OCSP_VERIFY_FAILED", "OCSP_CERT_UNKNOWN",
57
+ "UNSUPPORTED_SIGNATURE_ALGORITHM", "SIGNATURE_ALGORITHM_MISMATCH", "SIGNATURE_ALGORITHM_INCONSISTENCY",
58
+ "INVALID_CA", "PATHLEN_INVALID_FOR_NON_CA", "PATHLEN_WITHOUT_KU_KEY_CERT_SIGN",
59
+ "KU_KEY_CERT_SIGN_INVALID_FOR_NON_CA", "ISSUER_NAME_EMPTY", "SUBJECT_NAME_EMPTY",
60
+ "MISSING_AUTHORITY_KEY_IDENTIFIER", "MISSING_SUBJECT_KEY_IDENTIFIER", "EMPTY_SUBJECT_ALT_NAME",
61
+ "EMPTY_SUBJECT_SAN_NOT_CRITICAL", "CA_BCONS_NOT_CRITICAL", "AUTHORITY_KEY_IDENTIFIER_CRITICAL",
62
+ "SUBJECT_KEY_IDENTIFIER_CRITICAL", "CA_CERT_MISSING_KEY_USAGE", "EXTENSIONS_REQUIRE_VERSION_3",
63
+ "EC_KEY_EXPLICIT_PARAMS", "RPK_UNTRUSTED",
64
+ ]);
65
+ function transportFailed(error, depth = 0) {
66
+ if (!(error instanceof Error) || depth > 4)
67
+ return false;
68
+ if ("code" in error && typeof error.code === "string") {
69
+ if (error.code === "ERR_INVALID_URL" || error.code === "UND_ERR_INVALID_ARG")
70
+ return false;
71
+ if (transportErrorCodes.has(error.code) || /^(?:UND_ERR_|ERR_SSL_|ERR_TLS_|EAI_|CERT_|CRL_)/.test(error.code))
72
+ return true;
73
+ }
74
+ return transportFailed(error.cause, depth + 1)
75
+ || error instanceof AggregateError && error.errors.some((cause) => transportFailed(cause, depth + 1));
76
+ }
30
77
  function retryAfterMs(value, rejectNegativeSeconds = false) {
31
78
  if (!value)
32
79
  return undefined;
@@ -97,11 +144,13 @@ export class ApiClient {
97
144
  token;
98
145
  fetcher;
99
146
  signal;
147
+ classifyTransportErrors;
100
148
  baseUrl;
101
- constructor(baseUrl, token, fetcher = fetch, signal) {
149
+ constructor(baseUrl, token, fetcher = fetch, signal, classifyTransportErrors = true) {
102
150
  this.token = token;
103
151
  this.fetcher = fetcher;
104
152
  this.signal = signal;
153
+ this.classifyTransportErrors = classifyTransportErrors;
105
154
  let url;
106
155
  try {
107
156
  url = new URL(baseUrl);
@@ -132,6 +181,15 @@ export class ApiClient {
132
181
  }
133
182
  if (body !== undefined)
134
183
  headers["content-type"] = "application/json";
184
+ // Fetch also validates headers locally. Keep that failure outside the transport catch.
185
+ if (this.classifyTransportErrors) {
186
+ try {
187
+ new Headers(headers);
188
+ }
189
+ catch {
190
+ throw new SavedSignInError();
191
+ }
192
+ }
135
193
  const serialized = body === undefined ? undefined : JSON.stringify(safeBody);
136
194
  if (serialized !== undefined && Buffer.byteLength(serialized, "utf8") > CLI_BODY_LIMIT) {
137
195
  throw new ApiError(413, "body_too_large", cliRefusals.body_too_large);
@@ -151,8 +209,14 @@ export class ApiClient {
151
209
  redirect: "error", signal: controller.signal,
152
210
  });
153
211
  }
154
- catch {
212
+ catch (error) {
155
213
  throwIfAborted();
214
+ // Fetch can refuse locally before creating a request, so rejection alone is not transport evidence.
215
+ if (this.classifyTransportErrors && !controller.signal.aborted && !transportFailed(error)) {
216
+ if (localFailure(error))
217
+ throw error;
218
+ throw new ApiError(0, "request_failed", cliApiMessages.request_failed);
219
+ }
156
220
  throw new ApiError(0, "unreachable", cliApiMessages.unreachable, 0, "console_unreachable");
157
221
  }
158
222
  throwIfAborted();
@@ -190,4 +254,6 @@ export class ApiClient {
190
254
  }
191
255
  }
192
256
  export function errorCode(error) { return refusalExitCode(error instanceof ApiError ? error.code : undefined); }
193
- export function errorText(error) { return error instanceof Error ? error.message : cliApiMessages.request_failed; }
257
+ export function errorText(error, fileMode = true) {
258
+ return selectionRefusalMessage(localFailure(error)?.message ?? (error instanceof Error ? error.message : cliApiMessages.request_failed), fileMode);
259
+ }
@@ -1,8 +1,9 @@
1
1
  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
+ import { z } from "zod";
4
5
  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";
6
+ 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
7
  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
8
  const RESPONSE_LIMIT = 64 * 1024;
8
9
  const RESERVATION_BODY_LIMIT = 64 * 1024;
@@ -11,19 +12,22 @@ export const consoleErrorMessages = {
11
12
  invalid_configuration: START_FAILURE_MESSAGE,
12
13
  invalid_request: FINISH_FAILURE_MESSAGE,
13
14
  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.",
15
+ unauthorized: SIGN_IN_EXPIRED_MESSAGE,
16
+ not_allowed: NOT_ALLOWED_MESSAGE,
16
17
  not_found: "Counterfact couldn't find this analysis. Start a new analysis.",
17
18
  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.",
19
+ reservation_closed: UNSAVED_RESULTS_MESSAGE,
19
20
  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.",
21
+ delivery_incomplete: UNSAVED_RESULTS_MESSAGE,
22
+ check_conflict: CHECK_CONFLICT_MESSAGE,
22
23
  };
23
24
  export class ConsoleError extends Error {
24
25
  code;
25
26
  reason;
26
- constructor(code = "unavailable", context, localReason) {
27
+ localReason;
28
+ responseCode;
29
+ httpStatus;
30
+ constructor(code = "unavailable", context, options = {}) {
27
31
  let message = consoleErrorMessages[code];
28
32
  let reason = code === "unavailable" ? "results_not_saved" : code;
29
33
  if (context === "start" && (code === "unavailable" || code === "invalid_request" || code === "not_found")) {
@@ -39,10 +43,21 @@ export class ConsoleError extends Error {
39
43
  super(message);
40
44
  this.name = "ConsoleError";
41
45
  this.code = code;
42
- this.reason = localReason ?? reason;
46
+ this.reason = options.localReason ?? reason;
47
+ this.localReason = options.localReason;
48
+ this.responseCode = options.responseCode;
49
+ this.httpStatus = options.httpStatus;
43
50
  }
44
51
  }
45
52
  export const isConsoleToken = (token) => /^[\x21-\x7e]{1,4096}$/.test(token);
53
+ /** Strict older consoles accept optional metadata only after explicitly advertising it. */
54
+ export function consoleCapabilities(mode) {
55
+ return {
56
+ skippedFiles: "skippedFiles" in mode && mode.skippedFiles === true,
57
+ findingActivity: "findingActivity" in mode && mode.findingActivity === true,
58
+ architecture: "architecture" in mode && mode.architecture === true,
59
+ };
60
+ }
46
61
  /** Only this client may send repository metadata, content-free lifecycle or findings-only intake payloads to the console. */
47
62
  export class ConsoleClient {
48
63
  #url;
@@ -54,15 +69,15 @@ export class ConsoleClient {
54
69
  url = new URL(options.url);
55
70
  if (!consoleSchemeAllowed(url)
56
71
  || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
57
- throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
72
+ throw new ConsoleError("invalid_configuration", undefined, { localReason: "invalid_console_url" });
58
73
  }
59
74
  }
60
75
  catch {
61
- throw new ConsoleError("invalid_configuration", undefined, "invalid_console_url");
76
+ throw new ConsoleError("invalid_configuration", undefined, { localReason: "invalid_console_url" });
62
77
  }
63
78
  const timeout = options.timeoutMs ?? 15_000;
64
79
  if (!isConsoleToken(options.token) || !Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 120_000) {
65
- throw new ConsoleError("invalid_configuration", undefined, "request_failed");
80
+ throw new ConsoleError("invalid_configuration", undefined, { localReason: "request_failed" });
66
81
  }
67
82
  this.#url = url;
68
83
  this.#token = options.token;
@@ -78,6 +93,13 @@ export class ConsoleClient {
78
93
  }
79
94
  return this.#send("/api/cli/checks", input, localStartRequestSchema, localStartResponseSchema, LOCAL_START_BODY_LIMIT, { statuses: [201], headers: { ...headers, [LOCAL_START_EXECUTION_HEADER.name]: LOCAL_START_EXECUTION_HEADER.value }, context: "start" });
80
95
  }
96
+ async capabilities() {
97
+ const mode = await this.#send("/api/cli/checks/mode", {}, z.object({}), z.object({ skippedFiles: z.boolean().optional() }).passthrough(), 1024, { method: "GET", context: "start" });
98
+ return consoleCapabilities(mode);
99
+ }
100
+ async supportsSkippedFiles() {
101
+ return (await this.capabilities()).skippedFiles;
102
+ }
81
103
  create(input) {
82
104
  return this.#lifecycle({ ...input, action: "create" }, consoleCreatedSchema);
83
105
  }
@@ -93,8 +115,8 @@ export class ConsoleClient {
93
115
  fail(reservationId) {
94
116
  return this.#lifecycle({ action: "fail", reservationId }, consoleStatusSchema);
95
117
  }
96
- intake(input) {
97
- return this.#send("/api/cli/checks/local", input, localFindingsPayloadSchema, consoleIntakeResultSchema, LOCAL_FINDINGS_BODY_LIMIT, { statuses: [200, 201], context: "intake" });
118
+ intake(input, options = {}) {
119
+ return this.#send("/api/cli/checks/local", input, localFindingsPayloadSchema, consoleIntakeResultSchema, LOCAL_FINDINGS_BODY_LIMIT, { statuses: [200, 201], context: "intake", ...options });
98
120
  }
99
121
  #lifecycle(input, response) {
100
122
  // Starting work has no saved results to retry; recovery keeps its saving guidance.
@@ -120,7 +142,8 @@ export class ConsoleClient {
120
142
  throw new ConsoleError("invalid_request", options.context);
121
143
  }
122
144
  return new Promise((resolve, reject) => {
123
- let settled = false;
145
+ let settled = false, responseStatus;
146
+ const interrupted = () => new ConsoleError("unavailable", undefined, { httpStatus: responseStatus });
124
147
  const finish = (error, result) => {
125
148
  if (settled)
126
149
  return;
@@ -133,18 +156,22 @@ export class ConsoleClient {
133
156
  };
134
157
  const destination = new URL(path, this.#url);
135
158
  const request = (destination.protocol === "https:" ? httpsRequest : httpRequest)(destination, {
136
- method: "POST", agent: false, rejectUnauthorized: true, maxHeaderSize: 16 * 1024,
159
+ method: options.method ?? "POST", agent: false, rejectUnauthorized: true, maxHeaderSize: 16 * 1024,
137
160
  headers: { "x-counterfact-cli-version": CLI_VERSION, authorization: `Bearer ${this.#token}`, "content-type": "application/json", accept: "application/json",
138
- "content-length": body.length, "accept-encoding": "identity",
161
+ "content-length": options.method === "GET" ? 0 : body.length, "accept-encoding": "identity",
139
162
  ...options.headers },
140
163
  }, response => {
141
164
  const status = response.statusCode ?? 0;
165
+ responseStatus = status;
166
+ const accepted = options.acceptAnySuccess ? status >= 200 && status < 300 : (options.statuses ?? [200]).includes(status);
167
+ // Keep delivery status separate from validated receipt details.
168
+ const responseError = (code) => new ConsoleError(typeof code === "string" && Object.hasOwn(consoleErrorMessages, code) ? code : "unavailable", undefined, { responseCode: typeof code === "string" ? code : undefined, httpStatus: status });
142
169
  const declared = response.headers["content-length"];
143
170
  const contentType = response.headers["content-type"] ?? "";
144
171
  if (status >= 300 && status < 400 || !/^application\/json(?:\s*;|$)/i.test(contentType)
145
172
  || response.headers["content-encoding"] && response.headers["content-encoding"] !== "identity"
146
173
  || declared && (!/^\d+$/.test(declared) || Number(declared) > RESPONSE_LIMIT)) {
147
- finish(new ConsoleError());
174
+ finish(responseError());
148
175
  response.destroy();
149
176
  return;
150
177
  }
@@ -153,37 +180,37 @@ export class ConsoleClient {
153
180
  response.on("data", (chunk) => {
154
181
  size += chunk.length;
155
182
  if (size > RESPONSE_LIMIT) {
156
- finish(new ConsoleError());
183
+ finish(responseError());
157
184
  response.destroy();
158
185
  }
159
186
  else
160
187
  chunks.push(chunk);
161
188
  });
162
- response.on("error", () => finish(new ConsoleError()));
163
- response.on("aborted", () => finish(new ConsoleError()));
189
+ response.on("error", () => finish(interrupted()));
190
+ response.on("aborted", () => finish(interrupted()));
164
191
  response.on("end", () => {
165
192
  try {
166
193
  const decoded = JSON.parse(Buffer.concat(chunks).toString("utf8"));
167
- if (!(options.statuses ?? [200]).includes(status)) {
194
+ if (!accepted) {
168
195
  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");
196
+ throw responseError(code);
170
197
  }
171
198
  const result = responseSchema.parse(decoded);
172
199
  for (const [field, expected] of Object.entries(identities)) {
173
200
  if (result[field] !== expected)
174
- throw new ConsoleError();
201
+ throw responseError();
175
202
  }
176
203
  finish(undefined, result);
177
204
  }
178
205
  catch (error) {
179
- finish(error instanceof ConsoleError ? error : new ConsoleError());
206
+ finish(error instanceof ConsoleError ? error : responseError());
180
207
  }
181
208
  });
182
209
  });
183
210
  // 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()));
186
- request.end(body);
187
- }).catch(error => { throw new ConsoleError(error instanceof ConsoleError ? error.code : "unavailable", options.context); });
211
+ const timer = setTimeout(() => { finish(interrupted()); request.destroy(); }, this.#timeoutMs);
212
+ request.on("error", () => finish(interrupted()));
213
+ request.end(options.method === "GET" ? undefined : body);
214
+ }).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
215
  }
189
216
  }