@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
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ import { discoverLocalSources, fileScopeEnabled, readEntrySelection } from "../analysis-core/index.js";
2
+ import { measureAnalysisSize, assessAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
3
+ import { FileReadRefusal } from "../analysis-core/conventions/file-admission.js";
4
+ import { localRepoKeyFor } from "../analysis-core/github/repo-key.js";
5
+ import { cliRefusals } from "../contract/cli-refusals.js";
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
+ 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";
11
+ /** The CLI consumes the core membership answer and captures only its retained files. */
12
+ export const entrySelectionProvider = async (settings, request, options) => {
13
+ if (!fileScopeEnabled(settings) || request.kind !== "file")
14
+ throw new Error(cliRefusals.release_unavailable);
15
+ const port = await createEntryFiles(settings, request.launchRoot, options.admission, options.signal, request.entries ?? request.entry).catch(() => {
16
+ options.signal?.throwIfAborted();
17
+ throw new Error(INVALID_SELECTION_MESSAGE);
18
+ });
19
+ const input = { ...port, request: { ...request, scope: undefined }, admission: options.admission };
20
+ const full = await readEntrySelection(settings, input);
21
+ options.signal?.throwIfAborted();
22
+ const refusal = (reason) => {
23
+ if (reason === "over-limit")
24
+ throw new Error(SINGLE_FILE_TOO_LARGE_MESSAGE);
25
+ if (reason === "stale-snapshot")
26
+ throw new Error(STALE_SCOPE_MESSAGE);
27
+ if (reason === "invalid-scope")
28
+ throw new Error(options.proposal ? SCOPE_PROPOSAL_CONFLICT_MESSAGE : INVALID_SCOPE_MESSAGE);
29
+ if (reason === "excluded" || reason === "hidden" || reason === "secret" || reason === "secret-content")
30
+ throw new FileReadRefusal(reason, request.entry);
31
+ throw new Error(INVALID_SELECTION_MESSAGE);
32
+ };
33
+ if (full.kind !== "ready")
34
+ return refusal(full.reason);
35
+ // Replay the same admitted captures through the owner to validate a narrower scope.
36
+ const selected = request.scope || options.proposal ? await readEntrySelection(settings, { ...input, request, proposal: options.proposal }) : full;
37
+ options.signal?.throwIfAborted();
38
+ if (selected.kind !== "ready")
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
+ }
47
+ const { files } = selected;
48
+ const entries = selected.selection.files.map(({ path, size }) => ({ path, size, ref: path }));
49
+ const selection = { selected: entries, refused: [], totalBytes: entries.reduce((sum, file) => sum + file.size, 0) };
50
+ const repoKey = localRepoKeyFor(request.launchRoot);
51
+ const discovered = await discoverLocalSources(settings, { repoKey, entries, selection, membership: selected.selection, admission: options.admission,
52
+ read: async (entry) => files[entry.path] });
53
+ const analysisSize = measureAnalysisSize(settings, entries, options.admission);
54
+ return { membership: full.selection, source: { ...discovered, repoKey, root: ".", sourceBase: request.launchRoot, files, selection,
55
+ snapshot: Object.fromEntries(entries.map(({ path }) => [path, port.snapshot[path]])), analysisSize,
56
+ ...(settings.CF_REFUSE_TOO_LARGE === "0" ? {} : { analysisFit: assessAnalysisSize(analysisSize) }) } };
57
+ };
@@ -1,22 +1,42 @@
1
+ import { folderPartsEnabled, skippedFilesEnabled } from "../analysis-core/settings.js";
2
+ import { ModuleResolver, moduleResolutionLimits } from "../analysis-core/import/calls/modules.js";
1
3
  import { opendir, realpath, stat, readFile } from "node:fs/promises";
2
4
  import { dirname, join, posix } from "node:path";
3
5
  import { admitFile, FileReadRefusal, fileRefusalWarning, localSymlinkGuardEnabled, respectGitignore, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
4
6
  import { assessAnalysisSize, measureAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
5
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";
6
13
  import { fetchLocalSources } from "../analysis-core/index.js";
7
14
  import { repoPathFrom } from "../analysis-core/import/outside-loader-sources.js";
15
+ import { READ_LIMITS } from "../analysis-core/conventions/read-limits.js";
8
16
  import { mergeSkippedFiles, mergeUnlistedFileWarnings } from "../analysis-core/analysis/code-agents/skipped-files.js";
9
17
  import { createGitIgnore } from "./git-ignore.js";
10
18
  import { createIgnoredFileCollector } from "./ignored-files.js";
11
19
  import { resolveFolderPath } from "./folder-path.js";
12
20
  import { createRepositoryFiles } from "./repository-files.js";
13
- export const FOLDER_ENTRY_LIMIT = 10_000;
21
+ export const FOLDER_ENTRY_LIMIT = moduleResolutionLimits.inventoryEntries;
14
22
  export const FOLDER_ENTRY_LIMIT_MESSAGE = "The folder has too many entries to analyze. Choose a smaller folder.";
15
- 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 = {}) {
16
35
  const root = await resolveFolderPath(folder);
17
36
  if (!(await stat(root)).isDirectory())
18
37
  throw new Error("Select a folder to analyze.");
19
38
  const entries = [];
39
+ const knownPaths = new Set(), opaquePaths = new Set();
20
40
  const warnings = [];
21
41
  const refused = [];
22
42
  const gitSkipped = [];
@@ -75,12 +95,17 @@ export async function readLocalFolder(settings, folder, admission = {}, options
75
95
  throw new Error(FOLDER_ENTRY_LIMIT_MESSAGE);
76
96
  items.push(entry);
77
97
  }
98
+ opaquePaths.delete(prefix);
78
99
  const names = items.map(item => item.name);
79
100
  const facts = await ignoredPaths(names.map(name => join(target, name)));
80
101
  const gitIgnored = facts && new Set(names.filter(name => facts.has(join(target, name))).map(name => prefix ? `${prefix}/${name}` : name));
81
102
  for (const item of items.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
82
103
  const name = item.name;
83
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);
84
109
  // A link's filename-only refusal waits until its target type is known.
85
110
  const listed = { path, directory: item.isDirectory() || item.isSymbolicLink() };
86
111
  if (!admitFile(settings, listed, { purpose: "listing", route: "local-folder", ...admission }).admitted)
@@ -112,6 +137,7 @@ export async function readLocalFolder(settings, folder, admission = {}, options
112
137
  if (info.isDirectory())
113
138
  await walk(file, path, parents);
114
139
  else if (info.isFile()) {
140
+ opaquePaths.delete(path);
115
141
  entries.push({ path, size: info.size, ref: path });
116
142
  targets.set(path, file);
117
143
  }
@@ -121,9 +147,11 @@ export async function readLocalFolder(settings, folder, admission = {}, options
121
147
  gitIgnored: await ignoredPaths(["."]), localTarget: { root, path: root } });
122
148
  if (selectedRoot.admitted)
123
149
  await walk(root, "", new Set());
124
- else
150
+ else {
151
+ opaquePaths.add("");
125
152
  gitSkipped.push(...await ignoredFiles(root, ""));
126
- const analysisSize = measureAnalysisSize(settings, entries, admission);
153
+ }
154
+ const analysisSize = measureAnalysisSize(settings, entries, { ...admission, paths: fileScopeOf(settings) });
127
155
  // The service makes the final decision; local prediction never changes retained files.
128
156
  const analysisFit = settings.CF_REFUSE_TOO_LARGE === "0" ? undefined : assessAnalysisSize(analysisSize);
129
157
  const listing = new Set(entries.map(entry => entry.path));
@@ -141,6 +169,77 @@ export async function readLocalFolder(settings, folder, admission = {}, options
141
169
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
142
170
  }, { ...admission, listing, route: "local-folder", localTarget: { root, path: target, excludedTargets } }) : null;
143
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
+ }
144
243
  const discovered = await fetchLocalSources(settings, { repoKey, entries, admission, repository,
145
244
  proposal: options.proposal, proposalRefusals: [...gitSkipped, ...refused],
146
245
  read: entry => read(entry, "override"), proposalRead: entry => read(entry, "proposal"),
@@ -148,8 +247,15 @@ export async function readLocalFolder(settings, folder, admission = {}, options
148
247
  const expanded = discovered.root !== undefined;
149
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) })));
150
249
  const captured = { ...Object.fromEntries(Object.entries(snapshot).map(([path, bytes]) => [repoPathFrom(path, discovered.root), bytes])), ...repository.snapshot };
151
- 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 } : {}),
152
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
+ } }),
153
259
  ...(options.captureSnapshot ? { snapshot: Object.fromEntries(Object.keys(discovered.files).map(path => [path, captured[path]])) } : {}), fragment: discovered.fragment, selection: discovered.selection,
154
260
  skippedFiles: mergeSkippedFiles(discovered.skippedFiles, ignored, expanded ? refused.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : refused),
155
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,13 +1,28 @@
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";
8
+ import { INCLUDED_FILES_CAPTION, INCOMPLETE_IMPORTS_MESSAGE, SELECTION_COVERAGE_DESCRIPTION } from "../contract/scope-choice.js";
5
9
  export async function saveLocalResults(folder, report, refusedFiles, outputDir, options) {
6
- 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);
15
+ const scope = options.scope;
16
+ const incomplete = options.selection && !options.selection.completeness.complete
17
+ ? [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" : "";
7
20
  const [findingsPath, summaryPath] = await writeOutputFiles(folder, {
8
21
  "findings.json": `${JSON.stringify(publicReport, null, 2)}\n`,
9
- "summary.md": formatSummary(publicReport, options),
22
+ "summary.md": incomplete + coverage + formatSummary(publicReport, { ...options, analyzedFolder: options.analyzedFolder ?? folder, skippedOffer: !options.append, skippedFilesEnabled: skippedFilesEnabled(options.settings ?? {}) }),
23
+ ...(options.selection ? { "selection.json": JSON.stringify({ schemaVersion: 1, selection: options.selection,
24
+ ...(scope ? { scope } : {}) }, null, 2) + "\n" } : {}),
10
25
  }, outputDir);
11
- const ignoreAdded = await ignoreDefaultOutput(folder, outputDir, options);
26
+ const ignoreAdded = await ignoreDefaultOutput(folder, options.defaultFileOutput ? undefined : outputDir, options);
12
27
  return { findingsPath, summaryPath, ignoreAdded };
13
28
  }
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join, relative, sep } from "node:path";
3
+ import { analysisSelectionRequestSchema, analysisSelectionResultSchema, ANALYSIS_SELECTION_LIMITS, MAX_ANALYSIS_ENTRIES } from "../analysis-core/contracts/analysis-selection.js";
4
+ import { findEntryScopeChoice } from "../analysis-core/import/entry-selection.js";
5
+ import { INVALID_SCOPE_MESSAGE, INVALID_SELECTION_MESSAGE, SCOPE_PROPOSAL_CONFLICT_MESSAGE, STALE_SCOPE_MESSAGE } from "../contract/scope-choice.js";
6
+ import { assessAnalysisSize, measureAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
7
+ import { ScopeChoiceError, selectionScopeChoices } from "../contract/selection-offer.js";
8
+ import { readLocalFolder } from "./files.js";
9
+ import { scopeChoicesEnabled, selectionModeEnabled } from "./settings.js";
10
+ import { entrySelectionProvider } from "./entry-selection.js";
11
+ import { fittingEntryGroups } from "./entry-packing.js";
12
+ const samePaths = (left, right) => left.length === right.length
13
+ && new Set(left).size === left.length && left.every(path => right.includes(path));
14
+ function selectionRequest(selection) {
15
+ const parsed = analysisSelectionRequestSchema.safeParse(selection.request);
16
+ if (!parsed.success)
17
+ throw new Error(INVALID_SELECTION_MESSAGE);
18
+ const request = parsed.data;
19
+ const path = request.kind === "file" ? request.entry : request.folder;
20
+ if (request.launchRoot !== selection.launchRoot || request.kind !== selection.kind
21
+ || join(request.launchRoot, path) !== selection.path
22
+ || request.kind === "file" && request.entry !== selection.entryPath)
23
+ throw new Error(INVALID_SELECTION_MESSAGE);
24
+ return request;
25
+ }
26
+ /** The provider owns membership and admission; this boundary only verifies its captured answer. */
27
+ export async function readAnalysisSelection(settings, selection, options = {}) {
28
+ options.signal?.throwIfAborted();
29
+ const request = selectionRequest(selection);
30
+ const { source, membership: rawMembership } = await (options.provider ?? entrySelectionProvider)(settings, request, {
31
+ proposal: options.proposal, admission: options.admission, signal: options.signal,
32
+ });
33
+ options.signal?.throwIfAborted();
34
+ const parsed = analysisSelectionResultSchema.safeParse(rawMembership);
35
+ if (!parsed.success)
36
+ throw new Error(INVALID_SELECTION_MESSAGE);
37
+ const membership = parsed.data;
38
+ if (membership.launchRoot !== request.launchRoot || membership.kind !== request.kind
39
+ || request.kind === "file" && (membership.kind !== "file" || membership.entry !== request.entry)
40
+ || request.kind === "file" && membership.kind === "file" && JSON.stringify(request.entries) !== JSON.stringify(membership.entries)
41
+ || request.kind === "folder" && (membership.kind !== "folder" || membership.folder !== request.folder))
42
+ throw new Error(INVALID_SELECTION_MESSAGE);
43
+ let paths = membership.files.map(file => file.path);
44
+ if (request.kind === "file" && request.scope) {
45
+ if (request.scope.snapshot !== membership.snapshot)
46
+ throw new Error(STALE_SCOPE_MESSAGE);
47
+ const choice = findEntryScopeChoice(membership, request.scope);
48
+ if (!choice)
49
+ throw new Error(INVALID_SCOPE_MESSAGE);
50
+ paths = choice.includes;
51
+ }
52
+ if (options.proposal?.files.some(file => !paths.includes(file.path)))
53
+ throw new Error(SCOPE_PROPOSAL_CONFLICT_MESSAGE);
54
+ if (!samePaths(Object.keys(source.files), paths) || !source.snapshot || !samePaths(Object.keys(source.snapshot), paths)
55
+ || source.sourceBase !== undefined && source.sourceBase !== request.launchRoot
56
+ || source.root !== undefined && source.root !== ".")
57
+ throw new Error(INVALID_SELECTION_MESSAGE);
58
+ const snapshot = Object.create(null);
59
+ for (const path of paths) {
60
+ const file = membership.files.find(file => file.path === path);
61
+ const bytes = source.snapshot[path];
62
+ // Decode the capture itself: a clipped string or replacement character cannot prove complete evidence.
63
+ let text;
64
+ try {
65
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
66
+ }
67
+ catch {
68
+ throw new Error(INVALID_SELECTION_MESSAGE);
69
+ }
70
+ if (!Buffer.isBuffer(bytes) || bytes.length !== file.size || text !== source.files[path]
71
+ || createHash("sha256").update(bytes).digest("hex") !== file.hash)
72
+ throw new Error(INVALID_SELECTION_MESSAGE);
73
+ snapshot[path] = Buffer.from(bytes);
74
+ }
75
+ if (!samePaths(source.selection.selected.map(file => file.path), paths)
76
+ || source.selection.selected.some(file => file.size !== membership.files.find(member => member.path === file.path)?.size))
77
+ throw new Error(INVALID_SELECTION_MESSAGE);
78
+ // All downstream paths use launch-relative coordinates, including imported files outside the entry's parent.
79
+ const selectedPath = relative(request.launchRoot, selection.path).split(sep).join("/");
80
+ if (selectedPath === ".." || selectedPath.startsWith("../"))
81
+ throw new Error(INVALID_SELECTION_MESSAGE);
82
+ return { source: { ...source, sourceBase: request.launchRoot, files: Object.freeze({ ...source.files }), snapshot }, membership };
83
+ }
84
+ /** Local byte admission can prove a group fits; ordinary run admission still validates its captured source. */
85
+ export function selectionSizeOffer(settings, membership, source, options = {}) {
86
+ const currentPaths = Object.keys(source.files);
87
+ const entryCount = membership.kind === "file" && membership.entries
88
+ ? membership.files.filter(file => file.role === "entry" && currentPaths.includes(file.path)).length : 1;
89
+ if (!options.force && source.analysisFit?.fits !== false && entryCount <= MAX_ANALYSIS_ENTRIES)
90
+ return undefined;
91
+ const oversizedPaths = membership.files.filter(file => file.role === "entry"
92
+ && !assessAnalysisSize(measureAnalysisSize(settings, [file])).fits).map(file => file.path);
93
+ const groups = fittingEntryGroups(settings, membership, currentPaths);
94
+ const choices = [...groups, ...membership.choices].slice(0, ANALYSIS_SELECTION_LIMITS.choices);
95
+ const offer = selectionScopeChoices({ ...membership, choices }, { currentPaths, oversizedPaths, allowCurrent: options.force });
96
+ if (offer)
97
+ for (const choice of offer.choices)
98
+ if (groups.some(group => group.id === choice.id))
99
+ choice.fits = true;
100
+ return offer;
101
+ }
102
+ /** Both execution paths use this owner to select the reader and stop before a nonfitting run. */
103
+ export async function readAnalysisSource(settings, folder, options = {}) {
104
+ const selection = selectionModeEnabled(settings) ? options.selection : undefined;
105
+ const selected = selection && (selection.kind === "file" || options.provider && scopeChoicesEnabled(settings))
106
+ ? await readAnalysisSelection(settings, selection, options) : undefined;
107
+ const source = selected?.source ?? await (options.readFolder?.() ?? readLocalFolder(settings, folder, options.admission));
108
+ if (selected && scopeChoicesEnabled(settings)) {
109
+ const offer = selectionSizeOffer(settings, selected.membership, source);
110
+ if (offer)
111
+ throw new ScopeChoiceError(offer);
112
+ }
113
+ return { source, ...(selected ? { membership: selected.membership } : {}) };
114
+ }