@counterfact-labs/cli 0.2.7 → 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 (145) hide show
  1. package/bundle/client-release.json +1 -1
  2. package/dist/adapters/analysis-results.js +87 -0
  3. package/dist/adapters/combined-results.js +27 -0
  4. package/dist/adapters/entry-files.js +5 -2
  5. package/dist/adapters/entry-packing.js +161 -0
  6. package/dist/adapters/entry-selection.js +13 -1
  7. package/dist/adapters/files.js +110 -5
  8. package/dist/adapters/git.js +10 -1
  9. package/dist/adapters/relay-plan.js +5 -7
  10. package/dist/adapters/release.js +4 -1
  11. package/dist/adapters/results.js +9 -2
  12. package/dist/adapters/selection.js +19 -9
  13. package/dist/adapters/settings.js +37 -6
  14. package/dist/adapters/source-relay-plan.js +7 -0
  15. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -1
  16. package/dist/analysis-core/analysis/file-analysis-plan.js +1 -0
  17. package/dist/analysis-core/analysis/file-dependencies.js +1 -0
  18. package/dist/analysis-core/analysis/finding-activity-switch.js +1 -0
  19. package/dist/analysis-core/analysis/finding-identity.js +1 -0
  20. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
  21. package/dist/analysis-core/analysis/folder-analysis-plan.js +1 -0
  22. package/dist/analysis-core/analysis/passes/full-file-passes.js +1 -0
  23. package/dist/analysis-core/analysis/project-architecture.js +1 -0
  24. package/dist/analysis-core/analysis/schemas.js +1 -1
  25. package/dist/analysis-core/chunks/2AASSZRO.js +35 -0
  26. package/dist/analysis-core/chunks/2YREYGOG.js +1 -0
  27. package/dist/analysis-core/chunks/33HRXJ5Q.js +1 -0
  28. package/dist/analysis-core/chunks/33IGHOYT.js +1 -0
  29. package/dist/analysis-core/chunks/4PT5C4IT.js +7 -0
  30. package/dist/analysis-core/chunks/4RXRAB5S.js +1 -0
  31. package/dist/analysis-core/chunks/4UDQBDDE.js +1 -0
  32. package/dist/analysis-core/chunks/{6RWEWBHK.js → 5B5UCWD3.js} +1 -1
  33. package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
  34. package/dist/analysis-core/chunks/CQQ4TTXE.js +1 -0
  35. package/dist/analysis-core/chunks/CTLHKFPH.js +19 -0
  36. package/dist/analysis-core/chunks/F54DOWKN.js +6 -0
  37. package/dist/analysis-core/chunks/FRXOQ2M6.js +3 -0
  38. package/dist/analysis-core/chunks/HCPO6QPU.js +1 -0
  39. package/dist/analysis-core/chunks/HHFDNY6V.js +14 -0
  40. package/dist/analysis-core/chunks/{FBTOOUPL.js → HVQJKPQ4.js} +1 -1
  41. package/dist/analysis-core/chunks/INVTU73D.js +1 -0
  42. package/dist/analysis-core/chunks/JEWBPIQI.js +1 -0
  43. package/dist/analysis-core/chunks/JGK37AWN.js +1 -0
  44. package/dist/analysis-core/chunks/LFYTD7JF.js +1 -0
  45. package/dist/analysis-core/chunks/LMUXOKIM.js +1 -0
  46. package/dist/analysis-core/chunks/LOBIDUCA.js +1 -0
  47. package/dist/analysis-core/chunks/M5YWJY7P.js +1 -0
  48. package/dist/analysis-core/chunks/MGVIKA74.js +1 -0
  49. package/dist/analysis-core/chunks/MKYLK6DB.js +1 -0
  50. package/dist/analysis-core/chunks/MZOKZ3N6.js +1 -0
  51. package/dist/analysis-core/chunks/NXHVBJL4.js +1 -0
  52. package/dist/analysis-core/chunks/OKBK3JL3.js +1 -0
  53. package/dist/analysis-core/chunks/PBHHWFR6.js +1 -0
  54. package/dist/analysis-core/chunks/Q5WR6B2H.js +2 -0
  55. package/dist/analysis-core/chunks/QVXARKP7.js +1 -0
  56. package/dist/analysis-core/chunks/RQTNTIR4.js +1 -0
  57. package/dist/analysis-core/chunks/{RNTMH4MU.js → S27Y2TP5.js} +1 -1
  58. package/dist/analysis-core/chunks/SJESFYBA.js +10 -0
  59. package/dist/analysis-core/chunks/SP6XFHNH.js +1 -0
  60. package/dist/analysis-core/chunks/VDUFPTDD.js +1 -0
  61. package/dist/analysis-core/chunks/W24SFYZM.js +67 -0
  62. package/dist/analysis-core/chunks/YTHF2E2S.js +1 -0
  63. package/dist/analysis-core/chunks/YWL52FVW.js +2 -0
  64. package/dist/analysis-core/contracts/analysis-selection.js +1 -1
  65. package/dist/analysis-core/contracts/analysis-size.js +1 -0
  66. package/dist/analysis-core/contracts/execution-request.js +1 -0
  67. package/dist/analysis-core/contracts/intake-finding.js +1 -0
  68. package/dist/analysis-core/contracts/model-call-identity.js +1 -0
  69. package/dist/analysis-core/contracts/relay-analysis.js +1 -1
  70. package/dist/analysis-core/contracts/requests.js +1 -1
  71. package/dist/analysis-core/conventions/analysis-fit.js +1 -1
  72. package/dist/analysis-core/conventions/file-admission.js +1 -1
  73. package/dist/analysis-core/conventions/read-limits.js +1 -0
  74. package/dist/analysis-core/import/calls/modules.js +1 -1
  75. package/dist/analysis-core/import/entry-selection.js +1 -0
  76. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  77. package/dist/analysis-core/index.js +3 -3
  78. package/dist/analysis-core/requests/answer-formats.js +1 -1
  79. package/dist/analysis-core/requests/local-plan.js +1 -1
  80. package/dist/analysis-core/requests/planned-limits.js +1 -0
  81. package/dist/analysis-core/requests/registry.js +1 -1
  82. package/dist/analysis-core/requests/template-version.js +1 -1
  83. package/dist/analysis-core/requests/tools.js +1 -1
  84. package/dist/analysis-core/settings.js +1 -1
  85. package/dist/analysis-core/stages/engine-entry.js +1 -1
  86. package/dist/bundle/seal/engine.js +1 -1
  87. package/dist/cli/analysis-selection.js +47 -5
  88. package/dist/cli/api.js +1 -1
  89. package/dist/cli/commands/analyze.js +58 -15
  90. package/dist/cli/commands/skipped.js +65 -0
  91. package/dist/cli/context.js +2 -1
  92. package/dist/cli/finding-display.js +102 -7
  93. package/dist/cli/finding-settings.js +3 -1
  94. package/dist/cli/intake-payload.js +26 -2
  95. package/dist/cli/main.js +21 -7
  96. package/dist/cli/mcp-failure.js +6 -5
  97. package/dist/cli/mcp.js +134 -28
  98. package/dist/cli/relay-check.js +107 -23
  99. package/dist/cli/relay-failure.js +4 -4
  100. package/dist/cli/relay-preparation.js +50 -12
  101. package/dist/cli/result-check.js +31 -12
  102. package/dist/cli/saved-results.js +10 -0
  103. package/dist/cli/scope-choices.js +3 -3
  104. package/dist/cli/sign-in.js +2 -2
  105. package/dist/cli/skipped-files.js +22 -0
  106. package/dist/cli/summary.js +15 -1
  107. package/dist/contract/agent-choice.js +25 -2
  108. package/dist/contract/cli-api-messages.js +3 -0
  109. package/dist/contract/cli-refusals.js +31 -5
  110. package/dist/contract/cli-version.js +1 -1
  111. package/dist/contract/console-url.js +3 -0
  112. package/dist/contract/console.js +17 -38
  113. package/dist/contract/engine.js +1 -1
  114. package/dist/contract/messages.js +4 -3
  115. package/dist/contract/project-architecture.js +1 -0
  116. package/dist/contract/scope-choice.js +31 -11
  117. package/dist/contract/selection-offer.js +5 -8
  118. package/dist/gatekeeper/cli-api.js +70 -4
  119. package/dist/gatekeeper/console.js +19 -3
  120. package/dist/gatekeeper/model.js +5 -23
  121. package/dist/gatekeeper/trust/policy.js +2 -1
  122. package/package.json +4 -2
  123. package/dist/analysis-core/analysis/source-notes/types.js +0 -1
  124. package/dist/analysis-core/chunks/2PSNOSJ2.js +0 -1
  125. package/dist/analysis-core/chunks/2PZF54KH.js +0 -10
  126. package/dist/analysis-core/chunks/6B2HILSY.js +0 -1
  127. package/dist/analysis-core/chunks/6U2IR4XB.js +0 -1
  128. package/dist/analysis-core/chunks/7NEWZ74F.js +0 -1
  129. package/dist/analysis-core/chunks/CGCJN73D.js +0 -14
  130. package/dist/analysis-core/chunks/CWYVUGG4.js +0 -1
  131. package/dist/analysis-core/chunks/FSLWD2PE.js +0 -1
  132. package/dist/analysis-core/chunks/GOQVKWP3.js +0 -32
  133. package/dist/analysis-core/chunks/IHAVDX4L.js +0 -66
  134. package/dist/analysis-core/chunks/IVULXWQN.js +0 -21
  135. package/dist/analysis-core/chunks/K2GNDX3W.js +0 -1
  136. package/dist/analysis-core/chunks/K5FUABWW.js +0 -1
  137. package/dist/analysis-core/chunks/KGQYR62R.js +0 -7
  138. package/dist/analysis-core/chunks/KWECQJ6X.js +0 -1
  139. package/dist/analysis-core/chunks/PY5M22CL.js +0 -1
  140. package/dist/analysis-core/chunks/PYKAERZZ.js +0 -1
  141. package/dist/analysis-core/chunks/SKVEBOGA.js +0 -1
  142. package/dist/analysis-core/chunks/SLJ6CW7W.js +0 -1
  143. package/dist/analysis-core/chunks/UEORNVQB.js +0 -1
  144. package/dist/analysis-core/chunks/WYLIV2P2.js +0 -1
  145. package/dist/analysis-core/data/finding-types.js +0 -1
@@ -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.7",
4
+ "localVersion": "0.2.9",
5
5
  "relayUrl": "https://relay.counterfactlabs.com:8443",
6
6
  "projectContext": "checks"
7
7
  }
@@ -0,0 +1,87 @@
1
+ import { constants } from "node:fs";
2
+ import { open, unlink } from "node:fs/promises";
3
+ import { z } from "zod";
4
+ import { findingActivitySchema } from "../analysis-core/analysis/schemas.js";
5
+ import { snapshotSettings } from "../analysis-core/settings.js";
6
+ import { publicReportSchema } from "../contract/public-report.js";
7
+ import { outputPaths, writeOutputFiles } from "./output-paths.js";
8
+ import { savedFindings } from "../cli/finding-display.js";
9
+ import { rankFindings } from "../analysis-core/analysis/finding-quality/policy.js";
10
+ import { fingerprintOf } from "../analysis-core/analysis/finding-identity.js";
11
+ import { combineAssessments } from "./combined-results.js";
12
+ import { createHash } from "node:crypto";
13
+ const displayFields = { title: z.string(), audience: z.string().optional(), activity: findingActivitySchema.optional(),
14
+ severity: z.string().optional(), site: z.object({ path: z.string(), line: z.number().nullable() }).nullable().optional() };
15
+ const referenceSchema = z.object({ fingerprint: z.string(), id: z.string(), seq: z.number().optional(), number: z.string().optional(),
16
+ ...displayFields, shortFix: z.string().optional(), fixes: z.array(z.object({ title: z.string(), approach: z.string() })).optional() });
17
+ const pendingFindingSchema = z.object({ ...displayFields, fixes: z.array(z.object({ summary: z.string().nullable().optional(),
18
+ title: z.string().nullable().optional(), approach: z.string().nullable().optional() })).optional() });
19
+ const contextSchema = z.strictObject({ projectId: z.uuid(), analysisId: z.uuid(), settings: z.record(z.string(), z.string()),
20
+ reportHash: z.string(),
21
+ findings: z.array(referenceSchema).optional(),
22
+ topFindingIds: z.array(z.string()).optional(),
23
+ pending: z.strictObject({ checkId: z.uuid(), fingerprints: z.array(z.string()), titles: z.array(pendingFindingSchema) }).optional(),
24
+ assessments: z.array(z.strictObject({ fingerprint: z.string(), support: z.enum(["complete", "incomplete"]),
25
+ confidence: z.enum(["sure", "not sure"]), weak: z.boolean(), reason: z.string() })) });
26
+ async function readJson(path) {
27
+ const input = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
28
+ try {
29
+ const info = await input.stat();
30
+ if (!info.isFile() || info.size > 64 * 1024 * 1024)
31
+ throw new Error("Invalid saved analysis");
32
+ return JSON.parse(await input.readFile("utf8"));
33
+ }
34
+ finally {
35
+ await input.close();
36
+ }
37
+ }
38
+ /** Saved results are the authority for which omissions still need an analysis. */
39
+ export async function loadAnalysisResults(folder, outputDir) {
40
+ const [reportPath, contextPath] = await outputPaths(folder, ["findings.json", "analysis-context.json"], outputDir);
41
+ const report = publicReportSchema.parse(await readJson(reportPath));
42
+ const { reportHash, ...context } = contextSchema.parse(await readJson(contextPath));
43
+ if (reportHash !== hashReport(report))
44
+ throw new Error("Invalid saved analysis");
45
+ return { report, context };
46
+ }
47
+ export async function loadPublicReport(folder, outputDir) {
48
+ const [path] = await outputPaths(folder, ["findings.json"], outputDir);
49
+ return publicReportSchema.parse(await readJson(path));
50
+ }
51
+ /** Only non-secret analysis choices may survive into a later scoped run. */
52
+ export async function saveAnalysisContext(folder, context, outputDir) {
53
+ const report = await loadPublicReport(folder, outputDir);
54
+ const value = contextSchema.parse({ ...context, reportHash: hashReport(report), settings: snapshotSettings(context.settings) });
55
+ await writeOutputFiles(folder, { "analysis-context.json": `${JSON.stringify(value)}\n` }, outputDir);
56
+ }
57
+ function hashReport(report) { return createHash("sha256").update(JSON.stringify(report)).digest("hex"); }
58
+ export async function invalidateAnalysisContext(folder, outputDir) {
59
+ try {
60
+ const [path] = await outputPaths(folder, ["analysis-context.json"], outputDir);
61
+ await unlink(path);
62
+ }
63
+ catch (error) {
64
+ if (error.code !== "ENOENT")
65
+ throw error;
66
+ }
67
+ }
68
+ /** Intake assigns ids once; retries join them to the same saved claims without replacing original references. */
69
+ export async function settleAnalysisContext(folder, result, checkId, addToAnalysisId, outputDir) {
70
+ const { context, report } = await loadAnalysisResults(folder, outputDir);
71
+ if (context.projectId !== result.projectId)
72
+ throw new Error("Invalid saved analysis");
73
+ const { pending, ...before } = context;
74
+ if (!pending || pending.checkId !== checkId)
75
+ return context;
76
+ const known = new Map(context.findings?.map(finding => [finding.fingerprint, finding]));
77
+ savedFindings(result, pending.titles).forEach((finding, index) => {
78
+ const fingerprint = pending.fingerprints[index];
79
+ if (fingerprint && !known.has(fingerprint))
80
+ known.set(fingerprint, { ...finding, fingerprint });
81
+ });
82
+ const ranking = rankFindings(report.findings, combineAssessments([], context.assessments), fingerprintOf);
83
+ const next = { ...before, analysisId: addToAnalysisId ?? result.analysisId, findings: [...known.values()],
84
+ topFindingIds: ranking.defaultIds.flatMap(key => known.has(key) ? [known.get(key).id] : []) };
85
+ await saveAnalysisContext(folder, next, outputDir);
86
+ return next;
87
+ }
@@ -0,0 +1,27 @@
1
+ import { fingerprintOf } from "../analysis-core/analysis/finding-identity.js";
2
+ import { rankFindings } from "../analysis-core/analysis/finding-quality/policy.js";
3
+ import { mergeSkippedFiles } from "../analysis-core/analysis/code-agents/skipped-files.js";
4
+ /** Existing support belongs to the original claim; another reading cannot weaken it or make its identity ambiguous. */
5
+ export function combineAssessments(before, after) {
6
+ const known = new Map();
7
+ for (const assessment of [...before, ...after])
8
+ if (!known.has(assessment.fingerprint))
9
+ known.set(assessment.fingerprint, assessment);
10
+ return [...known.values()];
11
+ }
12
+ /** Additional reading cannot replace claims that it did not re-analyze. */
13
+ export function combineLocalReports(before, after, assessments, readPaths) {
14
+ const existing = new Set(before.findings.map(fingerprintOf));
15
+ const additions = after.findings.filter(finding => {
16
+ const key = fingerprintOf(finding);
17
+ if (existing.has(key))
18
+ return false;
19
+ existing.add(key);
20
+ return true;
21
+ });
22
+ const ranking = rankFindings([...before.findings, ...additions], combineAssessments([], assessments), fingerprintOf);
23
+ const read = new Set(readPaths);
24
+ return { report: { ...before, findings: ranking.ordered,
25
+ structures: [...before.structures, ...after.structures], warnings: [...new Set([...before.warnings, ...after.warnings])],
26
+ refusedFiles: mergeSkippedFiles(before.refusedFiles.filter(file => !read.has(file.path)), after.refusedFiles) }, ranking };
27
+ }
@@ -7,7 +7,7 @@ import { moduleResolutionLimits } from "../analysis-core/import/calls/modules.js
7
7
  import { createGitIgnore } from "./git-ignore.js";
8
8
  import { readTargetMatches, searchBoundary } from "./repository-root.js";
9
9
  /** Inventory and bounded reads supply filesystem facts; core decides which files belong. */
10
- export async function createEntryFiles(settings, root, admission = {}, signal, entryPath) {
10
+ export async function createEntryFiles(settings, root, admission = {}, signal, entryPaths) {
11
11
  signal?.throwIfAborted();
12
12
  const safety = { ...settings, CF_LOCAL_SYMLINK_GUARD: "1", CF_RESPECT_GITIGNORE: "1", CF_SECRET_SCAN: "1" };
13
13
  const ignored = createGitIgnore(safety, root), gitIgnored = new Set();
@@ -68,7 +68,10 @@ export async function createEntryFiles(settings, root, admission = {}, signal, e
68
68
  }
69
69
  }
70
70
  await walk(root, "");
71
- if (entryPath && !entries.some(entry => entry.path === entryPath)) {
71
+ for (const entryPath of typeof entryPaths === "string" ? [entryPaths] : entryPaths ?? []) {
72
+ signal?.throwIfAborted();
73
+ if (entries.some(entry => entry.path === entryPath))
74
+ continue;
72
75
  const info = await stat(join(root, entryPath)).catch(() => null);
73
76
  if (info?.isFile())
74
77
  entries.push({ path: entryPath, size: info.size, ref: entryPath });
@@ -0,0 +1,161 @@
1
+ import { MAX_ANALYSIS_ENTRIES } from "../analysis-core/contracts/analysis-selection.js";
2
+ import { entryGroupChoice } from "../analysis-core/import/entry-selection.js";
3
+ import { analysisSizeLimits, assessAnalysisSize, measureAnalysisSize, measureAnalysisSizeLowerBound } from "../analysis-core/conventions/analysis-fit.js";
4
+ const defaultBudget = { measurements: 4_096, candidates: 16_384, searchSteps: 100_000 };
5
+ /** Size stays at this boundary; core supplies and validates the complete entry import sets. */
6
+ export function fittingEntryGroups(settings, membership, currentPaths, budget = defaultBudget) {
7
+ if (membership.kind !== "file" || !membership.entrySets)
8
+ return [];
9
+ let optimizing = false, exhausted = false, measurements = 0, candidates = 0, searchSteps = 0;
10
+ const current = new Set(currentPaths);
11
+ const byteFloors = new Map(membership.files.map(file => [file.path, measureAnalysisSizeLowerBound(settings, [file])]));
12
+ const pools = [["totalBytes", analysisSizeLimits.totalLimitBytes], ["instructionBytes", analysisSizeLimits.instructionLimitBytes]];
13
+ const cache = new Map();
14
+ const candidate = (entries) => {
15
+ const key = JSON.stringify([...entries].sort());
16
+ if (cache.has(key))
17
+ return cache.get(key);
18
+ const choice = entryGroupChoice(membership, entries);
19
+ let result;
20
+ if (choice && choice.includes.every(path => current.has(path))) {
21
+ const floors = pools.map(([pool, limit]) => choice.includes.reduce((sum, path) => sum + byteFloors.get(path)[pool], 0) / limit);
22
+ const floorWeight = Math.max(...floors), floorFits = floorWeight <= 1;
23
+ const paths = new Set(choice.includes);
24
+ if (floorFits && optimizing && measurements++ >= budget.measurements) {
25
+ exhausted = true;
26
+ return undefined;
27
+ }
28
+ const size = !floorFits ? undefined
29
+ : measureAnalysisSize(settings, membership.files.filter(file => paths.has(file.path)));
30
+ result = { choice, fits: size ? assessAnalysisSize(size).fits : false, floorFits,
31
+ weight: size ? Math.max(size.totalBytes / analysisSizeLimits.totalLimitBytes, size.instructionBytes / analysisSizeLimits.instructionLimitBytes)
32
+ : floorWeight };
33
+ }
34
+ cache.set(key, result);
35
+ return result;
36
+ };
37
+ const sets = new Map(membership.entrySets.map(set => [set.entry, set.includes]));
38
+ const inputOrder = membership.entrySets.map(set => set.entry).filter(entry => candidate([entry]));
39
+ const entries = [...inputOrder].sort((left, right) => candidate([right]).weight - candidate([left]).weight);
40
+ if (!entries.length)
41
+ return [];
42
+ const minimumParts = (roots) => {
43
+ const owners = new Map();
44
+ for (const entry of roots)
45
+ for (const path of sets.get(entry))
46
+ owners.set(path, (owners.get(path) ?? 0) + 1);
47
+ let minimum = Math.ceil(roots.length / MAX_ANALYSIS_ENTRIES);
48
+ for (const [pool, limit] of pools) {
49
+ const exclusiveBytes = roots.map(entry => sets.get(entry).reduce((sum, path) => sum + (owners.get(path) === 1 ? byteFloors.get(path)[pool] : 0), 0));
50
+ minimum = Math.max(minimum, Math.ceil([...owners.keys()].reduce((sum, path) => sum + byteFloors.get(path)[pool], 0) / limit));
51
+ for (let count = 1; count < MAX_ANALYSIS_ENTRIES; count++)
52
+ minimum = Math.max(minimum, Math.ceil(exclusiveBytes.filter(bytes => bytes > limit / (count + 1)).length / count));
53
+ }
54
+ return minimum;
55
+ };
56
+ const pack = (order) => {
57
+ const groups = [];
58
+ let complete = true;
59
+ for (const entry of order) {
60
+ if (groups.some(group => sets.get(entry).every(path => candidate(group).choice.includes.includes(path))))
61
+ continue;
62
+ const group = groups.find(group => candidate([...group, entry])?.fits);
63
+ if (exhausted)
64
+ return undefined;
65
+ if (group)
66
+ group.push(entry);
67
+ else if (candidate([entry]).fits)
68
+ groups.push([entry]);
69
+ else
70
+ complete = false;
71
+ }
72
+ return complete ? groups : undefined;
73
+ };
74
+ let best = pack(entries);
75
+ const ordered = (groups) => groups.map(group => [...group].sort((a, b) => membership.entries.indexOf(a) - membership.entries.indexOf(b)))
76
+ .sort((a, b) => membership.entries.indexOf(a[0]) - membership.entries.indexOf(b[0]))
77
+ .map(group => candidate(group).choice);
78
+ const minimum = minimumParts(entries);
79
+ if (best && best.length === minimum)
80
+ return ordered(best);
81
+ // Keep the measured baseline before spending bounded extra work on fewer parts.
82
+ optimizing = true;
83
+ // Size sorting can miss a fitting arrangement that the user's entry order finds immediately.
84
+ // Proving either pass optimal avoids enumerating the full combination frontier.
85
+ const alternate = pack(inputOrder);
86
+ if (alternate && (!best || alternate.length < best.length))
87
+ best = alternate;
88
+ if (exhausted || best?.length === minimum)
89
+ return ordered(best ?? []);
90
+ const choices = new Map();
91
+ const extend = (group, start) => {
92
+ if (exhausted || candidates++ >= budget.candidates) {
93
+ exhausted = true;
94
+ return;
95
+ }
96
+ const value = candidate(group);
97
+ // Only an inventory-independent floor can rule out every larger combination.
98
+ if (!value || !value.floorFits)
99
+ return;
100
+ if (value.fits) {
101
+ const included = new Set(value.choice.includes);
102
+ const covered = new Set(entries.filter(entry => sets.get(entry).every(path => included.has(path))));
103
+ const key = JSON.stringify([...covered].sort());
104
+ if (!choices.has(key))
105
+ choices.set(key, { entries: group, covered, weight: value.weight });
106
+ }
107
+ if (group.length === MAX_ANALYSIS_ENTRIES)
108
+ return;
109
+ for (let index = start; index < entries.length && !exhausted; index++)
110
+ extend([...group, entries[index]], index + 1);
111
+ };
112
+ for (let index = 0; index < entries.length && !exhausted; index++)
113
+ extend([entries[index]], index + 1);
114
+ if (exhausted)
115
+ return ordered(best ?? []);
116
+ const groups = [...choices.values()].sort((left, right) => right.weight - left.weight || right.covered.size - left.covered.size);
117
+ const byEntry = new Map(entries.map(entry => [entry, groups.filter(group => group.covered.has(entry))]));
118
+ const eligible = entries.filter(entry => byEntry.get(entry).length);
119
+ if (!eligible.length)
120
+ return [];
121
+ const eligibleMinimum = minimumParts(eligible);
122
+ const visited = new Map();
123
+ const search = (remaining, selected) => {
124
+ if (exhausted || searchSteps++ >= budget.searchSteps) {
125
+ exhausted = true;
126
+ return;
127
+ }
128
+ if (best?.length === eligibleMinimum)
129
+ return;
130
+ if (!remaining.length) {
131
+ best = selected.map(group => [...group]);
132
+ return;
133
+ }
134
+ const key = JSON.stringify(remaining);
135
+ if ((visited.get(key) ?? Infinity) <= selected.length)
136
+ return;
137
+ visited.set(key, selected.length);
138
+ if (selected.length + minimumParts(remaining) >= (best?.length ?? Infinity))
139
+ return;
140
+ const entry = remaining.reduce((fewest, entry) => byEntry.get(entry).length < byEntry.get(fewest).length ? entry : fewest);
141
+ const seen = new Set();
142
+ for (const group of byEntry.get(entry)) {
143
+ if (exhausted || searchSteps++ >= budget.searchSteps) {
144
+ exhausted = true;
145
+ return;
146
+ }
147
+ const next = remaining.filter(entry => !group.covered.has(entry));
148
+ const nextKey = JSON.stringify(next);
149
+ if (seen.has(nextKey))
150
+ continue;
151
+ seen.add(nextKey);
152
+ selected.push(group.entries);
153
+ search(next, selected);
154
+ selected.pop();
155
+ if (best?.length === eligibleMinimum)
156
+ return;
157
+ }
158
+ };
159
+ search(eligible, []);
160
+ return ordered(best ?? []);
161
+ }
@@ -5,16 +5,20 @@ import { localRepoKeyFor } from "../analysis-core/github/repo-key.js";
5
5
  import { cliRefusals } from "../contract/cli-refusals.js";
6
6
  import { INVALID_SELECTION_MESSAGE, INVALID_SCOPE_MESSAGE, SINGLE_FILE_TOO_LARGE_MESSAGE, SCOPE_PROPOSAL_CONFLICT_MESSAGE, STALE_SCOPE_MESSAGE } from "../contract/scope-choice.js";
7
7
  import { createEntryFiles } from "./entry-files.js";
8
+ import { selectionSizeOffer } from "./selection.js";
9
+ import { MAX_ANALYSIS_ENTRIES } from "../analysis-core/contracts/analysis-selection.js";
10
+ import { ScopeChoiceError } from "../contract/selection-offer.js";
8
11
  /** The CLI consumes the core membership answer and captures only its retained files. */
9
12
  export const entrySelectionProvider = async (settings, request, options) => {
10
13
  if (!fileScopeEnabled(settings) || request.kind !== "file")
11
14
  throw new Error(cliRefusals.release_unavailable);
12
- const port = await createEntryFiles(settings, request.launchRoot, options.admission, options.signal, request.entry).catch(() => {
15
+ const port = await createEntryFiles(settings, request.launchRoot, options.admission, options.signal, request.entries ?? request.entry).catch(() => {
13
16
  options.signal?.throwIfAborted();
14
17
  throw new Error(INVALID_SELECTION_MESSAGE);
15
18
  });
16
19
  const input = { ...port, request: { ...request, scope: undefined }, admission: options.admission };
17
20
  const full = await readEntrySelection(settings, input);
21
+ options.signal?.throwIfAborted();
18
22
  const refusal = (reason) => {
19
23
  if (reason === "over-limit")
20
24
  throw new Error(SINGLE_FILE_TOO_LARGE_MESSAGE);
@@ -30,8 +34,16 @@ export const entrySelectionProvider = async (settings, request, options) => {
30
34
  return refusal(full.reason);
31
35
  // Replay the same admitted captures through the owner to validate a narrower scope.
32
36
  const selected = request.scope || options.proposal ? await readEntrySelection(settings, { ...input, request, proposal: options.proposal }) : full;
37
+ options.signal?.throwIfAborted();
33
38
  if (selected.kind !== "ready")
34
39
  return refusal(selected.reason);
40
+ if (request.entries && (selected.selection.files.filter(file => file.role === "entry").length > MAX_ANALYSIS_ENTRIES
41
+ || !request.scope && request.entries.some(entry => !selected.selection.files.some(file => file.path === entry)))) {
42
+ const choices = selectionSizeOffer(settings, full.selection, { files: selected.files }, { force: true });
43
+ if (choices)
44
+ throw new ScopeChoiceError(choices);
45
+ throw new Error(INVALID_SCOPE_MESSAGE);
46
+ }
35
47
  const { files } = selected;
36
48
  const entries = selected.selection.files.map(({ path, size }) => ({ path, size, ref: path }));
37
49
  const selection = { selected: entries, refused: [], totalBytes: entries.reduce((sum, file) => sum + file.size, 0) };
@@ -1,11 +1,18 @@
1
- import { moduleResolutionLimits } from "../analysis-core/import/calls/modules.js";
1
+ import { folderPartsEnabled, skippedFilesEnabled } from "../analysis-core/settings.js";
2
+ import { ModuleResolver, moduleResolutionLimits } from "../analysis-core/import/calls/modules.js";
2
3
  import { opendir, realpath, stat, readFile } from "node:fs/promises";
3
4
  import { dirname, join, posix } from "node:path";
4
5
  import { admitFile, FileReadRefusal, fileRefusalWarning, localSymlinkGuardEnabled, respectGitignore, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
5
6
  import { assessAnalysisSize, measureAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
6
7
  import { localRepoKeyFor } from "../analysis-core/github/repo-key.js";
8
+ import { fileScopeOf, planFileAnalyses } from "../analysis-core/analysis/file-analysis-plan.js";
9
+ import { planFolderAnalyses } from "../analysis-core/analysis/folder-analysis-plan.js";
10
+ import { fileDependencies } from "../analysis-core/analysis/file-dependencies.js";
11
+ import { mapLimit } from "../analysis-core/map-limit.js";
12
+ import { fileEvidenceBytes } from "../analysis-core/analysis/passes/full-file-passes.js";
7
13
  import { fetchLocalSources } from "../analysis-core/index.js";
8
14
  import { repoPathFrom } from "../analysis-core/import/outside-loader-sources.js";
15
+ import { READ_LIMITS } from "../analysis-core/conventions/read-limits.js";
9
16
  import { mergeSkippedFiles, mergeUnlistedFileWarnings } from "../analysis-core/analysis/code-agents/skipped-files.js";
10
17
  import { createGitIgnore } from "./git-ignore.js";
11
18
  import { createIgnoredFileCollector } from "./ignored-files.js";
@@ -13,11 +20,23 @@ import { resolveFolderPath } from "./folder-path.js";
13
20
  import { createRepositoryFiles } from "./repository-files.js";
14
21
  export const FOLDER_ENTRY_LIMIT = moduleResolutionLimits.inventoryEntries;
15
22
  export const FOLDER_ENTRY_LIMIT_MESSAGE = "The folder has too many entries to analyze. Choose a smaller folder.";
16
- export async function readLocalFolder(settings, folder, admission = {}, options = {}) {
23
+ export async function readLocalFolder(...args) {
24
+ try {
25
+ return await readFolder(...args);
26
+ }
27
+ catch (error) {
28
+ // An open failure is a read failure here; outside this owner it may belong to a write.
29
+ if (error instanceof Error)
30
+ Object.assign(error, { localOperation: "read" });
31
+ throw error;
32
+ }
33
+ }
34
+ async function readFolder(settings, folder, admission = {}, options = {}) {
17
35
  const root = await resolveFolderPath(folder);
18
36
  if (!(await stat(root)).isDirectory())
19
37
  throw new Error("Select a folder to analyze.");
20
38
  const entries = [];
39
+ const knownPaths = new Set(), opaquePaths = new Set();
21
40
  const warnings = [];
22
41
  const refused = [];
23
42
  const gitSkipped = [];
@@ -76,12 +95,17 @@ export async function readLocalFolder(settings, folder, admission = {}, options
76
95
  throw new Error(FOLDER_ENTRY_LIMIT_MESSAGE);
77
96
  items.push(entry);
78
97
  }
98
+ opaquePaths.delete(prefix);
79
99
  const names = items.map(item => item.name);
80
100
  const facts = await ignoredPaths(names.map(name => join(target, name)));
81
101
  const gitIgnored = facts && new Set(names.filter(name => facts.has(join(target, name))).map(name => prefix ? `${prefix}/${name}` : name));
82
102
  for (const item of items.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
83
103
  const name = item.name;
84
104
  const path = prefix ? `${prefix}/${name}` : name;
105
+ if (item.isDirectory() || item.isSymbolicLink())
106
+ opaquePaths.add(path);
107
+ if (item.isFile() || item.isSymbolicLink())
108
+ knownPaths.add(path);
85
109
  // A link's filename-only refusal waits until its target type is known.
86
110
  const listed = { path, directory: item.isDirectory() || item.isSymbolicLink() };
87
111
  if (!admitFile(settings, listed, { purpose: "listing", route: "local-folder", ...admission }).admitted)
@@ -113,6 +137,7 @@ export async function readLocalFolder(settings, folder, admission = {}, options
113
137
  if (info.isDirectory())
114
138
  await walk(file, path, parents);
115
139
  else if (info.isFile()) {
140
+ opaquePaths.delete(path);
116
141
  entries.push({ path, size: info.size, ref: path });
117
142
  targets.set(path, file);
118
143
  }
@@ -122,9 +147,11 @@ export async function readLocalFolder(settings, folder, admission = {}, options
122
147
  gitIgnored: await ignoredPaths(["."]), localTarget: { root, path: root } });
123
148
  if (selectedRoot.admitted)
124
149
  await walk(root, "", new Set());
125
- else
150
+ else {
151
+ opaquePaths.add("");
126
152
  gitSkipped.push(...await ignoredFiles(root, ""));
127
- const analysisSize = measureAnalysisSize(settings, entries, admission);
153
+ }
154
+ const analysisSize = measureAnalysisSize(settings, entries, { ...admission, paths: fileScopeOf(settings) });
128
155
  // The service makes the final decision; local prediction never changes retained files.
129
156
  const analysisFit = settings.CF_REFUSE_TOO_LARGE === "0" ? undefined : assessAnalysisSize(analysisSize);
130
157
  const listing = new Set(entries.map(entry => entry.path));
@@ -142,6 +169,77 @@ export async function readLocalFolder(settings, folder, admission = {}, options
142
169
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
143
170
  }, { ...admission, listing, route: "local-folder", localTarget: { root, path: target, excludedTargets } }) : null;
144
171
  };
172
+ let filePlan;
173
+ const planParts = options.planParts && folderPartsEnabled(settings);
174
+ if (options.planSkipped && skippedFilesEnabled(settings) || planParts) {
175
+ // The ordinary inventory owns exclusions and targets; planning never opens a second file walker.
176
+ const byPath = new Map(entries.map(entry => [entry.path, entry]));
177
+ const files = Object.create(null), visited = new Set(), unavailable = new Set();
178
+ const paths = planParts ? entries.map(entry => entry.path) : [...options.planSkipped];
179
+ const metadataPaths = new Set();
180
+ let metadataBytes = 0;
181
+ let pending = paths, dependencies = {}, retainedBytes = 0;
182
+ while (pending.length) {
183
+ const next = [...new Set(pending)].filter(path => !visited.has(path));
184
+ next.forEach(path => visited.add(path));
185
+ await mapLimit(next, 4, async (path) => {
186
+ const entry = byPath.get(path);
187
+ if (!entry || !admitFile(settings, entry, { ...admission, purpose: "proposal", route: "local-folder" }).admitted)
188
+ return;
189
+ if (planParts && entry.size > READ_LIMITS.archive.bytes - retainedBytes) {
190
+ unavailable.add(path);
191
+ return;
192
+ }
193
+ try {
194
+ const text = await read(entry, "proposal");
195
+ const size = text === null ? 0 : Buffer.byteLength(text);
196
+ if (text === null || planParts && size > READ_LIMITS.archive.bytes - retainedBytes)
197
+ unavailable.add(path);
198
+ else {
199
+ files[path] = text;
200
+ retainedBytes += size;
201
+ }
202
+ }
203
+ catch {
204
+ unavailable.add(path);
205
+ }
206
+ finally {
207
+ // The archive allowance counts kept text; mapLimit bounds the additional reads in flight.
208
+ if (planParts) {
209
+ delete snapshot[path];
210
+ snapshotTargets.delete(path);
211
+ }
212
+ }
213
+ });
214
+ 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));
216
+ const settingsPaths = metadata.filter(path => {
217
+ if (visited.has(path) || metadataPaths.has(path))
218
+ return false;
219
+ 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;
225
+ });
226
+ pending = [...settingsPaths, ...Object.values(dependencies).flat()].filter(path => !visited.has(path));
227
+ }
228
+ const plannedEntries = entries.filter(entry => planParts ? Object.hasOwn(files, entry.path) : !unavailable.has(entry.path)).map(entry => Object.hasOwn(files, entry.path)
229
+ ? { ...entry, size: Buffer.byteLength(files[entry.path]) } : entry);
230
+ const input = { entries: plannedEntries, paths: planParts ? plannedEntries.map(entry => entry.path) : paths,
231
+ inputBytes: Object.fromEntries(Object.entries(files).map(([path, text]) => [path, fileEvidenceBytes(path, text)])),
232
+ dependencies };
233
+ const discovered = planParts ? await fetchLocalSources(settings, { repoKey, entries: plannedEntries, admission, includeAgentPromptPaths: true,
234
+ read: async (entry) => files[entry.path] ?? null }) : undefined;
235
+ const analysisParts = discovered ? planFolderAnalyses(settings, { ...input, fragment: discovered.fragment,
236
+ agentPromptPaths: discovered.agentPromptPaths ?? [], codeAgents: discovered.codeAgents }) : undefined;
237
+ if (!planParts)
238
+ filePlan = planFileAnalyses(settings, input);
239
+ return { repoKey, ...(filePlan ? { filePlan } : {}), ...(analysisParts ? { analysisParts } : {}), files: {}, fragment: { nodes: [], edges: [], warnings: [] }, skippedFiles: [], warnings: [],
240
+ selection: { selected: [], refused: [], totalBytes: 0 }, analysisSize, ...(analysisFit ? { analysisFit } : {}),
241
+ ...(options.captureSnapshot ? { snapshot: {} } : {}) };
242
+ }
145
243
  const discovered = await fetchLocalSources(settings, { repoKey, entries, admission, repository,
146
244
  proposal: options.proposal, proposalRefusals: [...gitSkipped, ...refused],
147
245
  read: entry => read(entry, "override"), proposalRead: entry => read(entry, "proposal"),
@@ -149,8 +247,15 @@ export async function readLocalFolder(settings, folder, admission = {}, options
149
247
  const expanded = discovered.root !== undefined;
150
248
  const ignored = mergeSkippedFiles(expanded ? gitSkipped.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : gitSkipped, repository.skippedFiles.map(file => ({ ...file, path: expanded ? file.path : posix.relative(repository.selectedRoot ?? "", file.path) })));
151
249
  const captured = { ...Object.fromEntries(Object.entries(snapshot).map(([path, bytes]) => [repoPathFrom(path, discovered.root), bytes])), ...repository.snapshot };
152
- return { repoKey, ...(discovered.agentChoice ? { agentChoice: discovered.agentChoice } : {}), files: discovered.files, analysisSize, ...(analysisFit ? { analysisFit } : {}), ...(expanded ? { root: discovered.root, sourceBase: repository.sourceBase } : {}),
250
+ return { repoKey, ...(filePlan ? { filePlan } : {}), ...(discovered.agentChoice ? { agentChoice: discovered.agentChoice } : {}), files: discovered.files, analysisSize, ...(analysisFit ? { analysisFit } : {}), ...(expanded ? { root: discovered.root, sourceBase: repository.sourceBase } : {}),
153
251
  ...(discovered.proposalCounts ? { proposalCounts: discovered.proposalCounts } : {}),
252
+ ...(settings.CF_MECHANICAL_CHECKS === "0" ? {} : { mechanicalInventory: {
253
+ paths: [...knownPaths].map(path => repoPathFrom(path, discovered.root)), complete: !expanded,
254
+ admittedPaths: entries.map(entry => repoPathFrom(entry.path, discovered.root)),
255
+ opaquePaths: [...opaquePaths].map(path => repoPathFrom(path, discovered.root)),
256
+ asOf: new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()),
257
+ incompletePaths: discovered.selection.selected.filter(entry => entry.readBytes !== undefined).map(entry => repoPathFrom(entry.path, discovered.root)),
258
+ } }),
154
259
  ...(options.captureSnapshot ? { snapshot: Object.fromEntries(Object.keys(discovered.files).map(path => [path, captured[path]])) } : {}), fragment: discovered.fragment, selection: discovered.selection,
155
260
  skippedFiles: mergeSkippedFiles(discovered.skippedFiles, ignored, expanded ? refused.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : refused),
156
261
  warnings: [...new Set(mergeUnlistedFileWarnings([...warnings.map(file => fileRefusalWarning(repoPathFrom(file.path, discovered.root), file.reason)), ...discovered.warnings], ignored.length))] };
@@ -14,7 +14,16 @@ export async function runGit(folder, args, options = {}) {
14
14
  });
15
15
  if (input)
16
16
  running.child.stdin?.end(input);
17
- return (await running).stdout;
17
+ try {
18
+ return (await running).stdout;
19
+ }
20
+ catch (error) {
21
+ options.signal?.throwIfAborted();
22
+ // Callers still need exit codes for ordinary Git misses, but public errors must not expose stderr.
23
+ if (error instanceof Error)
24
+ Object.assign(error, { reason: "local_git_failed" });
25
+ throw error;
26
+ }
18
27
  };
19
28
  // Reading configuration runs no filters. Disable every filter and diff driver before any command touches working files.
20
29
  let configured;
@@ -1,12 +1,10 @@
1
1
  import { bundleContentHash } from "../bundle/seal/signature.js";
2
2
  import { checkPlannedCallsAgainstBundle } from "../bundle/seal/planned-calls.js";
3
- import { localSourcePlan } from "../analysis-core/requests/local-plan.js";
4
3
  import { relayPlanSchema } from "../contract/console.js";
5
- /** Callers authenticate the release before reading files; only this owner turns source facts into a paid plan. */
6
- export function prepareRelayPlan({ bundle, tools, relayVersion, settings, source }) {
7
- const { files, calls } = localSourcePlan(settings, source);
4
+ /** Callers authenticate the bundle; validate its richer descriptions before exposing aggregate fields. */
5
+ export function prepareRelayPlanFromCalls({ bundle, relayVersion, calls }) {
8
6
  checkPlannedCallsAgainstBundle(bundle, calls);
9
- return { files, plan: relayPlanSchema.parse({ bundleVersion: tools.bundleVersion,
10
- bundleHash: bundleContentHash(bundle.sealed), relayVersion,
11
- calls: calls.map(({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens }) => ({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens })) }) };
7
+ return relayPlanSchema.parse({ bundleVersion: bundle.version,
8
+ bundleHash: bundleContentHash(bundle.sealed), relayVersion,
9
+ calls: calls.map(({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens }) => ({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens })) });
12
10
  }
@@ -8,6 +8,7 @@ import { z } from "zod";
8
8
  import { cliRefusals } from "../contract/cli-refusals.js";
9
9
  import { RELEASE_KEY_SPKI_SHA256 } from "../contract/release-key.js";
10
10
  import { findingAudienceSettings } from "../analysis-core/analysis/finding-audience-switch.js";
11
+ import { findingActivitySettings } from "../analysis-core/analysis/finding-activity-switch.js";
11
12
  const releasePins = z.object({ verificationKey: z.string().min(1), relayVersion: z.string().min(1), localVersion: z.string().min(1),
12
13
  relayUrl: z.string().min(1), projectContext: z.literal("checks") });
13
14
  export function readClientReleasePins(value) {
@@ -21,7 +22,9 @@ export function validateClientRelease(release, settings = {}) {
21
22
  const tools = loadPublicRequestTools(bundle, release.verificationKey, settings);
22
23
  if (!bundle.relayVersions.includes(release.relayVersion))
23
24
  throw new Error();
24
- return { bundle, tools, settings: findingAudienceSettings(settings, bundle) };
25
+ // Request-only relays keep the earlier CLI output; static findings ship with whole-analysis engines.
26
+ const compatible = bundle.metadata.analysis ? settings : { ...settings, CF_COST_FINDINGS: "0" };
27
+ return { bundle, tools, settings: findingActivitySettings(findingAudienceSettings(compatible, bundle), bundle) };
25
28
  }
26
29
  export function readClientReleaseKey(pem) {
27
30
  const key = createPublicKey(pem);
@@ -1,10 +1,17 @@
1
+ import { skippedFilesEnabled } from "../analysis-core/settings.js";
2
+ import { combineLocalReports } from "./combined-results.js";
3
+ import { invalidateAnalysisContext, loadPublicReport } from "./analysis-results.js";
1
4
  import { formatSummary } from "../cli/summary.js";
2
5
  import { ignoreDefaultOutput } from "./gitignore-output.js";
3
6
  import { writeOutputFiles } from "./output-paths.js";
4
7
  import { projectPublicReport } from "../contract/public-report.js";
5
8
  import { INCLUDED_FILES_CAPTION, INCOMPLETE_IMPORTS_MESSAGE, SELECTION_COVERAGE_DESCRIPTION } from "../contract/scope-choice.js";
6
9
  export async function saveLocalResults(folder, report, refusedFiles, outputDir, options) {
7
- const publicReport = projectPublicReport(report, refusedFiles);
10
+ const next = projectPublicReport(report, refusedFiles);
11
+ const publicReport = options.append && skippedFilesEnabled(options.settings ?? {})
12
+ ? combineLocalReports(await loadPublicReport(folder, outputDir), next, options.append.assessments, options.append.readPaths).report : next;
13
+ if (!options.append)
14
+ await invalidateAnalysisContext(folder, outputDir);
8
15
  const scope = options.scope;
9
16
  const incomplete = options.selection && !options.selection.completeness.complete
10
17
  ? [INCOMPLETE_IMPORTS_MESSAGE, "", INCLUDED_FILES_CAPTION, ...options.includedPaths.map(path => ` ${path}`), "", ""].join("\n") : "";
@@ -12,7 +19,7 @@ export async function saveLocalResults(folder, report, refusedFiles, outputDir,
12
19
  .replace("{entry}", () => scope.entry).replace("{count}", String(Array.isArray(scope.imports) ? scope.imports.length : 0)) + "\n\n" : "";
13
20
  const [findingsPath, summaryPath] = await writeOutputFiles(folder, {
14
21
  "findings.json": `${JSON.stringify(publicReport, null, 2)}\n`,
15
- "summary.md": incomplete + coverage + formatSummary(publicReport, options),
22
+ "summary.md": incomplete + coverage + formatSummary(publicReport, { ...options, analyzedFolder: options.analyzedFolder ?? folder, skippedOffer: !options.append, skippedFilesEnabled: skippedFilesEnabled(options.settings ?? {}) }),
16
23
  ...(options.selection ? { "selection.json": JSON.stringify({ schemaVersion: 1, selection: options.selection,
17
24
  ...(scope ? { scope } : {}) }, null, 2) + "\n" } : {}),
18
25
  }, outputDir);