sequant 1.15.4 → 1.16.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/.claude-plugin/plugin.json +1 -1
- package/dist/bin/cli.js +13 -0
- package/dist/src/commands/merge.d.ts +22 -0
- package/dist/src/commands/merge.js +109 -0
- package/dist/src/lib/merge-check/combined-branch-test.d.ts +16 -0
- package/dist/src/lib/merge-check/combined-branch-test.js +179 -0
- package/dist/src/lib/merge-check/index.d.ts +41 -0
- package/dist/src/lib/merge-check/index.js +225 -0
- package/dist/src/lib/merge-check/mirroring-check.d.ts +14 -0
- package/dist/src/lib/merge-check/mirroring-check.js +97 -0
- package/dist/src/lib/merge-check/overlap-detection.d.ts +20 -0
- package/dist/src/lib/merge-check/overlap-detection.js +148 -0
- package/dist/src/lib/merge-check/report.d.ts +38 -0
- package/dist/src/lib/merge-check/report.js +238 -0
- package/dist/src/lib/merge-check/residual-pattern-scan.d.ts +26 -0
- package/dist/src/lib/merge-check/residual-pattern-scan.js +222 -0
- package/dist/src/lib/merge-check/types.d.ts +182 -0
- package/dist/src/lib/merge-check/types.js +22 -0
- package/package.json +1 -1
package/dist/bin/cli.js
CHANGED
|
@@ -44,6 +44,7 @@ import { statsCommand } from "../src/commands/stats.js";
|
|
|
44
44
|
import { dashboardCommand } from "../src/commands/dashboard.js";
|
|
45
45
|
import { stateInitCommand, stateRebuildCommand, stateCleanCommand, } from "../src/commands/state.js";
|
|
46
46
|
import { syncCommand, areSkillsOutdated } from "../src/commands/sync.js";
|
|
47
|
+
import { mergeCommand } from "../src/commands/merge.js";
|
|
47
48
|
import { getManifest } from "../src/lib/manifest.js";
|
|
48
49
|
const program = new Command();
|
|
49
50
|
// Handle --no-color before parsing
|
|
@@ -145,6 +146,18 @@ program
|
|
|
145
146
|
.option("--no-pr", "Skip PR creation after successful QA (manual PR workflow)")
|
|
146
147
|
.option("-f, --force", "Force re-execution of completed issues (bypass pre-flight state guard)")
|
|
147
148
|
.action(runCommand);
|
|
149
|
+
program
|
|
150
|
+
.command("merge")
|
|
151
|
+
.description("Batch-level integration QA — verify feature branches before merging")
|
|
152
|
+
.argument("[issues...]", "Issue numbers to check (auto-detects from most recent run if omitted)")
|
|
153
|
+
.option("--check", "Run Phase 1 deterministic checks (default)")
|
|
154
|
+
.option("--scan", "Run Phase 1 + Phase 2 residual pattern detection")
|
|
155
|
+
.option("--review", "Run Phase 1 + 2 + 3 AI briefing")
|
|
156
|
+
.option("--all", "Run all phases")
|
|
157
|
+
.option("--post", "Post report to GitHub as PR comments")
|
|
158
|
+
.option("--json", "Output as JSON")
|
|
159
|
+
.option("-v, --verbose", "Enable verbose output")
|
|
160
|
+
.action(mergeCommand);
|
|
148
161
|
program
|
|
149
162
|
.command("logs")
|
|
150
163
|
.description("View and analyze workflow run logs")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sequant merge - Batch-level integration QA for completed runs
|
|
3
|
+
*
|
|
4
|
+
* Runs deterministic checks on feature branches from a `sequant run` batch
|
|
5
|
+
* to catch integration issues before human review.
|
|
6
|
+
*
|
|
7
|
+
* Phases:
|
|
8
|
+
* - --check (Phase 1): Combined branch test, mirroring, overlap detection
|
|
9
|
+
* - --scan (Phase 1+2): Adds residual pattern detection
|
|
10
|
+
* - --review (Phase 1+2+3): Adds AI briefing (stub)
|
|
11
|
+
* - --all: Runs all phases
|
|
12
|
+
* - --post: Post report to GitHub PRs
|
|
13
|
+
*/
|
|
14
|
+
import type { MergeCommandOptions } from "../lib/merge-check/types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Determine exit code from batch verdict
|
|
17
|
+
*/
|
|
18
|
+
export declare function getExitCode(batchVerdict: string): number;
|
|
19
|
+
/**
|
|
20
|
+
* Main merge command handler
|
|
21
|
+
*/
|
|
22
|
+
export declare function mergeCommand(issues: string[], options: MergeCommandOptions): Promise<void>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sequant merge - Batch-level integration QA for completed runs
|
|
3
|
+
*
|
|
4
|
+
* Runs deterministic checks on feature branches from a `sequant run` batch
|
|
5
|
+
* to catch integration issues before human review.
|
|
6
|
+
*
|
|
7
|
+
* Phases:
|
|
8
|
+
* - --check (Phase 1): Combined branch test, mirroring, overlap detection
|
|
9
|
+
* - --scan (Phase 1+2): Adds residual pattern detection
|
|
10
|
+
* - --review (Phase 1+2+3): Adds AI briefing (stub)
|
|
11
|
+
* - --all: Runs all phases
|
|
12
|
+
* - --post: Post report to GitHub PRs
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "child_process";
|
|
15
|
+
import { ui, colors } from "../lib/cli-ui.js";
|
|
16
|
+
import { runMergeChecks, formatReportMarkdown, } from "../lib/merge-check/index.js";
|
|
17
|
+
/**
|
|
18
|
+
* Determine exit code from batch verdict
|
|
19
|
+
*/
|
|
20
|
+
export function getExitCode(batchVerdict) {
|
|
21
|
+
switch (batchVerdict) {
|
|
22
|
+
case "READY":
|
|
23
|
+
return 0;
|
|
24
|
+
case "NEEDS_ATTENTION":
|
|
25
|
+
return 1;
|
|
26
|
+
case "BLOCKED":
|
|
27
|
+
return 2;
|
|
28
|
+
default:
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get the git repository root
|
|
34
|
+
*/
|
|
35
|
+
function getRepoRoot() {
|
|
36
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
37
|
+
stdio: "pipe",
|
|
38
|
+
encoding: "utf-8",
|
|
39
|
+
});
|
|
40
|
+
if (result.status !== 0) {
|
|
41
|
+
throw new Error("Not in a git repository");
|
|
42
|
+
}
|
|
43
|
+
return result.stdout.trim();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Main merge command handler
|
|
47
|
+
*/
|
|
48
|
+
export async function mergeCommand(issues, options) {
|
|
49
|
+
// Default to --check if no phase flag is specified
|
|
50
|
+
if (!options.check && !options.scan && !options.review && !options.all) {
|
|
51
|
+
options.check = true;
|
|
52
|
+
}
|
|
53
|
+
const repoRoot = getRepoRoot();
|
|
54
|
+
const issueNumbers = issues
|
|
55
|
+
.map((i) => parseInt(i, 10))
|
|
56
|
+
.filter((n) => !isNaN(n));
|
|
57
|
+
// Determine mode label
|
|
58
|
+
let mode = "check";
|
|
59
|
+
if (options.all)
|
|
60
|
+
mode = "all";
|
|
61
|
+
else if (options.review)
|
|
62
|
+
mode = "review";
|
|
63
|
+
else if (options.scan)
|
|
64
|
+
mode = "scan";
|
|
65
|
+
if (!options.json) {
|
|
66
|
+
console.log(ui.headerBox("SEQUANT MERGE"));
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(colors.muted(issueNumbers.length > 0
|
|
69
|
+
? `Checking issues: ${issueNumbers.map((i) => `#${i}`).join(", ")} (mode: ${mode})`
|
|
70
|
+
: `Auto-detecting issues from most recent run (mode: ${mode})`));
|
|
71
|
+
console.log("");
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const report = await runMergeChecks(issueNumbers, options, repoRoot);
|
|
75
|
+
if (options.json) {
|
|
76
|
+
// JSON output: serialize the report (convert Map to object)
|
|
77
|
+
const jsonReport = {
|
|
78
|
+
...report,
|
|
79
|
+
issueVerdicts: Object.fromEntries(report.issueVerdicts),
|
|
80
|
+
};
|
|
81
|
+
console.log(JSON.stringify(jsonReport, null, 2));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Markdown output
|
|
85
|
+
const markdown = formatReportMarkdown(report);
|
|
86
|
+
console.log(markdown);
|
|
87
|
+
// Phase 3 stub
|
|
88
|
+
if (options.review || options.all) {
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log(colors.muted("Phase 3 (AI briefing) is not yet implemented. Use --check or --scan for deterministic checks."));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Set exit code based on verdict
|
|
94
|
+
const exitCode = getExitCode(report.batchVerdict);
|
|
95
|
+
if (exitCode !== 0) {
|
|
96
|
+
process.exitCode = exitCode;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
if (options.json) {
|
|
102
|
+
console.log(JSON.stringify({ error: message }, null, 2));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.error(ui.errorBox("Merge Check Failed", message));
|
|
106
|
+
}
|
|
107
|
+
process.exitCode = 2;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined branch testing (AC-1)
|
|
3
|
+
*
|
|
4
|
+
* Creates a temporary branch merging all feature branches from a run batch,
|
|
5
|
+
* runs npm test && npm run build on the combined state, and reports results.
|
|
6
|
+
*/
|
|
7
|
+
import type { BranchInfo, CheckResult, BranchCheckResult, CheckFinding } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Create temp branch, merge all feature branches, run tests and build.
|
|
10
|
+
*
|
|
11
|
+
* @param branches - Feature branches to merge
|
|
12
|
+
* @param repoRoot - Path to the git repository root
|
|
13
|
+
* @returns CheckResult with combined test findings
|
|
14
|
+
*/
|
|
15
|
+
export declare function runCombinedBranchTest(branches: BranchInfo[], repoRoot: string): CheckResult;
|
|
16
|
+
export declare function buildResult(branchResults: BranchCheckResult[], batchFindings: CheckFinding[], startTime: number): CheckResult;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined branch testing (AC-1)
|
|
3
|
+
*
|
|
4
|
+
* Creates a temporary branch merging all feature branches from a run batch,
|
|
5
|
+
* runs npm test && npm run build on the combined state, and reports results.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "child_process";
|
|
8
|
+
import { getBranchRef } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* Run a git command and return the result
|
|
11
|
+
*/
|
|
12
|
+
function git(args, cwd) {
|
|
13
|
+
const result = spawnSync("git", args, {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: "pipe",
|
|
16
|
+
encoding: "utf-8",
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
ok: result.status === 0,
|
|
20
|
+
stdout: result.stdout?.trim() ?? "",
|
|
21
|
+
stderr: result.stderr?.trim() ?? "",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run npm command and return result
|
|
26
|
+
*/
|
|
27
|
+
function npm(args, cwd) {
|
|
28
|
+
const result = spawnSync("npm", args, {
|
|
29
|
+
cwd,
|
|
30
|
+
stdio: "pipe",
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
timeout: 120_000, // 2 min timeout for test/build
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
ok: result.status === 0,
|
|
36
|
+
stdout: result.stdout?.trim() ?? "",
|
|
37
|
+
stderr: result.stderr?.trim() ?? "",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create temp branch, merge all feature branches, run tests and build.
|
|
42
|
+
*
|
|
43
|
+
* @param branches - Feature branches to merge
|
|
44
|
+
* @param repoRoot - Path to the git repository root
|
|
45
|
+
* @returns CheckResult with combined test findings
|
|
46
|
+
*/
|
|
47
|
+
export function runCombinedBranchTest(branches, repoRoot) {
|
|
48
|
+
const startTime = Date.now();
|
|
49
|
+
const tempBranch = `merge-check/temp-${Date.now()}`;
|
|
50
|
+
const branchResults = [];
|
|
51
|
+
const batchFindings = [];
|
|
52
|
+
const mergeAttempts = [];
|
|
53
|
+
// Save current branch to restore in finally block
|
|
54
|
+
const originalBranch = git(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot);
|
|
55
|
+
try {
|
|
56
|
+
// Fetch latest from remote
|
|
57
|
+
git(["fetch", "origin"], repoRoot);
|
|
58
|
+
// Create temp branch from main
|
|
59
|
+
const createResult = git(["checkout", "-b", tempBranch, "origin/main"], repoRoot);
|
|
60
|
+
if (!createResult.ok) {
|
|
61
|
+
batchFindings.push({
|
|
62
|
+
check: "combined-branch-test",
|
|
63
|
+
severity: "error",
|
|
64
|
+
message: `Failed to create temp branch: ${createResult.stderr}`,
|
|
65
|
+
});
|
|
66
|
+
return buildResult(branchResults, batchFindings, startTime);
|
|
67
|
+
}
|
|
68
|
+
// Merge each feature branch
|
|
69
|
+
for (const branch of branches) {
|
|
70
|
+
const mergeResult = git(["merge", "--no-ff", "--no-edit", getBranchRef(branch)], repoRoot);
|
|
71
|
+
if (mergeResult.ok) {
|
|
72
|
+
mergeAttempts.push({
|
|
73
|
+
issueNumber: branch.issueNumber,
|
|
74
|
+
branch: branch.branch,
|
|
75
|
+
success: true,
|
|
76
|
+
});
|
|
77
|
+
branchResults.push({
|
|
78
|
+
issueNumber: branch.issueNumber,
|
|
79
|
+
verdict: "PASS",
|
|
80
|
+
findings: [
|
|
81
|
+
{
|
|
82
|
+
check: "combined-branch-test",
|
|
83
|
+
severity: "info",
|
|
84
|
+
message: `Branch merged cleanly into combined state`,
|
|
85
|
+
issueNumber: branch.issueNumber,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Get conflicting files
|
|
92
|
+
const conflictResult = git(["diff", "--name-only", "--diff-filter=U"], repoRoot);
|
|
93
|
+
const conflictFiles = conflictResult.stdout
|
|
94
|
+
? conflictResult.stdout.split("\n")
|
|
95
|
+
: [];
|
|
96
|
+
mergeAttempts.push({
|
|
97
|
+
issueNumber: branch.issueNumber,
|
|
98
|
+
branch: branch.branch,
|
|
99
|
+
success: false,
|
|
100
|
+
conflictFiles,
|
|
101
|
+
error: mergeResult.stderr,
|
|
102
|
+
});
|
|
103
|
+
branchResults.push({
|
|
104
|
+
issueNumber: branch.issueNumber,
|
|
105
|
+
verdict: "FAIL",
|
|
106
|
+
findings: [
|
|
107
|
+
{
|
|
108
|
+
check: "combined-branch-test",
|
|
109
|
+
severity: "error",
|
|
110
|
+
message: `Merge conflict with ${conflictFiles.length} file(s): ${conflictFiles.join(", ")}`,
|
|
111
|
+
issueNumber: branch.issueNumber,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
// Abort the failed merge and continue
|
|
116
|
+
git(["merge", "--abort"], repoRoot);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// If any merges failed, skip tests but report what we have
|
|
120
|
+
const failedMerges = mergeAttempts.filter((m) => !m.success);
|
|
121
|
+
if (failedMerges.length > 0) {
|
|
122
|
+
batchFindings.push({
|
|
123
|
+
check: "combined-branch-test",
|
|
124
|
+
severity: "error",
|
|
125
|
+
message: `${failedMerges.length}/${branches.length} branches had merge conflicts — skipping test/build`,
|
|
126
|
+
});
|
|
127
|
+
return buildResult(branchResults, batchFindings, startTime);
|
|
128
|
+
}
|
|
129
|
+
// Run npm test
|
|
130
|
+
const testResult = npm(["test"], repoRoot);
|
|
131
|
+
if (testResult.ok) {
|
|
132
|
+
batchFindings.push({
|
|
133
|
+
check: "combined-branch-test",
|
|
134
|
+
severity: "info",
|
|
135
|
+
message: "npm test passed on combined state",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
batchFindings.push({
|
|
140
|
+
check: "combined-branch-test",
|
|
141
|
+
severity: "error",
|
|
142
|
+
message: `npm test failed on combined state: ${testResult.stderr.slice(0, 500)}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Run npm run build
|
|
146
|
+
const buildResult2 = npm(["run", "build"], repoRoot);
|
|
147
|
+
if (buildResult2.ok) {
|
|
148
|
+
batchFindings.push({
|
|
149
|
+
check: "combined-branch-test",
|
|
150
|
+
severity: "info",
|
|
151
|
+
message: "npm run build passed on combined state",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
batchFindings.push({
|
|
156
|
+
check: "combined-branch-test",
|
|
157
|
+
severity: "error",
|
|
158
|
+
message: `npm run build failed on combined state: ${buildResult2.stderr.slice(0, 500)}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return buildResult(branchResults, batchFindings, startTime);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
// Clean up: restore original branch and delete temp branch
|
|
165
|
+
const restoreBranch = originalBranch.ok ? originalBranch.stdout : "main";
|
|
166
|
+
git(["checkout", restoreBranch], repoRoot);
|
|
167
|
+
git(["branch", "-D", tempBranch], repoRoot);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function buildResult(branchResults, batchFindings, startTime) {
|
|
171
|
+
const hasErrors = batchFindings.some((f) => f.severity === "error");
|
|
172
|
+
return {
|
|
173
|
+
name: "combined-branch-test",
|
|
174
|
+
passed: !hasErrors,
|
|
175
|
+
branchResults,
|
|
176
|
+
batchFindings,
|
|
177
|
+
durationMs: Date.now() - startTime,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge check orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Coordinates all merge-check modules and resolves branches
|
|
5
|
+
* from run logs and git state.
|
|
6
|
+
*/
|
|
7
|
+
import { type RunLog } from "../workflow/run-log-schema.js";
|
|
8
|
+
import type { BranchInfo, CheckResult, MergeCommandOptions, MergeReport } from "./types.js";
|
|
9
|
+
import { formatReportMarkdown, formatBranchReportMarkdown, postReportToGitHub } from "./report.js";
|
|
10
|
+
/**
|
|
11
|
+
* Find the most recent run log file
|
|
12
|
+
*/
|
|
13
|
+
export declare function findMostRecentLog(logDir: string): RunLog | null;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve branches from issue numbers.
|
|
16
|
+
*
|
|
17
|
+
* Uses git worktree list and remote branch patterns to find
|
|
18
|
+
* the feature branches for each issue.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveBranches(issueNumbers: number[], repoRoot: string, runLog?: RunLog | null): BranchInfo[];
|
|
21
|
+
/**
|
|
22
|
+
* Determine which checks to run based on command options.
|
|
23
|
+
*
|
|
24
|
+
* --scan, --review, and --all currently return the same checks because
|
|
25
|
+
* Phase 3 (AI briefing) is not yet implemented. When Phase 3 is added,
|
|
26
|
+
* --review and --all will include additional AI-powered checks.
|
|
27
|
+
* The distinction is preserved so callers can detect review mode
|
|
28
|
+
* and show the Phase 3 stub message (see merge.ts).
|
|
29
|
+
*/
|
|
30
|
+
export declare function getChecksToRun(options: MergeCommandOptions): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Run all merge checks and produce a report.
|
|
33
|
+
*
|
|
34
|
+
* @param issueNumbers - Issue numbers to check (empty = auto-detect from most recent run)
|
|
35
|
+
* @param options - Command options controlling which checks to run
|
|
36
|
+
* @param repoRoot - Path to the git repository root
|
|
37
|
+
* @returns MergeReport with all findings
|
|
38
|
+
*/
|
|
39
|
+
export declare function runMergeChecks(issueNumbers: number[], options: MergeCommandOptions, repoRoot: string): Promise<MergeReport>;
|
|
40
|
+
export type { MergeReport, MergeCommandOptions, BranchInfo, CheckResult };
|
|
41
|
+
export { formatReportMarkdown, formatBranchReportMarkdown, postReportToGitHub };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge check orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Coordinates all merge-check modules and resolves branches
|
|
5
|
+
* from run logs and git state.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import * as os from "os";
|
|
10
|
+
import { spawnSync } from "child_process";
|
|
11
|
+
import { RunLogSchema, LOG_PATHS, } from "../workflow/run-log-schema.js";
|
|
12
|
+
import { getGitDiffStats } from "../workflow/git-diff-utils.js";
|
|
13
|
+
import { DEFAULT_MIRROR_PAIRS } from "./types.js";
|
|
14
|
+
import { runCombinedBranchTest } from "./combined-branch-test.js";
|
|
15
|
+
import { runMirroringCheck } from "./mirroring-check.js";
|
|
16
|
+
import { runOverlapDetection } from "./overlap-detection.js";
|
|
17
|
+
import { runResidualPatternScan } from "./residual-pattern-scan.js";
|
|
18
|
+
import { buildReport, formatReportMarkdown, formatBranchReportMarkdown, postReportToGitHub, } from "./report.js";
|
|
19
|
+
/**
|
|
20
|
+
* Resolve log directory path
|
|
21
|
+
*/
|
|
22
|
+
function resolveLogDir(customPath) {
|
|
23
|
+
if (customPath) {
|
|
24
|
+
return customPath.replace("~", os.homedir());
|
|
25
|
+
}
|
|
26
|
+
const projectPath = LOG_PATHS.project;
|
|
27
|
+
if (fs.existsSync(projectPath)) {
|
|
28
|
+
return projectPath;
|
|
29
|
+
}
|
|
30
|
+
const userPath = LOG_PATHS.user.replace("~", os.homedir());
|
|
31
|
+
if (fs.existsSync(userPath)) {
|
|
32
|
+
return userPath;
|
|
33
|
+
}
|
|
34
|
+
return projectPath;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Find the most recent run log file
|
|
38
|
+
*/
|
|
39
|
+
export function findMostRecentLog(logDir) {
|
|
40
|
+
if (!fs.existsSync(logDir)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const files = fs
|
|
44
|
+
.readdirSync(logDir)
|
|
45
|
+
.filter((f) => f.startsWith("run-") && f.endsWith(".json"))
|
|
46
|
+
.sort()
|
|
47
|
+
.reverse();
|
|
48
|
+
if (files.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
|
|
52
|
+
const parsed = RunLogSchema.safeParse(JSON.parse(content));
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return parsed.data;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve branches from issue numbers.
|
|
60
|
+
*
|
|
61
|
+
* Uses git worktree list and remote branch patterns to find
|
|
62
|
+
* the feature branches for each issue.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveBranches(issueNumbers, repoRoot, runLog) {
|
|
65
|
+
const branches = [];
|
|
66
|
+
// Get remote branches matching feature pattern
|
|
67
|
+
const branchResult = spawnSync("git", ["-C", repoRoot, "branch", "-r", "--list", "origin/feature/*"], { stdio: "pipe", encoding: "utf-8" });
|
|
68
|
+
const remoteBranches = branchResult.stdout
|
|
69
|
+
? branchResult.stdout
|
|
70
|
+
.split("\n")
|
|
71
|
+
.map((b) => b.trim().replace("origin/", ""))
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
: [];
|
|
74
|
+
// Also check worktrees for local-only branches
|
|
75
|
+
const worktreeResult = spawnSync("git", ["-C", repoRoot, "worktree", "list", "--porcelain"], { stdio: "pipe", encoding: "utf-8" });
|
|
76
|
+
const worktreePaths = new Map();
|
|
77
|
+
let currentPath = "";
|
|
78
|
+
for (const line of (worktreeResult.stdout ?? "").split("\n")) {
|
|
79
|
+
if (line.startsWith("worktree ")) {
|
|
80
|
+
currentPath = line.slice(9);
|
|
81
|
+
}
|
|
82
|
+
else if (line.startsWith("branch refs/heads/")) {
|
|
83
|
+
const branch = line.slice(18);
|
|
84
|
+
worktreePaths.set(branch, currentPath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Get run log issue info for titles
|
|
88
|
+
const issueInfo = new Map();
|
|
89
|
+
if (runLog) {
|
|
90
|
+
for (const issue of runLog.issues) {
|
|
91
|
+
issueInfo.set(issue.issueNumber, {
|
|
92
|
+
title: issue.title,
|
|
93
|
+
prNumber: issue.prNumber,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const issueNumber of issueNumbers) {
|
|
98
|
+
// Find the branch for this issue
|
|
99
|
+
const branchPattern = new RegExp(`^feature/${issueNumber}-`);
|
|
100
|
+
const branch = remoteBranches.find((b) => branchPattern.test(b)) ??
|
|
101
|
+
Array.from(worktreePaths.keys()).find((b) => branchPattern.test(b));
|
|
102
|
+
if (!branch) {
|
|
103
|
+
console.error(`No branch found for issue #${issueNumber}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Get modified files from the branch
|
|
107
|
+
const worktreePath = worktreePaths.get(branch);
|
|
108
|
+
let filesModified = [];
|
|
109
|
+
if (worktreePath) {
|
|
110
|
+
// Use worktree for diff
|
|
111
|
+
const diffStats = getGitDiffStats(worktreePath);
|
|
112
|
+
filesModified = diffStats.filesModified;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Use remote branch diff
|
|
116
|
+
const diffResult = spawnSync("git", [
|
|
117
|
+
"-C",
|
|
118
|
+
repoRoot,
|
|
119
|
+
"diff",
|
|
120
|
+
"--name-only",
|
|
121
|
+
`origin/main...origin/${branch}`,
|
|
122
|
+
], { stdio: "pipe", encoding: "utf-8" });
|
|
123
|
+
filesModified = diffResult.stdout
|
|
124
|
+
? diffResult.stdout.split("\n").filter(Boolean)
|
|
125
|
+
: [];
|
|
126
|
+
}
|
|
127
|
+
const info = issueInfo.get(issueNumber);
|
|
128
|
+
const title = info?.title ?? fetchIssueTitle(issueNumber) ?? `Issue #${issueNumber}`;
|
|
129
|
+
branches.push({
|
|
130
|
+
issueNumber,
|
|
131
|
+
title,
|
|
132
|
+
branch,
|
|
133
|
+
worktreePath,
|
|
134
|
+
prNumber: info?.prNumber,
|
|
135
|
+
filesModified,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return branches;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Fetch issue title from GitHub via gh CLI.
|
|
142
|
+
* Returns null if gh is not available or the issue doesn't exist.
|
|
143
|
+
*/
|
|
144
|
+
function fetchIssueTitle(issueNumber) {
|
|
145
|
+
const result = spawnSync("gh", ["issue", "view", String(issueNumber), "--json", "title", "--jq", ".title"], { stdio: "pipe", encoding: "utf-8", timeout: 10_000 });
|
|
146
|
+
if (result.status !== 0 || !result.stdout?.trim()) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return result.stdout.trim();
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Determine which checks to run based on command options.
|
|
153
|
+
*
|
|
154
|
+
* --scan, --review, and --all currently return the same checks because
|
|
155
|
+
* Phase 3 (AI briefing) is not yet implemented. When Phase 3 is added,
|
|
156
|
+
* --review and --all will include additional AI-powered checks.
|
|
157
|
+
* The distinction is preserved so callers can detect review mode
|
|
158
|
+
* and show the Phase 3 stub message (see merge.ts).
|
|
159
|
+
*/
|
|
160
|
+
export function getChecksToRun(options) {
|
|
161
|
+
const phase1 = ["combined-branch-test", "mirroring", "overlap-detection"];
|
|
162
|
+
const phase2 = ["residual-pattern-scan"];
|
|
163
|
+
// Phase 3 checks will be added here when AI briefing is implemented
|
|
164
|
+
// const phase3 = ["ai-briefing"];
|
|
165
|
+
if (options.all || options.review || options.scan) {
|
|
166
|
+
return [...phase1, ...phase2];
|
|
167
|
+
}
|
|
168
|
+
// Default --check: Phase 1 only
|
|
169
|
+
return phase1;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Run all merge checks and produce a report.
|
|
173
|
+
*
|
|
174
|
+
* @param issueNumbers - Issue numbers to check (empty = auto-detect from most recent run)
|
|
175
|
+
* @param options - Command options controlling which checks to run
|
|
176
|
+
* @param repoRoot - Path to the git repository root
|
|
177
|
+
* @returns MergeReport with all findings
|
|
178
|
+
*/
|
|
179
|
+
export async function runMergeChecks(issueNumbers, options, repoRoot) {
|
|
180
|
+
const logDir = resolveLogDir();
|
|
181
|
+
let runLog = null;
|
|
182
|
+
// Auto-detect issues from most recent run log if none specified
|
|
183
|
+
if (issueNumbers.length === 0) {
|
|
184
|
+
runLog = findMostRecentLog(logDir);
|
|
185
|
+
if (!runLog) {
|
|
186
|
+
throw new Error("No run logs found. Specify issue numbers or run `sequant run` first.");
|
|
187
|
+
}
|
|
188
|
+
issueNumbers = runLog.issues.map((i) => i.issueNumber);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// Still try to load run log for metadata
|
|
192
|
+
runLog = findMostRecentLog(logDir);
|
|
193
|
+
}
|
|
194
|
+
// Resolve branches for each issue
|
|
195
|
+
const branches = resolveBranches(issueNumbers, repoRoot, runLog);
|
|
196
|
+
if (branches.length === 0) {
|
|
197
|
+
throw new Error("No feature branches found for the specified issues. " +
|
|
198
|
+
"Ensure branches exist (pushed to remote or in local worktrees).");
|
|
199
|
+
}
|
|
200
|
+
// Determine which checks to run
|
|
201
|
+
const checksToRun = getChecksToRun(options);
|
|
202
|
+
const checkResults = [];
|
|
203
|
+
// Phase 1: Deterministic checks
|
|
204
|
+
if (checksToRun.includes("combined-branch-test")) {
|
|
205
|
+
checkResults.push(runCombinedBranchTest(branches, repoRoot));
|
|
206
|
+
}
|
|
207
|
+
if (checksToRun.includes("mirroring")) {
|
|
208
|
+
checkResults.push(runMirroringCheck(branches, DEFAULT_MIRROR_PAIRS));
|
|
209
|
+
}
|
|
210
|
+
if (checksToRun.includes("overlap-detection")) {
|
|
211
|
+
checkResults.push(runOverlapDetection(branches, repoRoot));
|
|
212
|
+
}
|
|
213
|
+
// Phase 2: Residual pattern detection
|
|
214
|
+
if (checksToRun.includes("residual-pattern-scan")) {
|
|
215
|
+
checkResults.push(runResidualPatternScan(branches, repoRoot));
|
|
216
|
+
}
|
|
217
|
+
// Build report
|
|
218
|
+
const report = buildReport(branches, checkResults, runLog?.runId);
|
|
219
|
+
// Post to GitHub if requested
|
|
220
|
+
if (options.post) {
|
|
221
|
+
postReportToGitHub(report);
|
|
222
|
+
}
|
|
223
|
+
return report;
|
|
224
|
+
}
|
|
225
|
+
export { formatReportMarkdown, formatBranchReportMarkdown, postReportToGitHub };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template/source mirroring check (AC-2)
|
|
3
|
+
*
|
|
4
|
+
* Detects paired directories and verifies that when a file is modified
|
|
5
|
+
* in one location, the corresponding file in the mirror is also modified.
|
|
6
|
+
*/
|
|
7
|
+
import type { BranchInfo, CheckResult, MirrorPair } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Run mirroring check across all branches
|
|
10
|
+
*
|
|
11
|
+
* For each file modified by a branch, if it falls within a mirrored directory,
|
|
12
|
+
* verify that the corresponding mirror file was also modified.
|
|
13
|
+
*/
|
|
14
|
+
export declare function runMirroringCheck(branches: BranchInfo[], mirrorPairs: MirrorPair[]): CheckResult;
|