@mrclrchtr/supi-review 2.8.0 → 3.0.0
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.
- package/README.md +71 -180
- package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
- package/package.json +3 -3
- package/src/config.ts +30 -21
- package/src/git-command.ts +78 -0
- package/src/git.ts +306 -505
- package/src/history/collect.ts +43 -120
- package/src/model.ts +3 -1
- package/src/review-path.ts +85 -0
- package/src/review-result.ts +43 -92
- package/src/review.ts +154 -290
- package/src/session/review-plan-store.ts +20 -51
- package/src/target/packet.ts +46 -369
- package/src/tool/agent-review-schemas.ts +101 -104
- package/src/tool/agent-review-tools.ts +268 -309
- package/src/tool/child-failure-diagnostics.ts +1 -1
- package/src/tool/child-resource-loader.ts +37 -0
- package/src/tool/child-session-runner.ts +83 -0
- package/src/tool/output-page.ts +47 -0
- package/src/tool/planner-runner.ts +69 -0
- package/src/tool/review-runner.ts +42 -257
- package/src/tool/review-system-prompt.ts +12 -110
- package/src/tool/review-tools.ts +119 -0
- package/src/tool/review-workflow.ts +309 -0
- package/src/tool/runner-helpers.ts +3 -8
- package/src/tool/schemas.ts +75 -62
- package/src/tui/common.ts +175 -0
- package/src/tui/prepare.ts +132 -0
- package/src/tui/run.ts +160 -0
- package/src/types.ts +153 -275
- package/src/history/synthesize.ts +0 -121
- package/src/tool/agent-review-workflow.ts +0 -398
- package/src/tool/brief-runner.ts +0 -180
- package/src/tool/guidance.ts +0 -21
- package/src/tool/review-handlers.ts +0 -314
- package/src/tool/snapshot-tools.ts +0 -117
- package/src/ui/flow.ts +0 -189
- package/src/ui/format-content.ts +0 -143
- package/src/ui/renderer.ts +0 -274
- package/src/ui/review-plan-inspector.ts +0 -391
- package/src/ui/review-tool-format.ts +0 -138
- package/src/ui/review-tool-renderer.ts +0 -234
package/src/history/collect.ts
CHANGED
|
@@ -1,135 +1,58 @@
|
|
|
1
1
|
import type { SessionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { extractVisibleText } from "../tool/runner-helpers.ts";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const MAX_PLANNER_CONTEXT_CHARS = 8_000;
|
|
5
|
+
type Message = SessionContext["messages"][number];
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
maxChars?: number;
|
|
7
|
+
interface PlannerRow {
|
|
8
|
+
kind: "summary" | "visible";
|
|
9
|
+
text: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
* This mirrors the overall approach Pi uses for compaction:
|
|
17
|
-
* - messages are labeled by role and rendered in chronological order
|
|
18
|
-
* - compaction and branch summaries appear with their own labels
|
|
19
|
-
* - the output is bounded so the synthesizer stays predictable on long sessions
|
|
20
|
-
* - no heuristic ranking or scoring is applied
|
|
21
|
-
*/
|
|
22
|
-
export function serializeSessionContext(
|
|
23
|
-
messages: ResolvedSessionMessage[],
|
|
24
|
-
options?: SerializeSessionContextOptions,
|
|
25
|
-
): string {
|
|
26
|
-
const maxChars = options?.maxChars ?? DEFAULT_MAX_CHARS;
|
|
27
|
-
if (messages.length === 0) return "";
|
|
28
|
-
|
|
29
|
-
const entries = messages
|
|
30
|
-
.map((message) => serializeEntry(message))
|
|
31
|
-
.filter(
|
|
32
|
-
(entry): entry is { label: string; text: string; isSummary: boolean } => entry !== undefined,
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
if (entries.length === 0) return "";
|
|
36
|
-
|
|
37
|
-
// Separate summary entries (compaction/branch summaries) from regular entries
|
|
38
|
-
const summaryEntries = entries.filter((e) => e.isSummary);
|
|
39
|
-
const regularEntries = entries.filter((e) => !e.isSummary);
|
|
40
|
-
|
|
41
|
-
// Estimate the total size if we keep everything
|
|
42
|
-
const allLines = entries.map(formatEntry);
|
|
43
|
-
const totalSize = measureLines(allLines);
|
|
44
|
-
|
|
45
|
-
if (totalSize <= maxChars) {
|
|
46
|
-
return allLines.join("\n");
|
|
12
|
+
function toPlannerRow(message: Message): PlannerRow | undefined {
|
|
13
|
+
if (message.role === "compactionSummary") {
|
|
14
|
+
return { kind: "summary", text: `[Summary]\n${message.summary}` };
|
|
47
15
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const summaryLines = summaryEntries.map(formatEntry);
|
|
51
|
-
|
|
52
|
-
// Walk regular entries from newest to oldest, keeping as many as fit
|
|
53
|
-
const keptRegular: typeof regularEntries = [];
|
|
54
|
-
let size = measureLines(summaryLines);
|
|
55
|
-
|
|
56
|
-
for (let i = regularEntries.length - 1; i >= 0; i--) {
|
|
57
|
-
const line = formatEntry(regularEntries[i]);
|
|
58
|
-
const lineLen = line.length + 1; // +1 for newline
|
|
59
|
-
if (keptRegular.length > 0 && size + lineLen > maxChars) {
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
keptRegular.unshift(regularEntries[i]);
|
|
63
|
-
size += lineLen;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Build final output: summaries first, then kept recent entries
|
|
67
|
-
const keptLines = [...summaryLines, ...keptRegular.map(formatEntry)];
|
|
68
|
-
|
|
69
|
-
// If still nothing fits with summaries alone, truncate the summaries
|
|
70
|
-
if (keptLines.length > 0) {
|
|
71
|
-
const output = keptLines.join("\n");
|
|
72
|
-
if (output.length <= maxChars) return output;
|
|
16
|
+
if (message.role === "branchSummary") {
|
|
17
|
+
return { kind: "summary", text: `[Branch summary]\n${message.summary}` };
|
|
73
18
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
function formatEntry(entry: { label: string; text: string }): string {
|
|
80
|
-
return `[${entry.label}]\n${entry.text}`;
|
|
19
|
+
if (message.role !== "user" && message.role !== "assistant") return undefined;
|
|
20
|
+
const text = extractVisibleText(message.content)?.trim();
|
|
21
|
+
if (!text) return undefined;
|
|
22
|
+
const label = message.role === "user" ? "User" : "Assistant";
|
|
23
|
+
return { kind: "visible", text: `[${label}]\n${text}` };
|
|
81
24
|
}
|
|
82
25
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function keepFirstLines(lines: string[], maxChars: number): string {
|
|
88
|
-
const kept: string[] = [];
|
|
26
|
+
function takeRecentRows(rows: string[], maxChars: number): string {
|
|
27
|
+
const recent: string[] = [];
|
|
89
28
|
let size = 0;
|
|
90
|
-
for (
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function serializeEntry(
|
|
100
|
-
message: ResolvedSessionMessage,
|
|
101
|
-
): { label: string; text: string; isSummary: boolean } | undefined {
|
|
102
|
-
switch (message.role) {
|
|
103
|
-
case "user":
|
|
104
|
-
case "assistant": {
|
|
105
|
-
const text = normalizeText(extractAssistantText(message.content) ?? "");
|
|
106
|
-
if (!text) return undefined;
|
|
107
|
-
return {
|
|
108
|
-
label: message.role === "user" ? "User" : "Assistant",
|
|
109
|
-
text,
|
|
110
|
-
isSummary: false,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
case "custom": {
|
|
114
|
-
const text = normalizeText(extractAssistantText(message.content) ?? "");
|
|
115
|
-
if (!text) return undefined;
|
|
116
|
-
return { label: "Custom", text, isSummary: false };
|
|
117
|
-
}
|
|
118
|
-
case "compactionSummary": {
|
|
119
|
-
const text = normalizeText(message.summary);
|
|
120
|
-
if (!text) return undefined;
|
|
121
|
-
return { label: "Compaction summary", text, isSummary: true };
|
|
122
|
-
}
|
|
123
|
-
case "branchSummary": {
|
|
124
|
-
const text = normalizeText(message.summary);
|
|
125
|
-
if (!text) return undefined;
|
|
126
|
-
return { label: "Branch summary", text, isSummary: true };
|
|
29
|
+
for (let index = rows.length - 1; index >= 0; index--) {
|
|
30
|
+
const row = rows[index];
|
|
31
|
+
const separator = recent.length > 0 ? 1 : 0;
|
|
32
|
+
const remaining = maxChars - size - separator;
|
|
33
|
+
if (row.length > remaining) {
|
|
34
|
+
if (recent.length === 0 && remaining > 0) recent.unshift(row.slice(0, remaining));
|
|
35
|
+
break;
|
|
127
36
|
}
|
|
128
|
-
|
|
129
|
-
|
|
37
|
+
recent.unshift(row);
|
|
38
|
+
size += row.length + separator;
|
|
130
39
|
}
|
|
40
|
+
return recent.join("\n");
|
|
131
41
|
}
|
|
132
42
|
|
|
133
|
-
|
|
134
|
-
|
|
43
|
+
/** Build a small planning-only projection of summaries and recent visible conversation. */
|
|
44
|
+
export function collectPlannerContext(messages: Message[]): string {
|
|
45
|
+
const rows = messages.flatMap((message) => {
|
|
46
|
+
const row = toPlannerRow(message);
|
|
47
|
+
return row ? [row] : [];
|
|
48
|
+
});
|
|
49
|
+
const summaries = rows
|
|
50
|
+
.filter((row) => row.kind === "summary")
|
|
51
|
+
.map((row) => row.text)
|
|
52
|
+
.join("\n")
|
|
53
|
+
.slice(0, MAX_PLANNER_CONTEXT_CHARS);
|
|
54
|
+
const visible = rows.filter((row) => row.kind === "visible").map((row) => row.text);
|
|
55
|
+
const separator = summaries && visible.length > 0 ? 1 : 0;
|
|
56
|
+
const recent = takeRecentRows(visible, MAX_PLANNER_CONTEXT_CHARS - summaries.length - separator);
|
|
57
|
+
return [summaries, recent].filter(Boolean).join("\n");
|
|
135
58
|
}
|
package/src/model.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { getSelectableModels } from "@mrclrchtr/supi-core/model-selection";
|
|
3
3
|
import type { ReviewModelSelection } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
/** Sentinel that resolves to the model active when review
|
|
5
|
+
/** Sentinel that resolves to the model active when an agent review operation starts. */
|
|
6
6
|
export const CURRENT_SESSION_REVIEW_MODEL = "current";
|
|
7
7
|
|
|
8
8
|
/** Build the canonical `provider/modelId` string used throughout the review flow. */
|
|
@@ -15,6 +15,7 @@ export { toCanonicalModelId } from "@mrclrchtr/supi-core/model-selection";
|
|
|
15
15
|
*/
|
|
16
16
|
export function getSelectableReviewModels(
|
|
17
17
|
ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
|
|
18
|
+
/** Optional override for Pi's scoped model patterns; omit to use configured defaults. */
|
|
18
19
|
enabledModelPatterns?: string[],
|
|
19
20
|
): ReviewModelSelection[] {
|
|
20
21
|
return getSelectableModels(ctx, enabledModelPatterns) as ReviewModelSelection[];
|
|
@@ -48,6 +49,7 @@ export function getCurrentReviewModel(
|
|
|
48
49
|
export function resolveAgentReviewModel(
|
|
49
50
|
ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
|
|
50
51
|
configuredModelId: string,
|
|
52
|
+
/** Optional override for Pi's scoped model patterns; omit to use configured defaults. */
|
|
51
53
|
enabledModelPatterns?: string[],
|
|
52
54
|
): ReviewModelSelection | undefined {
|
|
53
55
|
const modelId = configuredModelId.trim();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { lstat, readFile, readlink, realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, posix, relative, resolve, sep, win32 } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Verified repository-relative review path that cannot lexically escape the worktree. */
|
|
5
|
+
export interface SafeReviewPath {
|
|
6
|
+
absolute: string;
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Normalize a portable repository-relative path without touching the filesystem. */
|
|
11
|
+
export function normalizeRepositoryRelativePath(path: string): string {
|
|
12
|
+
if (!path || path.includes("\0") || posix.isAbsolute(path) || win32.isAbsolute(path)) {
|
|
13
|
+
throw new Error(`Review path must stay inside the repository: ${path}`);
|
|
14
|
+
}
|
|
15
|
+
const normalized = posix.normalize(path.replaceAll("\\", "/"));
|
|
16
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
17
|
+
throw new Error(`Review path must stay inside the repository: ${path}`);
|
|
18
|
+
}
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve a repository-relative tool path without allowing lexical escape. */
|
|
23
|
+
export function resolveReviewPath(cwd: string, path: string): SafeReviewPath {
|
|
24
|
+
const normalizedInput = normalizeRepositoryRelativePath(path);
|
|
25
|
+
const absolute = resolve(cwd, normalizedInput);
|
|
26
|
+
const normalized = relative(resolve(cwd), absolute);
|
|
27
|
+
if (!normalized || normalized === ".." || normalized.startsWith(`..${sep}`)) {
|
|
28
|
+
throw new Error(`Review path must stay inside the repository: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
return { absolute, path: normalized.split(sep).join("/") };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isMissingPath(error: unknown): boolean {
|
|
34
|
+
return (
|
|
35
|
+
!!error &&
|
|
36
|
+
typeof error === "object" &&
|
|
37
|
+
"code" in error &&
|
|
38
|
+
((error as { code?: unknown }).code === "ENOENT" ||
|
|
39
|
+
(error as { code?: unknown }).code === "ENOTDIR")
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertInsideRepository(repository: string, candidate: string, originalPath: string): void {
|
|
44
|
+
const rel = relative(repository, candidate);
|
|
45
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
46
|
+
throw new Error(`Review path must stay inside the repository: ${originalPath}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read a working-tree file without following an intermediate symlink outside the repository. */
|
|
51
|
+
export async function readWorkingTreeFile(cwd: string, path: string): Promise<string | undefined> {
|
|
52
|
+
const safe = resolveReviewPath(cwd, path);
|
|
53
|
+
try {
|
|
54
|
+
const [repository, parent, stat] = await Promise.all([
|
|
55
|
+
realpath(cwd),
|
|
56
|
+
realpath(dirname(safe.absolute)),
|
|
57
|
+
lstat(safe.absolute),
|
|
58
|
+
]);
|
|
59
|
+
assertInsideRepository(repository, parent, path);
|
|
60
|
+
if (stat.isSymbolicLink()) return await readlink(safe.absolute);
|
|
61
|
+
if (!stat.isFile()) return undefined;
|
|
62
|
+
const resolvedFile = await realpath(safe.absolute);
|
|
63
|
+
assertInsideRepository(repository, resolvedFile, path);
|
|
64
|
+
return await readFile(safe.absolute, "utf8");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (isMissingPath(error)) return undefined;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove deleted paths from a Git-produced repository-relative path list. */
|
|
72
|
+
export async function filterExistingReviewPaths(cwd: string, paths: string[]): Promise<string[]> {
|
|
73
|
+
const existing = await Promise.all(
|
|
74
|
+
paths.map(async (path) => {
|
|
75
|
+
try {
|
|
76
|
+
await lstat(resolveReviewPath(cwd, path).absolute);
|
|
77
|
+
return path;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (isMissingPath(error)) return undefined;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
return existing.filter((path): path is string => path !== undefined);
|
|
85
|
+
}
|
package/src/review-result.ts
CHANGED
|
@@ -1,98 +1,49 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
RawReviewResult,
|
|
4
|
-
ReviewItem,
|
|
5
|
-
ReviewItemCategory,
|
|
6
|
-
ReviewItemEffort,
|
|
7
|
-
ReviewItemImpact,
|
|
8
|
-
ReviewItemRecommendedAction,
|
|
9
|
-
ReviewOutputEvent,
|
|
10
|
-
ReviewResult,
|
|
11
|
-
ReviewSummary,
|
|
12
|
-
} from "./types.ts";
|
|
1
|
+
import { normalizeRepositoryRelativePath } from "./review-path.ts";
|
|
2
|
+
import type { NormalizedReviewSubmission, ReviewSubmission } from "./types.ts";
|
|
13
3
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const impactRank: Record<ReviewItemImpact, number> = {
|
|
21
|
-
high: 0,
|
|
22
|
-
medium: 1,
|
|
23
|
-
low: 2,
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const effortRank: Record<ReviewItemEffort, number> = {
|
|
27
|
-
low: 0,
|
|
28
|
-
medium: 1,
|
|
29
|
-
high: 2,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** Normalize raw reviewer output into the host-owned review contract. */
|
|
33
|
-
export function normalizeReviewOutput(output: ReviewOutputEvent): NormalizedReviewOutput {
|
|
34
|
-
const items = [...(Array.isArray(output.items) ? output.items : [])].sort(compareReviewItems);
|
|
35
|
-
const summary = summarizeReviewItems(items);
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
...output,
|
|
39
|
-
items,
|
|
40
|
-
overall_correctness: summary.actions.mustFix > 0 ? "PATCH HAS ISSUES" : "PATCH IS CORRECT",
|
|
41
|
-
summary,
|
|
42
|
-
};
|
|
4
|
+
function requireText(value: string, label: string): string {
|
|
5
|
+
const normalized = value.trim();
|
|
6
|
+
if (!normalized) throw new Error(`${label} must not be blank.`);
|
|
7
|
+
return normalized;
|
|
43
8
|
}
|
|
44
9
|
|
|
45
|
-
/**
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
10
|
+
/** Derive the machine verdict while preserving reviewer-authored finding order. */
|
|
11
|
+
export function normalizeReviewSubmission(
|
|
12
|
+
submission: ReviewSubmission,
|
|
13
|
+
): NormalizedReviewSubmission {
|
|
51
14
|
return {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
15
|
+
summary: requireText(submission.summary, "Review summary"),
|
|
16
|
+
findings: submission.findings.map((finding, index) => {
|
|
17
|
+
if (
|
|
18
|
+
!Number.isFinite(finding.confidence) ||
|
|
19
|
+
finding.confidence < 0 ||
|
|
20
|
+
finding.confidence > 1
|
|
21
|
+
) {
|
|
22
|
+
throw new Error(`Finding ${index + 1} confidence must be between 0 and 1.`);
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
finding.location &&
|
|
26
|
+
(!Number.isSafeInteger(finding.location.startLine) ||
|
|
27
|
+
!Number.isSafeInteger(finding.location.endLine) ||
|
|
28
|
+
finding.location.startLine < 1 ||
|
|
29
|
+
finding.location.endLine < finding.location.startLine)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error(`Finding ${index + 1} location must contain a valid ordered line range.`);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
...finding,
|
|
35
|
+
title: requireText(finding.title, `Finding ${index + 1} title`),
|
|
36
|
+
description: requireText(finding.description, `Finding ${index + 1} description`),
|
|
37
|
+
location: finding.location
|
|
38
|
+
? {
|
|
39
|
+
...finding.location,
|
|
40
|
+
path: normalizeRepositoryRelativePath(
|
|
41
|
+
requireText(finding.location.path, `Finding ${index + 1} location path`),
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
: undefined,
|
|
45
|
+
};
|
|
46
|
+
}),
|
|
47
|
+
verdict: submission.findings.some((finding) => finding.blocksAcceptance) ? "issues" : "pass",
|
|
79
48
|
};
|
|
80
|
-
|
|
81
|
-
for (const item of items) {
|
|
82
|
-
switch (item.recommended_action) {
|
|
83
|
-
case "must-fix":
|
|
84
|
-
summary.actions.mustFix++;
|
|
85
|
-
break;
|
|
86
|
-
case "should-fix":
|
|
87
|
-
summary.actions.shouldFix++;
|
|
88
|
-
break;
|
|
89
|
-
case "consider":
|
|
90
|
-
summary.actions.consider++;
|
|
91
|
-
break;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
categories[item.category] = (categories[item.category] ?? 0) + 1;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return summary;
|
|
98
49
|
}
|