preflight-mcp 0.1.3 → 0.1.4

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.
@@ -12,12 +12,15 @@ export const DependencyGraphInputSchema = {
12
12
  target: z.object({
13
13
  file: z
14
14
  .string()
15
- .describe('Bundle-relative file path (posix). Example: repos/owner/repo/norm/src/index.ts'),
15
+ .describe('Bundle-relative file path (NOT absolute path). Format: repos/{owner}/{repo}/norm/{path}. ' +
16
+ 'Example: repos/owner/repo/norm/src/index.ts or repos/jonnyhoo/langextract/norm/langextract/__init__.py. ' +
17
+ 'Use preflight_search_bundle to discover the correct path if unsure.'),
16
18
  symbol: z
17
19
  .string()
18
20
  .optional()
19
21
  .describe('Optional symbol name (function/class). If omitted, graph is file-level.'),
20
- }),
22
+ }).optional().describe('Target file/symbol to analyze. If omitted, generates a GLOBAL dependency graph of all code files in the bundle. ' +
23
+ 'Global mode shows import relationships between all files but may be truncated for large projects.'),
21
24
  options: z
22
25
  .object({
23
26
  maxFiles: z.number().int().min(1).max(500).default(200),
@@ -243,6 +246,26 @@ export async function generateDependencyGraph(cfg, rawArgs) {
243
246
  edges.push(e);
244
247
  };
245
248
  const bundleFileUri = (p) => toBundleFileUri({ bundleId: args.bundleId, relativePath: p });
249
+ // Global mode: no target specified
250
+ if (!args.target) {
251
+ return generateGlobalDependencyGraph({
252
+ cfg,
253
+ args,
254
+ paths,
255
+ manifest,
256
+ limits,
257
+ nodes,
258
+ edges,
259
+ warnings,
260
+ startedAt,
261
+ requestId,
262
+ timeBudgetMs,
263
+ checkBudget,
264
+ addNode,
265
+ addEdge,
266
+ bundleFileUri,
267
+ });
268
+ }
246
269
  const targetFile = args.target.file.replaceAll('\\', '/');
247
270
  const targetRepo = parseRepoNormPath(targetFile);
248
271
  const targetFileId = `file:${targetFile}`;
@@ -678,6 +701,38 @@ export async function generateDependencyGraph(cfg, rawArgs) {
678
701
  }
679
702
  }
680
703
  catch (err) {
704
+ // If target file not found, throw a helpful error instead of just warning
705
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
706
+ // Detect if path looks like an absolute filesystem path (wrong format)
707
+ const looksLikeAbsolutePath = /^[A-Za-z]:[\\/]|^\/(?:home|Users|var|tmp|etc)\//i.test(targetFile);
708
+ // Detect if path looks like correct bundle-relative format
709
+ const looksLikeBundleRelative = /^repos\/[^/]+\/[^/]+\/norm\//i.test(targetFile);
710
+ if (looksLikeAbsolutePath) {
711
+ throw new Error(`Target file not found: ${targetFile}\n\n` +
712
+ `ERROR: You provided an absolute filesystem path, but file paths must be bundle-relative.\n` +
713
+ `Correct format: repos/{owner}/{repo}/norm/{path/to/file}\n` +
714
+ `Example: repos/owner/myrepo/norm/src/main.py\n\n` +
715
+ `Use preflight_search_bundle to find the correct file path.`);
716
+ }
717
+ else if (looksLikeBundleRelative) {
718
+ throw new Error(`Target file not found: ${targetFile}\n\n` +
719
+ `The path format looks correct, but the file does not exist in the bundle.\n` +
720
+ `Possible causes:\n` +
721
+ `1. The bundle may be incomplete (download timed out or failed)\n` +
722
+ `2. The file path may have a typo\n\n` +
723
+ `Suggested actions:\n` +
724
+ `- Use preflight_search_bundle to verify available files\n` +
725
+ `- Use preflight_update_bundle with updateExisting:true to re-download\n` +
726
+ `- Check if repair shows "indexed 0 file(s)" which indicates incomplete bundle`);
727
+ }
728
+ else {
729
+ throw new Error(`Target file not found: ${targetFile}\n\n` +
730
+ `File paths must be bundle-relative, NOT absolute filesystem paths.\n` +
731
+ `Correct format: repos/{owner}/{repo}/norm/{path/to/file}\n` +
732
+ `Example: repos/owner/myrepo/norm/src/main.py\n\n` +
733
+ `Use preflight_search_bundle to find the correct file path.`);
734
+ }
735
+ }
681
736
  warnings.push({
682
737
  code: 'target_file_unreadable',
683
738
  message: `Failed to read target file for import extraction: ${err instanceof Error ? err.message : String(err)}`,
@@ -824,3 +879,258 @@ export async function generateDependencyGraph(cfg, rawArgs) {
824
879
  };
825
880
  return out;
826
881
  }
882
+ /**
883
+ * Global dependency graph mode: analyze all code files in the bundle.
884
+ * Generates import relationships between all files.
885
+ */
886
+ async function generateGlobalDependencyGraph(ctx) {
887
+ const { cfg, args, paths, manifest, limits, nodes, edges, warnings, startedAt, requestId, timeBudgetMs, checkBudget, addNode, addEdge, bundleFileUri, } = ctx;
888
+ let truncated = false;
889
+ let truncatedReason;
890
+ let filesProcessed = 0;
891
+ let usedAstCount = 0;
892
+ // Collect all code files
893
+ const codeExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php']);
894
+ const codeFiles = [];
895
+ async function* walkDir(dir, prefix) {
896
+ try {
897
+ const entries = await fs.readdir(dir, { withFileTypes: true });
898
+ for (const ent of entries) {
899
+ if (checkBudget('timeBudget exceeded during file discovery'))
900
+ return;
901
+ const relPath = prefix ? `${prefix}/${ent.name}` : ent.name;
902
+ if (ent.isDirectory()) {
903
+ yield* walkDir(path.join(dir, ent.name), relPath);
904
+ }
905
+ else if (ent.isFile()) {
906
+ const ext = path.extname(ent.name).toLowerCase();
907
+ if (codeExtensions.has(ext)) {
908
+ yield relPath;
909
+ }
910
+ }
911
+ }
912
+ }
913
+ catch {
914
+ // ignore unreadable directories
915
+ }
916
+ }
917
+ // Walk repos directory
918
+ for await (const relPath of walkDir(paths.reposDir, 'repos')) {
919
+ if (codeFiles.length >= limits.maxFiles) {
920
+ truncated = true;
921
+ truncatedReason = 'maxFiles reached during discovery';
922
+ break;
923
+ }
924
+ // Only include files under norm/ directories
925
+ if (relPath.includes('/norm/')) {
926
+ codeFiles.push(relPath);
927
+ }
928
+ }
929
+ warnings.push({
930
+ code: 'global_mode',
931
+ message: `Global dependency graph mode: analyzing ${codeFiles.length} code file(s). Results show import relationships between files.`,
932
+ });
933
+ // Process each file
934
+ const resolvedImportsCache = new Map();
935
+ for (const filePath of codeFiles) {
936
+ if (checkBudget('timeBudget exceeded during file processing')) {
937
+ truncated = true;
938
+ truncatedReason = 'timeBudget exceeded';
939
+ break;
940
+ }
941
+ const fileId = `file:${filePath}`;
942
+ addNode({ id: fileId, kind: 'file', name: filePath, file: filePath });
943
+ // Read and extract imports
944
+ try {
945
+ const absPath = safeJoin(paths.rootDir, filePath);
946
+ const raw = await fs.readFile(absPath, 'utf8');
947
+ const normalized = raw.replace(/\r\n/g, '\n');
948
+ const lines = normalized.split('\n');
949
+ const extracted = await extractImportsForFile(cfg, filePath, normalized, lines, warnings);
950
+ if (extracted.usedAst)
951
+ usedAstCount++;
952
+ filesProcessed++;
953
+ const fileRepo = parseRepoNormPath(filePath);
954
+ if (!fileRepo)
955
+ continue;
956
+ // Resolve imports to files in the bundle
957
+ for (const imp of extracted.imports) {
958
+ if (checkBudget('timeBudget exceeded during import resolution'))
959
+ break;
960
+ // Try to resolve the import to a file in the same repo
961
+ const resolvedFile = await resolveImportInRepo({
962
+ rootDir: paths.rootDir,
963
+ repoRoot: fileRepo.repoRoot,
964
+ importerRepoRel: fileRepo.repoRelativePath,
965
+ module: imp.module,
966
+ cache: resolvedImportsCache,
967
+ });
968
+ if (resolvedFile) {
969
+ const targetId = `file:${resolvedFile}`;
970
+ addNode({ id: targetId, kind: 'file', name: resolvedFile, file: resolvedFile });
971
+ const source = {
972
+ file: filePath,
973
+ range: imp.range,
974
+ uri: bundleFileUri(filePath),
975
+ snippet: clampSnippet(lines[imp.range.startLine - 1] ?? '', 200),
976
+ };
977
+ source.snippetSha256 = sha256Hex(source.snippet ?? '');
978
+ addEdge({
979
+ evidenceId: makeEvidenceId(['imports_resolved', fileId, targetId, String(imp.range.startLine)]),
980
+ kind: 'edge',
981
+ type: 'imports_resolved',
982
+ from: fileId,
983
+ to: targetId,
984
+ method: imp.method,
985
+ confidence: Math.min(0.85, imp.confidence),
986
+ sources: [source],
987
+ notes: [...imp.notes, `resolved import "${imp.module}" to ${resolvedFile}`],
988
+ });
989
+ }
990
+ }
991
+ }
992
+ catch {
993
+ // Skip unreadable files
994
+ }
995
+ }
996
+ // Post-process warnings
997
+ warnings.push({
998
+ code: 'limitations',
999
+ message: usedAstCount > 0
1000
+ ? `Global graph used AST parsing for ${usedAstCount}/${filesProcessed} files. Import resolution is best-effort. Only internal imports (resolved to files in the bundle) are shown.`
1001
+ : 'Global graph used regex-based import extraction. Import resolution is best-effort. Only internal imports (resolved to files in the bundle) are shown.',
1002
+ });
1003
+ const importEdges = edges.filter((e) => e.type === 'imports_resolved').length;
1004
+ return {
1005
+ meta: {
1006
+ requestId,
1007
+ generatedAt: nowIso(),
1008
+ timeMs: Date.now() - startedAt,
1009
+ repo: {
1010
+ bundleId: args.bundleId,
1011
+ headSha: manifest.repos?.[0]?.headSha,
1012
+ },
1013
+ budget: {
1014
+ timeBudgetMs,
1015
+ truncated,
1016
+ truncatedReason,
1017
+ limits,
1018
+ },
1019
+ },
1020
+ facts: {
1021
+ nodes: Array.from(nodes.values()),
1022
+ edges,
1023
+ },
1024
+ signals: {
1025
+ stats: {
1026
+ filesRead: filesProcessed,
1027
+ searchHits: 0,
1028
+ callEdges: 0,
1029
+ importEdges,
1030
+ },
1031
+ warnings,
1032
+ },
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Resolve an import to a file path within the same repo.
1037
+ */
1038
+ async function resolveImportInRepo(ctx) {
1039
+ const { rootDir, repoRoot, importerRepoRel, module, cache } = ctx;
1040
+ // Get or create cache for this repo
1041
+ let repoCache = cache.get(repoRoot);
1042
+ if (!repoCache) {
1043
+ repoCache = new Map();
1044
+ cache.set(repoRoot, repoCache);
1045
+ }
1046
+ const cacheKey = `${importerRepoRel}:${module}`;
1047
+ const cached = repoCache.get(cacheKey);
1048
+ if (cached !== undefined)
1049
+ return cached;
1050
+ const ext = path.extname(importerRepoRel).toLowerCase();
1051
+ const cleaned = (module.split(/[?#]/, 1)[0] ?? '').trim();
1052
+ if (!cleaned) {
1053
+ repoCache.set(cacheKey, null);
1054
+ return null;
1055
+ }
1056
+ const bundlePathForRepoRel = (repoRel) => `${repoRoot}/${repoRel.replaceAll('\\', '/')}`;
1057
+ const normalizeDir = (d) => (d === '.' ? '' : d);
1058
+ // JS/TS resolution
1059
+ const isJs = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
1060
+ if (isJs) {
1061
+ if (!cleaned.startsWith('.') && !cleaned.startsWith('/')) {
1062
+ repoCache.set(cacheKey, null);
1063
+ return null;
1064
+ }
1065
+ const importerDir = normalizeDir(path.posix.dirname(importerRepoRel));
1066
+ const base = cleaned.startsWith('/')
1067
+ ? path.posix.normalize(cleaned.slice(1))
1068
+ : path.posix.normalize(path.posix.join(importerDir, cleaned));
1069
+ const candidates = [];
1070
+ const baseExt = path.posix.extname(base).toLowerCase();
1071
+ if (baseExt) {
1072
+ candidates.push(base);
1073
+ if (['.js', '.mjs', '.cjs'].includes(baseExt)) {
1074
+ const stem = base.slice(0, -baseExt.length);
1075
+ candidates.push(`${stem}.ts`, `${stem}.tsx`, `${stem}.jsx`);
1076
+ }
1077
+ }
1078
+ else {
1079
+ const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
1080
+ for (const e of exts)
1081
+ candidates.push(`${base}${e}`);
1082
+ for (const e of exts)
1083
+ candidates.push(path.posix.join(base, `index${e}`));
1084
+ }
1085
+ for (const repoRel of candidates) {
1086
+ const bundleRel = bundlePathForRepoRel(repoRel);
1087
+ const abs = safeJoin(rootDir, bundleRel);
1088
+ if (await fileExists(abs)) {
1089
+ repoCache.set(cacheKey, bundleRel);
1090
+ return bundleRel;
1091
+ }
1092
+ }
1093
+ }
1094
+ // Python resolution
1095
+ if (ext === '.py') {
1096
+ if (cleaned.startsWith('.')) {
1097
+ // Relative import
1098
+ const m = cleaned.match(/^(\.+)(.*)$/);
1099
+ if (m) {
1100
+ const dotCount = m[1]?.length ?? 0;
1101
+ const rest = (m[2] ?? '').replace(/^\.+/, '');
1102
+ let baseDir = normalizeDir(path.posix.dirname(importerRepoRel));
1103
+ for (let i = 1; i < dotCount; i++) {
1104
+ baseDir = normalizeDir(path.posix.dirname(baseDir));
1105
+ }
1106
+ const restPath = rest ? rest.replace(/\./g, '/') : '';
1107
+ const candidates = restPath
1108
+ ? [path.posix.join(baseDir, `${restPath}.py`), path.posix.join(baseDir, restPath, '__init__.py')]
1109
+ : [path.posix.join(baseDir, '__init__.py')];
1110
+ for (const repoRel of candidates) {
1111
+ const bundleRel = bundlePathForRepoRel(repoRel);
1112
+ const abs = safeJoin(rootDir, bundleRel);
1113
+ if (await fileExists(abs)) {
1114
+ repoCache.set(cacheKey, bundleRel);
1115
+ return bundleRel;
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+ else {
1121
+ // Absolute import - try common patterns
1122
+ const modPath = cleaned.replace(/\./g, '/');
1123
+ const candidates = [`${modPath}.py`, path.posix.join(modPath, '__init__.py')];
1124
+ for (const repoRel of candidates) {
1125
+ const bundleRel = bundlePathForRepoRel(repoRel);
1126
+ const abs = safeJoin(rootDir, bundleRel);
1127
+ if (await fileExists(abs)) {
1128
+ repoCache.set(cacheKey, bundleRel);
1129
+ return bundleRel;
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+ repoCache.set(cacheKey, null);
1135
+ return null;
1136
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Progress tracking for long-running bundle creation tasks.
3
+ * Enables progress notifications via MCP and prevents duplicate task creation.
4
+ */
5
+ import crypto from 'node:crypto';
6
+ function nowIso() {
7
+ return new Date().toISOString();
8
+ }
9
+ /**
10
+ * Tracks progress of bundle creation tasks.
11
+ * Provides in-memory state for active tasks and emits progress updates.
12
+ */
13
+ export class ProgressTracker {
14
+ tasks = new Map();
15
+ fingerprintToTaskId = new Map();
16
+ progressCallback;
17
+ /**
18
+ * Set a callback to receive progress updates.
19
+ * Used to forward updates to MCP progress notifications.
20
+ */
21
+ setProgressCallback(callback) {
22
+ this.progressCallback = callback;
23
+ }
24
+ /**
25
+ * Start tracking a new task.
26
+ * @returns taskId for the new task
27
+ */
28
+ startTask(fingerprint, repos) {
29
+ // Check if task already exists for this fingerprint
30
+ const existingTaskId = this.fingerprintToTaskId.get(fingerprint);
31
+ if (existingTaskId) {
32
+ const existingTask = this.tasks.get(existingTaskId);
33
+ if (existingTask && existingTask.phase !== 'complete' && existingTask.phase !== 'failed') {
34
+ // Return existing active task
35
+ return existingTaskId;
36
+ }
37
+ }
38
+ const taskId = crypto.randomUUID();
39
+ const now = nowIso();
40
+ const task = {
41
+ taskId,
42
+ fingerprint,
43
+ phase: 'starting',
44
+ progress: 0,
45
+ message: `Starting bundle creation for ${repos.join(', ')}`,
46
+ startedAt: now,
47
+ updatedAt: now,
48
+ repos,
49
+ };
50
+ this.tasks.set(taskId, task);
51
+ this.fingerprintToTaskId.set(fingerprint, taskId);
52
+ this.emitProgress(task);
53
+ return taskId;
54
+ }
55
+ /**
56
+ * Update progress for an existing task.
57
+ */
58
+ updateProgress(taskId, phase, progress, message, total) {
59
+ const task = this.tasks.get(taskId);
60
+ if (!task)
61
+ return;
62
+ task.phase = phase;
63
+ task.progress = progress;
64
+ task.message = message;
65
+ task.updatedAt = nowIso();
66
+ if (total !== undefined) {
67
+ task.total = total;
68
+ }
69
+ this.emitProgress(task);
70
+ }
71
+ /**
72
+ * Mark a task as complete.
73
+ */
74
+ completeTask(taskId, bundleId) {
75
+ const task = this.tasks.get(taskId);
76
+ if (!task)
77
+ return;
78
+ task.phase = 'complete';
79
+ task.progress = 100;
80
+ task.message = `Bundle created: ${bundleId}`;
81
+ task.updatedAt = nowIso();
82
+ task.bundleId = bundleId;
83
+ this.emitProgress(task);
84
+ // Clean up after a delay to allow final status queries
85
+ setTimeout(() => {
86
+ this.tasks.delete(taskId);
87
+ if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
88
+ this.fingerprintToTaskId.delete(task.fingerprint);
89
+ }
90
+ }, 60_000); // Keep completed task for 1 minute
91
+ }
92
+ /**
93
+ * Mark a task as failed.
94
+ */
95
+ failTask(taskId, error) {
96
+ const task = this.tasks.get(taskId);
97
+ if (!task)
98
+ return;
99
+ task.phase = 'failed';
100
+ task.message = `Failed: ${error}`;
101
+ task.updatedAt = nowIso();
102
+ task.error = error;
103
+ this.emitProgress(task);
104
+ // Clean up after a delay
105
+ setTimeout(() => {
106
+ this.tasks.delete(taskId);
107
+ if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
108
+ this.fingerprintToTaskId.delete(task.fingerprint);
109
+ }
110
+ }, 60_000);
111
+ }
112
+ /**
113
+ * Get task by ID.
114
+ */
115
+ getTask(taskId) {
116
+ return this.tasks.get(taskId);
117
+ }
118
+ /**
119
+ * Get task by fingerprint.
120
+ */
121
+ getTaskByFingerprint(fingerprint) {
122
+ const taskId = this.fingerprintToTaskId.get(fingerprint);
123
+ if (!taskId)
124
+ return undefined;
125
+ return this.tasks.get(taskId);
126
+ }
127
+ /**
128
+ * List all active (non-complete, non-failed) tasks.
129
+ */
130
+ listActiveTasks() {
131
+ return Array.from(this.tasks.values()).filter((t) => t.phase !== 'complete' && t.phase !== 'failed');
132
+ }
133
+ /**
134
+ * List all tasks (including recently completed/failed).
135
+ */
136
+ listAllTasks() {
137
+ return Array.from(this.tasks.values());
138
+ }
139
+ /**
140
+ * Check if a task is active (in progress).
141
+ */
142
+ isTaskActive(fingerprint) {
143
+ const task = this.getTaskByFingerprint(fingerprint);
144
+ return task !== undefined && task.phase !== 'complete' && task.phase !== 'failed';
145
+ }
146
+ /**
147
+ * Remove a task (e.g., when lock times out).
148
+ */
149
+ removeTask(taskId) {
150
+ const task = this.tasks.get(taskId);
151
+ if (task) {
152
+ this.tasks.delete(taskId);
153
+ if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
154
+ this.fingerprintToTaskId.delete(task.fingerprint);
155
+ }
156
+ }
157
+ }
158
+ emitProgress(task) {
159
+ if (this.progressCallback) {
160
+ this.progressCallback(task);
161
+ }
162
+ }
163
+ }
164
+ // Singleton instance for global access
165
+ let globalTracker;
166
+ export function getProgressTracker() {
167
+ if (!globalTracker) {
168
+ globalTracker = new ProgressTracker();
169
+ }
170
+ return globalTracker;
171
+ }
172
+ /**
173
+ * Helper to format bytes for display.
174
+ */
175
+ export function formatBytes(bytes) {
176
+ if (bytes < 1024)
177
+ return `${bytes}B`;
178
+ if (bytes < 1024 * 1024)
179
+ return `${(bytes / 1024).toFixed(1)}KB`;
180
+ if (bytes < 1024 * 1024 * 1024)
181
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
182
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
183
+ }
184
+ /**
185
+ * Helper to calculate percentage.
186
+ */
187
+ export function calcPercent(current, total) {
188
+ if (total <= 0)
189
+ return 0;
190
+ return Math.min(100, Math.round((current / total) * 100));
191
+ }