agents-gitflow-guard 0.0.1

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,813 @@
1
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ //#region src/classify.ts
6
+ /** 拆分命令为多段(&& / 分号 / 换行), 每段独立分类; 引号内的分隔符不算 */
7
+ function classify(command, ctx = {}) {
8
+ return splitSegments(command).flatMap((seg) => classifySegment(seg, ctx));
9
+ }
10
+ /** 引号感知拆分: 保护 "..." 与 '...' 内的 && / ; / 换行 */
11
+ function splitSegments(command) {
12
+ const segments = [];
13
+ let current = "";
14
+ let quote = null;
15
+ const push = () => {
16
+ if (current.trim()) segments.push(current.trim());
17
+ current = "";
18
+ };
19
+ for (let i = 0; i < command.length; i++) {
20
+ const ch = command[i];
21
+ if (quote != null) {
22
+ current += ch;
23
+ if (ch === quote) quote = null;
24
+ continue;
25
+ }
26
+ if (ch === "\"" || ch === "'") {
27
+ quote = ch;
28
+ current += ch;
29
+ continue;
30
+ }
31
+ if (ch === "&" && command[i + 1] === "&") {
32
+ push();
33
+ i++;
34
+ continue;
35
+ }
36
+ if (ch === ";" || ch === "\n") {
37
+ push();
38
+ continue;
39
+ }
40
+ current += ch;
41
+ }
42
+ push();
43
+ return segments;
44
+ }
45
+ function classifySegment(segment, ctx) {
46
+ const tokens = tokenize(segment);
47
+ if (tokens.length === 0) return [{ kind: "other" }];
48
+ const [cmd, ...rest] = tokens;
49
+ if (cmd === "git") return classifyGit(rest, ctx);
50
+ if (cmd === "gh") return classifyGh(rest);
51
+ if (cmd === "gitflow-guard") return [{
52
+ kind: "guard-cli",
53
+ sub: guardSub(rest)
54
+ }];
55
+ return [{ kind: "other" }];
56
+ }
57
+ /** 分词: 引号内的空格不拆分 */
58
+ function tokenize(segment) {
59
+ return segment.match(/"[^"]*"|'[^']*'|\S+/g)?.map((t) => t.replace(/^['"]|['"]$/g, "")) ?? [];
60
+ }
61
+ function classifyGit(args, ctx) {
62
+ const [sub, ...rest] = args;
63
+ if (sub === "push") return parsePush(rest, ctx);
64
+ if (sub === "merge") return parseMerge(rest);
65
+ if (sub === "branch") return parseBranch(rest);
66
+ if (sub === "checkout" || sub === "switch") return parseCheckout(rest);
67
+ return [{ kind: "other" }];
68
+ }
69
+ /** 分支切换: 门禁放行, 分支状态由 evaluateCommand 按段模拟 */
70
+ function parseCheckout(args) {
71
+ const first = args[0];
72
+ if (first === "--") return [{
73
+ kind: "checkout",
74
+ branch: null
75
+ }];
76
+ if (first === "-b" || first === "-B" || first === "-c" || first === "-C") {
77
+ const name = args[1];
78
+ return [{
79
+ kind: "checkout",
80
+ branch: name && !name.startsWith("-") ? name : null
81
+ }];
82
+ }
83
+ if (first && !first.startsWith("-")) return [{
84
+ kind: "checkout",
85
+ branch: first
86
+ }];
87
+ return [{
88
+ kind: "checkout",
89
+ branch: null
90
+ }];
91
+ }
92
+ function parsePush(args, ctx) {
93
+ let force = false;
94
+ let isDelete = false;
95
+ let all = false;
96
+ const nonFlag = [];
97
+ for (const a of args) if (a === "-f" || a === "--force" || a === "--force-with-lease" || a.startsWith("--force-with-lease=")) force = true;
98
+ else if (a === "--delete" || a === "-d") isDelete = true;
99
+ else if (a === "--all" || a === "--mirror") all = true;
100
+ else if (a.startsWith("-")) {} else nonFlag.push(a);
101
+ if (all) return [{
102
+ kind: "push",
103
+ dst: null,
104
+ force,
105
+ delete: false,
106
+ all: true
107
+ }];
108
+ const refspecs = nonFlag.slice(1);
109
+ if (refspecs.length === 0) return [{
110
+ kind: "push",
111
+ dst: ctx.currentBranch ?? null,
112
+ force,
113
+ delete: false
114
+ }];
115
+ return refspecs.map((ref) => {
116
+ if (ref.startsWith(":")) return {
117
+ kind: "push",
118
+ dst: stripRefPrefix(ref.slice(1)) || null,
119
+ force,
120
+ delete: true
121
+ };
122
+ const colon = ref.indexOf(":");
123
+ if (colon >= 0) {
124
+ const deleteTarget = ref.endsWith(":");
125
+ const dst = deleteTarget ? ref.slice(colon + 1, ref.length - 1) || ref.slice(0, colon) : ref.slice(colon + 1);
126
+ return {
127
+ kind: "push",
128
+ dst: dst ? stripRefPrefix(dst) : null,
129
+ force,
130
+ delete: deleteTarget || isDelete
131
+ };
132
+ }
133
+ if (ref === "HEAD") return {
134
+ kind: "push",
135
+ dst: ctx.currentBranch ?? null,
136
+ force,
137
+ delete: isDelete
138
+ };
139
+ return {
140
+ kind: "push",
141
+ dst: stripRefPrefix(ref),
142
+ force,
143
+ delete: isDelete
144
+ };
145
+ });
146
+ }
147
+ /** 全限定 refspec(refs/heads/x)剥离前缀, 与角色分支名比对 */
148
+ function stripRefPrefix(branch) {
149
+ return branch.startsWith("refs/heads/") ? branch.slice(11) : branch;
150
+ }
151
+ function parseMerge(args) {
152
+ if (args.some((a) => a === "--abort")) return [{ kind: "other" }];
153
+ return [{
154
+ kind: "local-merge",
155
+ source: args.find((a, i) => !a.startsWith("-") && args[i - 1] !== "-m" && args[i - 1] !== "--message") ?? null
156
+ }];
157
+ }
158
+ function parseBranch(args) {
159
+ const [flag, name] = args;
160
+ if ((flag === "-d" || flag === "-D" || flag === "--delete") && name && !name.startsWith("-")) return [{
161
+ kind: "branch-delete",
162
+ branch: name,
163
+ force: flag === "-D"
164
+ }];
165
+ return [{ kind: "other" }];
166
+ }
167
+ function classifyGh(args) {
168
+ const [sub, action, ...rest] = args;
169
+ if (sub !== "pr") return [{ kind: "other" }];
170
+ if (action === "create") return parsePrCreate(rest);
171
+ if (action === "merge") return parsePrMerge(rest);
172
+ return [{ kind: "other" }];
173
+ }
174
+ function parsePrCreate(args) {
175
+ if (hasHelpFlag(args)) return [{ kind: "other" }];
176
+ const out = {
177
+ kind: "pr-create",
178
+ target: null
179
+ };
180
+ for (let i = 0; i < args.length; i++) {
181
+ const a = args[i];
182
+ if (a === "--base" || a === "-B") {
183
+ const value = args[i + 1];
184
+ if (value && !value.startsWith("-")) out.target = value;
185
+ } else if (a.startsWith("--base=")) out.target = a.slice(7) || null;
186
+ }
187
+ return [out];
188
+ }
189
+ function parsePrMerge(args) {
190
+ if (hasHelpFlag(args)) return [{ kind: "other" }];
191
+ return [{
192
+ kind: "pr-merge",
193
+ pr: args.find((a) => !a.startsWith("-") && /^\d+$/.test(a)) ?? null
194
+ }];
195
+ }
196
+ function hasHelpFlag(args) {
197
+ return args.some((a) => a === "-h" || a === "--help" || a === "--version");
198
+ }
199
+ function guardSub(args) {
200
+ const sub = args[0];
201
+ if (sub === "permit" || sub === "confirm" || sub === "status") return sub;
202
+ return "other";
203
+ }
204
+ //#endregion
205
+ //#region src/config.ts
206
+ const CONFIG_FILE = "gitflow-guard.config.json";
207
+ /** 默认配置(分支角色必须由项目显式配置, 无默认) */
208
+ const DEFAULT_CONFIG = {
209
+ enabled: false,
210
+ mode: "pr",
211
+ confirm: {
212
+ keywords: [
213
+ "确认",
214
+ "OK",
215
+ "可以",
216
+ "特许"
217
+ ],
218
+ featurePattern: "feature/[\\w-]+"
219
+ },
220
+ ci: { enabled: true }
221
+ };
222
+ /** 合并默认值并校验; 任何校验错误都会导致未启用 */
223
+ function mergeConfig(raw) {
224
+ const errors = [];
225
+ if (typeof raw !== "object" || raw === null) return {
226
+ config: null,
227
+ errors: ["配置文件必须是 JSON 对象"]
228
+ };
229
+ const r = raw;
230
+ const config = {
231
+ ...DEFAULT_CONFIG,
232
+ confirm: { ...DEFAULT_CONFIG.confirm },
233
+ ci: { ...DEFAULT_CONFIG.ci },
234
+ branches: {
235
+ base: "",
236
+ preview: ""
237
+ }
238
+ };
239
+ if (typeof r.enabled === "boolean") config.enabled = r.enabled;
240
+ if (r.mode === void 0) {} else if (r.mode === "pr" || r.mode === "flexible") config.mode = r.mode;
241
+ else errors.push("mode 必须是 \"pr\" 或 \"flexible\"");
242
+ const b = r.branches ?? {};
243
+ if (typeof b.base === "string" && b.base !== "") config.branches.base = b.base;
244
+ if (typeof b.preview === "string" && b.preview !== "") config.branches.preview = b.preview;
245
+ if (typeof b.trunk === "string" && b.trunk !== "") config.branches.trunk = b.trunk;
246
+ const c = r.confirm ?? {};
247
+ if (c.keywords !== void 0) {
248
+ if (!Array.isArray(c.keywords) || c.keywords.length === 0 || !c.keywords.every((k) => typeof k === "string")) errors.push("confirm.keywords 必须是非空字符串数组");
249
+ else config.confirm.keywords = c.keywords;
250
+ }
251
+ if (typeof c.featurePattern === "string" && c.featurePattern !== "") config.confirm.featurePattern = c.featurePattern;
252
+ const ci = r.ci ?? {};
253
+ if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
254
+ errors.push(...validateConfig(config));
255
+ return {
256
+ config: errors.length > 0 ? null : config,
257
+ errors
258
+ };
259
+ }
260
+ /** 配置校验: 角色分支冲突等(风险清单第 5 条兜底) */
261
+ function validateConfig(config) {
262
+ const errors = [];
263
+ if (!config.branches.base) errors.push("branches.base 必填");
264
+ if (!config.branches.preview) errors.push("branches.preview 必填");
265
+ if (config.branches.base && config.branches.preview && config.branches.base === config.branches.preview) errors.push("branches.base 与 branches.preview 不能是同一分支");
266
+ if (config.branches.trunk) {
267
+ if (config.branches.trunk === config.branches.base) errors.push("branches.trunk 与 branches.base 不能是同一分支");
268
+ if (config.branches.trunk === config.branches.preview) errors.push("branches.trunk 与 branches.preview 不能是同一分支");
269
+ }
270
+ if (config.confirm.keywords.length === 0) errors.push("confirm.keywords 不能为空");
271
+ try {
272
+ new RegExp(config.confirm.featurePattern);
273
+ } catch {
274
+ errors.push(`confirm.featurePattern 不是合法正则: ${config.confirm.featurePattern}`);
275
+ }
276
+ return errors;
277
+ }
278
+ /** 从项目根加载配置; 无文件 = 未启用(opt-in) */
279
+ async function loadConfig(repoRoot) {
280
+ try {
281
+ const text = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
282
+ return mergeConfig(JSON.parse(text));
283
+ } catch (e) {
284
+ if (e.code === "ENOENT") return {
285
+ config: null,
286
+ errors: []
287
+ };
288
+ return {
289
+ config: null,
290
+ errors: [`读取配置文件失败: ${e.message}`]
291
+ };
292
+ }
293
+ }
294
+ //#endregion
295
+ //#region src/gate.ts
296
+ const FEATURE_UNKNOWN = "当前分支";
297
+ /** 受保护分支集合: 基线/主干始终受保护; 预览仅在 pr 模式受保护 */
298
+ function protectedBranches(config) {
299
+ const set = /* @__PURE__ */ new Set([config.branches.base]);
300
+ if (config.branches.trunk) set.add(config.branches.trunk);
301
+ if (config.mode === "pr") set.add(config.branches.preview);
302
+ return set;
303
+ }
304
+ function isProtected(branch, config) {
305
+ return branch != null && protectedBranches(config).has(branch);
306
+ }
307
+ function decide(classified, facts, config) {
308
+ switch (classified.kind) {
309
+ case "push": return decidePush(classified, facts, config);
310
+ case "local-merge": return decideMerge(classified, facts, config);
311
+ case "pr-create": return decidePrCreate(classified, facts, config);
312
+ case "pr-merge": return decidePrMerge(classified, facts, config);
313
+ case "branch-delete": return isProtected(classified.branch, config) ? deny(`受保护分支「${classified.branch}」禁止删除或强推`, "删除分支请到受保护分支外操作; 受保护分支由用户亲手管理") : { kind: "allow" };
314
+ case "guard-cli": return classified.sub === "permit" || classified.sub === "confirm" ? deny("特许/确认是用户专属操作, agent 不能自我授权", "请让用户在终端执行 gitflow-guard permit/confirm, 或在聊天中给出确认") : { kind: "allow" };
315
+ case "checkout": return { kind: "allow" };
316
+ default: return { kind: "allow" };
317
+ }
318
+ }
319
+ function decidePush(c, facts, config) {
320
+ if (c.all) return deny("--all/--mirror 推送会包含受保护分支", "请逐分支推送并显式指定 refspec");
321
+ const dst = c.dst ?? facts.currentBranch;
322
+ if (dst == null) return deny("无法确定推送目标分支(可能处于 detached HEAD)", "请显式指定 refspec, 如 git push origin <分支名>");
323
+ if (isProtected(dst, config)) return deny(c.delete ? `受保护分支「${dst}」禁止删除` : `受保护分支「${dst}」禁止直推${c.force ? "(含强推)" : ""}`, branchNext(dst, config));
324
+ return { kind: "allow" };
325
+ }
326
+ function decideMerge(c, facts, config) {
327
+ const { base, preview, trunk } = config.branches;
328
+ const current = facts.currentBranch;
329
+ const source = c.source;
330
+ if (current === trunk) return deny("合入主干(trunk)仅允许用户亲手执行", "如需发布, 请让用户在自己终端完成; 创建指向 trunk 的 PR 需用户特许(P3)");
331
+ if (current === base) {
332
+ if (source == null) return { kind: "allow" };
333
+ if (source === preview) return deny("将预览分支整体合入基线会绕过逐 feature 验证", "请按 feature 逐个合入: 每个 feature 需已合入预览且用户确认(P2)");
334
+ if (source === trunk) return { kind: "allow" };
335
+ if (source === base) return { kind: "allow" };
336
+ return mergeIntoBase(source, facts, config);
337
+ }
338
+ if (current === preview) {
339
+ if (config.mode === "pr" && source != null && source !== base && source !== trunk) return deny(`pr 模式禁止本地合入预览分支(当前在 ${preview})`, "请创建 PR(feature → preview)合入, 或改用 flexible 模式");
340
+ return { kind: "allow" };
341
+ }
342
+ return { kind: "allow" };
343
+ }
344
+ function mergeIntoBase(feature, facts, config) {
345
+ if (!facts.featureInPreview(feature)) return deny(`流程违规: feature「${feature}」尚未合入预览分支(${config.branches.preview}), 不能合入基线(${config.branches.base})`, "请先合入预览(PR①)并测试确认, 再合入基线; 或请用户特许提前操作(聊天「确认」/ 终端 gitflow-guard permit)");
346
+ if (!facts.hasPermit("confirm", feature)) return deny(`缺少用户确认: feature「${feature}」已在预览分支, 但用户尚未确认测试通过`, "请让用户确认测试结果(聊天输入「feature xxx 测试 OK」/ 终端 gitflow-guard confirm feature-xxx)");
347
+ return { kind: "allow" };
348
+ }
349
+ function decidePrCreate(c, facts, config) {
350
+ const { base, preview, trunk } = config.branches;
351
+ const head = facts.currentBranch;
352
+ if (c.target == null) return deny("无法确定 PR 目标分支", `请显式指定 --base <分支名>(如 gh pr create --base ${config.branches.preview})`);
353
+ if (c.target === preview) return { kind: "allow" };
354
+ if (c.target === base) {
355
+ if (head == null || head === base || head === preview || head === trunk) return deny(`当前分支(${head ?? FEATURE_UNKNOWN})是角色分支, 不能作为指向基线的 PR 源`, "请在 feature 分支上创建指向基线的 PR");
356
+ if (facts.featureInPreview(head)) return { kind: "allow" };
357
+ if (facts.hasPermit("early-pr", head)) return { kind: "allow" };
358
+ return deny(`流程违规: feature「${head}」尚未合入预览分支(${preview}), 提前创建指向基线(${base})的 PR 需要用户特许(P1)`, "请先合入预览; 或请用户特许(聊天确认「提前建 PR」/ 终端 gitflow-guard permit)");
359
+ }
360
+ if (c.target === trunk) {
361
+ const head = facts.currentBranch;
362
+ if (head != null && facts.hasPermit("trunk-pr", head)) return { kind: "allow" };
363
+ return deny("创建指向主干(trunk)的 PR 需要用户特许(P3)", "请让用户特许(聊天确认「上主干」/ 终端 gitflow-guard permit --kind trunk-pr)");
364
+ }
365
+ return { kind: "allow" };
366
+ }
367
+ function decidePrMerge(c, facts, config) {
368
+ const resolved = facts.resolvePrTarget?.(c.pr) ?? null;
369
+ const target = resolved?.target ?? null;
370
+ const head = resolved?.head ?? facts.currentBranch;
371
+ if (target === "preview") return { kind: "allow" };
372
+ if (target === "trunk") return deny("合入主干(trunk)仅允许用户亲手执行", "如需发布, 请让用户在自己终端合并");
373
+ if (target === "other") return { kind: "allow" };
374
+ if (head == null) return deny("无法确认 PR 的 feature 分支", "请先 checkout 到 feature 分支再合并, 或确认 gh 查询可用");
375
+ if (target === null) {
376
+ const reason = facts.featureInPreview(head) ? `缺少用户确认: feature「${head}」已在预览分支, 但无法确认 PR 目标(gh 查询失败), 仍须用户确认(P2)` : `流程违规: feature「${head}」尚未合入预览分支, 且无法确认 PR 目标(gh 查询失败)`;
377
+ if (!facts.featureInPreview(head) || !facts.hasPermit("confirm", head)) return deny(reason, "请确认 gh 可用后重试, 或改用本地合入(git merge)路径; 确认测试通过需用户特许(P2)");
378
+ return { kind: "allow" };
379
+ }
380
+ return mergeIntoBase(head, facts, config);
381
+ }
382
+ function deny(reason, next) {
383
+ return {
384
+ kind: "deny",
385
+ reason,
386
+ next
387
+ };
388
+ }
389
+ /** 受保护分支被拦后的下一步引导 */
390
+ function branchNext(branch, config) {
391
+ if (branch == null) return "请明确目标分支后重试";
392
+ if (branch === config.branches.preview) return config.mode === "pr" ? `预览分支须走 PR: 先推 feature 分支, 再 gh pr create --base ${config.branches.preview}` : "预览分支可直推(当前 flexible 模式)";
393
+ if (branch === config.branches.base) return `基线分支(${branch})由 PR 合入: 先合入预览并确认(P2), 再创建指向基线的 PR`;
394
+ return `主干分支(${branch})仅用户亲手操作`;
395
+ }
396
+ //#endregion
397
+ //#region src/permits.ts
398
+ async function openPermitStore(stateFile, now = Date.now) {
399
+ let permits = [];
400
+ try {
401
+ const text = await readFile(stateFile, "utf8");
402
+ const parsed = JSON.parse(text);
403
+ if (!parsed || !Array.isArray(parsed.permits)) throw new Error("state.json 结构非法");
404
+ permits = parsed.permits;
405
+ } catch (e) {
406
+ if (e.code !== "ENOENT") throw new Error(`读取 ${stateFile} 失败: ${e.message}`);
407
+ }
408
+ async function save() {
409
+ await mkdir(dirname(stateFile), { recursive: true });
410
+ await writeFile(stateFile, JSON.stringify({
411
+ version: 1,
412
+ permits
413
+ }, null, 2), "utf8");
414
+ }
415
+ function findValid(kind, feature, at) {
416
+ return permits.find((p) => p.kind === kind && p.feature === feature && !p.used && (p.expiresAt == null || p.expiresAt > at));
417
+ }
418
+ return {
419
+ async grant(kind, feature, opts) {
420
+ permits = permits.filter((p) => !(p.kind === kind && p.feature === feature && !p.used));
421
+ const at = now();
422
+ const permit = {
423
+ id: randomUUID(),
424
+ kind,
425
+ feature,
426
+ grantedAt: at,
427
+ ...opts?.ttlMs != null ? { expiresAt: at + opts.ttlMs } : {},
428
+ used: false
429
+ };
430
+ permits.push(permit);
431
+ await save();
432
+ return permit;
433
+ },
434
+ async consume(kind, feature) {
435
+ const at = now();
436
+ const idx = permits.findIndex((p) => p.kind === kind && p.feature === feature && !p.used && (p.expiresAt == null || p.expiresAt > at));
437
+ if (idx < 0) return null;
438
+ permits[idx] = {
439
+ ...permits[idx],
440
+ used: true
441
+ };
442
+ await save();
443
+ return permits[idx];
444
+ },
445
+ hasValid(kind, feature, at = now()) {
446
+ return findValid(kind, feature, at) != null;
447
+ },
448
+ list() {
449
+ return [...permits];
450
+ }
451
+ };
452
+ }
453
+ //#endregion
454
+ //#region src/repo.ts
455
+ function makeRunner(bin) {
456
+ return { async run(args, cwd) {
457
+ return await new Promise((resolve) => {
458
+ execFile(bin, args, {
459
+ cwd,
460
+ maxBuffer: 16777216
461
+ }, (err, stdout, stderr) => {
462
+ resolve({
463
+ code: err ? typeof err.code === "number" ? err.code : 1 : 0,
464
+ stdout: stdout ?? "",
465
+ stderr: stderr ?? ""
466
+ });
467
+ });
468
+ });
469
+ } };
470
+ }
471
+ const gitRunner = makeRunner("git");
472
+ /** gh 适配器: 解析 PR 目标分支(可选增强, 失败返回 null 由门禁保守处理) */
473
+ const ghRunner = makeRunner("gh");
474
+ async function findRepoRoot(runner, cwd) {
475
+ const r = await runner.run(["rev-parse", "--show-toplevel"], cwd);
476
+ return r.code === 0 ? r.stdout.trim() || null : null;
477
+ }
478
+ async function currentBranch(runner, cwd) {
479
+ const r = await runner.run(["branch", "--show-current"], cwd);
480
+ return r.code === 0 ? r.stdout.trim() || null : null;
481
+ }
482
+ /** feature 是否已合入 descendant(merge-base --is-ancestor, 退出码 0 = 是) */
483
+ async function isAncestor(runner, cwd, ancestor, descendant) {
484
+ return (await runner.run([
485
+ "merge-base",
486
+ "--is-ancestor",
487
+ ancestor,
488
+ descendant
489
+ ], cwd)).code === 0;
490
+ }
491
+ /** gh pr view: 返回 base/head 分支名; PR 不存在或 gh 不可用 → null */
492
+ async function ghPrInfo(runner, cwd, pr) {
493
+ const args = pr ? [
494
+ "pr",
495
+ "view",
496
+ pr,
497
+ "--json",
498
+ "baseRefName,headRefName"
499
+ ] : [
500
+ "pr",
501
+ "view",
502
+ "--json",
503
+ "baseRefName,headRefName"
504
+ ];
505
+ const r = await runner.run(args, cwd);
506
+ if (r.code !== 0) return null;
507
+ try {
508
+ const j = JSON.parse(r.stdout);
509
+ if (typeof j.baseRefName === "string" && typeof j.headRefName === "string") return {
510
+ base: j.baseRefName,
511
+ head: j.headRefName
512
+ };
513
+ return null;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+ /** gh pr checks: 返回 PR 检查状态(SUCCESS/FAILURE/PENDING/...); 查不到返回 null(自动跳过) */
519
+ async function ghPrChecks(runner, cwd, pr) {
520
+ if (pr == null) return null;
521
+ const r = await runner.run([
522
+ "pr",
523
+ "checks",
524
+ pr,
525
+ "--json",
526
+ "state"
527
+ ], cwd);
528
+ if (r.code !== 0) return null;
529
+ try {
530
+ const states = JSON.parse(r.stdout);
531
+ if (!Array.isArray(states) || states.length === 0) return null;
532
+ const distinct = new Set(states.map((s) => String(s.state ?? "")));
533
+ if (distinct.has("FAILURE")) return "FAILURE";
534
+ if (distinct.has("PENDING") || distinct.has("IN_PROGRESS") || distinct.has("QUEUED")) return "PENDING";
535
+ return "SUCCESS";
536
+ } catch {
537
+ return null;
538
+ }
539
+ }
540
+ /** 把 PR 的 base 分支映射为角色; 无法解析返回 null */
541
+ function resolvePrTarget(info, config) {
542
+ if (!info) return null;
543
+ const { base, preview, trunk } = config.branches;
544
+ return {
545
+ target: info.base === base ? "base" : info.base === preview ? "preview" : info.base === trunk ? "trunk" : "other",
546
+ head: info.head
547
+ };
548
+ }
549
+ //#endregion
550
+ //#region src/session.ts
551
+ const EARLY_MARKERS = [
552
+ /提前\s*(?:建|开|创建)?\s*(?:pr|pull\s*request)/i,
553
+ /提前\s*建/i,
554
+ /early[- ]?pr/i
555
+ ];
556
+ const TRUNK_MARKERS = [
557
+ /主干/,
558
+ /trunk/i,
559
+ /上线/,
560
+ /上\s*main/i
561
+ ];
562
+ const CONFIRM_MARKERS = [
563
+ /测试\s*通过/,
564
+ /通过/,
565
+ /验证/,
566
+ /合入/,
567
+ /ok\b/i
568
+ ];
569
+ function extractFeature(text, pattern) {
570
+ try {
571
+ return text.match(new RegExp(pattern))?.[0] ?? null;
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+ /** 解析确认消息; 无法识别返回 null */
577
+ function parseConfirmation(text, config) {
578
+ const feature = extractFeature(text, config.confirm.featurePattern);
579
+ if (!feature) return null;
580
+ const matchAny = (markers) => markers.some((m) => m.test(text));
581
+ if (matchAny(EARLY_MARKERS)) return {
582
+ kind: "early-pr",
583
+ feature
584
+ };
585
+ if (matchAny(TRUNK_MARKERS) || config.branches.trunk != null && text.includes(config.branches.trunk)) return {
586
+ kind: "trunk-pr",
587
+ feature
588
+ };
589
+ if (matchAny(CONFIRM_MARKERS) || config.confirm.keywords.some((k) => text.toLowerCase().includes(k.toLowerCase()))) return {
590
+ kind: "confirm",
591
+ feature
592
+ };
593
+ return null;
594
+ }
595
+ //#endregion
596
+ //#region src/index.ts
597
+ const name = "gitflow-guard";
598
+ function stateDir(repoRoot) {
599
+ return join(repoRoot, ".git", "gitflow-guard");
600
+ }
601
+ function stateFile(repoRoot) {
602
+ return join(stateDir(repoRoot), "state.json");
603
+ }
604
+ /** 审计留痕; 失败不阻断门禁 */
605
+ async function appendAudit(repoRoot, entry) {
606
+ try {
607
+ await mkdir(stateDir(repoRoot), { recursive: true });
608
+ await appendFile(join(stateDir(repoRoot), "audit.jsonl"), `${JSON.stringify(entry)}\n`, "utf8");
609
+ } catch {}
610
+ }
611
+ /** 解析一条命令: 分类 → git 事实 → 门禁 → allow/deny */
612
+ async function evaluateCommand(command, opts) {
613
+ const runner = opts.runner ?? gitRunner;
614
+ const gh = opts.ghRunner ?? ghRunner;
615
+ const { config } = await loadConfig(opts.repoRoot);
616
+ if (!config?.enabled) return {
617
+ outcome: "skipped",
618
+ segmentCount: 0,
619
+ pendingConsume: []
620
+ };
621
+ const branch = opts.currentBranch ?? await currentBranch(runner, opts.repoRoot);
622
+ const store = await openPermitStore(stateFile(opts.repoRoot), opts.now);
623
+ const env = {
624
+ repoRoot: opts.repoRoot,
625
+ config,
626
+ branch,
627
+ runner,
628
+ gh,
629
+ store
630
+ };
631
+ const segments = classify(command, { currentBranch: branch });
632
+ const pendingConsume = [];
633
+ let simulatedBranch = branch;
634
+ for (const seg of segments) {
635
+ const { facts, head, inPreview } = await factsFor(seg, {
636
+ ...env,
637
+ branch: simulatedBranch
638
+ });
639
+ const decision = decide(seg, facts, config);
640
+ if (decision.kind === "deny") {
641
+ await appendAudit(env.repoRoot, {
642
+ time: Date.now(),
643
+ event: "deny",
644
+ command,
645
+ reason: decision.reason
646
+ });
647
+ return {
648
+ outcome: "deny",
649
+ reason: {
650
+ why: decision.reason,
651
+ next: decision.next
652
+ },
653
+ segmentCount: segments.length,
654
+ pendingConsume
655
+ };
656
+ }
657
+ pendingConsume.push(...permitsUsedBy(seg, env, head, inPreview));
658
+ await logCiReference(seg, env);
659
+ if (seg.kind === "checkout" && seg.branch != null) simulatedBranch = seg.branch;
660
+ }
661
+ return {
662
+ outcome: "allow",
663
+ segmentCount: segments.length,
664
+ pendingConsume
665
+ };
666
+ }
667
+ /** CI 参考(可选适配器): gh pr checks 状态记入审计日志, 查不到自动跳过 */
668
+ async function logCiReference(seg, env) {
669
+ if (!env.config.ci.enabled) return;
670
+ if (seg.kind !== "pr-merge") return;
671
+ const state = await ghPrChecks(env.gh, env.repoRoot, seg.pr);
672
+ if (state == null) return;
673
+ await appendAudit(env.repoRoot, {
674
+ time: Date.now(),
675
+ event: "ci",
676
+ kind: state,
677
+ feature: seg.pr ?? void 0
678
+ });
679
+ }
680
+ /** 按段预取 git 事实(异步 I/O 全部前置, 门禁保持纯函数) */
681
+ async function factsFor(seg, env) {
682
+ const { config, repoRoot, branch, runner } = env;
683
+ const preview = config.branches.preview;
684
+ let head = null;
685
+ let prRes = null;
686
+ if (seg.kind === "pr-merge") {
687
+ prRes = resolvePrTarget(await ghPrInfo(env.gh, repoRoot, seg.pr), config);
688
+ head = prRes?.head ?? branch;
689
+ } else if (seg.kind === "local-merge") head = seg.source;
690
+ else if (seg.kind === "pr-create") head = branch;
691
+ const inPreview = head ? await isAncestor(runner, repoRoot, head, preview) : false;
692
+ return {
693
+ head,
694
+ inPreview,
695
+ facts: {
696
+ currentBranch: branch,
697
+ featureInPreview: (f) => f === head && inPreview,
698
+ hasPermit: (kind, f) => env.store.hasValid(kind, f),
699
+ ...prRes ? { resolvePrTarget: () => prRes } : {}
700
+ }
701
+ };
702
+ }
703
+ /** 本次放行动作实际依赖了哪些特许(动作成功后消费; 未用到的特许不消耗) */
704
+ function permitsUsedBy(seg, env, head, inPreview) {
705
+ const out = [];
706
+ const has = (kind, feature) => feature != null && env.store.hasValid(kind, feature);
707
+ if (seg.kind === "local-merge" || seg.kind === "pr-merge") {
708
+ if (head != null && inPreview && has("confirm", head)) out.push({
709
+ kind: "confirm",
710
+ feature: head
711
+ });
712
+ }
713
+ if (seg.kind === "pr-create") {
714
+ if (seg.target === env.config.branches.base && head != null && !inPreview && has("early-pr", head)) out.push({
715
+ kind: "early-pr",
716
+ feature: head
717
+ });
718
+ if (seg.target === env.config.branches.trunk && head != null && has("trunk-pr", head)) out.push({
719
+ kind: "trunk-pr",
720
+ feature: head
721
+ });
722
+ }
723
+ return out;
724
+ }
725
+ function commandText(exec) {
726
+ const args = exec.arguments;
727
+ return typeof args?.command === "string" ? args.command : "";
728
+ }
729
+ function messageText(msg) {
730
+ return (msg.content ?? []).map((b) => b?.type === "text" ? b.text : "").join(" ");
731
+ }
732
+ function formatDeny(why, next) {
733
+ return `[gitflow-guard] 已拦截: ${why}\n下一步: ${next}`;
734
+ }
735
+ function apply(ctx, pluginConfig = {}) {
736
+ const toolNames = new Set(pluginConfig.toolNames ?? ["pwsh", "bash"]);
737
+ const pending = /* @__PURE__ */ new WeakMap();
738
+ ctx.on("tools/pre-execute", async (exec, next) => {
739
+ try {
740
+ const command = commandText(exec);
741
+ if (!command || !toolNames.has(exec.name)) return next();
742
+ const cwd = exec.agent?.session.header.cwd ?? process.cwd();
743
+ const repoRoot = await findRepoRoot(gitRunner, cwd);
744
+ if (!repoRoot) return next();
745
+ const result = await evaluateCommand(command, {
746
+ repoRoot,
747
+ runner: gitRunner
748
+ });
749
+ if (result.outcome === "deny" && result.reason) return {
750
+ kind: "deny",
751
+ reason: formatDeny(result.reason.why, result.reason.next)
752
+ };
753
+ if (result.pendingConsume.length > 0) pending.set(exec, result.pendingConsume);
754
+ return next();
755
+ } catch (e) {
756
+ ctx.logger?.warn?.(`gitflow-guard: 门禁内部错误, 已放行: ${e.message}`);
757
+ return next();
758
+ }
759
+ });
760
+ ctx.on("tools/post-execute", async (exec, result, next) => {
761
+ try {
762
+ const toConsume = pending.get(exec);
763
+ if (!toConsume) return next();
764
+ pending.delete(exec);
765
+ const cwd = exec.agent?.session.header.cwd ?? process.cwd();
766
+ const repoRoot = await findRepoRoot(gitRunner, cwd);
767
+ if (!repoRoot || result.isError !== false) return next();
768
+ const store = await openPermitStore(stateFile(repoRoot));
769
+ for (const p of toConsume) {
770
+ const used = await store.consume(p.kind, p.feature);
771
+ await appendAudit(repoRoot, {
772
+ time: Date.now(),
773
+ event: used ? "consume" : "remind",
774
+ feature: p.feature,
775
+ kind: p.kind
776
+ });
777
+ ctx.logger?.info?.(`gitflow-guard: ${used ? "已消费" : "未找到"}特许 ${p.kind} for ${p.feature}`);
778
+ }
779
+ return next();
780
+ } catch (e) {
781
+ ctx.logger?.warn?.(`gitflow-guard: 特许消费失败: ${e.message}`);
782
+ return next();
783
+ }
784
+ });
785
+ ctx.on("session/event", async (session, event) => {
786
+ try {
787
+ if (event.type !== "user/message") return;
788
+ const data = event.data;
789
+ if (data.source?.kind !== "user") return;
790
+ const text = messageText(data);
791
+ if (!text) return;
792
+ const cwd = session.header.cwd ?? process.cwd();
793
+ const repoRoot = await findRepoRoot(gitRunner, cwd);
794
+ if (!repoRoot) return;
795
+ const { config } = await loadConfig(repoRoot);
796
+ if (!config?.enabled) return;
797
+ const parsed = parseConfirmation(text, config);
798
+ if (!parsed) return;
799
+ await (await openPermitStore(stateFile(repoRoot))).grant(parsed.kind, parsed.feature, pluginConfig.permitTtlMs != null ? { ttlMs: pluginConfig.permitTtlMs } : void 0);
800
+ await appendAudit(repoRoot, {
801
+ time: Date.now(),
802
+ event: "grant",
803
+ feature: parsed.feature,
804
+ kind: parsed.kind
805
+ });
806
+ ctx.logger?.info?.(`gitflow-guard: 已记录特许 ${parsed.kind} for ${parsed.feature}`);
807
+ } catch (e) {
808
+ ctx.logger?.warn?.(`gitflow-guard: 确认解析失败: ${e.message}`);
809
+ }
810
+ });
811
+ }
812
+ //#endregion
813
+ export { stateDir as a, gitRunner as c, loadConfig as d, name as i, isAncestor as l, apply as n, currentBranch as o, evaluateCommand as r, findRepoRoot as s, appendAudit as t, openPermitStore as u };