@mainahq/core 0.2.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 +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff-cover Integration for the Verify Engine.
|
|
3
|
+
*
|
|
4
|
+
* Runs diff-cover to find changed lines that lack test coverage.
|
|
5
|
+
* Parses JSON output into the unified Finding type.
|
|
6
|
+
* Gracefully skips if diff-cover is not installed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isToolAvailable } from "./detect";
|
|
10
|
+
import type { Finding } from "./diff-filter";
|
|
11
|
+
|
|
12
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface CoverageOptions {
|
|
15
|
+
coverageXml?: string;
|
|
16
|
+
baseBranch?: string;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
/** Pre-resolved availability — skips redundant detection if provided. */
|
|
19
|
+
available?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CoverageResult {
|
|
23
|
+
findings: Finding[];
|
|
24
|
+
skipped: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── JSON Parsing ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse diff-cover JSON output into Finding[].
|
|
31
|
+
*
|
|
32
|
+
* Expected format:
|
|
33
|
+
* ```json
|
|
34
|
+
* {
|
|
35
|
+
* "src_stats": {
|
|
36
|
+
* "src/app.ts": {
|
|
37
|
+
* "covered_lines": [10, 11, 12],
|
|
38
|
+
* "violation_lines": [15, 16],
|
|
39
|
+
* "percent_covered": 60.0
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Each violation line becomes a Finding with warning severity.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDiffCoverJson(json: string): Finding[] {
|
|
48
|
+
let parsed: Record<string, unknown>;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(json) as Record<string, unknown>;
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const srcStats = parsed.src_stats;
|
|
56
|
+
if (!srcStats || typeof srcStats !== "object") {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const findings: Finding[] = [];
|
|
61
|
+
|
|
62
|
+
for (const [filePath, stats] of Object.entries(
|
|
63
|
+
srcStats as Record<string, unknown>,
|
|
64
|
+
)) {
|
|
65
|
+
const s = stats as Record<string, unknown>;
|
|
66
|
+
const violationLines = s.violation_lines;
|
|
67
|
+
const percentCovered = (s.percent_covered as number) ?? 0;
|
|
68
|
+
|
|
69
|
+
if (!Array.isArray(violationLines) || violationLines.length === 0) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const line of violationLines) {
|
|
74
|
+
if (typeof line !== "number") continue;
|
|
75
|
+
|
|
76
|
+
findings.push({
|
|
77
|
+
tool: "diff-cover",
|
|
78
|
+
file: filePath,
|
|
79
|
+
line,
|
|
80
|
+
message: `Changed line not covered by tests (file: ${Math.round(percentCovered)}% covered)`,
|
|
81
|
+
severity: "warning",
|
|
82
|
+
ruleId: "diff-cover/uncovered-line",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Runner ───────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run diff-cover and return parsed findings.
|
|
94
|
+
*
|
|
95
|
+
* If diff-cover is not installed, returns `{ findings: [], skipped: true }`.
|
|
96
|
+
* If diff-cover fails, returns `{ findings: [], skipped: false }`.
|
|
97
|
+
*/
|
|
98
|
+
export async function runCoverage(
|
|
99
|
+
options?: CoverageOptions,
|
|
100
|
+
): Promise<CoverageResult> {
|
|
101
|
+
const toolAvailable =
|
|
102
|
+
options?.available ?? (await isToolAvailable("diff-cover"));
|
|
103
|
+
if (!toolAvailable) {
|
|
104
|
+
return { findings: [], skipped: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
108
|
+
const coverageXml = options?.coverageXml ?? "coverage/cobertura-coverage.xml";
|
|
109
|
+
const baseBranch = options?.baseBranch ?? "main";
|
|
110
|
+
|
|
111
|
+
const args = [
|
|
112
|
+
"diff-cover",
|
|
113
|
+
coverageXml,
|
|
114
|
+
`--compare-branch=${baseBranch}`,
|
|
115
|
+
"--json",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const proc = Bun.spawn(args, {
|
|
120
|
+
cwd,
|
|
121
|
+
stdout: "pipe",
|
|
122
|
+
stderr: "pipe",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const stdout = await new Response(proc.stdout).text();
|
|
126
|
+
await new Response(proc.stderr).text();
|
|
127
|
+
await proc.exited;
|
|
128
|
+
|
|
129
|
+
const findings = parseDiffCoverJson(stdout);
|
|
130
|
+
return { findings, skipped: false };
|
|
131
|
+
} catch {
|
|
132
|
+
return { findings: [], skipped: false };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Auto-Detection for the Verify Engine.
|
|
3
|
+
*
|
|
4
|
+
* Detects which verification tools (biome, semgrep, trivy, etc.) are
|
|
5
|
+
* available on the system by attempting to run their version commands.
|
|
6
|
+
* Checks both global PATH and local node_modules/.bin/ for each tool.
|
|
7
|
+
* Missing tools are gracefully skipped.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export type ToolName =
|
|
14
|
+
| "biome"
|
|
15
|
+
| "semgrep"
|
|
16
|
+
| "trivy"
|
|
17
|
+
| "secretlint"
|
|
18
|
+
| "sonarqube"
|
|
19
|
+
| "stryker"
|
|
20
|
+
| "diff-cover"
|
|
21
|
+
| "ruff"
|
|
22
|
+
| "golangci-lint"
|
|
23
|
+
| "cargo-clippy"
|
|
24
|
+
| "cargo-audit"
|
|
25
|
+
| "playwright";
|
|
26
|
+
|
|
27
|
+
export interface DetectedTool {
|
|
28
|
+
name: string;
|
|
29
|
+
command: string;
|
|
30
|
+
version: string | null;
|
|
31
|
+
available: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const TOOL_REGISTRY: Record<
|
|
35
|
+
ToolName,
|
|
36
|
+
{ command: string; versionFlag: string }
|
|
37
|
+
> = {
|
|
38
|
+
biome: { command: "biome", versionFlag: "--version" },
|
|
39
|
+
semgrep: { command: "semgrep", versionFlag: "--version" },
|
|
40
|
+
trivy: { command: "trivy", versionFlag: "--version" },
|
|
41
|
+
secretlint: { command: "secretlint", versionFlag: "--version" },
|
|
42
|
+
sonarqube: { command: "sonar-scanner", versionFlag: "--version" },
|
|
43
|
+
stryker: { command: "stryker", versionFlag: "--version" },
|
|
44
|
+
"diff-cover": { command: "diff-cover", versionFlag: "--version" },
|
|
45
|
+
ruff: { command: "ruff", versionFlag: "--version" },
|
|
46
|
+
"golangci-lint": { command: "golangci-lint", versionFlag: "--version" },
|
|
47
|
+
"cargo-clippy": { command: "cargo", versionFlag: "clippy --version" },
|
|
48
|
+
"cargo-audit": { command: "cargo-audit", versionFlag: "--version" },
|
|
49
|
+
playwright: { command: "npx", versionFlag: "playwright --version" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a version string from command output.
|
|
54
|
+
* Looks for common version patterns like "1.2.3", "v1.2.3", "Version: 1.2.3".
|
|
55
|
+
*/
|
|
56
|
+
function parseVersion(output: string): string | null {
|
|
57
|
+
const match = output.match(/v?(\d+\.\d+(?:\.\d+)?(?:[._-]\w+)*)/);
|
|
58
|
+
return match?.[1] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Try to spawn a command and parse its version output.
|
|
63
|
+
* Returns the detected version string on success, or null on failure.
|
|
64
|
+
*/
|
|
65
|
+
async function tryCommand(
|
|
66
|
+
command: string,
|
|
67
|
+
versionFlag: string,
|
|
68
|
+
): Promise<string | null> {
|
|
69
|
+
try {
|
|
70
|
+
const args = versionFlag.includes(" ")
|
|
71
|
+
? [command, ...versionFlag.split(" ")]
|
|
72
|
+
: [command, versionFlag];
|
|
73
|
+
const proc = Bun.spawn(args, {
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const stdout = await new Response(proc.stdout).text();
|
|
79
|
+
const stderr = await new Response(proc.stderr).text();
|
|
80
|
+
const exitCode = await proc.exited;
|
|
81
|
+
|
|
82
|
+
if (exitCode === 0) {
|
|
83
|
+
return parseVersion(stdout) ?? parseVersion(stderr);
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the nearest node_modules/.bin directory by walking up from cwd.
|
|
93
|
+
* Returns the path if found, null otherwise.
|
|
94
|
+
*/
|
|
95
|
+
function findLocalBinDir(startDir: string = process.cwd()): string | null {
|
|
96
|
+
let dir = startDir;
|
|
97
|
+
const root = "/";
|
|
98
|
+
|
|
99
|
+
while (dir !== root) {
|
|
100
|
+
const binDir = join(dir, "node_modules", ".bin");
|
|
101
|
+
if (existsSync(binDir)) {
|
|
102
|
+
return binDir;
|
|
103
|
+
}
|
|
104
|
+
const parent = join(dir, "..");
|
|
105
|
+
if (parent === dir) break;
|
|
106
|
+
dir = parent;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Detect whether a single tool is available on the system.
|
|
113
|
+
* Tries the global PATH first, then falls back to node_modules/.bin/.
|
|
114
|
+
* Never throws — unavailable tools return `{ available: false }`.
|
|
115
|
+
*/
|
|
116
|
+
export async function detectTool(name: ToolName): Promise<DetectedTool> {
|
|
117
|
+
const entry = TOOL_REGISTRY[name];
|
|
118
|
+
|
|
119
|
+
// Try global PATH first
|
|
120
|
+
const globalVersion = await tryCommand(entry.command, entry.versionFlag);
|
|
121
|
+
if (globalVersion !== null) {
|
|
122
|
+
return {
|
|
123
|
+
name,
|
|
124
|
+
command: entry.command,
|
|
125
|
+
version: globalVersion,
|
|
126
|
+
available: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Try local node_modules/.bin/
|
|
131
|
+
const localBin = findLocalBinDir();
|
|
132
|
+
if (localBin) {
|
|
133
|
+
const localCommand = join(localBin, entry.command);
|
|
134
|
+
if (existsSync(localCommand)) {
|
|
135
|
+
const localVersion = await tryCommand(localCommand, entry.versionFlag);
|
|
136
|
+
if (localVersion !== null) {
|
|
137
|
+
return {
|
|
138
|
+
name,
|
|
139
|
+
command: localCommand,
|
|
140
|
+
version: localVersion,
|
|
141
|
+
available: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
name,
|
|
149
|
+
command: entry.command,
|
|
150
|
+
version: null,
|
|
151
|
+
available: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Detect all registered tools in parallel.
|
|
157
|
+
* Returns an array of DetectedTool in registry order.
|
|
158
|
+
*/
|
|
159
|
+
export async function detectTools(): Promise<DetectedTool[]> {
|
|
160
|
+
const names = Object.keys(TOOL_REGISTRY) as ToolName[];
|
|
161
|
+
const results = await Promise.all(names.map((name) => detectTool(name)));
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Quick check: is a specific tool available?
|
|
167
|
+
*/
|
|
168
|
+
export async function isToolAvailable(name: ToolName): Promise<boolean> {
|
|
169
|
+
const tool = await detectTool(name);
|
|
170
|
+
return tool.available;
|
|
171
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff-Only Filter — Reviewdog pattern for the Verify Engine.
|
|
3
|
+
*
|
|
4
|
+
* Filters findings to only changed lines via git diff. Pre-existing issues
|
|
5
|
+
* on unchanged lines are counted but hidden, so developers only see problems
|
|
6
|
+
* they introduced. This eliminates noise from legacy code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getDiff } from "../git/index";
|
|
10
|
+
|
|
11
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface Finding {
|
|
14
|
+
tool: string;
|
|
15
|
+
file: string;
|
|
16
|
+
line: number;
|
|
17
|
+
column?: number;
|
|
18
|
+
message: string;
|
|
19
|
+
severity: "error" | "warning" | "info";
|
|
20
|
+
ruleId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DiffFilterResult {
|
|
24
|
+
shown: Finding[];
|
|
25
|
+
hidden: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Diff Parsing ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse unified diff output to extract changed line numbers per file.
|
|
32
|
+
*
|
|
33
|
+
* Returns a Map from file path to Set of changed (added/modified) line numbers
|
|
34
|
+
* in the new version of the file. Only `+` lines are tracked — deletions don't
|
|
35
|
+
* have a line number in the new file.
|
|
36
|
+
*/
|
|
37
|
+
export function parseChangedLines(diff: string): Map<string, Set<number>> {
|
|
38
|
+
const result = new Map<string, Set<number>>();
|
|
39
|
+
|
|
40
|
+
if (!diff.trim()) {
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines = diff.split("\n");
|
|
45
|
+
let currentFile: string | null = null;
|
|
46
|
+
let newLineNum = 0;
|
|
47
|
+
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
// Detect file header: +++ b/path/to/file
|
|
50
|
+
if (line.startsWith("+++ ")) {
|
|
51
|
+
const filePath = line.slice(4);
|
|
52
|
+
if (filePath === "/dev/null") {
|
|
53
|
+
currentFile = null;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Strip the "b/" prefix that git uses
|
|
57
|
+
currentFile = filePath.startsWith("b/") ? filePath.slice(2) : filePath;
|
|
58
|
+
if (!result.has(currentFile)) {
|
|
59
|
+
result.set(currentFile, new Set());
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Skip --- header (old file)
|
|
65
|
+
if (line.startsWith("--- ")) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Parse hunk header: @@ -old,count +new,count @@
|
|
70
|
+
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
71
|
+
if (hunkMatch) {
|
|
72
|
+
newLineNum = Number.parseInt(hunkMatch[1] ?? "0", 10);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Skip diff metadata lines
|
|
77
|
+
if (
|
|
78
|
+
line.startsWith("diff --git") ||
|
|
79
|
+
line.startsWith("index ") ||
|
|
80
|
+
line.startsWith("new file mode") ||
|
|
81
|
+
line.startsWith("deleted file mode") ||
|
|
82
|
+
line.startsWith("old mode") ||
|
|
83
|
+
line.startsWith("new mode") ||
|
|
84
|
+
line.startsWith("similarity index") ||
|
|
85
|
+
line.startsWith("rename from") ||
|
|
86
|
+
line.startsWith("rename to") ||
|
|
87
|
+
line.startsWith("Binary files")
|
|
88
|
+
) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (currentFile === null) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Added line: track it
|
|
97
|
+
if (line.startsWith("+")) {
|
|
98
|
+
result.get(currentFile)?.add(newLineNum);
|
|
99
|
+
newLineNum++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Deleted line: skip (no line number in new file)
|
|
104
|
+
if (line.startsWith("-")) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Context line (space prefix or empty within hunk): advance line counter
|
|
109
|
+
if (line.startsWith(" ") || line === "") {
|
|
110
|
+
newLineNum++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No-newline-at-end-of-file marker
|
|
115
|
+
if (line.startsWith("\\")) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Any other line within a hunk — advance counter as context
|
|
120
|
+
newLineNum++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Filter Logic ─────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Filter findings against a pre-computed changed-lines map.
|
|
130
|
+
* Findings on changed lines are shown; all others are hidden.
|
|
131
|
+
*
|
|
132
|
+
* Exported for testing without needing to invoke git.
|
|
133
|
+
*/
|
|
134
|
+
export function filterByDiffWithMap(
|
|
135
|
+
findings: Finding[],
|
|
136
|
+
changedLines: Map<string, Set<number>>,
|
|
137
|
+
): DiffFilterResult {
|
|
138
|
+
const shown: Finding[] = [];
|
|
139
|
+
let hidden = 0;
|
|
140
|
+
|
|
141
|
+
for (const finding of findings) {
|
|
142
|
+
const fileChanges = changedLines.get(finding.file);
|
|
143
|
+
if (fileChanges?.has(finding.line)) {
|
|
144
|
+
shown.push(finding);
|
|
145
|
+
} else {
|
|
146
|
+
hidden++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { shown, hidden };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Filter findings to only those on lines changed relative to a base branch.
|
|
155
|
+
*
|
|
156
|
+
* Uses `git diff <baseBranch>` to determine which lines are new/modified,
|
|
157
|
+
* then partitions findings into shown (on changed lines) and hidden
|
|
158
|
+
* (on unchanged lines, i.e. pre-existing issues).
|
|
159
|
+
*
|
|
160
|
+
* @param findings - All findings from verification tools
|
|
161
|
+
* @param baseBranch - The branch to diff against (defaults to "main")
|
|
162
|
+
* @param cwd - Working directory for git commands
|
|
163
|
+
* @returns Partitioned findings with hidden count
|
|
164
|
+
*/
|
|
165
|
+
export async function filterByDiff(
|
|
166
|
+
findings: Finding[],
|
|
167
|
+
baseBranch?: string,
|
|
168
|
+
cwd?: string,
|
|
169
|
+
): Promise<DiffFilterResult> {
|
|
170
|
+
const base = baseBranch ?? "main";
|
|
171
|
+
|
|
172
|
+
// Get the diff against the base branch
|
|
173
|
+
const diff = await getDiff(base, undefined, cwd);
|
|
174
|
+
|
|
175
|
+
// If no diff (e.g. on the base branch itself, or git error),
|
|
176
|
+
// show all findings as a safe fallback
|
|
177
|
+
if (!diff.trim()) {
|
|
178
|
+
return { shown: findings, hidden: 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const changedLines = parseChangedLines(diff);
|
|
182
|
+
return filterByDiffWithMap(findings, changedLines);
|
|
183
|
+
}
|