@quan-huayan/dsh-research-engine 0.1.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.
@@ -0,0 +1,284 @@
1
+ // engine-git —— 内建版本库封装(内部基础设施,不注册任何工具)
2
+ //
3
+ // 定位(plan §1):为 P2「封闭变更」提供版本、还原、审计能力;**对模型完全不可见**——
4
+ // 不注册工具、服务名只在 preset 内部 isolate realm 里可见、返回给调用方的对象不含任何
5
+ // 面向模型的措辞。所有模型可见文本由各工具自己撰写,本模块只产出结构化结果。
6
+ //
7
+ // 硬规则(tool-flows §附):
8
+ // ① 每次调用都显式传 GIT_DIR / GIT_WORK_TREE,绝不依赖目录发现 → 物理上不可能读到用户仓库;
9
+ // ② 不写 .gitignore、不改用户 index/HEAD/config/hooks;
10
+ // ③ 不在工程根建 .git(仓库是 bare,位于 .research/engine.git);
11
+ // ④ 若工程本身是用户仓库,只只读取其 HEAD 作为证据标签(可选,见 userRepoHead);
12
+ // ⑤ .research/engine.git 在用户仓库里会显示为未跟踪 —— 不代改 ignore,只在 init 报告里提一句。
13
+ //
14
+ // 进程约束:只用 node:* 内建;不 import 兄弟插件文件;spawnSync 用**文件重定向 stdio**
15
+ //(不是管道),在受限执行器里也不会撞到 named pipe 限制。
16
+
17
+ import { createRequire } from 'node:module';
18
+ import { spawnSync } from 'node:child_process';
19
+ import { join, dirname, resolve } from 'node:path';
20
+ import {
21
+ readFileSync, writeFileSync, mkdirSync, openSync, closeSync, unlinkSync,
22
+ existsSync, readdirSync, statSync,
23
+ } from 'node:fs';
24
+ import os from 'node:os';
25
+ import { createHash } from 'node:crypto';
26
+
27
+ const STORE_DIR = join('.research', 'engine.git');
28
+ const ATTR_LINES = [
29
+ 'experiments/*/raw/** filter=lfs diff=lfs merge=lfs -text',
30
+ 'experiments/*/raw/** -text',
31
+ ].join('\n') + '\n';
32
+
33
+ let PROBE = null;
34
+
35
+ function sha256Hex(buf) { return createHash('sha256').update(buf).digest('hex'); }
36
+
37
+ /** 低层执行:显式 GIT_DIR/GIT_WORK_TREE + 文件重定向 stdio(无管道)。 */
38
+ function runGit(root, args, opts = {}) {
39
+ const gd = join(root, STORE_DIR);
40
+ const base = join(os.tmpdir(), 're-git');
41
+ try { mkdirSync(base, { recursive: true }); } catch { /* ignore */ }
42
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
43
+ const outPath = join(base, `${stamp}.out`);
44
+ const errPath = join(base, `${stamp}.err`);
45
+ let fdOut = null; let fdErr = null; let res = null; let out = ''; let err = '';
46
+ try {
47
+ fdOut = openSync(outPath, 'w');
48
+ fdErr = openSync(errPath, 'w');
49
+ res = spawnSync(opts.bin || 'git', args, {
50
+ cwd: root,
51
+ env: {
52
+ ...process.env,
53
+ GIT_DIR: gd,
54
+ GIT_WORK_TREE: root,
55
+ GIT_CONFIG_NOSYSTEM: '1',
56
+ GIT_TERMINAL_PROMPT: '0',
57
+ GIT_OPTIONAL_LOCKS: '0',
58
+ GIT_LFS_SKIP_SMUDGE: opts.skipSmudge === true ? '1' : '0',
59
+ LC_ALL: 'C',
60
+ },
61
+ stdio: ['ignore', fdOut, fdErr],
62
+ windowsHide: true,
63
+ timeout: opts.timeoutMs ?? 180000,
64
+ });
65
+ } catch (e) {
66
+ return { ok: false, status: null, stdout: '', stderr: String(e?.message || e), spawnFailed: true };
67
+ } finally {
68
+ if (fdOut !== null) { try { closeSync(fdOut); } catch { /* ignore */ } }
69
+ if (fdErr !== null) { try { closeSync(fdErr); } catch { /* ignore */ } }
70
+ }
71
+ try { out = readFileSync(outPath, 'utf8'); } catch { out = ''; }
72
+ try { err = readFileSync(errPath, 'utf8'); } catch { err = ''; }
73
+ try { unlinkSync(outPath); } catch { /* ignore */ }
74
+ try { unlinkSync(errPath); } catch { /* ignore */ }
75
+ const status = res === null ? null : res.status;
76
+ return { ok: status === 0, status, stdout: out, stderr: err, spawnFailed: res !== null && res.error !== undefined && res.error !== null };
77
+ }
78
+
79
+ /** 能力探测(进程内缓存一次)。 */
80
+ function probe() {
81
+ if (PROBE !== null) return PROBE;
82
+ const tmp = join(os.tmpdir());
83
+ const v = runGit(tmp, ['--version'], { timeoutMs: 15000 });
84
+ const available = v.ok === true;
85
+ let lfs = false; let lfsVersion = '';
86
+ if (available) {
87
+ const l = runGit(tmp, ['lfs', 'version'], { timeoutMs: 20000 });
88
+ lfs = l.ok === true;
89
+ lfsVersion = (l.stdout || '').trim().split('\n')[0] || '';
90
+ }
91
+ PROBE = {
92
+ available,
93
+ version: (v.stdout || '').trim().split('\n')[0] || '',
94
+ lfs,
95
+ lfsVersion,
96
+ detail: available ? '' : (v.stderr || 'command unavailable').trim().slice(0, 200),
97
+ };
98
+ return PROBE;
99
+ }
100
+
101
+ function readText(p) { try { return readFileSync(p, 'utf8'); } catch { return null; } }
102
+
103
+ function createEngineGit() {
104
+ const svc = {
105
+ /** 能力探测结果(不含面向模型的措辞)。 */
106
+ capabilities() { return { ...probe() }; },
107
+
108
+ storeDir(root) { return join(root, STORE_DIR); },
109
+
110
+ /** 是否已经建过版本库。 */
111
+ exists(root) { return existsSync(join(root, STORE_DIR, 'HEAD')); },
112
+
113
+ /** 一次性初始化:bare 仓库 + 隔离配置 + LFS 属性。 */
114
+ init(root) {
115
+ const cap = probe();
116
+ if (!cap.available) return { ok: false, mode: 'ledger-only', lfs: false, detail: cap.detail };
117
+ const gd = join(root, STORE_DIR);
118
+ mkdirSync(gd, { recursive: true });
119
+ mkdirSync(join(gd, 'info'), { recursive: true });
120
+ mkdirSync(join(gd, 'empty-hooks'), { recursive: true });
121
+ if (!existsSync(join(gd, 'HEAD'))) {
122
+ const bare = spawnSync('git', ['init', '--quiet', '--bare', '--', gd], {
123
+ stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, timeout: 60000,
124
+ });
125
+ if (bare.status !== 0) return { ok: false, mode: 'ledger-only', lfs: false, detail: '版本库初始化失败' };
126
+ }
127
+ const cfgs = [
128
+ ['user.email', 'engine@research.local'],
129
+ ['user.name', 'research-engine'],
130
+ ['core.hooksPath', join(gd, 'empty-hooks')],
131
+ ['core.autocrlf', 'false'],
132
+ ['core.safecrlf', 'false'],
133
+ ['core.fileMode', 'false'],
134
+ ['gc.auto', '0'],
135
+ ['commit.gpgsign', 'false'],
136
+ ['advice.detachedHead', 'false'],
137
+ ];
138
+ for (const [k, v] of cfgs) runGit(root, ['config', '--local', k, v], { timeoutMs: 30000 });
139
+ // info/exclude:引擎仓库自身永不被登记
140
+ const excludePath = join(gd, 'info', 'exclude');
141
+ if (!existsSync(excludePath)) writeFileSync(excludePath, 'engine.git/\n', 'utf8');
142
+ // LFS 属性(写在仓库 info/ 里,不影响工作树、不影响用户仓库)
143
+ writeFileSync(join(gd, 'info', 'attributes'), ATTR_LINES, 'utf8');
144
+ let lfs = false;
145
+ if (cap.lfs) {
146
+ const ins = runGit(root, ['lfs', 'install', '--local'], { timeoutMs: 60000 });
147
+ lfs = ins.ok === true;
148
+ }
149
+ return { ok: true, mode: 'versioned', lfs, detail: '' };
150
+ },
151
+
152
+ /** 当前版本指针;无提交时返回 null。 */
153
+ head(root) {
154
+ const r = runGit(root, ['rev-parse', '--verify', '--quiet', 'HEAD']);
155
+ const v = (r.stdout || '').trim();
156
+ return r.ok && /^[0-9a-f]{7,64}$/.test(v) ? v : null;
157
+ },
158
+
159
+ /** 指定对象是否在本仓库历史中(防历史被重写)。 */
160
+ hasObject(root, ref) {
161
+ if (typeof ref !== 'string' || ref === '') return false;
162
+ const r = runGit(root, ['cat-file', '-e', `${ref}^{commit}`], { timeoutMs: 30000 });
163
+ return r.ok === true;
164
+ },
165
+
166
+ /** 受管路径的越权改动:M/D/R → dirty。路径未入库时返回空(尚无基线)。 */
167
+ changed(root, paths) {
168
+ const head = this.head(root);
169
+ if (head === null) return [];
170
+ if (!Array.isArray(paths) || paths.length === 0) return [];
171
+ const r = runGit(root, ['diff-index', '--name-status', '--no-renames', 'HEAD', '--', ...paths]);
172
+ if (!r.ok) return [];
173
+ const out = [];
174
+ for (const line of (r.stdout || '').split('\n')) {
175
+ const t = line.trim();
176
+ if (!t) continue;
177
+ const m = /^([A-Z?]+)\t(.+)$/.exec(t);
178
+ if (m) out.push({ code: m[1], path: m[2] });
179
+ }
180
+ return out;
181
+ },
182
+
183
+ /** 受管区里的未登记文件(unknown 候选)。 */
184
+ untracked(root, paths) {
185
+ if (!Array.isArray(paths) || paths.length === 0) return [];
186
+ const r = runGit(root, ['ls-files', '--others', '--exclude-standard', '--', ...paths]);
187
+ if (!r.ok) return [];
188
+ return (r.stdout || '').split('\n').map((s) => s.trim()).filter(Boolean);
189
+ },
190
+
191
+ /** 已登记文件清单(index 内容)。 */
192
+ tracked(root, paths) {
193
+ const args = ['ls-files', '--'];
194
+ if (Array.isArray(paths) && paths.length) args.push(...paths);
195
+ const r = runGit(root, args);
196
+ if (!r.ok) return [];
197
+ return (r.stdout || '').split('\n').map((s) => s.trim()).filter(Boolean);
198
+ },
199
+
200
+ /** 落盘 → 入库:只登记显式白名单路径。返回 {commit|null, changed:bool}。 */
201
+ record(root, paths, message) {
202
+ const list = (Array.isArray(paths) ? paths : []).filter((p) => typeof p === 'string' && p !== '');
203
+ if (!list.length) return { ok: true, commit: this.head(root), changed: false };
204
+ const add = runGit(root, ['add', '--', ...list]);
205
+ if (!add.ok) return { ok: false, commit: null, changed: false, detail: (add.stderr || '').trim().slice(0, 300) };
206
+ const head = this.head(root);
207
+ if (head !== null) {
208
+ // 内容无变化时不产生空提交
209
+ const diff = runGit(root, ['diff-index', '--cached', '--name-only', 'HEAD', '--', ...list]);
210
+ if (diff.ok && (diff.stdout || '').trim() === '') return { ok: true, commit: head, changed: false };
211
+ }
212
+ const args = ['commit', '--quiet', '--no-verify', '-m', String(message || 'engine: update'), '--', ...list];
213
+ const c = runGit(root, args);
214
+ if (!c.ok) {
215
+ const first = (c.stderr || '').split('\n').find((l) => l.trim() !== '') || '';
216
+ return { ok: false, commit: null, changed: false, detail: first.trim().slice(0, 300) };
217
+ }
218
+ return { ok: true, commit: this.head(root), changed: true };
219
+ },
220
+
221
+ /** 从最近一次提交取回受管路径(覆盖工作树)。 */
222
+ restore(root, paths) {
223
+ const list = (Array.isArray(paths) ? paths : []).filter((p) => typeof p === 'string' && p !== '');
224
+ if (!list.length) return { ok: true, restored: [] };
225
+ const r = runGit(root, ['checkout', 'HEAD', '--', ...list]);
226
+ return { ok: r.ok === true, restored: r.ok ? list : [], detail: r.ok ? '' : (r.stderr || '').trim().slice(0, 300) };
227
+ },
228
+
229
+ /** 历史(仅内部审计用)。 */
230
+ history(root, limit = 20) {
231
+ const r = runGit(root, ['log', `-n${Math.max(1, limit)}`, '--format=%H%x1f%cI%x1f%s']);
232
+ if (!r.ok) return [];
233
+ return (r.stdout || '').split('\n').filter(Boolean).map((l) => {
234
+ const [commit, ts, ...rest] = l.split('\x1f');
235
+ return { commit, ts, message: rest.join('\x1f') };
236
+ });
237
+ },
238
+
239
+ /** 只读取工程自身是否为用户仓库的当前指针(规则④;不读写它的任何状态)。 */
240
+ userRepoHead(root) {
241
+ const r = spawnSync('git', ['-C', root, 'rev-parse', '--verify', '--quiet', 'HEAD'], {
242
+ stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, timeout: 30000,
243
+ });
244
+ return r.status === 0 ? null : null;
245
+ },
246
+
247
+ sha256File(p) {
248
+ try { return sha256Hex(readFileSync(p)); } catch { return null; }
249
+ },
250
+
251
+ sha256Text(t) { return sha256Hex(Buffer.from(String(t), 'utf8')); },
252
+
253
+ /** 递归列出目录下所有文件(工程内相对路径,posix 分隔)。 */
254
+ listFiles(root, relDir) {
255
+ const abs = join(root, relDir);
256
+ const out = [];
257
+ const walk = (dir, rel) => {
258
+ let ents;
259
+ try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
260
+ for (const e of ents) {
261
+ const childAbs = join(dir, e.name);
262
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`;
263
+ if (e.isDirectory()) { if (e.name !== '.git') walk(childAbs, childRel); continue; }
264
+ if (e.isFile()) out.push(childRel);
265
+ }
266
+ };
267
+ walk(abs, relDir.replace(/\\/g, '/').replace(/\/+$/, ''));
268
+ return out;
269
+ },
270
+
271
+ sizeMtime(p) {
272
+ try { const s = statSync(p); return { bytes: s.size, mtime: new Date(s.mtimeMs).toISOString() }; }
273
+ catch { return null; }
274
+ },
275
+ };
276
+ return svc;
277
+ }
278
+
279
+ export const name = 'engine-git';
280
+ export const inject = [];
281
+
282
+ export function apply(ctx) {
283
+ ctx.provide('research.engineGit', createEngineGit());
284
+ }