switchman-dev 0.1.5 → 0.1.7
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/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/README.md +173 -4
- package/examples/README.md +28 -0
- package/package.json +1 -1
- package/src/cli/index.js +2941 -314
- package/src/core/ci.js +204 -0
- package/src/core/db.js +822 -26
- package/src/core/enforcement.js +18 -5
- package/src/core/git.js +286 -1
- package/src/core/merge-gate.js +17 -2
- package/src/core/outcome.js +1 -1
- package/src/core/pipeline.js +2399 -59
- package/src/core/planner.js +25 -5
- package/src/core/policy.js +105 -0
- package/src/core/queue.js +643 -27
- package/src/core/semantic.js +71 -5
- package/src/core/telemetry.js +210 -0
package/src/core/semantic.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
3
|
+
import { dirname, join, posix } from 'path';
|
|
4
4
|
|
|
5
5
|
const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
|
|
6
6
|
|
|
@@ -111,21 +111,22 @@ function parseFileObjects(repoPath, filePath) {
|
|
|
111
111
|
}));
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
function
|
|
114
|
+
export function listTrackedFiles(repoPath, { sourceOnly = false } = {}) {
|
|
115
115
|
try {
|
|
116
116
|
const output = execSync('git ls-files', {
|
|
117
117
|
cwd: repoPath,
|
|
118
118
|
encoding: 'utf8',
|
|
119
119
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
120
|
}).trim();
|
|
121
|
-
|
|
121
|
+
const files = output.split('\n').filter(Boolean);
|
|
122
|
+
return sourceOnly ? files.filter(isSourceLikePath) : files;
|
|
122
123
|
} catch {
|
|
123
124
|
return [];
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
export function buildSemanticIndexForPath(repoPath, filePaths = null) {
|
|
128
|
-
const files = filePaths ||
|
|
129
|
+
const files = filePaths || listTrackedFiles(repoPath);
|
|
129
130
|
const objects = files
|
|
130
131
|
.filter(isSourceLikePath)
|
|
131
132
|
.flatMap((filePath) => parseFileObjects(repoPath, filePath))
|
|
@@ -142,6 +143,67 @@ export function buildSemanticIndexForPath(repoPath, filePaths = null) {
|
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
function extractImportSpecifiers(content) {
|
|
147
|
+
const specifiers = new Set();
|
|
148
|
+
const patterns = [
|
|
149
|
+
/import\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
|
|
150
|
+
/export\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
|
|
151
|
+
/require\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
152
|
+
/import\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
153
|
+
];
|
|
154
|
+
for (const pattern of patterns) {
|
|
155
|
+
for (const match of content.matchAll(pattern)) {
|
|
156
|
+
if (match[1]) specifiers.add(match[1]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [...specifiers];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveImportTarget(filePath, specifier, trackedSourceFiles) {
|
|
163
|
+
if (!specifier || !specifier.startsWith('.')) return null;
|
|
164
|
+
const fromDir = dirname(filePath);
|
|
165
|
+
const rawTarget = posix.normalize(posix.join(fromDir === '.' ? '' : fromDir, specifier));
|
|
166
|
+
const candidates = [];
|
|
167
|
+
if ([...SOURCE_EXTENSIONS].some((ext) => rawTarget.endsWith(ext))) {
|
|
168
|
+
candidates.push(rawTarget);
|
|
169
|
+
} else {
|
|
170
|
+
for (const ext of SOURCE_EXTENSIONS) {
|
|
171
|
+
candidates.push(`${rawTarget}${ext}`);
|
|
172
|
+
candidates.push(posix.join(rawTarget, `index${ext}`));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return candidates.find((candidate) => trackedSourceFiles.has(candidate)) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildModuleDependencyIndexForPath(repoPath, { filePaths = null } = {}) {
|
|
179
|
+
const files = (filePaths || listTrackedFiles(repoPath, { sourceOnly: true })).filter(isSourceLikePath);
|
|
180
|
+
const trackedSourceFiles = new Set(files);
|
|
181
|
+
const dependencies = [];
|
|
182
|
+
|
|
183
|
+
for (const filePath of files) {
|
|
184
|
+
const absolutePath = join(repoPath, filePath);
|
|
185
|
+
if (!existsSync(absolutePath)) continue;
|
|
186
|
+
const content = readFileSync(absolutePath, 'utf8');
|
|
187
|
+
for (const specifier of extractImportSpecifiers(content)) {
|
|
188
|
+
const resolvedPath = resolveImportTarget(filePath, specifier, trackedSourceFiles);
|
|
189
|
+
if (!resolvedPath) continue;
|
|
190
|
+
dependencies.push({
|
|
191
|
+
file_path: filePath,
|
|
192
|
+
imported_path: resolvedPath,
|
|
193
|
+
import_specifier: specifier,
|
|
194
|
+
area: areaForPath(filePath),
|
|
195
|
+
subsystem_tags: classifySubsystems(filePath),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
generated_at: new Date().toISOString(),
|
|
202
|
+
dependency_count: dependencies.length,
|
|
203
|
+
dependencies,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
145
207
|
export function detectSemanticConflicts(semanticIndexes = []) {
|
|
146
208
|
const conflicts = [];
|
|
147
209
|
|
|
@@ -227,7 +289,7 @@ function normalizeObjectRow(row) {
|
|
|
227
289
|
}
|
|
228
290
|
|
|
229
291
|
export function importCodeObjectsToStore(db, repoRoot, { filePaths = null } = {}) {
|
|
230
|
-
const files = filePaths ||
|
|
292
|
+
const files = filePaths || listTrackedFiles(repoRoot);
|
|
231
293
|
const objects = files
|
|
232
294
|
.filter(isSourceLikePath)
|
|
233
295
|
.flatMap((filePath) => parseFileObjects(repoRoot, filePath));
|
|
@@ -309,3 +371,7 @@ export function materializeCodeObjects(db, repoRoot, { outputRoot = repoRoot } =
|
|
|
309
371
|
files: files.sort(),
|
|
310
372
|
};
|
|
311
373
|
}
|
|
374
|
+
|
|
375
|
+
export function classifySubsystemsForPath(filePath) {
|
|
376
|
+
return classifySubsystems(filePath);
|
|
377
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import readline from 'readline/promises';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TELEMETRY_HOST = 'https://us.i.posthog.com';
|
|
8
|
+
|
|
9
|
+
function normalizeBoolean(value) {
|
|
10
|
+
if (typeof value === 'boolean') return value;
|
|
11
|
+
if (typeof value !== 'string') return null;
|
|
12
|
+
const lowered = value.trim().toLowerCase();
|
|
13
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(lowered)) return true;
|
|
14
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(lowered)) return false;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getTelemetryConfigPath(homeDir = homedir()) {
|
|
19
|
+
return join(homeDir, '.switchman', 'config.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getTelemetryRuntimeConfig(env = process.env) {
|
|
23
|
+
return {
|
|
24
|
+
apiKey: env.SWITCHMAN_TELEMETRY_API_KEY || env.POSTHOG_API_KEY || null,
|
|
25
|
+
host: env.SWITCHMAN_TELEMETRY_HOST || env.POSTHOG_HOST || DEFAULT_TELEMETRY_HOST,
|
|
26
|
+
disabled: normalizeBoolean(env.SWITCHMAN_TELEMETRY_DISABLED) === true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadTelemetryConfig(homeDir = homedir()) {
|
|
31
|
+
const configPath = getTelemetryConfigPath(homeDir);
|
|
32
|
+
if (!existsSync(configPath)) {
|
|
33
|
+
return {
|
|
34
|
+
telemetry_enabled: null,
|
|
35
|
+
telemetry_install_id: null,
|
|
36
|
+
telemetry_prompted_at: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
42
|
+
return {
|
|
43
|
+
telemetry_enabled: typeof parsed?.telemetry_enabled === 'boolean' ? parsed.telemetry_enabled : null,
|
|
44
|
+
telemetry_install_id: typeof parsed?.telemetry_install_id === 'string' ? parsed.telemetry_install_id : null,
|
|
45
|
+
telemetry_prompted_at: typeof parsed?.telemetry_prompted_at === 'string' ? parsed.telemetry_prompted_at : null,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return {
|
|
49
|
+
telemetry_enabled: null,
|
|
50
|
+
telemetry_install_id: null,
|
|
51
|
+
telemetry_prompted_at: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function writeTelemetryConfig(homeDir = homedir(), config = {}) {
|
|
57
|
+
const configPath = getTelemetryConfigPath(homeDir);
|
|
58
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
59
|
+
const normalized = {
|
|
60
|
+
telemetry_enabled: typeof config.telemetry_enabled === 'boolean' ? config.telemetry_enabled : null,
|
|
61
|
+
telemetry_install_id: typeof config.telemetry_install_id === 'string' ? config.telemetry_install_id : (config.telemetry_enabled ? randomUUID() : null),
|
|
62
|
+
telemetry_prompted_at: typeof config.telemetry_prompted_at === 'string' ? config.telemetry_prompted_at : null,
|
|
63
|
+
};
|
|
64
|
+
writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
65
|
+
return { path: configPath, config: normalized };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function enableTelemetry(homeDir = homedir()) {
|
|
69
|
+
const current = loadTelemetryConfig(homeDir);
|
|
70
|
+
return writeTelemetryConfig(homeDir, {
|
|
71
|
+
...current,
|
|
72
|
+
telemetry_enabled: true,
|
|
73
|
+
telemetry_install_id: current.telemetry_install_id || randomUUID(),
|
|
74
|
+
telemetry_prompted_at: new Date().toISOString(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function disableTelemetry(homeDir = homedir()) {
|
|
79
|
+
const current = loadTelemetryConfig(homeDir);
|
|
80
|
+
return writeTelemetryConfig(homeDir, {
|
|
81
|
+
...current,
|
|
82
|
+
telemetry_enabled: false,
|
|
83
|
+
telemetry_install_id: current.telemetry_install_id || randomUUID(),
|
|
84
|
+
telemetry_prompted_at: new Date().toISOString(),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function maybePromptForTelemetry({ homeDir = homedir(), stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
|
|
89
|
+
const runtime = getTelemetryRuntimeConfig(env);
|
|
90
|
+
if (!runtime.apiKey || runtime.disabled) {
|
|
91
|
+
return { prompted: false, enabled: false, available: Boolean(runtime.apiKey) && !runtime.disabled };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const current = loadTelemetryConfig(homeDir);
|
|
95
|
+
if (typeof current.telemetry_enabled === 'boolean') {
|
|
96
|
+
return { prompted: false, enabled: current.telemetry_enabled, available: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!stdin?.isTTY || !stdout?.isTTY) {
|
|
100
|
+
return { prompted: false, enabled: false, available: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
104
|
+
try {
|
|
105
|
+
stdout.write('\nHelp improve Switchman?\n');
|
|
106
|
+
stdout.write('If you opt in, Switchman will send anonymous usage events like setup success,\n');
|
|
107
|
+
stdout.write('verify-setup pass, status --watch, queue usage, and gate outcomes.\n');
|
|
108
|
+
stdout.write('No code, prompts, file contents, repo names, or secrets are collected.\n\n');
|
|
109
|
+
const answer = await rl.question('Enable telemetry? [y/N] ');
|
|
110
|
+
const enabled = ['y', 'yes'].includes(String(answer || '').trim().toLowerCase());
|
|
111
|
+
if (enabled) {
|
|
112
|
+
enableTelemetry(homeDir);
|
|
113
|
+
} else {
|
|
114
|
+
disableTelemetry(homeDir);
|
|
115
|
+
}
|
|
116
|
+
return { prompted: true, enabled, available: true };
|
|
117
|
+
} finally {
|
|
118
|
+
rl.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function captureTelemetryEvent(event, properties = {}, {
|
|
123
|
+
homeDir = homedir(),
|
|
124
|
+
env = process.env,
|
|
125
|
+
timeoutMs = 1500,
|
|
126
|
+
} = {}) {
|
|
127
|
+
const result = await sendTelemetryEvent(event, properties, {
|
|
128
|
+
homeDir,
|
|
129
|
+
env,
|
|
130
|
+
timeoutMs,
|
|
131
|
+
});
|
|
132
|
+
return result.ok;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function sendTelemetryEvent(event, properties = {}, {
|
|
136
|
+
homeDir = homedir(),
|
|
137
|
+
env = process.env,
|
|
138
|
+
timeoutMs = 1500,
|
|
139
|
+
} = {}) {
|
|
140
|
+
const runtime = getTelemetryRuntimeConfig(env);
|
|
141
|
+
if (!runtime.apiKey) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
reason: 'not_configured',
|
|
145
|
+
status: null,
|
|
146
|
+
destination: runtime.host,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (runtime.disabled) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: 'disabled_by_env',
|
|
153
|
+
status: null,
|
|
154
|
+
destination: runtime.host,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (typeof fetch !== 'function') {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
reason: 'fetch_unavailable',
|
|
161
|
+
status: null,
|
|
162
|
+
destination: runtime.host,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const config = loadTelemetryConfig(homeDir);
|
|
167
|
+
if (config.telemetry_enabled !== true || !config.telemetry_install_id) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: 'not_enabled',
|
|
171
|
+
status: null,
|
|
172
|
+
destination: runtime.host,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
179
|
+
const response = await fetch(`${runtime.host.replace(/\/$/, '')}/capture/`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
api_key: runtime.apiKey,
|
|
184
|
+
event,
|
|
185
|
+
distinct_id: config.telemetry_install_id,
|
|
186
|
+
properties: {
|
|
187
|
+
source: 'switchman-cli',
|
|
188
|
+
...properties,
|
|
189
|
+
},
|
|
190
|
+
timestamp: new Date().toISOString(),
|
|
191
|
+
}),
|
|
192
|
+
signal: controller.signal,
|
|
193
|
+
});
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
return {
|
|
196
|
+
ok: response.ok,
|
|
197
|
+
reason: response.ok ? null : 'http_error',
|
|
198
|
+
status: response.status,
|
|
199
|
+
destination: runtime.host,
|
|
200
|
+
};
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: err?.name === 'AbortError' ? 'timeout' : 'network_error',
|
|
205
|
+
status: null,
|
|
206
|
+
destination: runtime.host,
|
|
207
|
+
error: String(err?.message || err),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|