@counterfact-labs/cli 0.2.10 → 0.2.11

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 (118) hide show
  1. package/README.md +4 -5
  2. package/bundle/client-release.json +1 -1
  3. package/dist/adapters/analysis-results.js +10 -5
  4. package/dist/adapters/completion.js +75 -41
  5. package/dist/adapters/config-directory.js +5 -0
  6. package/dist/adapters/files.js +25 -10
  7. package/dist/adapters/legacy-reports.js +48 -0
  8. package/dist/adapters/output-paths.js +85 -5
  9. package/dist/adapters/result-receipt.js +78 -0
  10. package/dist/adapters/results.js +5 -8
  11. package/dist/adapters/settings.js +10 -4
  12. package/dist/adapters/storage-identity.js +27 -0
  13. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -1
  14. package/dist/analysis-core/analysis/file-analysis-plan.js +1 -1
  15. package/dist/analysis-core/analysis/file-dependencies.js +1 -1
  16. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -1
  17. package/dist/analysis-core/analysis/folder-analysis-plan.js +1 -1
  18. package/dist/analysis-core/analysis/passes/full-file-passes.js +1 -1
  19. package/dist/analysis-core/analysis/schemas.js +1 -1
  20. package/dist/analysis-core/chunks/{5VQ4VYIP.js → 2IQN26TC.js} +1 -1
  21. package/dist/analysis-core/chunks/{N2L4JMJE.js → 2J3PWUPG.js} +1 -1
  22. package/dist/analysis-core/chunks/{3GUKUEEJ.js → 5JG3ZRNJ.js} +1 -1
  23. package/dist/analysis-core/chunks/{LFJQ4YLP.js → 6E4K7JTI.js} +1 -1
  24. package/dist/analysis-core/chunks/{66JRJPJC.js → 6W6B7VSP.js} +1 -1
  25. package/dist/analysis-core/chunks/{P253B344.js → AIS3FHQR.js} +7 -7
  26. package/dist/analysis-core/chunks/{YWL52FVW.js → EJ2IZUN2.js} +2 -2
  27. package/dist/analysis-core/chunks/{5UWDUYHF.js → GOBOE2SC.js} +1 -1
  28. package/dist/analysis-core/chunks/GYXDN5W4.js +1 -0
  29. package/dist/analysis-core/chunks/GZSUTDGX.js +1 -0
  30. package/dist/analysis-core/chunks/HB5BTGTI.js +6 -0
  31. package/dist/analysis-core/chunks/HH3HDT5K.js +73 -0
  32. package/dist/analysis-core/chunks/{TA2NIQAY.js → I5F7S63K.js} +1 -1
  33. package/dist/analysis-core/chunks/ICJV6KIZ.js +11 -0
  34. package/dist/analysis-core/chunks/{2CWULBP2.js → JCVB6IC4.js} +1 -1
  35. package/dist/analysis-core/chunks/{DHVEGIU6.js → JLW44GIU.js} +1 -1
  36. package/dist/analysis-core/chunks/JS6HTVYX.js +1 -0
  37. package/dist/analysis-core/chunks/{UKX3A6AX.js → JWQXG5RB.js} +1 -1
  38. package/dist/analysis-core/chunks/LOODGUMW.js +1 -0
  39. package/dist/analysis-core/chunks/{XVGLY4EO.js → NWYNJHY4.js} +1 -1
  40. package/dist/analysis-core/chunks/{KBTM5MBC.js → OHDC3L6V.js} +1 -1
  41. package/dist/analysis-core/chunks/OXGKALKM.js +1 -0
  42. package/dist/analysis-core/chunks/{MJGB25S5.js → P5FCHJ7W.js} +1 -1
  43. package/dist/analysis-core/chunks/PISOGSJF.js +1 -0
  44. package/dist/analysis-core/chunks/{6VHZNBII.js → QENYFKG3.js} +8 -8
  45. package/dist/analysis-core/chunks/{W73WF226.js → QY57UR6E.js} +1 -1
  46. package/dist/analysis-core/chunks/QYYN4YKB.js +35 -0
  47. package/dist/analysis-core/chunks/UYG422QR.js +0 -0
  48. package/dist/analysis-core/chunks/V7GZHSVR.js +1 -0
  49. package/dist/analysis-core/chunks/{VWRFSVLI.js → VBGQKYIT.js} +1 -1
  50. package/dist/analysis-core/chunks/{5BTATES5.js → VVSHUUWX.js} +1 -1
  51. package/dist/analysis-core/chunks/{JKWTHRUR.js → YDSB7UOR.js} +1 -1
  52. package/dist/analysis-core/chunks/ZLUI3EHB.js +1 -0
  53. package/dist/analysis-core/contracts/execution-request.js +1 -1
  54. package/dist/analysis-core/contracts/model-errors.js +1 -0
  55. package/dist/analysis-core/contracts/relay-analysis.js +1 -1
  56. package/dist/analysis-core/contracts/requests.js +1 -1
  57. package/dist/analysis-core/conventions/analysis-fit.js +1 -1
  58. package/dist/analysis-core/conventions/file-admission.js +1 -1
  59. package/dist/analysis-core/errors.js +1 -1
  60. package/dist/analysis-core/import/calls/modules.js +1 -1
  61. package/dist/analysis-core/import/entry-selection.js +1 -1
  62. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  63. package/dist/analysis-core/index.js +1 -1
  64. package/dist/analysis-core/llm/cache-policy.js +1 -1
  65. package/dist/analysis-core/llm/cost.js +1 -1
  66. package/dist/analysis-core/llm/structured-output-error.js +1 -1
  67. package/dist/analysis-core/requests/frame-budget.js +1 -0
  68. package/dist/analysis-core/requests/local-plan.js +1 -1
  69. package/dist/analysis-core/requests/planned-limits.js +1 -1
  70. package/dist/analysis-core/requests/registry.js +1 -1
  71. package/dist/analysis-core/requests/template-version.js +1 -1
  72. package/dist/analysis-core/requests/tools.js +1 -1
  73. package/dist/analysis-core/settings.js +1 -1
  74. package/dist/analysis-core/stages/engine-entry.js +1 -1
  75. package/dist/analysis-core/wording.js +1 -0
  76. package/dist/cli/analysis-preview.js +22 -0
  77. package/dist/cli/analysis-selection.js +6 -8
  78. package/dist/cli/commands/analyze.js +27 -21
  79. package/dist/cli/commands/login.js +5 -0
  80. package/dist/cli/compatibility.js +39 -0
  81. package/dist/cli/completion-delivery.js +4 -3
  82. package/dist/cli/completion-recovery.js +25 -23
  83. package/dist/cli/credentials.js +3 -2
  84. package/dist/cli/finding-display.js +3 -2
  85. package/dist/cli/git.js +5 -2
  86. package/dist/cli/main.js +8 -2
  87. package/dist/cli/mcp.js +35 -10
  88. package/dist/cli/prior-completion.js +1 -1
  89. package/dist/cli/relay-check.js +85 -23
  90. package/dist/cli/relay-failure.js +19 -11
  91. package/dist/cli/relay-preparation.js +6 -3
  92. package/dist/cli/scope-choices.js +5 -4
  93. package/dist/cli/skipped-files.js +2 -1
  94. package/dist/cli/suggested-email.js +6 -1
  95. package/dist/cli/summary.js +2 -1
  96. package/dist/contract/agent-choice.js +2 -0
  97. package/dist/contract/cli-refusals.js +105 -9
  98. package/dist/contract/cli-version.js +1 -1
  99. package/dist/contract/console.js +2 -1
  100. package/dist/contract/email-sign-in-messages.js +2 -1
  101. package/dist/contract/scope-choice.js +4 -1
  102. package/dist/gatekeeper/client.js +11 -2
  103. package/dist/gatekeeper/connection-exchange.js +19 -4
  104. package/dist/gatekeeper/connection.js +15 -6
  105. package/dist/gatekeeper/console.js +8 -4
  106. package/dist/gatekeeper/model.js +13 -3
  107. package/package.json +1 -1
  108. package/dist/adapters/gitignore-output.js +0 -41
  109. package/dist/analysis-core/chunks/2M55DR2J.js +0 -35
  110. package/dist/analysis-core/chunks/34NVU26H.js +0 -1
  111. package/dist/analysis-core/chunks/C77LGCKF.js +0 -1
  112. package/dist/analysis-core/chunks/HCPO6QPU.js +0 -1
  113. package/dist/analysis-core/chunks/J4AXGE5W.js +0 -1
  114. package/dist/analysis-core/chunks/M5YWJY7P.js +0 -1
  115. package/dist/analysis-core/chunks/NCW5LJ5O.js +0 -6
  116. package/dist/analysis-core/chunks/QPIBHTON.js +0 -67
  117. package/dist/analysis-core/chunks/SJESFYBA.js +0 -10
  118. package/dist/analysis-core/chunks/VC3PYT2B.js +0 -1
package/README.md CHANGED
@@ -28,8 +28,8 @@ npx tsx cli/check.ts /path/to/agent --output /path/to/results
28
28
  ```
29
29
 
30
30
  Analysis makes paid model calls and needs a provider connection. The command reads source files without modifying
31
- them, prints a readable summary, and writes `findings.json` and `summary.md` to the agent's `.counterfact/` directory
32
- by default. `--output` chooses another directory. Reports can contain source excerpts; existing reports at those
31
+ them, prints a readable summary, and writes `findings.json` and `summary.md` under
32
+ `~/.config/counterfact-labs/reports/<repository-id>/` by default. `--output` chooses a directory outside the analyzed repository. Reports can contain source excerpts; existing reports at those
33
33
  paths are replaced after a completed analysis. Exit status is zero when analysis completes, including when it finds
34
34
  problems, and nonzero for invalid arguments, a missing key or a failed run.
35
35
 
@@ -112,8 +112,7 @@ An over-limit selection returns included and omitted files for the user to
112
112
  choose. The coding agent must ask before narrowing coverage. Terminal offers
113
113
  exit with status 3 and save a private scope JSON file for `--scope <json-file>`;
114
114
  MCP returns a successful `choose_scope` response. Each retry uses the saved
115
- snapshot and the ordinary admission checks. File reports live under the file's
116
- parent at `.counterfact/files/<entry-path-hash>/` and include selection metadata.
115
+ snapshot and the ordinary admission checks. Reports for a file live under `~/.config/counterfact-labs/reports/<repository-id>/files/<entry-path-hash>/` and include selection metadata.
117
116
 
118
117
  The archive also ships `bundle/client-release.json` with its public Ed25519 verification key and local/relay versions,
119
118
  and the gatekeeper's public production trust policy. Production trust has no environment override. With exactly
@@ -164,7 +163,7 @@ The command uses the existing saved sign-in and sends `X-Counterfact-CLI-Version
164
163
  responses stop the analysis; the command never falls back to uploading files. Settings are captured at command start.
165
164
  An analysis reads the agent's files as they are on disk, including uncommitted changes, and also works in folders without Git.
166
165
  The console owns prices, expressed in millionths of a dollar.
167
- By default, the CLI adds .counterfact/ to the folder's .gitignore when Git doesn't already ignore it. Set CF_LOCAL_GITIGNORE_OUTPUT=0 to turn this off.
166
+ The CLI leaves the repository and its `.gitignore` unchanged. Older in-repository reports remain readable for recovery.
168
167
 
169
168
  The installed command uses the relay address in `bundle/client-release.json` and saved sign-in. The relay runner sends
170
169
  repository facts and the admitted file list (paths and sizes, without contents or absolute checkout paths) to
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "verificationKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbBab1PYdmBKXxCvoJ47toJhQvxPevo9nlZnb0zbDWoc=\n-----END PUBLIC KEY-----\n",
3
3
  "relayVersion": "relay-1.0.1",
4
- "localVersion": "0.2.10",
4
+ "localVersion": "0.2.11",
5
5
  "relayUrl": "https://relay.counterfactlabs.com:8443",
6
6
  "projectContext": "checks"
7
7
  }
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { findingActivitySchema } from "../analysis-core/analysis/schemas.js";
5
5
  import { snapshotSettings } from "../analysis-core/settings.js";
6
6
  import { publicReportSchema } from "../contract/public-report.js";
7
- import { outputPaths, writeOutputFiles } from "./output-paths.js";
7
+ import { outputPaths, reportOutputDirectory, reportReadPaths, writeReportFiles } from "./output-paths.js";
8
8
  import { savedFindings } from "../cli/finding-display.js";
9
9
  import { rankFindings } from "../analysis-core/analysis/finding-quality/policy.js";
10
10
  import { fingerprintOf } from "../analysis-core/analysis/finding-identity.js";
@@ -37,7 +37,7 @@ async function readJson(path) {
37
37
  }
38
38
  /** Saved results are the authority for which omissions still need an analysis. */
39
39
  export async function loadAnalysisResults(folder, outputDir) {
40
- const [reportPath, contextPath] = await outputPaths(folder, ["findings.json", "analysis-context.json"], outputDir);
40
+ const { paths: [reportPath, contextPath] } = await reportReadPaths(folder, ["findings.json", "analysis-context.json"], outputDir);
41
41
  const report = publicReportSchema.parse(await readJson(reportPath));
42
42
  const { reportHash, ...context } = contextSchema.parse(await readJson(contextPath));
43
43
  if (reportHash !== hashReport(report))
@@ -45,17 +45,22 @@ export async function loadAnalysisResults(folder, outputDir) {
45
45
  return { report, context };
46
46
  }
47
47
  export async function loadPublicReport(folder, outputDir) {
48
- const [path] = await outputPaths(folder, ["findings.json"], outputDir);
48
+ const { paths: [path] } = await reportReadPaths(folder, ["findings.json"], outputDir);
49
49
  return publicReportSchema.parse(await readJson(path));
50
50
  }
51
51
  /** Only non-secret analysis choices may survive into a later scoped run. */
52
52
  export async function saveAnalysisContext(folder, context, outputDir) {
53
- const report = await loadPublicReport(folder, outputDir);
53
+ outputDir = await reportOutputDirectory(folder, outputDir);
54
+ const { paths: [path], legacy } = await reportReadPaths(folder, ["findings.json"], outputDir);
55
+ const report = publicReportSchema.parse(await readJson(path));
54
56
  const value = contextSchema.parse({ ...context, reportHash: hashReport(report), settings: snapshotSettings(context.settings) });
55
- await writeOutputFiles(folder, { "analysis-context.json": `${JSON.stringify(value)}\n` }, outputDir);
57
+ // A legacy report stays read-only; settlement carries its validated findings into the home store.
58
+ await writeReportFiles(folder, { ...(legacy ? { "findings.json": `${JSON.stringify(report, null, 2)}\n` } : {}),
59
+ "analysis-context.json": `${JSON.stringify(value)}\n` }, outputDir);
56
60
  }
57
61
  function hashReport(report) { return createHash("sha256").update(JSON.stringify(report)).digest("hex"); }
58
62
  export async function invalidateAnalysisContext(folder, outputDir) {
63
+ outputDir = await reportOutputDirectory(folder, outputDir);
59
64
  try {
60
65
  const [path] = await outputPaths(folder, ["analysis-context.json"], outputDir);
61
66
  await unlink(path);
@@ -1,16 +1,16 @@
1
1
  import { constants } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { lstat, open, readdir, realpath } from "node:fs/promises";
3
+ import { lstat, open, readdir, realpath, unlink } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { LOCAL_FINDINGS_BODY_LIMIT, localFindingsPayloadSchema } from "../contract/console.js";
8
8
  import { OutputPathError, outputPaths, writeOutputFiles } from "./output-paths.js";
9
- import { standaloneGitFailure } from "./repository-root.js";
10
- import { runGit } from "./git.js";
11
- import { resolveFolderPath } from "./folder-path.js";
9
+ import { legacyFilesTracked, legacyReportDirectory } from "./legacy-reports.js";
12
10
  import { matchesSnapshotProvenance } from "./snapshot.js";
13
11
  import { acquireCompletionLock } from "./completion-lock.js";
12
+ import { storageIdentity } from "./storage-identity.js";
13
+ import { configDirectory } from "./config-directory.js";
14
14
  const record = z.strictObject({ version: z.literal(1), consoleOrigin: z.url().optional(), uploaded: z.boolean().optional(),
15
15
  finished: z.boolean().optional(), completedAt: z.number().finite().nonnegative().optional(),
16
16
  absoluteExpiresAt: z.number().finite().nonnegative().optional(), payload: localFindingsPayloadSchema });
@@ -23,32 +23,11 @@ const storedRecord = z.union([records, retiredRecord, selectedRetiredRecord]);
23
23
  const maxBytes = LOCAL_FINDINGS_BODY_LIMIT + 1024;
24
24
  /** Recovery belongs to this checkout and origin, and survives edits and new commits. */
25
25
  export async function completionPath(folder, identity) {
26
- const root = await resolveFolderPath(folder);
27
- let repository = null;
28
- let common;
29
- try {
30
- common = (await runGit(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).toString("utf8").trim();
31
- }
32
- // Standalone folders need no Git identity; a usable checkout must never silently lose its identity.
33
- catch (error) {
34
- if (!await standaloneGitFailure(root, error))
35
- throw error;
36
- }
37
- if (common !== undefined) {
38
- let origin = "";
39
- try {
40
- origin = (await runGit(root, ["config", "--get", "remote.origin.url"])).toString("utf8").trim();
41
- }
42
- catch (error) {
43
- if (error.code !== 1)
44
- throw error;
45
- }
46
- repository = [await realpath(common), origin];
47
- }
26
+ const [root, repository] = await storageIdentity(folder);
48
27
  const coordinates = identity ? [root, repository, selectionIdentity.parse(identity)] : [root, repository];
49
28
  const key = createHash("sha256").update(JSON.stringify(coordinates)).digest("hex");
50
29
  const home = await realpath(os.homedir());
51
- return { home, path: join(home, ".config/counterfact-labs/completions", `${key}.json`) };
30
+ return { home, path: join(configDirectory(home), "completions", `${key}.json`) };
52
31
  }
53
32
  export function matchesCompletionSetup(saved, setup) {
54
33
  return saved.consoleOrigin === setup.consoleOrigin && (setup.projectId === undefined || saved.payload.projectId === setup.projectId);
@@ -140,19 +119,12 @@ export async function loadCompletion(folder, options = {}) {
140
119
  // Repository-owned records cannot supply recovery data, even with matching provenance.
141
120
  let legacy;
142
121
  try {
143
- await lstat(join(folder, ".counterfact/completion.json"));
144
- try {
145
- await runGit(folder, ["ls-files", "--error-unmatch", "--with-tree=HEAD", "--", ".counterfact/completion.json"]);
122
+ const directory = legacyReportDirectory(folder);
123
+ await lstat(join(directory, "completion.json"));
124
+ // Without Git there is no provenance to verify, so standalone records are ignored too.
125
+ if (await legacyFilesTracked(folder, [join(directory, "completion.json")]) !== false)
146
126
  return null;
147
- }
148
- catch (error) {
149
- // Without Git there is no provenance to verify, and uncertain legacy data must not block analysis.
150
- if (await standaloneGitFailure(folder, error))
151
- return null;
152
- if (error.code !== 1)
153
- throw error;
154
- }
155
- const [path] = await outputPaths(folder, ["completion.json"]);
127
+ const [path] = await outputPaths(folder, ["completion.json"], directory);
156
128
  const { value, modifiedAt } = await readCompletion(path);
157
129
  if (!("payload" in value))
158
130
  return null;
@@ -214,14 +186,76 @@ function isRetiredCopyName(record, name) {
214
186
  const digest = name.startsWith(`${record}.`) && name.endsWith(".json") ? name.slice(record.length + 1, -".json".length) : "";
215
187
  return /^[a-f0-9]{64}$/.test(digest);
216
188
  }
189
+ /** Bound private archives across folders; the ordinary reports remain the readable result. */
190
+ async function pruneRetainedCompletions(home, directory) {
191
+ await outputPaths(home, [], directory);
192
+ const copies = [];
193
+ for (const name of await readdir(directory)) {
194
+ if (!/^[a-f0-9-]{36}\.[a-f0-9]{64}\.json$/.test(name))
195
+ continue;
196
+ try {
197
+ const [path] = await outputPaths(home, [name], directory);
198
+ copies.push({ path, modifiedAt: (await lstat(path)).mtimeMs });
199
+ }
200
+ catch (error) {
201
+ if (error.code !== "ENOENT")
202
+ throw error;
203
+ }
204
+ }
205
+ copies.sort((a, b) => b.modifiedAt - a.modifiedAt || a.path.localeCompare(b.path));
206
+ for (const copy of copies.slice(10)) {
207
+ try {
208
+ await outputPaths(home, [basename(copy.path)], directory);
209
+ await unlink(copy.path);
210
+ }
211
+ catch (error) {
212
+ if (error.code !== "ENOENT")
213
+ throw error;
214
+ }
215
+ }
216
+ }
217
217
  /** The empty marker also prevents a retired legacy copy from being imported again. */
218
- export async function discardCompletion(folder, recordPath, identity) {
218
+ export async function discardCompletion(folder, recordPath, identity, options = {}) {
219
219
  const { home, path } = await completionPath(folder, identity);
220
220
  const destination = recordPath ?? path;
221
221
  if (dirname(destination) !== dirname(path) || destination !== path && !isRetiredCopyName(basename(path, ".json"), basename(destination)))
222
222
  throw new OutputPathError();
223
+ let retainedPath;
224
+ if (options.keepCopy !== false) {
225
+ try {
226
+ // This is a local backup, excluded from automatic upload recovery.
227
+ await outputPaths(home, [basename(destination)], dirname(path));
228
+ const saved = await readCompletion(destination, identity).catch(error => {
229
+ if (error.code !== "ENOENT")
230
+ throw error;
231
+ return undefined;
232
+ });
233
+ if (saved && "payload" in saved.value) {
234
+ const bytes = JSON.stringify(saved.value);
235
+ const digest = createHash("sha256").update(bytes).digest("hex");
236
+ const retained = join(dirname(path), "retained");
237
+ await writeOutputFiles(home, { [`${saved.value.payload.checkId}.${digest}.json`]: bytes }, retained);
238
+ await pruneRetainedCompletions(home, retained);
239
+ retainedPath = join(retained, `${saved.value.payload.checkId}.${digest}.json`);
240
+ }
241
+ }
242
+ catch (error) {
243
+ // A readable report is enough to retire refused work and let a new analysis start.
244
+ // Without one, leave the pending payload intact if its backup cannot be written.
245
+ if (!options.reportStored)
246
+ throw error;
247
+ }
248
+ }
223
249
  const value = identity ? selectedRetiredRecord.parse({ version: 2, finished: true, ...identity }) : { version: 1, finished: true };
224
- await writeOutputFiles(home, { [basename(destination)]: JSON.stringify(value) }, dirname(path));
250
+ try {
251
+ await writeOutputFiles(home, { [basename(destination)]: JSON.stringify(value) }, dirname(path));
252
+ }
253
+ catch (error) {
254
+ // Replacement can succeed before directory sync fails; the confirmed backup still identifies the results.
255
+ if (!retainedPath)
256
+ throw error;
257
+ }
258
+ return retainedPath;
225
259
  }
226
260
  export async function lockCompletion(folder, onLost, identity) {
227
261
  const { home, path } = await completionPath(folder, identity);
@@ -0,0 +1,5 @@
1
+ import { join } from "node:path";
2
+ /** Credentials, reports and recovery records share the CLI's existing home convention. */
3
+ export function configDirectory(home) {
4
+ return join(home, ".config/counterfact-labs");
5
+ }
@@ -176,22 +176,41 @@ async function readFolder(settings, folder, admission = {}, options = {}) {
176
176
  const byPath = new Map(entries.map(entry => [entry.path, entry]));
177
177
  const files = Object.create(null), visited = new Set(), unavailable = new Set();
178
178
  const paths = planParts ? entries.map(entry => entry.path) : [...options.planSkipped];
179
- const metadataPaths = new Set();
179
+ const metadataPaths = new Set(), metadataOnly = new Set();
180
180
  let metadataBytes = 0;
181
+ const reserveMetadata = (path, size) => {
182
+ if (metadataPaths.has(path))
183
+ return true;
184
+ if (metadataPaths.size >= moduleResolutionLimits.settingsFiles || size > moduleResolutionLimits.settingsBytes - metadataBytes)
185
+ return false;
186
+ metadataPaths.add(path);
187
+ metadataBytes += size;
188
+ return true;
189
+ };
190
+ let metadata = settings.CF_LOCAL_IMPORT_RESOLUTION === "0" ? [] : ModuleResolver.settingsFiles(listing, new Map(), paths, settings);
181
191
  let pending = paths, dependencies = {}, retainedBytes = 0;
182
192
  while (pending.length) {
183
193
  const next = [...new Set(pending)].filter(path => !visited.has(path));
184
194
  next.forEach(path => visited.add(path));
185
195
  await mapLimit(next, 4, async (path) => {
186
196
  const entry = byPath.get(path);
187
- if (!entry || !admitFile(settings, entry, { ...admission, purpose: "proposal", route: "local-folder" }).admitted)
197
+ if (!entry)
188
198
  return;
199
+ if (!admitFile(settings, entry, { ...admission, purpose: "proposal", route: "local-folder" }).admitted) {
200
+ const pythonMetadata = metadata.includes(path) && ModuleResolver.isPythonSettingsFile(path);
201
+ if (!pythonMetadata || !admitFile(settings, entry, { ...admission, purpose: "entry", route: "local-folder" }).admitted)
202
+ return;
203
+ // Metadata can describe imports without becoming an analysis candidate.
204
+ metadataOnly.add(path);
205
+ if (!reserveMetadata(path, entry.size))
206
+ return;
207
+ }
189
208
  if (planParts && entry.size > READ_LIMITS.archive.bytes - retainedBytes) {
190
209
  unavailable.add(path);
191
210
  return;
192
211
  }
193
212
  try {
194
- const text = await read(entry, "proposal");
213
+ const text = await read(entry, metadataOnly.has(path) ? "entry" : "proposal");
195
214
  const size = text === null ? 0 : Buffer.byteLength(text);
196
215
  if (text === null || planParts && size > READ_LIMITS.archive.bytes - retainedBytes)
197
216
  unavailable.add(path);
@@ -212,20 +231,16 @@ async function readFolder(settings, folder, admission = {}, options = {}) {
212
231
  }
213
232
  });
214
233
  dependencies = fileDependencies({ entries, files, settings });
215
- const metadata = settings.CF_LOCAL_IMPORT_RESOLUTION === "0" ? [] : ModuleResolver.settingsFiles(listing, new Map(Object.entries(files)), Object.keys(files));
234
+ metadata = settings.CF_LOCAL_IMPORT_RESOLUTION === "0" ? [] : ModuleResolver.settingsFiles(listing, new Map(Object.entries(files)), Object.keys(files), settings);
216
235
  const settingsPaths = metadata.filter(path => {
217
236
  if (visited.has(path) || metadataPaths.has(path))
218
237
  return false;
219
238
  const size = byPath.get(path)?.size;
220
- if (size === undefined || metadataPaths.size >= moduleResolutionLimits.settingsFiles || size > moduleResolutionLimits.settingsBytes - metadataBytes)
221
- return false;
222
- metadataPaths.add(path);
223
- metadataBytes += size;
224
- return true;
239
+ return size !== undefined && reserveMetadata(path, size);
225
240
  });
226
241
  pending = [...settingsPaths, ...Object.values(dependencies).flat()].filter(path => !visited.has(path));
227
242
  }
228
- const plannedEntries = entries.filter(entry => planParts ? Object.hasOwn(files, entry.path) : !unavailable.has(entry.path)).map(entry => Object.hasOwn(files, entry.path)
243
+ const plannedEntries = entries.filter(entry => !metadataOnly.has(entry.path) && (planParts ? Object.hasOwn(files, entry.path) : !unavailable.has(entry.path))).map(entry => Object.hasOwn(files, entry.path)
229
244
  ? { ...entry, size: Buffer.byteLength(files[entry.path]) } : entry);
230
245
  const input = { entries: plannedEntries, paths: planParts ? plannedEntries.map(entry => entry.path) : paths,
231
246
  inputBytes: Object.fromEntries(Object.entries(files).map(([path, text]) => [path, fileEvidenceBytes(path, text)])),
@@ -0,0 +1,48 @@
1
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
2
+ import { resolveFolderPath } from "./folder-path.js";
3
+ import { runGit } from "./git.js";
4
+ import { standaloneGitFailure } from "./repository-root.js";
5
+ /** Only default report locations from earlier releases may supply a fallback. */
6
+ export function legacyReportDirectory(folder, selected) {
7
+ const legacy = join(folder, ".counterfact");
8
+ if (selected === undefined)
9
+ return legacy;
10
+ const parts = relative(selected.storeRoot, resolve(selected.directory)).split(sep);
11
+ const digest = (part) => /^[a-f0-9]{64}$/.test(part);
12
+ if (parts.length === 1 && digest(parts[0]))
13
+ return legacy;
14
+ if (parts.length === 3 && digest(parts[0]) && parts[1] === "files" && digest(parts[2]))
15
+ return join(legacy, "files", parts[2]);
16
+ return undefined;
17
+ }
18
+ /** Repository files cannot impersonate saved results; null means there is no Git provenance. */
19
+ export async function legacyFilesTracked(folder, paths) {
20
+ const root = await resolveFolderPath(folder);
21
+ const canonical = await Promise.all(paths.map(async (path) => join(await resolveFolderPath(dirname(path)), basename(path))));
22
+ const args = ["ls-files", "--", ...canonical.map(path => relative(root, path))];
23
+ try {
24
+ let tracked;
25
+ try {
26
+ tracked = await runGit(root, [args[0], "--with-tree=HEAD", ...args.slice(1)]);
27
+ }
28
+ catch (error) {
29
+ if (error.code !== 128)
30
+ throw error;
31
+ // An unborn branch has an index but no HEAD tree to compare against.
32
+ const head = (await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"]).catch(error => {
33
+ if (error.code !== 1)
34
+ throw error;
35
+ return Buffer.alloc(0);
36
+ })).length;
37
+ if (head)
38
+ throw error;
39
+ tracked = await runGit(root, args);
40
+ }
41
+ return tracked.length > 0;
42
+ }
43
+ catch (error) {
44
+ if (await standaloneGitFailure(root, error))
45
+ return null;
46
+ throw error;
47
+ }
48
+ }
@@ -1,12 +1,54 @@
1
1
  import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import os from "node:os";
2
4
  import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
- import { FINISH_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
5
+ import { OUTPUT_PATH_MESSAGE, REPORT_HOME_IN_REPOSITORY_MESSAGE, REPORT_OUTPUT_IN_REPOSITORY_MESSAGE } from "../contract/cli-refusals.js";
4
6
  import { resolveFolderPath } from "./folder-path.js";
5
7
  import { durableWrite } from "./durable-write.js";
6
- export const DEFAULT_OUTPUT_DIR = ".counterfact";
8
+ import { storageIdentity } from "./storage-identity.js";
9
+ import { gitRepositoryRoot } from "./repository-root.js";
10
+ import { runGit } from "./git.js";
11
+ import { configDirectory } from "./config-directory.js";
12
+ import { legacyFilesTracked, legacyReportDirectory } from "./legacy-reports.js";
13
+ /** The home store uses the same checkout coordinates and home convention as completion recovery. */
14
+ export async function reportDirectory(folder, entries) {
15
+ const key = createHash("sha256").update(JSON.stringify(await storageIdentity(folder))).digest("hex");
16
+ const directory = join(reportStoreRoot(await realpath(os.homedir())), key);
17
+ if (entries === undefined)
18
+ return directory;
19
+ const hash = createHash("sha256").update(typeof entries === "string" ? entries : JSON.stringify(entries)).digest("hex");
20
+ return join(directory, "files", hash);
21
+ }
22
+ /** Only reads may fall back to reports left by earlier CLI releases. */
23
+ export async function reportReadPaths(folder, names, outputDir) {
24
+ try {
25
+ const paths = await outputPaths(folder, names, outputDir);
26
+ if (paths.length)
27
+ await lstat(paths[0]);
28
+ return { paths, legacy: false };
29
+ }
30
+ catch (error) {
31
+ if (!missing(error))
32
+ throw error;
33
+ const legacy = legacyReportDirectory(folder, outputDir === undefined ? undefined
34
+ : { directory: outputDir, storeRoot: reportStoreRoot(await realpath(os.homedir())) });
35
+ if (!legacy)
36
+ throw error;
37
+ const paths = await outputPaths(folder, names, legacy);
38
+ // Files supplied by the repository must never stand in for this user's saved analysis.
39
+ if (await legacyFilesTracked(folder, paths))
40
+ throw new OutputPathError();
41
+ return { paths, legacy: true };
42
+ }
43
+ }
7
44
  export class OutputPathError extends Error {
8
- constructor() {
9
- super(FINISH_FAILURE_MESSAGE);
45
+ constructor(message = OUTPUT_PATH_MESSAGE) {
46
+ super(message);
47
+ }
48
+ }
49
+ export class ReportLocationError extends OutputPathError {
50
+ constructor(homeInRepository) {
51
+ super(homeInRepository ? REPORT_HOME_IN_REPOSITORY_MESSAGE : REPORT_OUTPUT_IN_REPOSITORY_MESSAGE);
10
52
  }
11
53
  }
12
54
  const missing = (error) => error.code === "ENOENT";
@@ -14,15 +56,48 @@ const within = (base, path) => {
14
56
  const rel = relative(base, path);
15
57
  return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
16
58
  };
59
+ const reportStoreRoot = (home) => join(configDirectory(home), "reports");
60
+ /** Report storage must leave the entire analyzed checkout read-only, even when it contains the home directory. */
61
+ export async function reportOutputDirectory(folder, outputDir) {
62
+ const defaultStore = outputDir === undefined;
63
+ outputDir ??= await reportDirectory(folder);
64
+ const root = await resolveFolderPath(folder);
65
+ const repository = await gitRepositoryRoot(async (args) => (await runGit(root, args)).toString("utf8"), root);
66
+ let existing = resolve(outputDir), suffix = "";
67
+ for (;;) {
68
+ try {
69
+ existing = resolve(await realpath(existing), suffix);
70
+ break;
71
+ }
72
+ catch (error) {
73
+ if (!missing(error) || dirname(existing) === existing)
74
+ throw new OutputPathError();
75
+ suffix = join(parse(existing).base, suffix);
76
+ existing = dirname(existing);
77
+ }
78
+ }
79
+ const boundary = repository ? await realpath(repository) : root;
80
+ if (within(boundary, existing)) {
81
+ throw new ReportLocationError(defaultStore || within(boundary, await realpath(os.homedir())));
82
+ }
83
+ // Keep the selected spelling so output admission can still refuse symlink components.
84
+ return resolve(outputDir);
85
+ }
17
86
  /** Validate the selected anchor and every output component before creating or replacing files. */
18
87
  export async function outputPaths(folder, names, outputDir, create = false) {
19
88
  const selected = resolve(folder), root = await resolveFolderPath(folder);
20
- const requested = resolve(outputDir ?? join(selected, DEFAULT_OUTPUT_DIR));
89
+ const requested = resolve(outputDir ?? await reportDirectory(root));
21
90
  let directory = within(selected, requested) ? resolve(root, relative(selected, requested)) : requested;
22
91
  const external = !within(root, directory);
23
92
  const userUid = process.getuid?.();
24
93
  let systemUid;
25
94
  let anchor = root;
95
+ // Home storage starts at the user's boundary; arbitrary --output paths need no home lookup.
96
+ if (directory.includes(`${sep}${reportStoreRoot("")}${sep}`)) {
97
+ const home = await realpath(os.homedir());
98
+ if (within(reportStoreRoot(home), directory))
99
+ anchor = home;
100
+ }
26
101
  while (!within(anchor, directory))
27
102
  anchor = dirname(anchor);
28
103
  try {
@@ -83,6 +158,7 @@ export async function outputPaths(folder, names, outputDir, create = false) {
83
158
  }
84
159
  }
85
160
  export async function writeOutputFiles(folder, files, outputDir) {
161
+ outputDir ??= await reportDirectory(folder);
86
162
  const names = Object.keys(files);
87
163
  const paths = await outputPaths(folder, names, outputDir, true);
88
164
  if (paths.length)
@@ -93,3 +169,7 @@ export async function writeOutputFiles(folder, files, outputDir) {
93
169
  }
94
170
  return paths;
95
171
  }
172
+ /** Every report writer enforces the repository boundary, including direct library calls. */
173
+ export async function writeReportFiles(folder, files, outputDir) {
174
+ return writeOutputFiles(folder, files, await reportOutputDirectory(folder, outputDir));
175
+ }
@@ -0,0 +1,78 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open, realpath } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import { basename, dirname } from "node:path";
6
+ import { z } from "zod";
7
+ import { outputPaths, reportOutputDirectory, writeOutputFiles } from "./output-paths.js";
8
+ export const resultReceiptSchema = z.strictObject({ directory: z.string().min(1).max(16384),
9
+ hashes: z.tuple([z.string().regex(/^[a-f0-9]{64}$/), z.string().regex(/^[a-f0-9]{64}$/)]) });
10
+ const storedReceiptSchema = z.strictObject({ checkId: z.uuid(), reports: resultReceiptSchema });
11
+ // Older CLIs strictly parse completion records and only scan sibling slots ending in a hex digest.
12
+ const receiptName = (recordPath) => `${basename(recordPath, ".json")}.reports.json`;
13
+ export async function saveResultReceipt(recordPath, checkId, reports) {
14
+ const home = await realpath(os.homedir());
15
+ const bytes = JSON.stringify(storedReceiptSchema.parse({ checkId, reports }));
16
+ await writeOutputFiles(home, { [receiptName(recordPath)]: bytes }, dirname(recordPath));
17
+ }
18
+ /** An absent or unreadable receipt cannot prevent recovering the completion payload. */
19
+ export async function loadResultReceipt(recordPath, checkId) {
20
+ let file;
21
+ try {
22
+ const home = await realpath(os.homedir());
23
+ const [path] = await outputPaths(home, [receiptName(recordPath)], dirname(recordPath));
24
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
25
+ const limit = 128 * 1024;
26
+ const info = await file.stat();
27
+ if (!info.isFile() || info.size > limit)
28
+ return undefined;
29
+ const bytes = Buffer.alloc(limit + 1);
30
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
31
+ if (bytesRead > limit)
32
+ return undefined;
33
+ const value = storedReceiptSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytesRead))));
34
+ return value.checkId === checkId ? value.reports : undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ finally {
40
+ await file?.close();
41
+ }
42
+ }
43
+ /** Hash both durable reports so a later run cannot mistake an older report for this result. */
44
+ export async function readResultReceipt(folder, outputDir) {
45
+ const directory = await reportOutputDirectory(folder, outputDir);
46
+ const [findingsPath, summaryPath] = await outputPaths(folder, ["findings.json", "summary.md"], directory);
47
+ const hashes = [];
48
+ const limit = 64 * 1024 * 1024;
49
+ for (const path of [findingsPath, summaryPath]) {
50
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
51
+ try {
52
+ const info = await file.stat();
53
+ if (!info.isFile() || info.size > limit)
54
+ throw new Error("Invalid saved analysis");
55
+ const hash = createHash("sha256"), buffer = Buffer.alloc(64 * 1024);
56
+ let total = 0;
57
+ for (;;) {
58
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, limit - total + 1), null);
59
+ if (!bytesRead)
60
+ break;
61
+ total += bytesRead;
62
+ if (total > limit)
63
+ throw new Error("Invalid saved analysis");
64
+ hash.update(buffer.subarray(0, bytesRead));
65
+ }
66
+ hashes.push(hash.digest("hex"));
67
+ }
68
+ finally {
69
+ await file.close();
70
+ }
71
+ }
72
+ return { paths: { findingsPath, summaryPath }, receipt: resultReceiptSchema.parse({ directory, hashes }) };
73
+ }
74
+ /** Only the exact reports recorded after completion may replace a failed backup. */
75
+ export async function verifiedResultPaths(folder, receipt) {
76
+ const current = await readResultReceipt(folder, receipt.directory);
77
+ return current.receipt.hashes.every((hash, index) => hash === receipt.hashes[index]) ? current.paths : undefined;
78
+ }
@@ -2,10 +2,9 @@ import { skippedFilesEnabled } from "../analysis-core/settings.js";
2
2
  import { combineLocalReports } from "./combined-results.js";
3
3
  import { invalidateAnalysisContext, loadPublicReport } from "./analysis-results.js";
4
4
  import { formatSummary } from "../cli/summary.js";
5
- import { ignoreDefaultOutput } from "./gitignore-output.js";
6
- import { writeOutputFiles } from "./output-paths.js";
5
+ import { writeReportFiles } from "./output-paths.js";
7
6
  import { projectPublicReport } from "../contract/public-report.js";
8
- import { INCLUDED_FILES_CAPTION, INCOMPLETE_IMPORTS_MESSAGE, SELECTION_COVERAGE_DESCRIPTION } from "../contract/scope-choice.js";
7
+ import { INCLUDED_FILES_CAPTION, INCOMPLETE_IMPORTS_MESSAGE, selectionCoverageDescription } from "../contract/scope-choice.js";
9
8
  export async function saveLocalResults(folder, report, refusedFiles, outputDir, options) {
10
9
  const next = projectPublicReport(report, refusedFiles);
11
10
  const publicReport = options.append && skippedFilesEnabled(options.settings ?? {})
@@ -15,14 +14,12 @@ export async function saveLocalResults(folder, report, refusedFiles, outputDir,
15
14
  const scope = options.scope;
16
15
  const incomplete = options.selection && !options.selection.completeness.complete
17
16
  ? [INCOMPLETE_IMPORTS_MESSAGE, "", INCLUDED_FILES_CAPTION, ...options.includedPaths.map(path => ` ${path}`), "", ""].join("\n") : "";
18
- const coverage = scope && scope.imports !== "all" ? SELECTION_COVERAGE_DESCRIPTION
19
- .replace("{entry}", () => scope.entry).replace("{count}", String(Array.isArray(scope.imports) ? scope.imports.length : 0)) + "\n\n" : "";
20
- const [findingsPath, summaryPath] = await writeOutputFiles(folder, {
17
+ const coverage = scope && scope.imports !== "all" ? selectionCoverageDescription(scope.entry, Array.isArray(scope.imports) ? scope.imports.length : 0) + "\n\n" : "";
18
+ const [findingsPath, summaryPath] = await writeReportFiles(folder, {
21
19
  "findings.json": `${JSON.stringify(publicReport, null, 2)}\n`,
22
20
  "summary.md": incomplete + coverage + formatSummary(publicReport, { ...options, analyzedFolder: options.analyzedFolder ?? folder, skippedOffer: !options.append, skippedFilesEnabled: skippedFilesEnabled(options.settings ?? {}) }),
23
21
  ...(options.selection ? { "selection.json": JSON.stringify({ schemaVersion: 1, selection: options.selection,
24
22
  ...(scope ? { scope } : {}) }, null, 2) + "\n" } : {}),
25
23
  }, outputDir);
26
- const ignoreAdded = await ignoreDefaultOutput(folder, options.defaultFileOutput ? undefined : outputDir, options);
27
- return { findingsPath, summaryPath, ignoreAdded };
24
+ return { findingsPath, summaryPath };
28
25
  }