switchman-dev 0.1.1 → 0.1.3
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 +327 -16
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +1 -1
- package/src/cli/index.js +2077 -216
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1848 -73
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +42 -5
- package/src/core/ignore.js +47 -0
- package/src/core/mcp.js +47 -0
- package/src/core/merge-gate.js +305 -0
- package/src/core/monitor.js +39 -0
- package/src/core/outcome.js +153 -0
- package/src/core/pipeline.js +1113 -0
- package/src/core/planner.js +508 -0
- package/src/core/semantic.js +311 -0
- package/src/mcp/server.js +491 -23
package/src/core/git.js
CHANGED
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { execSync, spawnSync } from 'child_process';
|
|
7
|
-
import { existsSync } from 'fs';
|
|
7
|
+
import { existsSync, realpathSync } from 'fs';
|
|
8
8
|
import { join, relative, resolve, basename } from 'path';
|
|
9
|
+
import { filterIgnoredPaths } from './ignore.js';
|
|
10
|
+
|
|
11
|
+
function normalizeFsPath(path) {
|
|
12
|
+
try {
|
|
13
|
+
return realpathSync(path);
|
|
14
|
+
} catch {
|
|
15
|
+
return resolve(path);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* Find the switchman database root from cwd or a given path.
|
|
@@ -55,12 +64,26 @@ export function findRepoRoot(startPath = process.cwd()) {
|
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
export function getGitCommonDir(startPath = process.cwd()) {
|
|
68
|
+
try {
|
|
69
|
+
const commonDir = execSync('git rev-parse --git-common-dir', {
|
|
70
|
+
cwd: startPath,
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
}).trim();
|
|
74
|
+
return resolve(startPath, commonDir);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error('Not inside a git repository. Run switchman from inside a git repo.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
/**
|
|
59
81
|
* List all git worktrees for this repo
|
|
60
82
|
* Returns: [{ name, path, branch, isMain, HEAD }]
|
|
61
83
|
*/
|
|
62
84
|
export function listGitWorktrees(repoRoot) {
|
|
63
85
|
try {
|
|
86
|
+
const normalizedRepoRoot = normalizeFsPath(repoRoot);
|
|
64
87
|
const output = execSync('git worktree list --porcelain', {
|
|
65
88
|
cwd: repoRoot,
|
|
66
89
|
encoding: 'utf8',
|
|
@@ -83,8 +106,9 @@ export function listGitWorktrees(repoRoot) {
|
|
|
83
106
|
}
|
|
84
107
|
|
|
85
108
|
if (wt.path) {
|
|
86
|
-
|
|
87
|
-
wt.
|
|
109
|
+
const normalizedPath = normalizeFsPath(wt.path);
|
|
110
|
+
wt.name = normalizedPath === normalizedRepoRoot ? 'main' : wt.path.split('/').pop();
|
|
111
|
+
wt.isMain = normalizedPath === normalizedRepoRoot;
|
|
88
112
|
worktrees.push(wt);
|
|
89
113
|
}
|
|
90
114
|
}
|
|
@@ -95,6 +119,12 @@ export function listGitWorktrees(repoRoot) {
|
|
|
95
119
|
}
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
export function getCurrentWorktree(repoRoot, startPath = process.cwd()) {
|
|
123
|
+
const currentPath = normalizeFsPath(startPath);
|
|
124
|
+
const worktrees = listGitWorktrees(repoRoot);
|
|
125
|
+
return worktrees.find((wt) => normalizeFsPath(wt.path) === currentPath) || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
/**
|
|
99
129
|
* Get files changed in a worktree relative to its base branch
|
|
100
130
|
* Returns array of file paths (relative to repo root)
|
|
@@ -114,12 +144,19 @@ export function getWorktreeChangedFiles(worktreePath, repoRoot) {
|
|
|
114
144
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
115
145
|
}).trim();
|
|
116
146
|
|
|
147
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
148
|
+
cwd: worktreePath,
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
151
|
+
}).trim();
|
|
152
|
+
|
|
117
153
|
const allFiles = [
|
|
118
154
|
...staged.split('\n'),
|
|
119
155
|
...unstaged.split('\n'),
|
|
156
|
+
...untracked.split('\n'),
|
|
120
157
|
].filter(Boolean);
|
|
121
158
|
|
|
122
|
-
return [...new Set(allFiles)];
|
|
159
|
+
return filterIgnoredPaths([...new Set(allFiles)]);
|
|
123
160
|
} catch {
|
|
124
161
|
return [];
|
|
125
162
|
}
|
|
@@ -262,4 +299,4 @@ export function getWorktreeStats(worktreePath) {
|
|
|
262
299
|
} catch {
|
|
263
300
|
return { modified: 0, added: 0, deleted: 0, total: 0, raw: '' };
|
|
264
301
|
}
|
|
265
|
-
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const DEFAULT_SCAN_IGNORE_PATTERNS = [
|
|
2
|
+
'node_modules/**',
|
|
3
|
+
'.git/**',
|
|
4
|
+
'.switchman/**',
|
|
5
|
+
'dist/**',
|
|
6
|
+
'build/**',
|
|
7
|
+
'coverage/**',
|
|
8
|
+
'.next/**',
|
|
9
|
+
'.nuxt/**',
|
|
10
|
+
'.svelte-kit/**',
|
|
11
|
+
'.turbo/**',
|
|
12
|
+
'.cache/**',
|
|
13
|
+
'.parcel-cache/**',
|
|
14
|
+
'target/**',
|
|
15
|
+
'out/**',
|
|
16
|
+
'tmp/**',
|
|
17
|
+
'temp/**',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function normalizePath(filePath) {
|
|
21
|
+
return String(filePath || '')
|
|
22
|
+
.replace(/\\/g, '/')
|
|
23
|
+
.replace(/^\.\/+/, '')
|
|
24
|
+
.replace(/^\/+/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function patternPrefix(pattern) {
|
|
28
|
+
return normalizePath(pattern).replace(/\/\*\*$/, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function matchesPathPatterns(filePath, patterns = DEFAULT_SCAN_IGNORE_PATTERNS) {
|
|
32
|
+
const normalized = normalizePath(filePath);
|
|
33
|
+
if (!normalized) return false;
|
|
34
|
+
|
|
35
|
+
return patterns.some((pattern) => {
|
|
36
|
+
const prefix = patternPrefix(pattern);
|
|
37
|
+
return normalized === prefix || normalized.startsWith(`${prefix}/`) || normalized.includes(`/${prefix}/`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isIgnoredPath(filePath, patterns = DEFAULT_SCAN_IGNORE_PATTERNS) {
|
|
42
|
+
return matchesPathPatterns(filePath, patterns);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function filterIgnoredPaths(filePaths, patterns = DEFAULT_SCAN_IGNORE_PATTERNS) {
|
|
46
|
+
return filePaths.filter((filePath) => !isIgnoredPath(filePath, patterns));
|
|
47
|
+
}
|
package/src/core/mcp.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function getSwitchmanMcpConfig() {
|
|
5
|
+
return {
|
|
6
|
+
mcpServers: {
|
|
7
|
+
switchman: {
|
|
8
|
+
command: 'switchman-mcp',
|
|
9
|
+
args: [],
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function upsertProjectMcpConfig(targetDir) {
|
|
16
|
+
const configPath = join(targetDir, '.mcp.json');
|
|
17
|
+
let config = {};
|
|
18
|
+
let created = true;
|
|
19
|
+
|
|
20
|
+
if (existsSync(configPath)) {
|
|
21
|
+
created = false;
|
|
22
|
+
const raw = readFileSync(configPath, 'utf8').trim();
|
|
23
|
+
config = raw ? JSON.parse(raw) : {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nextConfig = {
|
|
27
|
+
...config,
|
|
28
|
+
mcpServers: {
|
|
29
|
+
...(config.mcpServers || {}),
|
|
30
|
+
...getSwitchmanMcpConfig().mcpServers,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const before = JSON.stringify(config);
|
|
35
|
+
const after = JSON.stringify(nextConfig);
|
|
36
|
+
const changed = before !== after;
|
|
37
|
+
|
|
38
|
+
if (changed) {
|
|
39
|
+
writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
path: configPath,
|
|
44
|
+
created,
|
|
45
|
+
changed,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { getTask, listBoundaryValidationStates, listDependencyInvalidations, logAuditEvent } from './db.js';
|
|
2
|
+
import { scanAllWorktrees } from './detector.js';
|
|
3
|
+
|
|
4
|
+
const RISK_PATTERNS = [
|
|
5
|
+
{ key: 'auth', regex: /(^|\/)(auth|login|session|permissions?|rbac|acl)(\/|$)/i, label: 'authentication or permissions' },
|
|
6
|
+
{ key: 'schema', regex: /(^|\/)(schema|migrations?|db|database|sql)(\/|$)|schema\./i, label: 'schema or database' },
|
|
7
|
+
{ key: 'config', regex: /(^|\/)(config|configs|settings)(\/|$)|(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|tsconfig.*|vite\.config.*|webpack\.config.*|dockerfile|docker-compose.*)$/i, label: 'shared configuration' },
|
|
8
|
+
{ key: 'api', regex: /(^|\/)(api|routes?|controllers?)(\/|$)/i, label: 'API surface' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function isTestPath(filePath) {
|
|
12
|
+
return /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(filePath);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isSourcePath(filePath) {
|
|
16
|
+
return /(^|\/)(src|app|lib|server|client)(\/|$)/i.test(filePath) && !isTestPath(filePath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function classifyRiskTags(filePath) {
|
|
20
|
+
return RISK_PATTERNS.filter((pattern) => pattern.regex.test(filePath)).map((pattern) => pattern.key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function describeRiskTag(tag) {
|
|
24
|
+
return RISK_PATTERNS.find((pattern) => pattern.key === tag)?.label || tag;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pathAreas(filePath) {
|
|
28
|
+
const parts = filePath.split('/').filter(Boolean);
|
|
29
|
+
if (parts.length === 0) return [];
|
|
30
|
+
if (parts.length === 1) return [parts[0]];
|
|
31
|
+
if (['src', 'app', 'lib', 'tests', 'test', 'spec'].includes(parts[0])) {
|
|
32
|
+
return [`${parts[0]}/${parts[1]}`];
|
|
33
|
+
}
|
|
34
|
+
return [parts[0]];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function intersection(a, b) {
|
|
38
|
+
const setB = new Set(b);
|
|
39
|
+
return [...new Set(a)].filter((item) => setB.has(item));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function summarizeWorktree(worktree, changedFiles, complianceState) {
|
|
43
|
+
const sourceFiles = changedFiles.filter(isSourcePath);
|
|
44
|
+
const testFiles = changedFiles.filter(isTestPath);
|
|
45
|
+
const riskTags = [...new Set(changedFiles.flatMap(classifyRiskTags))];
|
|
46
|
+
const areas = [...new Set(changedFiles.flatMap(pathAreas))];
|
|
47
|
+
const findings = [];
|
|
48
|
+
let score = 0;
|
|
49
|
+
|
|
50
|
+
if (sourceFiles.length > 0 && testFiles.length === 0) {
|
|
51
|
+
findings.push('source changes without corresponding test updates');
|
|
52
|
+
score += 15;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (riskTags.length > 0) {
|
|
56
|
+
findings.push(`touches ${riskTags.map(describeRiskTag).join(', ')}`);
|
|
57
|
+
score += Math.min(25, riskTags.length * 10);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (complianceState === 'non_compliant' || complianceState === 'stale') {
|
|
61
|
+
findings.push(`worktree is ${complianceState}`);
|
|
62
|
+
score += 60;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
worktree: worktree.name,
|
|
67
|
+
branch: worktree.branch ?? 'unknown',
|
|
68
|
+
changed_files: changedFiles,
|
|
69
|
+
source_files: sourceFiles,
|
|
70
|
+
test_files: testFiles,
|
|
71
|
+
risk_tags: riskTags,
|
|
72
|
+
areas,
|
|
73
|
+
findings,
|
|
74
|
+
score,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildPairAnalysis(left, right, report) {
|
|
79
|
+
const directFileConflict = report.fileConflicts.filter((conflict) =>
|
|
80
|
+
conflict.worktrees.includes(left.worktree) && conflict.worktrees.includes(right.worktree),
|
|
81
|
+
);
|
|
82
|
+
const branchConflict = report.conflicts.find((conflict) =>
|
|
83
|
+
(conflict.worktreeA === left.worktree && conflict.worktreeB === right.worktree)
|
|
84
|
+
|| (conflict.worktreeA === right.worktree && conflict.worktreeB === left.worktree),
|
|
85
|
+
) || null;
|
|
86
|
+
const ownershipConflicts = (report.ownershipConflicts || []).filter((conflict) =>
|
|
87
|
+
(conflict.worktreeA === left.worktree && conflict.worktreeB === right.worktree)
|
|
88
|
+
|| (conflict.worktreeA === right.worktree && conflict.worktreeB === left.worktree),
|
|
89
|
+
);
|
|
90
|
+
const semanticConflicts = (report.semanticConflicts || []).filter((conflict) =>
|
|
91
|
+
(conflict.worktreeA === left.worktree && conflict.worktreeB === right.worktree)
|
|
92
|
+
|| (conflict.worktreeA === right.worktree && conflict.worktreeB === left.worktree),
|
|
93
|
+
);
|
|
94
|
+
const sharedAreas = intersection(left.areas, right.areas);
|
|
95
|
+
const sharedRiskTags = intersection(left.risk_tags, right.risk_tags);
|
|
96
|
+
|
|
97
|
+
const reasons = [];
|
|
98
|
+
let score = 0;
|
|
99
|
+
|
|
100
|
+
if (branchConflict) {
|
|
101
|
+
reasons.push(`git merge conflict predicted between ${left.branch} and ${right.branch}`);
|
|
102
|
+
score = 100;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (directFileConflict.length > 0) {
|
|
106
|
+
reasons.push(`direct file overlap in ${directFileConflict.map((item) => item.file).join(', ')}`);
|
|
107
|
+
score = Math.max(score, 95);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (ownershipConflicts.length > 0) {
|
|
111
|
+
for (const conflict of ownershipConflicts) {
|
|
112
|
+
if (conflict.type === 'subsystem_overlap') {
|
|
113
|
+
reasons.push(`both worktrees reserve the ${conflict.subsystemTag} subsystem`);
|
|
114
|
+
score = Math.max(score, 85);
|
|
115
|
+
} else if (conflict.type === 'scope_overlap') {
|
|
116
|
+
reasons.push(`both worktrees reserve overlapping scopes (${conflict.scopeA} vs ${conflict.scopeB})`);
|
|
117
|
+
score = Math.max(score, 90);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (semanticConflicts.length > 0) {
|
|
123
|
+
for (const conflict of semanticConflicts) {
|
|
124
|
+
if (conflict.type === 'semantic_object_overlap') {
|
|
125
|
+
reasons.push(`both worktrees changed exported ${conflict.object_kind} ${conflict.object_name}`);
|
|
126
|
+
score = Math.max(score, 92);
|
|
127
|
+
} else if (conflict.type === 'semantic_name_overlap') {
|
|
128
|
+
reasons.push(`both worktrees changed semantically similar object ${conflict.object_name}`);
|
|
129
|
+
score = Math.max(score, 65);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (sharedAreas.length > 0) {
|
|
135
|
+
reasons.push(`both worktrees touch ${sharedAreas.join(', ')}`);
|
|
136
|
+
score += 35;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (sharedRiskTags.length > 0) {
|
|
140
|
+
reasons.push(`both worktrees change ${sharedRiskTags.map(describeRiskTag).join(', ')}`);
|
|
141
|
+
score += 25;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (left.source_files.length > 0 && left.test_files.length === 0 && sharedAreas.length > 0) {
|
|
145
|
+
reasons.push(`${left.worktree} changes shared source areas without tests`);
|
|
146
|
+
score += 10;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (right.source_files.length > 0 && right.test_files.length === 0 && sharedAreas.length > 0) {
|
|
150
|
+
reasons.push(`${right.worktree} changes shared source areas without tests`);
|
|
151
|
+
score += 10;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (left.changed_files.length >= 5 && right.changed_files.length >= 5) {
|
|
155
|
+
reasons.push('both worktrees are large enough to raise integration risk');
|
|
156
|
+
score += 10;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const status = score >= 80 ? 'blocked' : score >= 40 ? 'warn' : 'pass';
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
worktree_a: left.worktree,
|
|
163
|
+
worktree_b: right.worktree,
|
|
164
|
+
branch_a: left.branch,
|
|
165
|
+
branch_b: right.branch,
|
|
166
|
+
status,
|
|
167
|
+
score: Math.min(score, 100),
|
|
168
|
+
reasons,
|
|
169
|
+
shared_areas: sharedAreas,
|
|
170
|
+
shared_risk_tags: sharedRiskTags,
|
|
171
|
+
ownership_conflicts: ownershipConflicts,
|
|
172
|
+
semantic_conflicts: semanticConflicts,
|
|
173
|
+
conflicting_files: branchConflict?.conflictingFiles || directFileConflict.map((item) => item.file),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function summarizeOverall(pairAnalyses, worktreeAnalyses, boundaryValidations, dependencyInvalidations) {
|
|
178
|
+
const blockedPairs = pairAnalyses.filter((item) => item.status === 'blocked');
|
|
179
|
+
const warnedPairs = pairAnalyses.filter((item) => item.status === 'warn');
|
|
180
|
+
const riskyWorktrees = worktreeAnalyses.filter((item) => item.score >= 40);
|
|
181
|
+
const blockedValidations = boundaryValidations.filter((item) => item.severity === 'blocked');
|
|
182
|
+
const warnedValidations = boundaryValidations.filter((item) => item.severity === 'warn');
|
|
183
|
+
const blockedInvalidations = dependencyInvalidations.filter((item) => item.severity === 'blocked');
|
|
184
|
+
const warnedInvalidations = dependencyInvalidations.filter((item) => item.severity === 'warn');
|
|
185
|
+
|
|
186
|
+
if (blockedPairs.length > 0 || blockedValidations.length > 0 || blockedInvalidations.length > 0) {
|
|
187
|
+
return {
|
|
188
|
+
status: 'blocked',
|
|
189
|
+
summary: `AI merge gate blocked: ${blockedPairs.length} risky pair(s), ${blockedValidations.length} boundary validation issue(s), and ${blockedInvalidations.length} stale dependency issue(s) need resolution.`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (warnedPairs.length > 0 || riskyWorktrees.length > 0 || warnedValidations.length > 0 || warnedInvalidations.length > 0) {
|
|
193
|
+
return {
|
|
194
|
+
status: 'warn',
|
|
195
|
+
summary: `AI merge gate warns: ${warnedPairs.length} pair(s), ${riskyWorktrees.length} worktree(s), ${warnedValidations.length} boundary validation issue(s), or ${warnedInvalidations.length} stale dependency issue(s) need review.`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
status: 'pass',
|
|
200
|
+
summary: 'AI merge gate passed: no elevated semantic merge risks detected.',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function evaluateBoundaryValidations(db) {
|
|
205
|
+
return listBoundaryValidationStates(db)
|
|
206
|
+
.filter((state) => state.status === 'blocked' || state.status === 'pending_validation')
|
|
207
|
+
.map((state) => {
|
|
208
|
+
const task = getTask(db, state.task_id);
|
|
209
|
+
return {
|
|
210
|
+
pipeline_id: state.pipeline_id,
|
|
211
|
+
task_id: state.task_id,
|
|
212
|
+
worktree: task?.worktree || null,
|
|
213
|
+
severity: state.status === 'blocked' ? 'blocked' : 'warn',
|
|
214
|
+
missing_task_types: state.missing_task_types || [],
|
|
215
|
+
rationale: state.details?.rationale || [],
|
|
216
|
+
subsystem_tags: state.details?.subsystem_tags || [],
|
|
217
|
+
summary: `${task?.title || state.task_id} is missing completed ${(state.missing_task_types || []).join(', ')} validation work`,
|
|
218
|
+
touched_at: state.touched_at,
|
|
219
|
+
validation_status: state.status,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function evaluateDependencyInvalidations(db) {
|
|
225
|
+
return listDependencyInvalidations(db, { status: 'stale' })
|
|
226
|
+
.map((state) => {
|
|
227
|
+
const affectedTask = getTask(db, state.affected_task_id);
|
|
228
|
+
const severity = affectedTask?.status === 'done' ? 'blocked' : 'warn';
|
|
229
|
+
const staleArea = state.reason_type === 'subsystem_overlap'
|
|
230
|
+
? `subsystem:${state.subsystem_tag}`
|
|
231
|
+
: `${state.source_scope_pattern} ↔ ${state.affected_scope_pattern}`;
|
|
232
|
+
return {
|
|
233
|
+
source_lease_id: state.source_lease_id,
|
|
234
|
+
source_task_id: state.source_task_id,
|
|
235
|
+
source_pipeline_id: state.source_pipeline_id,
|
|
236
|
+
source_worktree: state.source_worktree,
|
|
237
|
+
affected_task_id: state.affected_task_id,
|
|
238
|
+
affected_pipeline_id: state.affected_pipeline_id,
|
|
239
|
+
affected_worktree: state.affected_worktree,
|
|
240
|
+
affected_task_status: affectedTask?.status || null,
|
|
241
|
+
severity,
|
|
242
|
+
reason_type: state.reason_type,
|
|
243
|
+
subsystem_tag: state.subsystem_tag,
|
|
244
|
+
source_scope_pattern: state.source_scope_pattern,
|
|
245
|
+
affected_scope_pattern: state.affected_scope_pattern,
|
|
246
|
+
summary: `${affectedTask?.title || state.affected_task_id} is stale because ${state.details?.source_task_title || state.source_task_id} changed shared ${staleArea}`,
|
|
247
|
+
stale_area: staleArea,
|
|
248
|
+
created_at: state.created_at,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function runAiMergeGate(db, repoRoot) {
|
|
254
|
+
const report = await scanAllWorktrees(db, repoRoot);
|
|
255
|
+
const worktreeAnalyses = report.worktrees.map((worktree) => {
|
|
256
|
+
const changedFiles = report.fileMap?.[worktree.name] ?? [];
|
|
257
|
+
const complianceState = report.worktreeCompliance?.find((entry) => entry.worktree === worktree.name)?.compliance_state
|
|
258
|
+
?? worktree.compliance_state
|
|
259
|
+
?? 'observed';
|
|
260
|
+
return summarizeWorktree(worktree, changedFiles, complianceState);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const pairAnalyses = [];
|
|
264
|
+
for (let i = 0; i < worktreeAnalyses.length; i++) {
|
|
265
|
+
for (let j = i + 1; j < worktreeAnalyses.length; j++) {
|
|
266
|
+
pairAnalyses.push(buildPairAnalysis(worktreeAnalyses[i], worktreeAnalyses[j], report));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const boundaryValidations = evaluateBoundaryValidations(db);
|
|
271
|
+
const dependencyInvalidations = evaluateDependencyInvalidations(db);
|
|
272
|
+
const overall = summarizeOverall(pairAnalyses, worktreeAnalyses, boundaryValidations, dependencyInvalidations);
|
|
273
|
+
const result = {
|
|
274
|
+
ok: overall.status === 'pass',
|
|
275
|
+
status: overall.status,
|
|
276
|
+
summary: overall.summary,
|
|
277
|
+
worktrees: worktreeAnalyses,
|
|
278
|
+
pairs: pairAnalyses,
|
|
279
|
+
boundary_validations: boundaryValidations,
|
|
280
|
+
dependency_invalidations: dependencyInvalidations,
|
|
281
|
+
compliance: report.complianceSummary,
|
|
282
|
+
unclaimed_changes: report.unclaimedChanges,
|
|
283
|
+
branch_conflicts: report.conflicts,
|
|
284
|
+
file_conflicts: report.fileConflicts,
|
|
285
|
+
ownership_conflicts: report.ownershipConflicts || [],
|
|
286
|
+
semantic_conflicts: report.semanticConflicts || [],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
logAuditEvent(db, {
|
|
290
|
+
eventType: 'ai_merge_gate',
|
|
291
|
+
status: overall.status === 'blocked' ? 'denied' : (overall.status === 'warn' ? 'warn' : 'allowed'),
|
|
292
|
+
reasonCode: overall.status === 'blocked' ? 'semantic_merge_risk' : null,
|
|
293
|
+
details: JSON.stringify({
|
|
294
|
+
status: result.status,
|
|
295
|
+
blocked_pairs: pairAnalyses.filter((item) => item.status === 'blocked').length,
|
|
296
|
+
warned_pairs: pairAnalyses.filter((item) => item.status === 'warn').length,
|
|
297
|
+
blocked_boundary_validations: boundaryValidations.filter((item) => item.severity === 'blocked').length,
|
|
298
|
+
warned_boundary_validations: boundaryValidations.filter((item) => item.severity === 'warn').length,
|
|
299
|
+
blocked_dependency_invalidations: dependencyInvalidations.filter((item) => item.severity === 'blocked').length,
|
|
300
|
+
warned_dependency_invalidations: dependencyInvalidations.filter((item) => item.severity === 'warn').length,
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function getMonitorStatePath(repoRoot) {
|
|
5
|
+
return join(repoRoot, '.switchman', 'monitor.json');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function readMonitorState(repoRoot) {
|
|
9
|
+
const statePath = getMonitorStatePath(repoRoot);
|
|
10
|
+
if (!existsSync(statePath)) return null;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(statePath, 'utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeMonitorState(repoRoot, state) {
|
|
20
|
+
const statePath = getMonitorStatePath(repoRoot);
|
|
21
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
22
|
+
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
23
|
+
return statePath;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clearMonitorState(repoRoot) {
|
|
27
|
+
const statePath = getMonitorStatePath(repoRoot);
|
|
28
|
+
rmSync(statePath, { force: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isProcessRunning(pid) {
|
|
32
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { getActiveFileClaims, getTask, getTaskSpec, getWorktree } from './db.js';
|
|
2
|
+
import { getWorktreeChangedFiles } from './git.js';
|
|
3
|
+
import { matchesPathPatterns } from './ignore.js';
|
|
4
|
+
|
|
5
|
+
function isTestPath(filePath) {
|
|
6
|
+
return /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(filePath);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isDocsPath(filePath) {
|
|
10
|
+
return /(^|\/)(docs?|readme)(\/|$)|(^|\/)README(\.[^.]+)?$/i.test(filePath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isSourcePath(filePath) {
|
|
14
|
+
return /(^|\/)(src|app|lib|server|client)(\/|$)/i.test(filePath) && !isTestPath(filePath);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fileMatchesKeyword(filePath, keyword) {
|
|
18
|
+
const normalizedPath = String(filePath || '').toLowerCase();
|
|
19
|
+
const normalizedKeyword = String(keyword || '').toLowerCase();
|
|
20
|
+
return normalizedKeyword.length >= 3 && normalizedPath.includes(normalizedKeyword);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function evaluateTaskOutcome(db, repoRoot, { taskId }) {
|
|
24
|
+
const task = getTask(db, taskId);
|
|
25
|
+
const taskSpec = getTaskSpec(db, taskId);
|
|
26
|
+
if (!task || !task.worktree) {
|
|
27
|
+
return {
|
|
28
|
+
status: 'failed',
|
|
29
|
+
reason_code: 'task_not_assigned',
|
|
30
|
+
changed_files: [],
|
|
31
|
+
findings: ['task has no assigned worktree'],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const worktree = getWorktree(db, task.worktree);
|
|
36
|
+
if (!worktree) {
|
|
37
|
+
return {
|
|
38
|
+
status: 'failed',
|
|
39
|
+
reason_code: 'worktree_missing',
|
|
40
|
+
changed_files: [],
|
|
41
|
+
findings: ['assigned worktree is not registered'],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const changedFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
|
|
46
|
+
const activeClaims = getActiveFileClaims(db)
|
|
47
|
+
.filter((claim) => claim.task_id === taskId && claim.worktree === task.worktree)
|
|
48
|
+
.map((claim) => claim.file_path);
|
|
49
|
+
const changedOutsideClaims = changedFiles.filter((filePath) => !activeClaims.includes(filePath));
|
|
50
|
+
const changedInsideClaims = changedFiles.filter((filePath) => activeClaims.includes(filePath));
|
|
51
|
+
const allowedPaths = taskSpec?.allowed_paths || [];
|
|
52
|
+
const changedOutsideTaskScope = allowedPaths.length > 0
|
|
53
|
+
? changedFiles.filter((filePath) => !matchesPathPatterns(filePath, allowedPaths))
|
|
54
|
+
: [];
|
|
55
|
+
const expectedOutputTypes = taskSpec?.expected_output_types || [];
|
|
56
|
+
const requiredDeliverables = taskSpec?.required_deliverables || [];
|
|
57
|
+
const objectiveKeywords = taskSpec?.objective_keywords || [];
|
|
58
|
+
const findings = [];
|
|
59
|
+
|
|
60
|
+
if (changedFiles.length === 0) {
|
|
61
|
+
findings.push('command exited successfully but produced no tracked file changes');
|
|
62
|
+
return {
|
|
63
|
+
status: 'needs_followup',
|
|
64
|
+
reason_code: 'no_changes_detected',
|
|
65
|
+
changed_files: changedFiles,
|
|
66
|
+
findings,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (activeClaims.length > 0 && changedOutsideClaims.length > 0) {
|
|
71
|
+
findings.push(`changed files outside claimed scope: ${changedOutsideClaims.join(', ')}`);
|
|
72
|
+
return {
|
|
73
|
+
status: 'needs_followup',
|
|
74
|
+
reason_code: 'changes_outside_claims',
|
|
75
|
+
changed_files: changedFiles,
|
|
76
|
+
findings,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (changedOutsideTaskScope.length > 0) {
|
|
81
|
+
findings.push(`changed files outside task scope: ${changedOutsideTaskScope.join(', ')}`);
|
|
82
|
+
return {
|
|
83
|
+
status: 'needs_followup',
|
|
84
|
+
reason_code: 'changes_outside_task_scope',
|
|
85
|
+
changed_files: changedFiles,
|
|
86
|
+
findings,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const title = String(task.title || '').toLowerCase();
|
|
91
|
+
const expectsTests = expectedOutputTypes.includes('tests') || title.includes('test');
|
|
92
|
+
const expectsDocs = expectedOutputTypes.includes('docs') || title.includes('docs') || title.includes('readme') || title.includes('integration notes');
|
|
93
|
+
const expectsSource = expectedOutputTypes.includes('source') || title.startsWith('implement:') || title.includes('implement');
|
|
94
|
+
const changedTestFiles = changedFiles.filter(isTestPath);
|
|
95
|
+
const changedDocsFiles = changedFiles.filter(isDocsPath);
|
|
96
|
+
const changedSourceFiles = changedFiles.filter(isSourcePath);
|
|
97
|
+
|
|
98
|
+
if ((expectsTests || requiredDeliverables.includes('tests')) && changedTestFiles.length === 0) {
|
|
99
|
+
findings.push('task looks like a test task but no test files changed');
|
|
100
|
+
return {
|
|
101
|
+
status: 'needs_followup',
|
|
102
|
+
reason_code: 'missing_expected_tests',
|
|
103
|
+
changed_files: changedFiles,
|
|
104
|
+
findings,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if ((expectsDocs || requiredDeliverables.includes('docs')) && changedDocsFiles.length === 0) {
|
|
109
|
+
findings.push('task looks like a docs task but no docs files changed');
|
|
110
|
+
return {
|
|
111
|
+
status: 'needs_followup',
|
|
112
|
+
reason_code: 'missing_expected_docs',
|
|
113
|
+
changed_files: changedFiles,
|
|
114
|
+
findings,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if ((expectsSource || requiredDeliverables.includes('source')) && changedSourceFiles.length === 0) {
|
|
119
|
+
findings.push('implementation task finished without source-file changes');
|
|
120
|
+
return {
|
|
121
|
+
status: 'needs_followup',
|
|
122
|
+
reason_code: 'missing_expected_source_changes',
|
|
123
|
+
changed_files: changedFiles,
|
|
124
|
+
findings,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const matchedObjectiveKeywords = objectiveKeywords.filter((keyword) =>
|
|
129
|
+
changedFiles.some((filePath) => fileMatchesKeyword(filePath, keyword)),
|
|
130
|
+
);
|
|
131
|
+
const minimumKeywordMatches = taskSpec?.risk_level === 'high'
|
|
132
|
+
? Math.min(2, objectiveKeywords.length)
|
|
133
|
+
: Math.min(1, objectiveKeywords.length);
|
|
134
|
+
|
|
135
|
+
if (objectiveKeywords.length > 0 && matchedObjectiveKeywords.length < minimumKeywordMatches) {
|
|
136
|
+
findings.push(`changed files do not clearly satisfy task objective keywords: ${objectiveKeywords.join(', ')}`);
|
|
137
|
+
return {
|
|
138
|
+
status: 'needs_followup',
|
|
139
|
+
reason_code: 'objective_not_evidenced',
|
|
140
|
+
changed_files: changedFiles,
|
|
141
|
+
findings,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
status: 'accepted',
|
|
147
|
+
reason_code: null,
|
|
148
|
+
changed_files: changedFiles,
|
|
149
|
+
task_spec: taskSpec,
|
|
150
|
+
claimed_files: activeClaims,
|
|
151
|
+
findings: changedInsideClaims.length > 0 ? ['changes stayed within claimed scope'] : [],
|
|
152
|
+
};
|
|
153
|
+
}
|