vectorforce 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/AGENTS.md +12 -0
- package/.agents/agents/vectorforce-architect.md +35 -0
- package/.agents/agents/vectorforce-auditor.md +25 -0
- package/.agents/agents/vectorforce-samples-builder.md +30 -0
- package/.agents/mcp_config.json +11 -0
- package/.codex/config.toml +5 -0
- package/AGENTS.md +115 -0
- package/LICENSE +21 -0
- package/README.md +198 -2
- package/bin/vf.js +1159 -0
- package/codex.json +14 -0
- package/commandcode.json +11 -0
- package/musecode.json +14 -0
- package/package.json +40 -4
- package/src/core/errors.js +45 -0
- package/src/core/hash.js +56 -0
- package/src/core/types.js +194 -0
- package/src/index.js +17 -0
- package/src/mcp/harness-config.js +546 -0
- package/src/mcp/mcp-server.js +507 -0
- package/src/operations/briefing-service.js +313 -0
- package/src/operations/checkpoint-service.js +120 -0
- package/src/operations/handoff-service.js +343 -0
- package/src/operations/project-service.js +132 -0
- package/src/operations/reasoning-service.js +356 -0
- package/src/policy/authorizer.js +147 -0
- package/src/providers/code-index-provider.js +285 -0
- package/src/providers/document-provider.js +105 -0
- package/src/providers/git-provider.js +98 -0
- package/src/providers/llm-provider.js +425 -0
- package/src/storage/sqlite-store.js +387 -0
- package/src/studio/server.js +764 -0
- package/studio/dist/assets/index-C0_eVpV1.js +9 -0
- package/studio/dist/assets/index-CxTK-2ty.css +2 -0
- package/studio/dist/favicon.svg +1 -0
- package/studio/dist/icons.svg +24 -0
- package/studio/dist/index.html +17 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, extname, posix } from 'node:path';
|
|
3
|
+
import { sha256 } from '../core/hash.js';
|
|
4
|
+
import { Authorizer } from '../policy/authorizer.js';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MAX_FILES = 2000;
|
|
7
|
+
export const DEFAULT_MAX_FILE_BYTES = 200000;
|
|
8
|
+
export const SNIPPET_LINES = 40;
|
|
9
|
+
export const SNIPPET_MAX_CHARS = 4000;
|
|
10
|
+
|
|
11
|
+
const SKIP_DIRS = new Set([
|
|
12
|
+
'.git', 'node_modules', 'dist', 'build', 'coverage', '.vectorforce',
|
|
13
|
+
'.commandcode', '.agents', 'tmp', '.next', '.cache', 'out', 'vendor', 'target'
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const SKIP_FILE_PATTERNS = [
|
|
17
|
+
/^\.env($|\.)/i,
|
|
18
|
+
/\.pem$/i,
|
|
19
|
+
/\.key$/i,
|
|
20
|
+
/\.p12$/i,
|
|
21
|
+
/\.pfx$/i,
|
|
22
|
+
/credentials/i,
|
|
23
|
+
/secret/i,
|
|
24
|
+
/^id_(rsa|dsa|ecdsa|ed25519)/i,
|
|
25
|
+
/^\.npmrc$/i
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const CODE_EXTENSIONS = new Set([
|
|
29
|
+
'.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.py', '.go', '.rs',
|
|
30
|
+
'.java', '.rb', '.sql', '.sh', '.md', '.json', '.yaml', '.yml'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const LANGUAGE_BY_EXT = {
|
|
34
|
+
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript',
|
|
35
|
+
'.ts': 'typescript', '.tsx': 'typescript', '.py': 'python', '.go': 'go', '.rs': 'rust',
|
|
36
|
+
'.java': 'java', '.rb': 'ruby', '.sql': 'sql', '.sh': 'shell', '.md': 'markdown',
|
|
37
|
+
'.json': 'json', '.yaml': 'yaml', '.yml': 'yaml'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const RESOLUTION_SUFFIXES = [
|
|
41
|
+
'', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
|
|
42
|
+
'/index.js', '/index.ts', '/index.jsx', '/index.tsx'
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const STOPWORDS = new Set([
|
|
46
|
+
'the', 'and', 'for', 'with', 'this', 'that', 'from', 'into', 'when', 'then', 'than',
|
|
47
|
+
'are', 'was', 'were', 'will', 'should', 'must', 'add', 'use', 'via', 'per', 'not',
|
|
48
|
+
'but', 'you', 'your', 'our', 'its', 'has', 'have', 'had', 'can', 'may', 'all', 'any',
|
|
49
|
+
'new', 'old', 'out', 'one', 'two', 'task', 'code', 'work'
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function uniqueMatches(content, regex, group = 1) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
let match;
|
|
55
|
+
regex.lastIndex = 0;
|
|
56
|
+
while ((match = regex.exec(content)) !== null) {
|
|
57
|
+
const value = match[group];
|
|
58
|
+
if (value) seen.add(value);
|
|
59
|
+
}
|
|
60
|
+
return Array.from(seen);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deterministic, dependency-free index of a workspace's source tree.
|
|
65
|
+
* Extracts a structural skeleton (files, symbols, import edges) that a language
|
|
66
|
+
* model can reason over. Never follows symlinks and never escapes the workspace.
|
|
67
|
+
*/
|
|
68
|
+
export class CodeIndexProvider {
|
|
69
|
+
/**
|
|
70
|
+
* Build a structural index of the workspace.
|
|
71
|
+
* @param {string} canonicalWorkspace
|
|
72
|
+
* @param {{ maxFiles?: number, maxFileBytes?: number }} [opts]
|
|
73
|
+
* @returns {{ files: Array<object>, edges: Array<object>, truncated: boolean, gaps: Array<object> }}
|
|
74
|
+
*/
|
|
75
|
+
static build(canonicalWorkspace, opts = {}) {
|
|
76
|
+
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
77
|
+
const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
78
|
+
const root = realpathSync(canonicalWorkspace);
|
|
79
|
+
|
|
80
|
+
const files = [];
|
|
81
|
+
const gaps = [];
|
|
82
|
+
let truncated = false;
|
|
83
|
+
|
|
84
|
+
const walk = (dir, relDir) => {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
gaps.push({ type: 'code', target: relDir || '.', reason: `readdir failed: ${err.message}` });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (files.length >= maxFiles) {
|
|
96
|
+
truncated = true;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const name = entry.name;
|
|
100
|
+
const abs = join(dir, name);
|
|
101
|
+
const rel = relDir ? `${relDir}/${name}` : name;
|
|
102
|
+
|
|
103
|
+
if (entry.isSymbolicLink()) continue;
|
|
104
|
+
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
107
|
+
walk(abs, rel);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!entry.isFile()) continue;
|
|
111
|
+
if (CodeIndexProvider._isSecretPath(name, rel)) continue;
|
|
112
|
+
|
|
113
|
+
const ext = extname(name).toLowerCase();
|
|
114
|
+
if (!CODE_EXTENSIONS.has(ext)) continue;
|
|
115
|
+
if (!Authorizer.isWithinWorkspace(root, abs)) continue;
|
|
116
|
+
|
|
117
|
+
let st;
|
|
118
|
+
try {
|
|
119
|
+
st = statSync(abs);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (st.size > maxFileBytes) {
|
|
124
|
+
gaps.push({ type: 'code', target: rel, reason: `Skipped: ${st.size} bytes exceeds ${maxFileBytes} byte limit` });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let content;
|
|
129
|
+
try {
|
|
130
|
+
content = readFileSync(abs, 'utf8');
|
|
131
|
+
} catch (err) {
|
|
132
|
+
gaps.push({ type: 'code', target: rel, reason: `read failed: ${err.message}` });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (content.includes('\u0000')) continue;
|
|
136
|
+
|
|
137
|
+
const language = LANGUAGE_BY_EXT[ext] || ext.slice(1);
|
|
138
|
+
files.push({
|
|
139
|
+
path: rel,
|
|
140
|
+
size: st.size,
|
|
141
|
+
sha256: sha256(content),
|
|
142
|
+
language,
|
|
143
|
+
symbols: CodeIndexProvider.extractSymbols(content, language),
|
|
144
|
+
snippet: CodeIndexProvider._snippet(content)
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
walk(root, '');
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
files,
|
|
153
|
+
edges: CodeIndexProvider._buildEdges(files),
|
|
154
|
+
truncated,
|
|
155
|
+
gaps
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Extract a structural symbol skeleton using deterministic regex heuristics.
|
|
161
|
+
* @param {string} content
|
|
162
|
+
* @param {string} language
|
|
163
|
+
* @returns {{ exports: string[], imports: string[], functions: string[], classes: string[] }}
|
|
164
|
+
*/
|
|
165
|
+
static extractSymbols(content, language) {
|
|
166
|
+
const result = { exports: [], imports: [], functions: [], classes: [] };
|
|
167
|
+
|
|
168
|
+
if (language === 'javascript' || language === 'typescript') {
|
|
169
|
+
result.imports = uniqueMatches(content, /(?:import\s+(?:[^'"]*?\s+from\s+)?|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g);
|
|
170
|
+
result.exports = uniqueMatches(content, /\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g);
|
|
171
|
+
result.exports.push(...uniqueMatches(content, /\bmodule\.exports\s*=\s*([A-Za-z_$][\w$]*)/g));
|
|
172
|
+
result.functions = uniqueMatches(content, /\bfunction\s+([A-Za-z_$][\w$]*)/g);
|
|
173
|
+
result.functions.push(...uniqueMatches(content, /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/g));
|
|
174
|
+
result.classes = uniqueMatches(content, /\bclass\s+([A-Za-z_$][\w$]*)/g);
|
|
175
|
+
} else if (language === 'python') {
|
|
176
|
+
result.imports = uniqueMatches(content, /^\s*(?:import|from)\s+([\w.]+)/gm);
|
|
177
|
+
result.functions = uniqueMatches(content, /^\s*def\s+([A-Za-z_]\w*)/gm);
|
|
178
|
+
result.classes = uniqueMatches(content, /^\s*class\s+([A-Za-z_]\w*)/gm);
|
|
179
|
+
result.exports = result.functions.slice();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const key of Object.keys(result)) {
|
|
183
|
+
result[key] = Array.from(new Set(result[key])).slice(0, 50);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Select the most relevant files for a reasoning task, deterministically.
|
|
190
|
+
* Ranking: changed-since-checkpoint > token match in path/symbols > entry points.
|
|
191
|
+
* @param {object} index result of build()
|
|
192
|
+
* @param {{ queryTokens?: string[], changedFiles?: string[], limit?: number, byteBudget?: number }} [opts]
|
|
193
|
+
* @returns {{ files: Array<object>, usedBytes: number }}
|
|
194
|
+
*/
|
|
195
|
+
static selectRelevantFiles(index, opts = {}) {
|
|
196
|
+
const limit = opts.limit ?? 12;
|
|
197
|
+
const byteBudget = opts.byteBudget ?? 48000;
|
|
198
|
+
const tokenSet = new Set((opts.queryTokens || []).map(t => String(t).toLowerCase()).filter(Boolean));
|
|
199
|
+
const changed = new Set(opts.changedFiles || []);
|
|
200
|
+
const entryPoints = new Set([
|
|
201
|
+
'package.json', 'readme.md', 'index.js', 'index.ts', 'src/index.js', 'src/index.ts',
|
|
202
|
+
'main.js', 'main.py', 'app.py', 'app.js', 'app.ts', 'pyproject.toml'
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const scored = index.files.map(file => {
|
|
206
|
+
let score = 0;
|
|
207
|
+
const hay = `${file.path} ${file.symbols.exports.join(' ')} ${file.symbols.functions.join(' ')} ${file.symbols.classes.join(' ')}`.toLowerCase();
|
|
208
|
+
for (const token of tokenSet) {
|
|
209
|
+
if (token.length >= 3 && hay.includes(token)) score += 2;
|
|
210
|
+
}
|
|
211
|
+
if (changed.has(file.path)) score += 5;
|
|
212
|
+
if (entryPoints.has(file.path.toLowerCase())) score += 3;
|
|
213
|
+
return { file, score };
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
scored.sort((a, b) => b.score - a.score || a.file.path.localeCompare(b.file.path));
|
|
217
|
+
|
|
218
|
+
const selected = [];
|
|
219
|
+
let usedBytes = 0;
|
|
220
|
+
for (const { file, score } of scored) {
|
|
221
|
+
if (selected.length >= limit) break;
|
|
222
|
+
const size = file.snippet.length;
|
|
223
|
+
if (usedBytes + size > byteBudget) continue;
|
|
224
|
+
selected.push({
|
|
225
|
+
path: file.path,
|
|
226
|
+
language: file.language,
|
|
227
|
+
symbols: file.symbols,
|
|
228
|
+
snippet: file.snippet,
|
|
229
|
+
changed: changed.has(file.path),
|
|
230
|
+
score
|
|
231
|
+
});
|
|
232
|
+
usedBytes += size;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { files: selected, usedBytes };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Tokenize free text into relevance keywords (lowercased, dedupliced, stopwords removed).
|
|
240
|
+
* @param {...string} texts
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
static tokenize(...texts) {
|
|
244
|
+
const tokens = new Set();
|
|
245
|
+
for (const text of texts) {
|
|
246
|
+
if (!text) continue;
|
|
247
|
+
for (const raw of String(text).toLowerCase().split(/[^a-z0-9_]+/)) {
|
|
248
|
+
if (raw.length >= 3 && !STOPWORDS.has(raw)) tokens.add(raw);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return Array.from(tokens);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
static _isSecretPath(name, rel) {
|
|
255
|
+
return SKIP_FILE_PATTERNS.some(re => re.test(name) || re.test(rel));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
static _snippet(content) {
|
|
259
|
+
let snippet = content.split('\n').slice(0, SNIPPET_LINES).join('\n');
|
|
260
|
+
if (snippet.length > SNIPPET_MAX_CHARS) snippet = snippet.slice(0, SNIPPET_MAX_CHARS);
|
|
261
|
+
return snippet;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
static _buildEdges(files) {
|
|
265
|
+
const known = new Set(files.map(f => f.path));
|
|
266
|
+
const edges = [];
|
|
267
|
+
const seen = new Set();
|
|
268
|
+
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
const dir = posix.dirname(file.path);
|
|
271
|
+
for (const spec of file.symbols.imports) {
|
|
272
|
+
if (!spec.startsWith('.')) continue;
|
|
273
|
+
const base = posix.normalize(posix.join(dir, spec));
|
|
274
|
+
const hit = RESOLUTION_SUFFIXES.map(suffix => base + suffix).find(candidate => known.has(candidate));
|
|
275
|
+
if (!hit || hit === file.path) continue;
|
|
276
|
+
const key = `${file.path}->${hit}`;
|
|
277
|
+
if (seen.has(key)) continue;
|
|
278
|
+
seen.add(key);
|
|
279
|
+
edges.push({ from: file.path, to: hit });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return edges;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { Authorizer, MAX_FILE_BYTES, MAX_TOTAL_DOCUMENT_BYTES } from '../policy/authorizer.js';
|
|
3
|
+
import { sha256 } from '../core/hash.js';
|
|
4
|
+
import { ErrorCode, VectorForceError } from '../core/errors.js';
|
|
5
|
+
|
|
6
|
+
export class DocumentProvider {
|
|
7
|
+
/**
|
|
8
|
+
* Collect allowlisted documents safely.
|
|
9
|
+
* @param {string} canonicalWorkspace
|
|
10
|
+
* @param {string[]} requestedPaths Relative paths
|
|
11
|
+
* @param {string[]} allowedPaths Project allowlist
|
|
12
|
+
* @returns {{ documents: Array<object>, gaps: Array<object> }}
|
|
13
|
+
*/
|
|
14
|
+
static collect(canonicalWorkspace, requestedPaths = [], allowedPaths = []) {
|
|
15
|
+
if (!requestedPaths || requestedPaths.length === 0) {
|
|
16
|
+
return { documents: [], gaps: [] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (requestedPaths.length > 8) {
|
|
20
|
+
throw new VectorForceError(
|
|
21
|
+
ErrorCode.POLICY_DENIED,
|
|
22
|
+
`Too many documents requested: ${requestedPaths.length}. Maximum is 8.`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 1. Check authorization against allowlist
|
|
27
|
+
Authorizer.authorizeDocumentSelection(requestedPaths, allowedPaths);
|
|
28
|
+
|
|
29
|
+
// 2. Canonicalize path order (sort uniquely)
|
|
30
|
+
const uniqueSorted = Array.from(new Set(requestedPaths)).sort();
|
|
31
|
+
|
|
32
|
+
const documents = [];
|
|
33
|
+
const gaps = [];
|
|
34
|
+
let totalBytes = 0;
|
|
35
|
+
|
|
36
|
+
for (const relPath of uniqueSorted) {
|
|
37
|
+
const fullPath = Authorizer.validateDocumentPath(canonicalWorkspace, relPath);
|
|
38
|
+
|
|
39
|
+
// Check existence and stat
|
|
40
|
+
let st;
|
|
41
|
+
try {
|
|
42
|
+
st = statSync(fullPath);
|
|
43
|
+
} catch {
|
|
44
|
+
gaps.push({
|
|
45
|
+
type: 'document',
|
|
46
|
+
target: relPath,
|
|
47
|
+
reason: 'File does not exist or is inaccessible'
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
53
|
+
throw new VectorForceError(
|
|
54
|
+
ErrorCode.DOCUMENT_TOO_LARGE,
|
|
55
|
+
`Document '${relPath}' exceeds maximum file size (${st.size} bytes > ${MAX_FILE_BYTES} bytes limit)`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (totalBytes + st.size > MAX_TOTAL_DOCUMENT_BYTES) {
|
|
60
|
+
throw new VectorForceError(
|
|
61
|
+
ErrorCode.TOTAL_DOCUMENTS_TOO_LARGE,
|
|
62
|
+
`Total document size exceeds limit (${totalBytes + st.size} bytes > ${MAX_TOTAL_DOCUMENT_BYTES} bytes limit)`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read buffer
|
|
67
|
+
let buffer;
|
|
68
|
+
try {
|
|
69
|
+
buffer = readFileSync(fullPath);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
gaps.push({
|
|
72
|
+
type: 'document',
|
|
73
|
+
target: relPath,
|
|
74
|
+
reason: `Read failed: ${err.message}`
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check for NUL byte
|
|
80
|
+
if (buffer.includes(0x00)) {
|
|
81
|
+
throw new VectorForceError(
|
|
82
|
+
ErrorCode.INVALID_DOCUMENT_CONTENT,
|
|
83
|
+
`Document '${relPath}' contains invalid binary or NUL bytes`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const contentStr = buffer.toString('utf8');
|
|
88
|
+
const hash = sha256(buffer);
|
|
89
|
+
const collectedAt = new Date().toISOString();
|
|
90
|
+
|
|
91
|
+
documents.push({
|
|
92
|
+
path: relPath,
|
|
93
|
+
sha256: hash,
|
|
94
|
+
size_bytes: st.size,
|
|
95
|
+
collected_at: collectedAt,
|
|
96
|
+
content: contentStr,
|
|
97
|
+
untrusted_content: true
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
totalBytes += st.size;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { documents, gaps };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export class GitProvider {
|
|
4
|
+
/**
|
|
5
|
+
* Collect read-only Git status and HEAD commit.
|
|
6
|
+
* Never mutates source or calls network commands.
|
|
7
|
+
* @param {string} workspacePath
|
|
8
|
+
* @returns {{ git: object|null, gap: object|null }}
|
|
9
|
+
*/
|
|
10
|
+
static collect(workspacePath) {
|
|
11
|
+
try {
|
|
12
|
+
// 1. Commit hash
|
|
13
|
+
const commitHash = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
14
|
+
cwd: workspacePath,
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
timeout: 3000,
|
|
17
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
18
|
+
}).trim();
|
|
19
|
+
|
|
20
|
+
// 2. Branch name
|
|
21
|
+
let branch = 'HEAD';
|
|
22
|
+
try {
|
|
23
|
+
branch = execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], {
|
|
24
|
+
cwd: workspacePath,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
timeout: 2000,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
28
|
+
}).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
// Detached HEAD or initial commit
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Status porcelain (check if dirty)
|
|
34
|
+
const statusOutput = execFileSync('git', ['status', '--porcelain'], {
|
|
35
|
+
cwd: workspacePath,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
timeout: 3000,
|
|
38
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
39
|
+
}).trim();
|
|
40
|
+
|
|
41
|
+
const isDirty = statusOutput.length > 0;
|
|
42
|
+
const collectedAt = new Date().toISOString();
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
git: {
|
|
46
|
+
commit_hash: commitHash,
|
|
47
|
+
branch,
|
|
48
|
+
is_dirty: isDirty,
|
|
49
|
+
collected_at: collectedAt
|
|
50
|
+
},
|
|
51
|
+
gap: null
|
|
52
|
+
};
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return {
|
|
55
|
+
git: null,
|
|
56
|
+
gap: {
|
|
57
|
+
type: 'git',
|
|
58
|
+
target: 'workspace',
|
|
59
|
+
reason: `Git metadata unavailable: ${err.message || 'Not a git repository or git command failed'}`
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* List workspace-relative files changed since a given commit (read-only).
|
|
67
|
+
* Deleted files are excluded so callers only reason over files that still exist.
|
|
68
|
+
* @param {string} workspacePath
|
|
69
|
+
* @param {string} [sinceCommit] 40-hex commit hash; when omitted, uses working-tree changes only
|
|
70
|
+
* @returns {{ files: string[], gap: object|null }}
|
|
71
|
+
*/
|
|
72
|
+
static collectChangedFiles(workspacePath, sinceCommit = null) {
|
|
73
|
+
try {
|
|
74
|
+
const args = ['diff', '--name-only', '--diff-filter=d'];
|
|
75
|
+
if (sinceCommit) args.push(sinceCommit);
|
|
76
|
+
args.push('--');
|
|
77
|
+
|
|
78
|
+
const output = execFileSync('git', args, {
|
|
79
|
+
cwd: workspacePath,
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
timeout: 3000,
|
|
82
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
83
|
+
}).trim();
|
|
84
|
+
|
|
85
|
+
const files = output.length > 0 ? output.split('\n').map(f => f.trim()).filter(Boolean) : [];
|
|
86
|
+
return { files, gap: null };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
files: [],
|
|
90
|
+
gap: {
|
|
91
|
+
type: 'git',
|
|
92
|
+
target: 'changed-files',
|
|
93
|
+
reason: `Git changed-file listing unavailable: ${err.message || 'Not a git repository or git command failed'}`
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|