@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,3 +1,5 @@
1
+ import { formatSavedResults } from "../saved-results.js";
2
+ import { skipped } from "./skipped.js";
1
3
  import { loadDevelopmentRelay } from "../development-relay.js";
2
4
  import { loadInstalledRelease } from "../../adapters/release.js";
3
5
  import { cliRefusals, START_FAILURE_MESSAGE } from "../../contract/cli-refusals.js";
@@ -10,7 +12,13 @@ import { GITIGNORE_OUTPUT_NOTICE } from "../../adapters/gitignore-output.js";
10
12
  import { readFile } from "node:fs/promises";
11
13
  import { resolve } from "node:path";
12
14
  import { loadAgentProposal } from "../../contract/agent-proposal.js";
13
- import { formatSavedFindings } from "../finding-display.js";
15
+ import { fileScopeOf } from "../../analysis-core/analysis/file-analysis-plan.js";
16
+ import { folderPartsEnabled } from "../../analysis-core/settings.js";
17
+ import { captureArchitectureSettings, scopeChoicesEnabled } from "../../adapters/settings.js";
18
+ import { resolveRunSelection } from "../analysis-selection.js";
19
+ import { renderScopeChoices } from "../scope-choices.js";
20
+ import { progressLine } from "../../contract/scope-choice.js";
21
+ import { consoleCapabilities } from "../../gatekeeper/console.js";
14
22
  // Only a real pause says "paused"; an unreviewed release or an upload-only server cannot start.
15
23
  const unavailable = START_FAILURE_MESSAGE;
16
24
  function requireLocal(value) {
@@ -30,29 +38,70 @@ function requireLocal(value) {
30
38
  // A local-capable client never falls back to sending source files to the hosted upload API.
31
39
  if (value.mode !== "local")
32
40
  throw new ApiError(503, "unavailable", unavailable);
41
+ return consoleCapabilities(value);
33
42
  }
34
- export function parseAnalyzeOptions(args) {
35
- let path, proposalFile;
43
+ export function parseAnalyzeOptions(args, choices = false, multiple = false) {
44
+ let path, proposalFile, scopeFile;
45
+ let skipped;
46
+ let scope;
47
+ const paths = [];
36
48
  for (let index = 0; index < args.length; index++) {
37
49
  const arg = args[index];
38
- if (arg === "--proposal" && proposalFile === undefined) {
50
+ if (["--skipped", "--skipped-plan", "--show-skipped"].includes(arg) && skipped === undefined) {
51
+ skipped = arg === "--skipped" ? "analyze" : arg === "--skipped-plan" ? "plan" : "files";
52
+ }
53
+ else if (arg === "--proposal" && proposalFile === undefined) {
39
54
  const value = args[++index];
40
55
  if (!value || value.startsWith("-"))
41
56
  throw new Error();
42
57
  proposalFile = value;
43
58
  }
44
- else if (!arg.startsWith("-") && path === undefined)
45
- path = arg;
59
+ else if ((choices || args[index + 1]?.startsWith("[")) && arg === "--scope" && scope === undefined && scopeFile === undefined) {
60
+ const value = args[++index];
61
+ if (!value || value.startsWith("-"))
62
+ throw new Error();
63
+ if (value.startsWith("["))
64
+ scope = JSON.parse(value);
65
+ else
66
+ scopeFile = value;
67
+ }
68
+ else if (!arg.startsWith("-") && (path === undefined || multiple)) {
69
+ path ??= arg;
70
+ paths.push(arg);
71
+ }
46
72
  else
47
73
  throw new Error();
48
74
  }
49
- return { path: path ?? ".", options: proposalFile === undefined ? {} : { proposalFile } };
75
+ if (skipped && (paths.length > 1 || proposalFile || scope !== undefined || scopeFile !== undefined))
76
+ throw new Error();
77
+ return { path: path ?? ".", options: { ...(paths.length > 1 ? { paths } : {}), ...(scope === undefined ? {} : { scope }), ...(proposalFile === undefined ? {} : { proposalFile }), ...(scopeFile === undefined ? {} : { scopeFile }), ...(skipped ? { skipped } : {}) } };
50
78
  }
51
79
  export async function analyze(path, options, ctx) {
80
+ if (Array.isArray(options.scope) && (options.proposal !== undefined || options.proposalFile !== undefined)) {
81
+ throw new ApiError(400, "invalid_body", cliRefusals.invalid_body);
82
+ }
83
+ if (options.skipped)
84
+ return skipped(path, options.skipped, ctx, analyze, options.surface);
52
85
  const startup = ctx.startup;
86
+ let settings = startup.settings;
87
+ if (Array.isArray(options.scope)) {
88
+ try {
89
+ if (!folderPartsEnabled(settings))
90
+ throw new Error();
91
+ const scope = fileScopeOf({ ...settings, CF_PART_FILES: JSON.stringify(options.scope) });
92
+ if (!scope?.length)
93
+ throw new Error();
94
+ settings = Object.freeze({ ...settings, CF_PART_FILES: JSON.stringify(scope) });
95
+ }
96
+ catch {
97
+ throw new ApiError(400, "invalid_body", cliRefusals.invalid_body);
98
+ }
99
+ }
100
+ const scope = Array.isArray(options.scope) ? undefined : options.scope;
53
101
  const proposal = await loadAgentProposal(async () => options.proposalFile === undefined ? options.proposal
54
102
  : JSON.parse(await readFile(resolve(ctx.cwd ?? process.cwd(), options.proposalFile), "utf8")), startup.settings);
55
- requireLocal(await ctx.api.request("checks/mode"));
103
+ const mode = await (ctx.consoleMode?.() ?? ctx.api.request("checks/mode"));
104
+ const capabilities = requireLocal(mode);
56
105
  const development = startup.transport.CF_RELAY_DEV === "1" ? await loadDevelopmentRelay(startup.transport) : undefined;
57
106
  const release = development?.release ?? ctx.relayRuntime?.release ?? await loadInstalledRelease();
58
107
  if (release.projectContext !== "checks")
@@ -60,7 +109,8 @@ export async function analyze(path, options, ctx) {
60
109
  const token = await ctx.token?.();
61
110
  if (!token)
62
111
  throw new ApiError(401, "unauthorized", cliApiMessages.unauthorized);
63
- const folder = resolveAgentFolder(path, ctx.cwd);
112
+ const selection = await resolveRunSelection(path, ctx.cwd ?? process.cwd(), settings, { ...options, scope });
113
+ const folder = selection?.path ?? resolveAgentFolder(path, ctx.cwd);
64
114
  const config = Object.freeze({ ...startup.transport, CF_RELAY_URL: startup.transport.CF_RELAY_URL ?? release.relayUrl,
65
115
  CF_LOCAL_RELAY: "1", CF_CONSOLE_URL: ctx.api.baseUrl, CF_CONSOLE_TOKEN: token });
66
116
  const controller = new AbortController();
@@ -72,13 +122,28 @@ export async function analyze(path, options, ctx) {
72
122
  ctx.write("\r\u001b[2K");
73
123
  progressShown = false;
74
124
  } };
125
+ let choice;
75
126
  try {
76
- return await (ctx.relayRuntime?.run ?? runRelayCheck)(folder, {
77
- settings: startup.settings, config, release, cwd: ctx.cwd, connect: development?.connect ?? ctx.relayRuntime?.connect,
127
+ const code = await (ctx.relayRuntime?.run ?? runRelayCheck)(folder, {
128
+ analyzedFolder: options.analyzedFolder ?? path,
129
+ settings: captureArchitectureSettings(settings, config), config, release, skippedFilesSupported: capabilities.skippedFiles,
130
+ consoleSupportsActivity: capabilities.findingActivity, consoleSupportsArchitecture: capabilities.architecture,
131
+ cwd: ctx.cwd, connect: development?.connect ?? ctx.relayRuntime?.connect,
132
+ ...(ctx.relayRuntime?.selectionProvider ? { selectionProvider: ctx.relayRuntime.selectionProvider } : {}),
78
133
  ...(proposal ? { proposal } : {}),
134
+ ...(selection ? { selection } : {}), ...(scope ? { scope } : {}),
135
+ ...(options.addToAnalysisId ? { addToAnalysisId: options.addToAnalysisId, expectedProjectId: options.expectedProjectId } : {}),
136
+ ...(options.retryUpload ? { retryUpload: true } : {}),
79
137
  signal: ctx.signal ? AbortSignal.any([controller.signal, ctx.signal]) : controller.signal,
80
138
  stdout() { }, stderr() { },
81
- onStart: () => { ctx.onCheckStart?.(); ctx.write("\r\u001b[2KAnalyzing the agent…"); progressShown = true; },
139
+ onStart: () => { ctx.onCheckStart?.(); ctx.write(`\r\u001b[2K${progressLine(selection, "terminal")}`); progressShown = true; },
140
+ ...(scopeChoicesEnabled(startup.settings) ? { onScopeChoice: (value) => {
141
+ choice = value;
142
+ clearProgress();
143
+ ctx.onScopeChoice?.(value);
144
+ } } : {}),
145
+ onNotice: message => { clearProgress(); ctx.write(`${message}\n`); },
146
+ onSaving: () => { clearProgress(); ctx.write("\r\u001b[2KSaving the results…"); progressShown = true; },
82
147
  onLocalSaved: result => {
83
148
  if (result.ignoreAdded) {
84
149
  clearProgress();
@@ -95,10 +160,13 @@ export async function analyze(path, options, ctx) {
95
160
  onSaved: result => {
96
161
  clearProgress();
97
162
  ctx.onCheckComplete?.(result);
98
- ctx.write(formatSavedFindings(result, { numbersEnabled: ctx.findingNumbers, audienceEnabled: result.audienceEnabled }));
99
- ctx.write(`Full results: ${result.url}\n`);
163
+ if (!options.deferResults)
164
+ ctx.write(formatSavedResults(result, settings, ctx.findingNumbers, !options.addToAnalysisId, { agentReadableResults: ctx.findingSettings.agentReadableResults, surface: options.surface, analyzedFolder: options.analyzedFolder ?? path }));
100
165
  },
101
166
  });
167
+ if (choice && !ctx.onScopeChoice && selection)
168
+ ctx.write(await renderScopeChoices(choice, selection));
169
+ return code;
102
170
  }
103
171
  finally {
104
172
  clearProgress();
@@ -0,0 +1,65 @@
1
+ import { followupSettings, skippedFilesEnabled } from "../../analysis-core/settings.js";
2
+ import { loadAnalysisResults } from "../../adapters/analysis-results.js";
3
+ import { readLocalFolder } from "../../adapters/files.js";
4
+ import { loadCompletion } from "../../adapters/completion.js";
5
+ import { sizeSkippedFiles } from "../../analysis-core/analysis/code-agents/skipped-files.js";
6
+ import { followupPlanText } from "../skipped-files.js";
7
+ import { resolveAgentFolder } from "../agent-folder.js";
8
+ import { formatSavedResults } from "../saved-results.js";
9
+ export async function skipped(path, action, ctx, run, surface) {
10
+ if (!skippedFilesEnabled(ctx.startup.settings))
11
+ throw new Error("Skipped-file analyses are not enabled.");
12
+ const folder = resolveAgentFolder(path, ctx.cwd);
13
+ let latest;
14
+ let mode;
15
+ const deferred = { ...ctx, consoleMode: ctx.consoleMode ?? (() => mode ??= ctx.api.request("checks/mode")),
16
+ onCheckComplete: (result) => { latest = result; } };
17
+ const publish = () => {
18
+ if (!latest)
19
+ return;
20
+ ctx.onCheckComplete?.(latest);
21
+ ctx.write(formatSavedResults(latest, ctx.startup.settings, ctx.findingNumbers, false, { agentReadableResults: ctx.findingSettings.agentReadableResults, surface }));
22
+ };
23
+ // Finish an already completed upload before planning; its reading must not be purchased again.
24
+ const pending = await loadCompletion(folder);
25
+ const unsettled = pending?.uploaded ? await loadAnalysisResults(folder).then(saved => saved.context.pending?.checkId === pending.payload.checkId).catch(() => false) : false;
26
+ if (pending && !pending.finished && (!pending.uploaded || unsettled)) {
27
+ const recovered = await run(folder, { retryUpload: true, deferResults: true, surface }, deferred);
28
+ if (recovered !== 0) {
29
+ if (action === "analyze")
30
+ publish();
31
+ return recovered;
32
+ }
33
+ }
34
+ let saved;
35
+ try {
36
+ saved = await loadAnalysisResults(folder);
37
+ }
38
+ catch {
39
+ throw new Error("The saved analysis is missing or unreadable. Run an analysis of this folder first.");
40
+ }
41
+ const paths = sizeSkippedFiles(saved.report.refusedFiles).map(file => file.path);
42
+ if (action === "files") {
43
+ ctx.write(paths.length ? `${paths.join("\n")}\n` : "No skipped files remain to analyze.\n");
44
+ return 0;
45
+ }
46
+ const settings = followupSettings(ctx.startup.settings, saved.context.settings);
47
+ const source = await readLocalFolder(settings, folder, undefined, { planSkipped: paths });
48
+ const plan = source.filePlan;
49
+ ctx.write(followupPlanText(plan));
50
+ if (action === "plan")
51
+ return 0;
52
+ // Groups share the same local result files; save one completely before starting the next.
53
+ for (const group of plan.groups) {
54
+ ctx.signal?.throwIfAborted();
55
+ const scoped = { ...deferred,
56
+ startup: { ...ctx.startup, settings: Object.freeze({ ...settings, CF_PART_FILES: JSON.stringify(group.files) }) } };
57
+ const code = await run(folder, { deferResults: true, surface, addToAnalysisId: saved.context.analysisId, expectedProjectId: saved.context.projectId }, scoped);
58
+ if (code !== 0) {
59
+ publish();
60
+ return code;
61
+ }
62
+ }
63
+ publish();
64
+ return 0;
65
+ }
@@ -0,0 +1,63 @@
1
+ import { OutputPathError } from "../adapters/output-paths.js";
2
+ import { FINISH_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
3
+ import { completionRecoveryDecision, observedCompletionTiming } from "./completion-recovery.js";
4
+ import { ResultsUploadError } from "./results-upload.js";
5
+ /** Keep delivery ahead of report writes, including when private storage is unavailable. */
6
+ export async function deliverCompletedResults(operations) {
7
+ const result = { uploaded: false };
8
+ let storageError, uploadError;
9
+ try {
10
+ await operations.persist(false);
11
+ }
12
+ catch (error) {
13
+ storageError = error;
14
+ }
15
+ try {
16
+ result.saved = await operations.submit();
17
+ result.uploaded = true;
18
+ try {
19
+ await operations.persist(true);
20
+ }
21
+ catch (error) {
22
+ storageError ??= error;
23
+ }
24
+ }
25
+ catch (error) {
26
+ uploadError = error;
27
+ if (error instanceof ResultsUploadError) {
28
+ result.outcome = error.outcome;
29
+ result.timing = error.timing;
30
+ }
31
+ }
32
+ try {
33
+ result.paths = await operations.saveReports();
34
+ }
35
+ catch {
36
+ result.localErrorMessage = "The local report couldn't be written.";
37
+ }
38
+ if (storageError) {
39
+ const message = storageError instanceof OutputPathError ? storageError.message : FINISH_FAILURE_MESSAGE;
40
+ result.localErrorMessage = result.localErrorMessage ? `${message}\n${result.localErrorMessage}` : message;
41
+ }
42
+ result.error = storageError ?? uploadError ?? (result.localErrorMessage ? new Error(result.localErrorMessage) : undefined);
43
+ return result;
44
+ }
45
+ /** Cleanup consumes the same decision as upload and writes only while the runner owns the folder. */
46
+ export async function recoverCompletedResults(input, operations) {
47
+ const timing = observedCompletionTiming(input.timing ?? {}, input.statusLookup, input.payload);
48
+ const decision = completionRecoveryDecision({ ...input, timing });
49
+ if (input.canWrite) {
50
+ try {
51
+ if (decision.action === "saved")
52
+ await operations.markUploaded();
53
+ else if (decision.action === "give-up")
54
+ await operations.retire();
55
+ else if (decision.action === "retry" && input.retryEnabled && input.completionStored)
56
+ await operations.keepPending?.(timing);
57
+ }
58
+ catch { /* A local write cannot change a confirmed server outcome. */ }
59
+ }
60
+ const recoveryMessage = "message" in decision && (input.completionStored || decision.action === "give-up") ? decision.message : undefined;
61
+ const message = input.localErrorMessage && recoveryMessage ? `${input.localErrorMessage}\n${recoveryMessage}` : recoveryMessage ?? input.localErrorMessage;
62
+ return { decision, message };
63
+ }
@@ -1,18 +1,72 @@
1
- export const COMPLETION_SETTINGS_MISMATCH_MESSAGE = "This folder has a saved result for a different Counterfact project or address. Run the analysis again with the original settings to save it, or delete .counterfact/completion.json to start a new analysis.";
1
+ import { cliRefusals, NOT_ALLOWED_MESSAGE, SIGN_IN_EXPIRED_MESSAGE, CHECK_CONFLICT_MESSAGE, PENDING_RESULTS_MESSAGE, UNSAVED_RESULTS_MESSAGE } from "../contract/cli-refusals.js";
2
+ export const OTHER_SETUP_RESULTS_MESSAGE = "A saved result from another Counterfact setup was left untouched.";
3
+ export const COMPLETION_SETTINGS_MISMATCH_MESSAGE = "This folder has a saved result for a different Counterfact project or address. Run the analysis again with the original settings to save it.";
2
4
  export const matchesCheck = (status, identity) => status.checkId === identity.checkId && status.projectId === identity.projectId;
5
+ export function observedCompletionTiming(timing, lookup, payload) {
6
+ const status = lookup?.kind === "found" ? lookup.status : undefined;
7
+ if (!status || !payload || !matchesCheck(status, payload) || !status.absoluteExpiresAt)
8
+ return timing;
9
+ return { ...timing, absoluteExpiresAt: Math.min(timing.absoluteExpiresAt ?? Infinity, Date.parse(status.absoluteExpiresAt)) };
10
+ }
11
+ export function completionDeadline(timing, lookup, payload, outcome) {
12
+ const observed = observedCompletionTiming(timing, lookup, payload);
13
+ const reachable = lookup?.kind === "found" || lookup?.kind === "failed" && lookup.httpStatus !== undefined
14
+ || outcome && "httpStatus" in outcome && outcome.httpStatus !== undefined;
15
+ return Math.min(observed.completedAt === undefined ? Infinity : observed.completedAt + 2 * 60 * 60_000, reachable ? Infinity : observed.absoluteExpiresAt ?? Infinity);
16
+ }
17
+ export function completionRetryDeadline(timing, lookup, payload, outcome) {
18
+ return Math.min(completionDeadline(timing, lookup, payload, outcome), timing.retryStartedAt === undefined ? Infinity : timing.retryStartedAt + 10 * 60_000);
19
+ }
20
+ /** HTTP status owns certainty; proxy text and unfamiliar server codes cannot discard paid work. */
3
21
  export function completionRecoveryDecision(input) {
22
+ const { statusLookup, uploadOutcome, payload, retryEnabled, timing = {}, now = Date.now() } = input;
23
+ const uploadSaved = uploadOutcome.kind === "saved" || uploadOutcome.kind === "decided" && uploadOutcome.decision.action === "saved";
4
24
  if (input.localMismatch)
5
- return { finished: false, message: COMPLETION_SETTINGS_MISMATCH_MESSAGE };
6
- if (!input.statusSucceeded)
7
- return { finished: false };
8
- const { status, completedPayload } = input;
9
- // A different analysis cannot settle this saved payload, even when submission reports a conflict.
10
- if (status && completedPayload && !matchesCheck(status, completedPayload))
11
- return { finished: false };
12
- // Only trusted terminal status retires unsaved work; outages and closing work remain retryable.
13
- if (status?.uploadState === "missing" && (status.state === "closed" || status.state === "expired")
14
- && completedPayload && matchesCheck(status, completedPayload))
15
- return { finished: true };
16
- // A refusal never retires paid work by itself; only the console's own terminal status above can.
17
- return { finished: false };
25
+ return !retryEnabled ? { action: "retry", automatic: false, message: COMPLETION_SETTINGS_MISMATCH_MESSAGE }
26
+ : !uploadSaved && now >= completionDeadline(timing) ? { action: "give-up" }
27
+ : { action: "leave", message: OTHER_SETUP_RESULTS_MESSAGE };
28
+ const status = statusLookup.kind === "found" ? statusLookup.status : undefined;
29
+ const matching = status && matchesCheck(status, payload);
30
+ if (uploadSaved
31
+ || retryEnabled && uploadOutcome.kind === "refused" && uploadOutcome.httpStatus !== undefined && uploadOutcome.httpStatus >= 200 && uploadOutcome.httpStatus < 300
32
+ || matching && status.uploadState === "saved")
33
+ return { action: "saved" };
34
+ // With retry off, trusted terminal status retires unsaved work using the original refusal.
35
+ if (!retryEnabled)
36
+ return matching && status.uploadState === "missing" && ["closed", "expired"].includes(status.state)
37
+ ? { action: "give-up" } : { action: "retry", automatic: false };
38
+ if (uploadOutcome.kind === "decided" && uploadOutcome.decision.action === "give-up")
39
+ return uploadOutcome.decision;
40
+ if (status && !matching)
41
+ return { action: "give-up", code: "check_conflict", message: CHECK_CONFLICT_MESSAGE };
42
+ if (now >= completionDeadline(timing, statusLookup, payload, uploadOutcome)) {
43
+ return { action: "give-up", message: UNSAVED_RESULTS_MESSAGE };
44
+ }
45
+ const refusals = [uploadOutcome.kind === "refused" ? uploadOutcome : undefined]
46
+ .filter(value => value !== undefined);
47
+ const uncertain = ({ code, httpStatus, transportFailure }) => transportFailure || (httpStatus === undefined
48
+ ? code === "unavailable"
49
+ : httpStatus === 408 || httpStatus === 429 || httpStatus >= 500 && httpStatus <= 599 || httpStatus === 409 && code === "reservation_closing"
50
+ || httpStatus < 400 || httpStatus >= 600);
51
+ const refusal = refusals.find(value => !uncertain(value));
52
+ if (refusal) {
53
+ // Guidance is explicit per code; copy edits never decide whether results survive.
54
+ const messages = {
55
+ check_conflict: CHECK_CONFLICT_MESSAGE,
56
+ not_allowed: NOT_ALLOWED_MESSAGE,
57
+ unauthorized: SIGN_IN_EXPIRED_MESSAGE,
58
+ invalid_body: cliRefusals.invalid_body,
59
+ cli_update_required: cliRefusals.cli_update_required,
60
+ local_checks_paused: cliRefusals.local_checks_paused,
61
+ };
62
+ return { action: "give-up", code: refusal.code, message: Object.hasOwn(messages, refusal.code)
63
+ ? refusal.code === "check_conflict" ? messages[refusal.code] : `${messages[refusal.code]}\n${UNSAVED_RESULTS_MESSAGE}`
64
+ : UNSAVED_RESULTS_MESSAGE };
65
+ }
66
+ if (matching && status.uploadState === "missing" && status.settlement === "released")
67
+ return { action: "give-up",
68
+ code: status.state === "expired" ? "reservation_expired" : "reservation_closed", message: UNSAVED_RESULTS_MESSAGE };
69
+ if (uploadOutcome.kind === "decided")
70
+ return uploadOutcome.decision;
71
+ return { action: "retry", automatic: now < completionRetryDeadline(timing, statusLookup, payload, uploadOutcome) && refusals.some(value => uncertain(value)), message: PENDING_RESULTS_MESSAGE };
18
72
  }
@@ -8,18 +8,20 @@ import { userEmail } from "./git.js";
8
8
  import { terminalSignInInput } from "./sign-in-input.js";
9
9
  import { readFindingSettings } from "./finding-settings.js";
10
10
  export function commandContext(options = {}) {
11
- const startup = readCliSettings(options.env, options.emailSignIn);
11
+ const startup = options.startup ?? readCliSettings(options.env, options.emailSignIn);
12
12
  const homeDir = options.homeDir ?? homedir();
13
13
  const baseUrl = (options.baseUrl ?? startup.baseUrl ?? DEFAULT_URL).replace(/\/+$/, "");
14
- const api = new ApiClient(baseUrl, () => readCredentials(homeDir, api.baseUrl), undefined, options.signal);
14
+ const api = new ApiClient(baseUrl, () => readCredentials(homeDir, api.baseUrl), undefined, options.signal, startup.transport.CF_CLI_TRANSPORT_ERRORS !== "0");
15
15
  const write = options.write ?? (text => { process.stdout.write(text); });
16
16
  const findingSettings = options.findingSettings ?? readFindingSettings(options.env);
17
17
  return {
18
18
  findingNumbers: findingSettings.numbersEnabled,
19
+ findingSettings,
19
20
  signInRetry: (options.env ?? process.env).CF_SIGN_IN_RETRY !== "0",
20
21
  startup, signal: options.signal, relayRuntime: options.relayRuntime, cwd: options.cwd ?? process.cwd(),
21
22
  token: () => readCredentials(homeDir, api.baseUrl), onCheckComplete: options.onCheckComplete, onCheckStart: options.onCheckStart, onAgentChoice: options.onAgentChoice,
22
23
  onCheckFailure: options.onCheckFailure,
24
+ onScopeChoice: options.onScopeChoice,
23
25
  api, homeDir, write, openBrowser: options.openBrowser ?? openBrowser,
24
26
  signInInput: options.signInInput ?? terminalSignInInput(), readEmail: options.readEmail ?? userEmail,
25
27
  sleep: options.sleep ?? (async (ms) => { await delay(ms, undefined, { signal: options.signal }); }), now: options.now ?? Date.now,
@@ -1,5 +1,9 @@
1
- import { groupByAudience } from "../analysis-core/analysis/schemas.js";
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { groupByActivity, groupByAudience } from "../analysis-core/analysis/schemas.js";
2
3
  export const TEAM_WORKFLOW_HEADING = "Team workflow";
4
+ export const notActiveCountLine = (count) => `${count} more ${count === 1 ? "finding is" : "findings are"} about code that doesn't run today (not active)`;
5
+ export const activityAdvice = (activity) => activity.state === "dead" ? "Remove it (cleanup)."
6
+ : `Fix this before turning on ${activity.gate}, or remove it if you don't plan to use it.`;
3
7
  export const formatFindingNumber = (seq) => `F-${seq}`;
4
8
  /** Intake returns one id per payload finding in order; the optional number mapping is joined by id. */
5
9
  export function savedFindings(result, report) {
@@ -7,28 +11,119 @@ export function savedFindings(result, report) {
7
11
  return result.findingIds.map((id, index) => {
8
12
  const numbered = numbers.get(id), local = report[index];
9
13
  return { id, ...(numbered ? { seq: numbered.seq, number: formatFindingNumber(numbered.seq) } : {}),
10
- title: local?.title ?? "", ...(local?.audience === undefined ? {} : { audience: local.audience }) };
14
+ title: local?.title ?? "", ...(local?.audience === undefined ? {} : { audience: local.audience }),
15
+ ...(local?.activity === undefined ? {} : { activity: local.activity }),
16
+ ...(local?.activity === undefined || local.fixes === undefined ? {} : { fixes: local.fixes.map(({ title, approach }) => ({ title: title ?? "", approach: approach ?? "" })) }),
17
+ ...(local?.severity === undefined ? {} : { severity: local.severity }),
18
+ ...(local?.site === undefined ? {} : { site: local.site }),
19
+ ...(local?.fixes === undefined ? {} : { shortFix: shortFix(local.fixes[0]) }) };
11
20
  });
12
21
  }
22
+ function findingLabel(value, enabled, index, labels) {
23
+ return labels === "position" ? `${index + 1}.` : enabled && value.seq !== undefined ? formatFindingNumber(value.seq) : value.id ?? `${index + 1}.`;
24
+ }
13
25
  function findingLine(value, enabled, index, labels, audienceEnabled) {
14
26
  // The off switch preserves the original Markdown summary, including whitespace.
15
27
  if (labels === "position" && !audienceEnabled)
16
28
  return `${index + 1}. ${value.title}`;
17
29
  const title = value.title.replace(/[\r\n\u2028\u2029]+/g, " ");
18
- const label = labels === "position" ? `${index + 1}.` : enabled && value.seq !== undefined ? formatFindingNumber(value.seq) : value.id ?? `${index + 1}.`;
30
+ const label = findingLabel(value, enabled, index, labels);
19
31
  return `${label}${title ? ` ${title}` : ""}`;
20
32
  }
21
- export function findingDisplayRows(findings, { numbersEnabled = true, audienceEnabled, labels }) {
22
- const groups = audienceEnabled ? groupByAudience(findings) : { users: findings, team: [] };
33
+ export function findingDisplayRows(findings, { numbersEnabled = true, audienceEnabled, notActiveEnabled = false, labels }) {
34
+ const { active } = groupByActivity(findings, notActiveEnabled);
35
+ const groups = audienceEnabled ? groupByAudience(active) : { users: active, team: [] };
23
36
  return [...groups.users, ...groups.team].map((finding, index) => ({ finding,
24
37
  label: findingLine(finding, numbersEnabled, index, labels, audienceEnabled), startsTeamGroup: groups.team.length > 0 && index === groups.users.length }));
25
38
  }
26
- export function formatSavedFindings(result, options) {
39
+ function legacyFindingList(result, options) {
40
+ const groups = groupByActivity(result.findings ?? [], options.notActiveEnabled === true);
41
+ const collapsed = groups.notActive.length ? notActiveCountLine(groups.notActive.length) + "\n" : "";
27
42
  if (options.numbersEnabled !== false && result.findings?.some(value => value.seq !== undefined)) {
28
- return "Findings:\n" + findingDisplayRows(result.findings, { ...options, labels: "reference" }).map(row => `${row.startsTeamGroup ? `${TEAM_WORKFLOW_HEADING}:\n` : ""}${row.label}\n`).join("");
43
+ return "Findings:\n" + findingDisplayRows(result.findings, { ...options, labels: "reference" }).map(row => `${row.startsTeamGroup ? `${TEAM_WORKFLOW_HEADING}:\n` : ""}${row.label}\n`).join("") + collapsed;
29
44
  }
45
+ if (collapsed)
46
+ return (groups.active.length ? `Finding ids: ${groups.active.map(f => f.id).join(", ")}\n` : "") + collapsed;
30
47
  return result.findingIds?.length ? `Finding ids: ${result.findingIds.join(", ")}\n` : "";
31
48
  }
49
+ export function formatFindingActivity(activity) {
50
+ return `${activity.state === "dead" ? "Dead" : "Switched off"}: ${activity.reason}\n${activityAdvice(activity)}\n`;
51
+ }
52
+ // This is a presentation budget, separate from the size of the saved report.
53
+ export const AGENT_FINDING_LIST_BUDGET = 12_000;
54
+ function shortFix(fix) {
55
+ return [fix?.summary, fix?.title, fix?.approach].find(text => text?.trim()) ?? "";
56
+ }
57
+ function compactText(value, limit) {
58
+ const text = stripVTControlCharacters(value).replace(/[\p{Cc}\s]+/gu, " ").replace(/\s*\u2014\s*/g, ", ").trim();
59
+ if (text.length <= limit)
60
+ return text;
61
+ // Avoid ending on half of a supplementary Unicode character.
62
+ return Array.from(text.slice(0, limit - 3)).filter(char => !/^[\ud800-\udbff]$/.test(char)).join("") + "...";
63
+ }
64
+ function sentence(text) { return /[.!?]$/.test(text) ? text : `${text}.`; }
65
+ function compactFinding(value) {
66
+ const compact = legacyFinding(value);
67
+ // Full fixes remain in saved detail; compact lists use only the existing short fix.
68
+ delete compact.fixes;
69
+ return { ...compact, title: compactText(value.title, 160) || "Untitled finding",
70
+ severity: compactText(value.severity ?? "", 24) || "severity unavailable",
71
+ ...(value.site ? { site: { path: compactText(value.site.path, 240), line: value.site.line } } : {}),
72
+ shortFix: compactText(value.shortFix ?? "", 240) || "No suggested fix available." };
73
+ }
74
+ function legacyFinding(value) {
75
+ const original = { ...value };
76
+ delete original.severity;
77
+ delete original.site;
78
+ delete original.shortFix;
79
+ return original;
80
+ }
81
+ export function findingDetailReference(value, projectId) {
82
+ return { id: value.seq !== undefined ? formatFindingNumber(value.seq) : value.id, ...(projectId ? { projectId } : {}) };
83
+ }
84
+ export function formatFindingContinuation(next) {
85
+ return `Next finding: get_finding(${JSON.stringify(next)})\n`;
86
+ }
87
+ export function presentSavedFindings(result, options) {
88
+ const rows = findingDisplayRows(result.findings ?? [], { ...options, labels: "reference" });
89
+ const orderedReferences = rows.flatMap(({ finding }) => finding.id ? [{ id: finding.id, seq: finding.seq }] : []);
90
+ if (options.agentReadableResults === false || options.numbersEnabled === false || !rows.length) {
91
+ return { text: legacyFindingList(result, options), findings: rows.map(row => legacyFinding(row.finding)),
92
+ findingIds: result.findingIds ?? [], nextFinding: undefined, orderedReferences };
93
+ }
94
+ const { notActive } = groupByActivity(result.findings ?? [], options.notActiveEnabled === true);
95
+ const collapsed = notActive.length ? notActiveCountLine(notActive.length) + "\n" : "";
96
+ let text = "Findings:\n";
97
+ const findings = [];
98
+ let nextFinding;
99
+ for (const [index, row] of rows.entries()) {
100
+ const value = compactFinding(row.finding);
101
+ const label = findingLabel(value, true, index, "reference");
102
+ const site = value.site ? `${value.site.path}:${value.site.line ?? "?"}` : "File unavailable";
103
+ const line = `${row.startsTeamGroup ? `${TEAM_WORKFLOW_HEADING}:\n` : ""}${label} (${value.severity}) ${sentence(value.title)} ${site}. Fix: ${value.shortFix}\n`;
104
+ const next = rows[index + 1]?.finding;
105
+ const footer = next ? overflowLine(rows.length - index - 1, next, options) : "";
106
+ const candidate = { text: text + line + footer + collapsed, findings: [...findings, value],
107
+ findingIds: [...findings, value].flatMap(finding => finding.id ? [finding.id] : []),
108
+ ...(next ? { nextFinding: findingDetailReference(next, options.projectId) } : {}) };
109
+ if (JSON.stringify(candidate).length > AGENT_FINDING_LIST_BUDGET) {
110
+ nextFinding = findingDetailReference(row.finding, options.projectId);
111
+ text += overflowLine(rows.length - index, row.finding, options);
112
+ break;
113
+ }
114
+ text += line;
115
+ findings.push(value);
116
+ }
117
+ return { text: text + collapsed, findings, findingIds: findings.flatMap(value => value.id ? [value.id] : []), nextFinding, orderedReferences };
118
+ }
119
+ function overflowLine(count, next, options) {
120
+ const guidance = options.surface === "mcp" ? `Next: get_finding(${JSON.stringify(findingDetailReference(next, options.projectId))}); follow nextFinding for the rest.`
121
+ : `Run counterfact-labs finding ${next.id} for the next one, or open Full results below.`;
122
+ return `${count} more ${count === 1 ? "finding" : "findings"}. ${guidance}\n`;
123
+ }
124
+ export function formatSavedFindings(result, options) {
125
+ return presentSavedFindings(result, options).text;
126
+ }
32
127
  export function formatWaitingFixes(items, enabled, needsProject) {
33
128
  if (!items.length)
34
129
  return "No waiting fixes.";
@@ -1,5 +1,7 @@
1
1
  import { findingAudienceEnabled } from "../analysis-core/analysis/finding-audience-switch.js";
2
+ import { notActiveEnabled } from "../analysis-core/analysis/finding-activity-switch.js";
2
3
  export function readFindingSettings(env = process.env, bundle) {
3
4
  return { numbersEnabled: env.CF_FINDING_NUMBERS !== "0", fixesEnabled: env.CF_WAITING_FIXES !== "0",
4
- audienceEnabled: findingAudienceEnabled(env, bundle) };
5
+ audienceEnabled: findingAudienceEnabled(env, bundle), notActiveEnabled: notActiveEnabled(env),
6
+ agentReadableResults: env.CF_AGENT_READABLE_RESULTS !== "0" };
5
7
  }
@@ -1,8 +1,11 @@
1
+ import { skippedFilesEnabled } from "../analysis-core/settings.js";
2
+ import { fingerprintOf } from "../analysis-core/analysis/finding-identity.js";
1
3
  import { resultSafetyCheckEnabled } from "../analysis-core/settings.js";
2
4
  import { localFindingsPayloadSchema } from "../contract/console.js";
3
5
  import { projectPublicFinding } from "../contract/public-report.js";
4
6
  import { checkIntakeFindings } from "./result-check.js";
5
7
  import { RESULTS_TOO_LARGE_MESSAGE } from "../contract/cli-refusals.js";
8
+ import { projectArchitecture } from "../contract/project-architecture.js";
6
9
  export function buildIntakePayload(identity, agent, report, options = {}) {
7
10
  const repo = (key) => key === null || key === report.repoKey ? agent.repository : key;
8
11
  let findings = report.findings.map(finding => projectPublicFinding(finding, repo));
@@ -19,8 +22,29 @@ export function buildIntakePayload(identity, agent, report, options = {}) {
19
22
  resultCheck = counts;
20
23
  findingIndexes = acceptedIndexes;
21
24
  }
22
- const result = localFindingsPayloadSchema.safeParse({ ...identity, agent, findings });
25
+ const metadata = !skippedFilesEnabled(options.settings ?? {}) ? {} : {
26
+ ...(options.coverage ? { coverage: options.coverage } : {}),
27
+ ...(options.addToAnalysisId ? { addToAnalysisId: options.addToAnalysisId } : {}),
28
+ ...(options.findingAssessments ? { findingAssessments: findingIndexes.flatMap((index, at) => {
29
+ const assessment = options.findingAssessments.find(row => row.fingerprint === fingerprintOf(report.findings[index]));
30
+ return assessment ? [{ ...assessment, fingerprint: fingerprintOf(findings[at]) }] : [];
31
+ }) } : {}),
32
+ };
33
+ const findingActivities = findings.map(finding => finding.activity);
34
+ // Older consoles use a strict schema. Local grouping survives without sending an unsupported field.
35
+ if (options.consoleSupportsActivity !== true)
36
+ findings = findings.map(finding => {
37
+ const legacy = { ...finding };
38
+ delete legacy.activity;
39
+ return legacy;
40
+ });
41
+ const architecture = options.consoleSupportsArchitecture === true && options.settings?.CF_LOCAL_ARCHITECTURE === "1"
42
+ ? projectArchitecture(report.graph) : undefined;
43
+ let result = localFindingsPayloadSchema.safeParse({ ...identity, agent, findings, ...metadata, ...(architecture ? { architecture } : {}) });
44
+ // Optional map metadata must not crowd otherwise valid findings out of the total upload allowance.
45
+ if (!result.success && architecture)
46
+ result = localFindingsPayloadSchema.safeParse({ ...identity, agent, findings, ...metadata });
23
47
  if (!result.success)
24
48
  throw new Error(RESULTS_TOO_LARGE_MESSAGE);
25
- return { payload: result.data, resultCheck, findingIndexes };
49
+ return { payload: result.data, resultCheck, findingIndexes, findingActivities };
26
50
  }