agents-gitflow-guard 0.0.10 → 0.0.12
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/README.md +15 -9
- package/README.zh.md +16 -9
- package/lib/cli.mjs +67 -28
- package/lib/index.d.mts +18 -3
- package/lib/index.mjs +2 -2
- package/lib/{src-CeEqUVQP.mjs → src-CPX1Ks_Z.mjs} +474 -199
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ You define your own branches —
|
|
|
42
42
|
# installs the latest release
|
|
43
43
|
dsh plugin --profile web add agents-gitflow-guard
|
|
44
44
|
# ...or pin an exact known-good version (recommended; also bypasses stale registry caches)
|
|
45
|
-
dsh plugin --profile web add agents-gitflow-guard@0.0.
|
|
45
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.12
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
> **Version gotcha**: a bare `add` resolves whatever `latest` is at install time — on machines behind a stale npm/pnpm registry cache or mirror it may install an old version. If the installed version looks wrong, pin it explicitly. The peer-dependency *warning* pnpm may print is expected: DSH supplies `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools` through its shared profile module fallback at startup (the plugin works normally).
|
|
@@ -256,7 +256,8 @@ archive (optional; you archive after release)
|
|
|
256
256
|
"production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // optional
|
|
257
257
|
"archive": ["main"] // optional
|
|
258
258
|
},
|
|
259
|
-
"locale": "en", // optional: message language ('en'
|
|
259
|
+
"locale": "en", // optional: message language — any registered locale ('en'/'zh' built-in); unknown values warn in status and fall back to English
|
|
260
|
+
"strict": false, // optional: fail-closed — invalid config / internal errors block instead of warn-and-allow
|
|
260
261
|
"ci": { "enabled": true } // optional: gh pr checks logged as reference
|
|
261
262
|
}
|
|
262
263
|
```
|
|
@@ -264,9 +265,12 @@ archive (optional; you archive after release)
|
|
|
264
265
|
- Roles accept either an **array** (shorthand) or an **object** `{ branches, update?, mergeBy? }`.
|
|
265
266
|
- `update`: `pr` (default) = updates only via PR/MR; `flexible` = allow direct/local merges (small teams).
|
|
266
267
|
- `mergeBy` (production): `user` (default) = only you click merge; `anyone` = allow PR merge through.
|
|
267
|
-
- Each branch entry is an exact name or a regex (auto-detected).
|
|
268
|
-
- **Language**: messages are English by default; add `"locale": "zh"` for Chinese.
|
|
268
|
+
- Each branch entry is an exact name or a regex (auto-detected). **Regex safety**: branch patterns are authored by you and compiled as-is — avoid catastrophic-backtracking constructs (e.g. nested quantifiers like `(\w+)+`) in `featurePattern` and branch entries.
|
|
269
|
+
- **Language**: messages are English by default; add `"locale": "zh"` for Chinese, or pass `--locale <en|zh>` to any `gitflow-guard` subcommand (priority: CLI flag > project config > English). All user-facing text follows the locale — including CLI framework messages such as `--help`, unknown-command notices, and the empty-audit line.
|
|
270
|
+
- **Custom locales**: downstream packages can add a language at runtime — `import { registerLocale } from 'agents-gitflow-guard'`, call `registerLocale('fr', frDict)` with a dictionary covering exactly the same keys as built-in English (validated on registration), then set `"locale": "fr"` in the project config to activate it.
|
|
271
|
+
- **Unknown locales**: an unregistered `"locale"` value falls back to English during interception (by design — hooks never stall on wording), so a typo is easy to miss; the one-line warning shows up in `gitflow-guard status`.
|
|
269
272
|
- **Validation**: `integration` is required; overlapping role entries are rejected; invalid regex is rejected. **Any error disables the plugin for that project** (reported) rather than applying a half-guessed setup.
|
|
273
|
+
- **Strict mode**: by default a broken config warns on stderr once and lets the command pass (fail-open, so a typo can't wedge your tooling). `"strict": true` flips config errors and internal errors to **block** (fail-closed) — for high-risk repos. A missing file or explicit `enabled: false` stays silent either way.
|
|
270
274
|
|
|
271
275
|
---
|
|
272
276
|
|
|
@@ -299,7 +303,7 @@ The PR/MR target is resolved via `gh pr view` (GitHub) or `glab mr view` (GitLab
|
|
|
299
303
|
**From the npm registry** — the standard path, already covered in [Quick Start](#quick-start--30-seconds-to-a-guarded-repo):
|
|
300
304
|
|
|
301
305
|
```bash
|
|
302
|
-
dsh plugin --profile web add agents-gitflow-guard@0.0.
|
|
306
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.12 # pin recommended, see note above
|
|
303
307
|
```
|
|
304
308
|
|
|
305
309
|
Then restart DSH. Upgrades are the same command, followed by another restart.
|
|
@@ -391,7 +395,9 @@ No. Add only the roles your flow actually has. A solo repo with just `develop` c
|
|
|
391
395
|
|
|
392
396
|
No, and it is important that you don't treat it as one. It is a workflow guard: it makes an agreed process mechanically enforceable. Text-based command recognition is inherently best-effort — an agent determined to obfuscate a command can slip past the parser.
|
|
393
397
|
|
|
394
|
-
|
|
398
|
+
Within its supported command forms, the role boundary is enforced locally: merging into a protected role branch (integration / preview / production / archive) requires the configured path (PR/MR, or a human merge for production/archive). Standard obfuscation wrappers are classified and blocked — shell wrappers (`sh -c` / `bash -lc`), subshells and backtick/`$()` nesting, `env`/`command`/`nohup`/`xargs` prefixes and `VAR=x` assignments, absolute paths, pipelines and `||` tails, git global options (`-C .`, `--git-dir=…`), wildcard refspecs (`refs/heads/*:refs/heads/*`), `git pull` used as fetch+merge, and the `send-pack`/`update-ref` plumbing. The executable adversarial corpus lives in `tests/accuracy-audit.spec.ts`.
|
|
399
|
+
|
|
400
|
+
What remains **locally non-defensible**: direct forge-API calls (`gh api repos/…/pulls/N/merge`, `curl`) and commands inside interpreter subprocesses (`node -e "child_process.exec('git push …')"`); arbitrarily deep quoting or encoding stays best-effort by nature. The real, non-bypassable boundary lives in branch-protection rules on your hosting service. Use both — treat this guard as instant feedback and audit trail, not as a security boundary.
|
|
395
401
|
|
|
396
402
|
---
|
|
397
403
|
|
|
@@ -403,7 +409,7 @@ Because the gate classifies those as **user-only** actions. An agent may create
|
|
|
403
409
|
|
|
404
410
|
### Do I need the `gh` or `glab` CLI?
|
|
405
411
|
|
|
406
|
-
No. They are optional adapters used only to resolve what a `pr merge` / `mr merge` is targeting, so the gate can tell "merge into integration/preview" (okay) from "merge into production/archive" (blocked).
|
|
412
|
+
No. They are optional adapters used only to resolve what a `pr merge` / `mr merge` is targeting, so the gate can tell "merge into integration/preview" (okay) from "merge into production/archive" (blocked). When neither CLI can confirm the target — missing, unauthenticated, offline, or the query fails — the gate **refuses the merge**, even when run from a feature branch: that PR could actually point at production/archive. Retry once the CLI works, or let the user click merge. Everything else works the same. The core enforcement never touches a hosting service, which is why it works identically on GitHub, GitLab, self-hosted, or offline.
|
|
407
413
|
|
|
408
414
|
---
|
|
409
415
|
|
|
@@ -417,7 +423,7 @@ The blocks are reserved for: (1) direct writes to protected role branches, and (
|
|
|
417
423
|
|
|
418
424
|
### What if my config has a mistake?
|
|
419
425
|
|
|
420
|
-
|
|
426
|
+
A half-guessed setup is never applied by accident: any validation error disables the guard for that project and reports the errors.
|
|
421
427
|
|
|
422
428
|
Common mistakes: missing `integration` (required), overlapping a branch across two roles (rejected explicitly), and a `featurePattern` that doesn't compile (rejected as invalid regex). The failure is loud and the file is one JSON object, so the fix is usually a thirty-second correction.
|
|
423
429
|
|
|
@@ -479,7 +485,7 @@ npm install
|
|
|
479
485
|
npm test # unit tests: classify / gate / config / cli / repo / platform
|
|
480
486
|
npm run typecheck # tsc --noEmit, 0 errors
|
|
481
487
|
npm run build # tsdown → lib/ (CLI and plugin share the build)
|
|
482
|
-
npm run verify:matrix # continuous cross-agent regression: DSH logic + Claude Code / Codex /
|
|
488
|
+
npm run verify:matrix # continuous cross-agent regression: DSH logic + zh-locale regression + Claude Code / Codex / OpenCode / Antigravity hook wiring
|
|
483
489
|
```
|
|
484
490
|
|
|
485
491
|
**Rule**: any logic change must pass a 0-error build + all green tests + a green `verify:matrix` before done.
|
package/README.zh.md
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
# 安装最新版
|
|
42
42
|
dsh plugin --profile web add agents-gitflow-guard
|
|
43
43
|
# ...或锁定已知良好版本(推荐; 同时绕开 registry 陈旧缓存)
|
|
44
|
-
dsh plugin --profile web add agents-gitflow-guard@0.0.
|
|
44
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.12
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
> **版本坑**: 裸 `add` 装的是安装时刻的 `latest`——在 npm/pnpm 注册表缓存或镜像陈旧的机器上可能拿到旧版本。看到版本不对就锁版本。pnpm 打印的 peer 依赖 *警告* 属预期: DSH 启动时经共享模块回退提供 `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools`(插件正常工作)。
|
|
@@ -253,7 +253,8 @@ archive(可选, 发布后你亲手归档)
|
|
|
253
253
|
"production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // 可选
|
|
254
254
|
"archive": ["main"] // 可选
|
|
255
255
|
},
|
|
256
|
-
"locale": "en", // 可选:
|
|
256
|
+
"locale": "en", // 可选: 文案语言——任意已注册 locale('en'/'zh' 内置); 未注册值在 status 告警并回退英文
|
|
257
|
+
"strict": false, // 可选: fail-closed —— 配置异常/内部错误改为拦截, 而非告警放行
|
|
257
258
|
"ci": { "enabled": true } // 可选: gh pr checks 作参考日志
|
|
258
259
|
}
|
|
259
260
|
```
|
|
@@ -261,9 +262,12 @@ archive(可选, 发布后你亲手归档)
|
|
|
261
262
|
- 每个角色既可用**数组**(简写),也可用**对象** `{ branches, update?, mergeBy? }`。
|
|
262
263
|
- `update`:`pr`(默认)= 只能 PR/MR 合入;`flexible` = 允许直推/本地合入(小团队)。
|
|
263
264
|
- `mergeBy`(生产):`user`(默认)= 只能你点合并;`anyone` = 放行 PR 合并。
|
|
264
|
-
- 每条分支条目是精确名或正则(自动识别)。
|
|
265
|
-
- **文案语言**:默认英文;加 `"locale": "zh"`
|
|
265
|
+
- 每条分支条目是精确名或正则(自动识别)。**正则安全**:分支正则由项目作者提供并按原样编译——`featurePattern` 与分支条目请避免灾难性回溯写法(如 `(\w+)+` 这类嵌套量词)。
|
|
266
|
+
- **文案语言**:默认英文;加 `"locale": "zh"` 切中文,或给任意 `gitflow-guard` 子命令传 `--locale <en|zh>`(优先级:CLI 旗标 > 项目配置 > 英文)。全部用户可见文案都跟随 locale——包括 `--help`、未知子命令提示、审计为空的提示等 CLI 框架文案。
|
|
267
|
+
- **自定义语言**:下游包可在运行时追加语言——`import { registerLocale } from 'agents-gitflow-guard'`,调用 `registerLocale('fr', frDict)` 注册一份与内置英文键完全一致的字典(注册时校验),再在项目配置写 `"locale": "fr"` 即生效。
|
|
268
|
+
- **未注册语言**:拦截路径对未注册的 `"locale"` 静默回退英文(设计如此——hook 不因文案缺失卡死),笔误因此容易被忽略;一行告警在 `gitflow-guard status` 中可见。
|
|
266
269
|
- **校验**:`integration` 必填;角色条目重叠会被拒;非法正则会报错。**任何错误都会让该项目的插件禁用并上报**(而不是用半吊子配置)。
|
|
270
|
+
- **strict 模式**:默认配置损坏时 stderr 告警一次后放行(fail-open,避免一个笔误卡死工具管道);`"strict": true` 把配置异常与内部错误翻转为**拦截**(fail-closed)——供高风险仓库选用。文件不存在或显式 `enabled: false` 两种模式下都保持静默。
|
|
267
271
|
|
|
268
272
|
---
|
|
269
273
|
|
|
@@ -296,7 +300,7 @@ PR/MR 目标通过 `gh pr view`(GitHub)或 `glab mr view`(GitLab)解析;没有
|
|
|
296
300
|
**从 npm registry**——标准路径,已在[快速开始](#快速开始30-秒用上)覆盖:
|
|
297
301
|
|
|
298
302
|
```bash
|
|
299
|
-
dsh plugin --profile web add agents-gitflow-guard@0.0.
|
|
303
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.12 # 建议锁版本, 见上文提示
|
|
300
304
|
```
|
|
301
305
|
|
|
302
306
|
然后重启 DSH。升级用同一命令,再重启一次。
|
|
@@ -388,7 +392,9 @@ hooks:
|
|
|
388
392
|
|
|
389
393
|
不是,请注意别把它当安全工具。它是工作流守卫:把既定流程变成可机制执行的东西。基于文本的命令识别天然是尽力而为——铁心混淆命令的 agent 可以绕过解析器。
|
|
390
394
|
|
|
391
|
-
|
|
395
|
+
在其支持的命令形态内,角色边界在本地强制生效:合入受保护角色分支(integration / preview / production / archive)必须走配置好的路径(PR/MR,或生产/归档的人工合并)。常见混淆包装已纳入分类与拦截——shell 包装(`sh -c` / `bash -lc`)、子 shell 与反引号/`$()` 内嵌、`env`/`command`/`nohup`/`xargs` 前缀与 `VAR=x` 赋值、绝对路径、管道与 `||` 后半段、git 全局选项(`-C .`、`--git-dir=…`)、通配 refspec(`refs/heads/*:refs/heads/*`)、当 fetch+merge 用的 `git pull`,以及 `send-pack`/`update-ref` 等 plumbing。可执行对抗语料见 `tests/accuracy-audit.spec.ts`。
|
|
396
|
+
|
|
397
|
+
已知**本地不可防**的通道:直连 forge API(`gh api repos/…/pulls/N/merge`、`curl`)与解释器子进程内嵌(`node -e "child_process.exec('git push …')"`);任意深度的引号/编码变换天然只能尽力而为。真正不可绕过的边界在你托管服务的分支保护设置。两边都用——把本守卫当作即时反馈与审计留痕,而不是安全边界。
|
|
392
398
|
|
|
393
399
|
---
|
|
394
400
|
|
|
@@ -400,7 +406,7 @@ hooks:
|
|
|
400
406
|
|
|
401
407
|
### 必须装 `gh` 或 `glab` CLI 吗?
|
|
402
408
|
|
|
403
|
-
不用。它们只是可选适配器,用来解析 `pr merge` / `mr merge` 到底指向哪个分支,好让门禁区分"合入 integration/preview"(放行)与"合入 production/archive"(拦截)
|
|
409
|
+
不用。它们只是可选适配器,用来解析 `pr merge` / `mr merge` 到底指向哪个分支,好让门禁区分"合入 integration/preview"(放行)与"合入 production/archive"(拦截)。当两个 CLI 都无法确认目标——未安装、未认证、离线或查询失败——门禁**一律拒绝合并**,即使在 feature 分支上执行也照拦:该 PR 可能实际指向生产/归档分支。等 CLI 可用后重试,或由用户亲手点合并。其余一切照常。核心校验不碰任何托管服务,所以它在 GitHub、GitLab、自托管或离线环境里行为一致。
|
|
404
410
|
|
|
405
411
|
---
|
|
406
412
|
|
|
@@ -414,7 +420,7 @@ hooks:
|
|
|
414
420
|
|
|
415
421
|
### 配置写错了会怎样?
|
|
416
422
|
|
|
417
|
-
|
|
423
|
+
半吊子配置绝不会意外生效:任何校验错误都会让该项目的守卫禁用并上报错误。
|
|
418
424
|
|
|
419
425
|
常见错误:`integration` 缺失(必填)、同一个分支被配到两个角色里(显式拒绝)、`featurePattern` 写不成合法正则(报错)。失败提示很明确,文件又是一个 JSON 对象,通常三十秒改好。
|
|
420
426
|
|
|
@@ -476,9 +482,10 @@ npm install
|
|
|
476
482
|
npm test # 单测: classify / gate / config / cli / repo / platform
|
|
477
483
|
npm run typecheck # tsc --noEmit, 0 Error
|
|
478
484
|
npm run build # tsdown → lib/(CLI 与插件共用)
|
|
485
|
+
npm run verify:matrix # 连续复测矩阵: DSH 逻辑 + zh 文案回归 + Claude Code / Codex / OpenCode / Antigravity hook 编码
|
|
479
486
|
```
|
|
480
487
|
|
|
481
|
-
**铁律**:任何逻辑改动必须 0 Error 构建 +
|
|
488
|
+
**铁律**:任何逻辑改动必须 0 Error 构建 + 单测全绿 + 连续复测矩阵(`verify:matrix`)全绿后才算完成。
|
|
482
489
|
|
|
483
490
|
---
|
|
484
491
|
|
package/lib/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as findRepoRoot, d as
|
|
1
|
+
import { c as findRepoRoot, d as roleMatches, f as makeT, h as classify, i as formatDeny, l as gitRunner, m as resolveLocale, o as stateDir, r as evaluateCommand, s as currentBranch, u as loadConfig } from "./src-CPX1Ks_Z.mjs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
//#region src/platform.ts
|
|
@@ -94,31 +94,45 @@ function parseFlags(argv) {
|
|
|
94
94
|
else if (a === "--lines") flags.lines = Number(next());
|
|
95
95
|
else if (a === "--platform") flags.platform = next();
|
|
96
96
|
else if (a === "--command") flags.command = next();
|
|
97
|
+
else if (a === "--locale") flags.locale = next();
|
|
97
98
|
else if (a.startsWith("--repo=")) flags.repo = a.slice(7);
|
|
98
99
|
else if (a.startsWith("--lines=")) flags.lines = Number(a.slice(8));
|
|
99
100
|
else if (a.startsWith("--platform=")) flags.platform = a.slice(11);
|
|
100
101
|
else if (a.startsWith("--command=")) flags.command = a.slice(10);
|
|
102
|
+
else if (a.startsWith("--locale=")) flags.locale = a.slice(9);
|
|
101
103
|
}
|
|
102
104
|
return flags;
|
|
103
105
|
}
|
|
104
|
-
async function resolveRepo(flags) {
|
|
106
|
+
async function resolveRepo(flags, runner) {
|
|
105
107
|
if (flags.repo) return flags.repo;
|
|
106
|
-
return await findRepoRoot(
|
|
108
|
+
return await findRepoRoot(runner, process.cwd());
|
|
109
|
+
}
|
|
110
|
+
/** CLI 文案 locale 解析(P2-1): --locale 旗标 > 项目 config.locale > en; 白名单外一律 en */
|
|
111
|
+
function cliLocale(flags, configLocale) {
|
|
112
|
+
return resolveLocale(flags.locale ?? configLocale);
|
|
113
|
+
}
|
|
114
|
+
/** 无 config 在手时的 locale 解析(help / unknownCommand 等框架路径): 旗标优先, 否则定位仓库读配置 */
|
|
115
|
+
async function resolveFrameworkLocale(flags, runner) {
|
|
116
|
+
if (flags.locale != null) return resolveLocale(flags.locale);
|
|
117
|
+
const repoRoot = await resolveRepo(flags, runner);
|
|
118
|
+
if (!repoRoot) return "en";
|
|
119
|
+
const { config } = await loadConfig(repoRoot);
|
|
120
|
+
return resolveLocale(config?.locale);
|
|
107
121
|
}
|
|
108
122
|
async function main(argv, opts = {}) {
|
|
109
123
|
const runner = opts.runner ?? gitRunner;
|
|
110
|
-
const usage = makeT("en")("usage.text");
|
|
111
124
|
const [cmd, ...rest] = argv;
|
|
125
|
+
const flags = parseFlags(rest);
|
|
112
126
|
if (cmd === "--help" || cmd === "help" || cmd === void 0) {
|
|
113
|
-
console.log(usage);
|
|
127
|
+
console.log(makeT(await resolveFrameworkLocale(flags, runner))("usage.text"));
|
|
114
128
|
return 0;
|
|
115
129
|
}
|
|
116
|
-
const flags = parseFlags(rest);
|
|
117
130
|
try {
|
|
118
131
|
if (cmd === "status") return await status(flags, runner);
|
|
119
132
|
if (cmd === "audit") return await audit(flags);
|
|
120
133
|
if (cmd === "check") return await check(flags);
|
|
121
|
-
|
|
134
|
+
const t = makeT(await resolveFrameworkLocale(flags, runner));
|
|
135
|
+
console.error(`${t("cli.unknownCommand", { cmd: cmd ?? "" })}\n\n${t("usage.text")}`);
|
|
122
136
|
return 1;
|
|
123
137
|
} catch (e) {
|
|
124
138
|
console.error(`[gitflow-guard] ${e.message}`);
|
|
@@ -126,20 +140,22 @@ async function main(argv, opts = {}) {
|
|
|
126
140
|
}
|
|
127
141
|
}
|
|
128
142
|
async function status(flags, runner) {
|
|
129
|
-
const repoRoot = await resolveRepo(flags);
|
|
143
|
+
const repoRoot = await resolveRepo(flags, runner);
|
|
130
144
|
if (!repoRoot) {
|
|
131
|
-
console.error(makeT(
|
|
145
|
+
console.error(makeT(resolveLocale(flags.locale))("cli.cannotLocate"));
|
|
132
146
|
return 1;
|
|
133
147
|
}
|
|
134
|
-
const { config, errors } = await loadConfig(repoRoot);
|
|
148
|
+
const { config, errors, warnings } = await loadConfig(repoRoot);
|
|
135
149
|
const enabled = config?.enabled === true;
|
|
136
|
-
const t = makeT(
|
|
150
|
+
const t = makeT(cliLocale(flags, config?.locale));
|
|
137
151
|
console.log(t("cli.statusTitle", { repo: repoRoot }));
|
|
138
152
|
if (!enabled) {
|
|
139
153
|
console.log(t("cli.statusDisabled"));
|
|
140
154
|
for (const e of errors) console.log(t("cli.statusConfigError", { err: e }));
|
|
155
|
+
for (const w of warnings) console.log(t("cli.statusConfigWarning", { warn: w }));
|
|
141
156
|
return 0;
|
|
142
157
|
}
|
|
158
|
+
for (const w of warnings) console.log(t("cli.statusConfigWarning", { warn: w }));
|
|
143
159
|
const branch = await currentBranch(runner, repoRoot);
|
|
144
160
|
const c = config;
|
|
145
161
|
console.log(t("cli.statusEnabled", { pattern: c.featurePattern }));
|
|
@@ -177,22 +193,24 @@ async function status(flags, runner) {
|
|
|
177
193
|
return 0;
|
|
178
194
|
}
|
|
179
195
|
async function audit(flags) {
|
|
180
|
-
const repoRoot = await resolveRepo(flags);
|
|
196
|
+
const repoRoot = await resolveRepo(flags, gitRunner);
|
|
181
197
|
if (!repoRoot) {
|
|
182
|
-
console.error(makeT(
|
|
198
|
+
console.error(makeT(resolveLocale(flags.locale))("cli.cannotLocate"));
|
|
183
199
|
return 1;
|
|
184
200
|
}
|
|
201
|
+
const { config } = await loadConfig(repoRoot);
|
|
202
|
+
const t = makeT(cliLocale(flags, config?.locale));
|
|
185
203
|
const lines = flags.lines != null && Number.isFinite(flags.lines) && flags.lines > 0 ? Math.floor(flags.lines) : 20;
|
|
186
204
|
try {
|
|
187
205
|
const all = (await readFile(join(stateDir(repoRoot), "audit.jsonl"), "utf8")).split("\n").filter(Boolean);
|
|
188
206
|
for (const line of all.slice(-lines)) try {
|
|
189
207
|
const e = JSON.parse(line);
|
|
190
|
-
console.log(` ${new Date(e.time).
|
|
208
|
+
console.log(` ${new Date(e.time).toISOString()} ${e.event} ${e.role ?? ""}${e.command ? ` | ${e.command.slice(0, 80)}` : ""}${e.reason ? ` | ${e.reason.slice(0, 60)}` : ""}`);
|
|
191
209
|
} catch {
|
|
192
210
|
console.log(` ${line}`);
|
|
193
211
|
}
|
|
194
212
|
} catch {
|
|
195
|
-
console.log(
|
|
213
|
+
console.log(t("cli.auditEmpty"));
|
|
196
214
|
}
|
|
197
215
|
return 0;
|
|
198
216
|
}
|
|
@@ -205,35 +223,56 @@ function readStdin() {
|
|
|
205
223
|
process.stdin.on("error", () => resolve(data));
|
|
206
224
|
});
|
|
207
225
|
}
|
|
226
|
+
/** 按平台编码并输出 deny, 返回退出码 */
|
|
227
|
+
function emitDeny(platform, why, next, locale = "en") {
|
|
228
|
+
const enc = encodeDeny(platform, formatDeny(locale, why, next));
|
|
229
|
+
if (enc.stdout) process.stdout.write(enc.stdout + "\n");
|
|
230
|
+
if (enc.stderr) process.stderr.write(enc.stderr + "\n");
|
|
231
|
+
return enc.exitCode;
|
|
232
|
+
}
|
|
208
233
|
/** check: agent hook 门禁。读 stdin hook payload(或 --command), exit 0=放行 / 2=拦截(按平台编码) */
|
|
209
234
|
async function check(flags) {
|
|
210
|
-
const
|
|
235
|
+
const platformFlag = flags.platform ?? "auto";
|
|
236
|
+
let raw = "";
|
|
237
|
+
let strict = false;
|
|
238
|
+
let denyPlatform = "claude";
|
|
211
239
|
try {
|
|
212
|
-
|
|
240
|
+
raw = flags.command != null ? "" : await readStdin();
|
|
241
|
+
denyPlatform = platformFlag === "auto" ? detectPlatform(raw) : platformFlag;
|
|
213
242
|
const payload = flags.command != null ? {
|
|
214
243
|
command: flags.command,
|
|
215
244
|
cwd: flags.repo,
|
|
216
245
|
event: "pre"
|
|
217
|
-
} : extractHookPayload(raw,
|
|
246
|
+
} : extractHookPayload(raw, platformFlag);
|
|
218
247
|
if (!payload?.command) return 0;
|
|
219
248
|
const segments = classify(payload.command);
|
|
220
249
|
if (segments.length === 0 || segments.every((s) => s.kind === "other")) return 0;
|
|
221
250
|
const cwd = payload.cwd ?? process.cwd();
|
|
222
251
|
const repoRoot = flags.repo ?? await findRepoRoot(gitRunner, cwd);
|
|
223
252
|
if (!repoRoot) return 0;
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return enc.exitCode;
|
|
253
|
+
const loaded = await loadConfig(repoRoot);
|
|
254
|
+
strict = loaded.strict === true || loaded.config?.strict === true;
|
|
255
|
+
if (!loaded.config?.enabled) {
|
|
256
|
+
if (loaded.errors.length > 0) {
|
|
257
|
+
const t = makeT("en");
|
|
258
|
+
if (strict) return emitDeny(denyPlatform, t("guardStrictConfigBroken.why"), t("guardStrictConfigBroken.next"));
|
|
259
|
+
process.stderr.write(`${t("cli.guardDisabledInvalidConfig", { err: loaded.errors.join("; ") })}\n`);
|
|
260
|
+
}
|
|
261
|
+
return 0;
|
|
234
262
|
}
|
|
263
|
+
const config = loaded.config;
|
|
264
|
+
const locale = flags.locale != null ? resolveLocale(flags.locale) : resolveLocale(config.locale);
|
|
265
|
+
const result = await evaluateCommand(payload.command, {
|
|
266
|
+
repoRoot,
|
|
267
|
+
locale
|
|
268
|
+
});
|
|
269
|
+
if (result.outcome === "deny" && result.reason) return emitDeny(denyPlatform, result.reason.why, result.reason.next, locale);
|
|
235
270
|
return 0;
|
|
236
271
|
} catch (e) {
|
|
272
|
+
if (strict) {
|
|
273
|
+
const t = makeT("en");
|
|
274
|
+
return emitDeny(denyPlatform, t("guardStrictInternalError.why", { msg: e.message }), t("guardStrictInternalError.next"));
|
|
275
|
+
}
|
|
237
276
|
process.stderr.write(`${makeT("en")("cli.checkInternalError", { msg: e.message })}\n`);
|
|
238
277
|
return 0;
|
|
239
278
|
}
|
package/lib/index.d.mts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { t as Runner } from "./repo-DrgptHl1.mjs";
|
|
2
2
|
import { Context } from "@deepseek-ai/cordis";
|
|
3
3
|
//#region src/types.d.ts
|
|
4
|
-
/** 文案语言: 默认 en; 'zh'
|
|
5
|
-
type Locale = 'en' | 'zh';
|
|
4
|
+
/** 文案语言: 默认 en; 'zh' 切中文; 可经 registerLocale 运行时扩展(保留字面量提示的宽字符串) */
|
|
5
|
+
type Locale = 'en' | 'zh' | (string & {});
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/i18n.d.ts
|
|
8
|
+
/** 可插值变量(分支名/角色名等) */
|
|
9
|
+
type I18nVars = Record<string, string>;
|
|
10
|
+
/** 单一文案条目: (vars) => 最终文本 */
|
|
11
|
+
type Entry = (v: I18nVars) => string;
|
|
12
|
+
/** 一门语言的文案字典: key 集合必须与内置 en 完全一致(registerLocale / 加载期校验) */
|
|
13
|
+
type Dict = Record<string, Entry>;
|
|
14
|
+
/**
|
|
15
|
+
* 注册一门新语言(运行时扩展点, P2-2): key 集合必须与内置 en 完全一致, 否则抛英文异常。
|
|
16
|
+
* 注册后 makeT/resolveLocale 即接受该 locale; 未注册的 locale 一律回退英文。
|
|
17
|
+
*/
|
|
18
|
+
declare function registerLocale(name: string, dict: Dict): void;
|
|
6
19
|
//#endregion
|
|
7
20
|
//#region src/index.d.ts
|
|
8
21
|
declare const name = "gitflow-guard";
|
|
@@ -19,6 +32,8 @@ interface EvaluateOptions {
|
|
|
19
32
|
glabRunner?: Runner;
|
|
20
33
|
/** 当前分支(缺省时内部查询) */
|
|
21
34
|
currentBranch?: string | null;
|
|
35
|
+
/** 覆盖文案 locale(CLI --locale 旗标用, P2-1); 缺省按项目 config.locale 解析 */
|
|
36
|
+
locale?: Locale;
|
|
22
37
|
}
|
|
23
38
|
interface EvaluateResult {
|
|
24
39
|
outcome: 'allow' | 'deny' | 'skipped';
|
|
@@ -45,4 +60,4 @@ declare function evaluateCommand(command: string, opts: EvaluateOptions): Promis
|
|
|
45
60
|
declare function formatDeny(locale: Locale, why: string, next: string): string;
|
|
46
61
|
declare function apply(ctx: Context, pluginConfig?: PluginConfig): void;
|
|
47
62
|
//#endregion
|
|
48
|
-
export { AuditEntry, EvaluateOptions, EvaluateResult, PluginConfig, appendAudit, apply, evaluateCommand, formatDeny, name, stateDir };
|
|
63
|
+
export { AuditEntry, type Dict, EvaluateOptions, EvaluateResult, PluginConfig, appendAudit, apply, evaluateCommand, formatDeny, name, registerLocale, stateDir };
|
package/lib/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as name, i as formatDeny, n as apply, o as stateDir, r as evaluateCommand, t as appendAudit } from "./src-
|
|
2
|
-
export { appendAudit, apply, evaluateCommand, formatDeny, name, stateDir };
|
|
1
|
+
import { a as name, i as formatDeny, n as apply, o as stateDir, p as registerLocale, r as evaluateCommand, t as appendAudit } from "./src-CPX1Ks_Z.mjs";
|
|
2
|
+
export { appendAudit, apply, evaluateCommand, formatDeny, name, registerLocale, stateDir };
|
|
@@ -2,11 +2,58 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
//#region src/classify.ts
|
|
5
|
-
/** 拆分命令为多段(&& / 分号 / 换行), 每段独立分类; 引号内的分隔符不算 */
|
|
5
|
+
/** 拆分命令为多段(&& / || / | / 分号 / 换行), 每段独立分类; 引号内的分隔符不算 */
|
|
6
6
|
function classify(command, ctx = {}) {
|
|
7
|
-
|
|
7
|
+
const { plain, nested } = extractNested(command);
|
|
8
|
+
return [...splitSegments(plain).flatMap((seg) => classifySegment(seg, ctx)), ...nested.flatMap((n) => classify(n, ctx))];
|
|
8
9
|
}
|
|
9
|
-
/**
|
|
10
|
+
/** 提取反引号与 $() 内层命令文本一并送分类(单引号内不展开, 与 shell 语义一致); 外层文本剥离内嵌段后返回 */
|
|
11
|
+
function extractNested(command) {
|
|
12
|
+
const nested = [];
|
|
13
|
+
let plain = "";
|
|
14
|
+
let inSingle = false;
|
|
15
|
+
for (let i = 0; i < command.length; i++) {
|
|
16
|
+
const ch = command[i];
|
|
17
|
+
if (inSingle) {
|
|
18
|
+
plain += ch;
|
|
19
|
+
if (ch === "'") inSingle = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "'") {
|
|
23
|
+
inSingle = true;
|
|
24
|
+
plain += ch;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (ch === "`") {
|
|
28
|
+
const end = command.indexOf("`", i + 1);
|
|
29
|
+
if (end === -1) {
|
|
30
|
+
plain += ch;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
nested.push(command.slice(i + 1, end));
|
|
34
|
+
i = end;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === "$" && command[i + 1] === "(") {
|
|
38
|
+
let depth = 1;
|
|
39
|
+
let j = i + 2;
|
|
40
|
+
while (j < command.length && depth > 0) {
|
|
41
|
+
if (command[j] === "(") depth++;
|
|
42
|
+
else if (command[j] === ")") depth--;
|
|
43
|
+
j++;
|
|
44
|
+
}
|
|
45
|
+
nested.push(command.slice(i + 2, depth === 0 ? j - 1 : j));
|
|
46
|
+
i = j - 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
plain += ch;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
plain,
|
|
53
|
+
nested
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** 引号感知拆分: 保护 "..." 与 '...' 内的 && / || / | / ; / 换行 */
|
|
10
57
|
function splitSegments(command) {
|
|
11
58
|
const segments = [];
|
|
12
59
|
let current = "";
|
|
@@ -32,7 +79,12 @@ function splitSegments(command) {
|
|
|
32
79
|
i++;
|
|
33
80
|
continue;
|
|
34
81
|
}
|
|
35
|
-
if (ch === "
|
|
82
|
+
if (ch === "|" && command[i + 1] === "|") {
|
|
83
|
+
push();
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === ";" || ch === "\n" || ch === "|") {
|
|
36
88
|
push();
|
|
37
89
|
continue;
|
|
38
90
|
}
|
|
@@ -41,31 +93,128 @@ function splitSegments(command) {
|
|
|
41
93
|
push();
|
|
42
94
|
return segments;
|
|
43
95
|
}
|
|
96
|
+
/** shell 解释器包装(sh/bash/zsh -c "<script>"): 脚本文本整体重分类 */
|
|
97
|
+
const SHELLS = /* @__PURE__ */ new Set([
|
|
98
|
+
"sh",
|
|
99
|
+
"bash",
|
|
100
|
+
"zsh",
|
|
101
|
+
"dash",
|
|
102
|
+
"ksh"
|
|
103
|
+
]);
|
|
104
|
+
/** 可剥离的执行前缀(env/nohup/xargs/command 及 VAR=x 赋值) */
|
|
105
|
+
const WRAPPERS = /* @__PURE__ */ new Set([
|
|
106
|
+
"env",
|
|
107
|
+
"command",
|
|
108
|
+
"nohup",
|
|
109
|
+
"xargs"
|
|
110
|
+
]);
|
|
44
111
|
function classifySegment(segment, ctx) {
|
|
45
|
-
const
|
|
112
|
+
const trimmed = segment.trim();
|
|
113
|
+
const tokens = tokenize(trimmed.startsWith("(") && trimmed.endsWith(")") ? trimmed.slice(1, -1).trim() : trimmed);
|
|
46
114
|
if (tokens.length === 0) return [{ kind: "other" }];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
115
|
+
return classifyTokens(tokens, ctx);
|
|
116
|
+
}
|
|
117
|
+
/** 分派: 已知命令直接解析; 包装器剥壳后递归(token 只减不增, 必然终止) */
|
|
118
|
+
function classifyTokens(tokens, ctx) {
|
|
119
|
+
const rawCmd = tokens[0];
|
|
120
|
+
const cmd = rawCmd.includes("/") ? rawCmd.slice(rawCmd.lastIndexOf("/") + 1) : rawCmd;
|
|
121
|
+
if (SHELLS.has(cmd)) return classifyShellWrapped(tokens, ctx);
|
|
122
|
+
if (cmd === "env") return classifyTokens(stripEnvArgs(tokens.slice(1)), ctx);
|
|
123
|
+
if (WRAPPERS.has(cmd)) return classifyTokens(stripWrapperArgs(tokens.slice(1)), ctx);
|
|
124
|
+
if (/^[\w-][\w.-]*=/.test(rawCmd)) return classifyTokens(tokens.slice(1), ctx);
|
|
125
|
+
if (cmd === "git") return classifyGit(tokens.slice(1), ctx);
|
|
126
|
+
if (cmd === "gh") return classifyGh(tokens.slice(1));
|
|
127
|
+
if (cmd === "glab") return classifyGlab(tokens.slice(1));
|
|
51
128
|
if (cmd === "gitflow-guard") return [{
|
|
52
129
|
kind: "guard-cli",
|
|
53
|
-
sub: guardSub(
|
|
130
|
+
sub: guardSub(tokens.slice(1))
|
|
54
131
|
}];
|
|
55
132
|
return [{ kind: "other" }];
|
|
56
133
|
}
|
|
134
|
+
/** sh/bash -lc "<script>": 定位 -c(含合并短旗标如 -lc)取脚本文本递归; 取不到按 other 放行 */
|
|
135
|
+
function classifyShellWrapped(tokens, ctx) {
|
|
136
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
137
|
+
const a = tokens[i];
|
|
138
|
+
if (!(a === "-c" || a.startsWith("-") && !a.startsWith("--") && a.includes("c"))) continue;
|
|
139
|
+
const script = tokens[i + 1];
|
|
140
|
+
if (script == null) break;
|
|
141
|
+
return script.length > 0 ? classify(script, ctx) : [{ kind: "other" }];
|
|
142
|
+
}
|
|
143
|
+
return [{ kind: "other" }];
|
|
144
|
+
}
|
|
145
|
+
/** env 参数剥离: 旗标与 VAR=x 赋值;-u/--unset 消费下一个参数 */
|
|
146
|
+
function stripEnvArgs(args) {
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < args.length) {
|
|
149
|
+
const a = args[i];
|
|
150
|
+
if (a === "-u" || a === "--unset") {
|
|
151
|
+
i += args[i + 1] != null ? 2 : 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (a.startsWith("-") || /^[\w-]+=/.test(a)) {
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
return args.slice(i);
|
|
161
|
+
}
|
|
162
|
+
/** nohup/xargs/command 参数剥离: 旗标、纯数字(xargs -n 2 的值)与 VAR=x */
|
|
163
|
+
function stripWrapperArgs(args) {
|
|
164
|
+
let i = 0;
|
|
165
|
+
while (i < args.length && (args[i].startsWith("-") || /^\d+$/.test(args[i]) || /^[\w-]+=/.test(args[i]))) i++;
|
|
166
|
+
return args.slice(i);
|
|
167
|
+
}
|
|
57
168
|
/** 分词: 引号内的空格不拆分 */
|
|
58
169
|
function tokenize(segment) {
|
|
59
170
|
return segment.match(/"[^"]*"|'[^']*'|\S+/g)?.map((t) => t.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
60
171
|
}
|
|
61
172
|
function classifyGit(args, ctx) {
|
|
62
|
-
const [sub, ...rest] = args;
|
|
173
|
+
const [sub, ...rest] = stripGlobalOptions(args);
|
|
63
174
|
if (sub === "push") return parsePush(rest, ctx);
|
|
175
|
+
if (sub === "pull") return parsePull(rest);
|
|
64
176
|
if (sub === "merge") return parseMerge(rest);
|
|
65
177
|
if (sub === "branch") return parseBranch(rest);
|
|
66
178
|
if (sub === "checkout" || sub === "switch") return parseCheckout(rest);
|
|
179
|
+
if (sub === "send-pack") return parseSendPack(rest);
|
|
180
|
+
if (sub === "update-ref") return parseUpdateRef(rest);
|
|
67
181
|
return [{ kind: "other" }];
|
|
68
182
|
}
|
|
183
|
+
/** 剥离子命令前的全局选项(-C <path> / -c <k=v> / --git-dir 等), 否则 git -C . push 会被判 other */
|
|
184
|
+
function stripGlobalOptions(args) {
|
|
185
|
+
const WITH_VALUE = /* @__PURE__ */ new Set([
|
|
186
|
+
"-C",
|
|
187
|
+
"-c",
|
|
188
|
+
"--git-dir",
|
|
189
|
+
"--work-tree",
|
|
190
|
+
"--namespace",
|
|
191
|
+
"--super-prefix"
|
|
192
|
+
]);
|
|
193
|
+
const BARE = /* @__PURE__ */ new Set([
|
|
194
|
+
"--bare",
|
|
195
|
+
"--no-pager",
|
|
196
|
+
"--no-optional-locks",
|
|
197
|
+
"--paginate",
|
|
198
|
+
"--no-replace-objects",
|
|
199
|
+
"--literal-pathspecs",
|
|
200
|
+
"-p",
|
|
201
|
+
"-P"
|
|
202
|
+
]);
|
|
203
|
+
let i = 0;
|
|
204
|
+
while (i < args.length) {
|
|
205
|
+
const a = args[i];
|
|
206
|
+
if (BARE.has(a) || /^--(git-dir|work-tree|namespace|super-prefix)=/.test(a)) {
|
|
207
|
+
i++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (WITH_VALUE.has(a)) {
|
|
211
|
+
i += 2;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
return args.slice(i);
|
|
217
|
+
}
|
|
69
218
|
/** 分支切换: 门禁放行, 分支状态由 evaluateCommand 按段模拟 */
|
|
70
219
|
function parseCheckout(args) {
|
|
71
220
|
const first = args[0];
|
|
@@ -106,47 +255,72 @@ function parsePush(args, ctx) {
|
|
|
106
255
|
delete: false,
|
|
107
256
|
all: true
|
|
108
257
|
}];
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
258
|
+
const [first, second] = nonFlag;
|
|
259
|
+
if (first == null) return [{
|
|
111
260
|
kind: "push",
|
|
112
|
-
dst:
|
|
261
|
+
dst: null,
|
|
113
262
|
force,
|
|
114
263
|
delete: false
|
|
115
264
|
}];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
265
|
+
if (second == null) return [{
|
|
266
|
+
kind: "push",
|
|
267
|
+
dst: stripRefPrefix(first),
|
|
268
|
+
force,
|
|
269
|
+
delete: isDelete
|
|
270
|
+
}, {
|
|
271
|
+
kind: "push",
|
|
272
|
+
dst: null,
|
|
273
|
+
force,
|
|
274
|
+
delete: isDelete
|
|
275
|
+
}];
|
|
276
|
+
return mapRefspecs(nonFlag.slice(1), {
|
|
277
|
+
force,
|
|
278
|
+
delete: isDelete
|
|
279
|
+
}, ctx);
|
|
280
|
+
}
|
|
281
|
+
/** refspec → push 分类。含 * 视为批量推送(与 --all 同级); HEAD/空 dst 延迟为 null(门禁按模拟分支解析) */
|
|
282
|
+
function mapRefspecs(refspecs, base, ctx) {
|
|
283
|
+
return refspecs.map((raw) => {
|
|
284
|
+
let refForce = base.force;
|
|
285
|
+
if (raw.startsWith("+")) {
|
|
119
286
|
refForce = true;
|
|
120
|
-
|
|
287
|
+
raw = raw.slice(1);
|
|
121
288
|
}
|
|
122
|
-
if (
|
|
289
|
+
if (raw.includes("*")) return {
|
|
290
|
+
kind: "push",
|
|
291
|
+
dst: null,
|
|
292
|
+
force: refForce,
|
|
293
|
+
delete: false,
|
|
294
|
+
all: true
|
|
295
|
+
};
|
|
296
|
+
if (raw.startsWith(":")) return {
|
|
123
297
|
kind: "push",
|
|
124
|
-
dst: stripRefPrefix(
|
|
298
|
+
dst: stripRefPrefix(raw.slice(1)) || null,
|
|
125
299
|
force: refForce,
|
|
126
300
|
delete: true
|
|
127
301
|
};
|
|
128
|
-
const colon =
|
|
302
|
+
const colon = raw.indexOf(":");
|
|
129
303
|
if (colon >= 0) {
|
|
130
|
-
const deleteTarget =
|
|
131
|
-
const dst = deleteTarget ?
|
|
304
|
+
const deleteTarget = raw.endsWith(":");
|
|
305
|
+
const dst = deleteTarget ? raw.slice(colon + 1, raw.length - 1) || raw.slice(0, colon) : raw.slice(colon + 1);
|
|
132
306
|
return {
|
|
133
307
|
kind: "push",
|
|
134
308
|
dst: dst ? stripRefPrefix(dst) : null,
|
|
135
309
|
force: refForce,
|
|
136
|
-
delete: deleteTarget ||
|
|
310
|
+
delete: deleteTarget || base.delete
|
|
137
311
|
};
|
|
138
312
|
}
|
|
139
|
-
if (
|
|
313
|
+
if (raw === "HEAD") return {
|
|
140
314
|
kind: "push",
|
|
141
|
-
dst:
|
|
315
|
+
dst: null,
|
|
142
316
|
force: refForce,
|
|
143
|
-
delete:
|
|
317
|
+
delete: base.delete
|
|
144
318
|
};
|
|
145
319
|
return {
|
|
146
320
|
kind: "push",
|
|
147
|
-
dst: stripRefPrefix(
|
|
321
|
+
dst: stripRefPrefix(raw),
|
|
148
322
|
force: refForce,
|
|
149
|
-
delete:
|
|
323
|
+
delete: base.delete
|
|
150
324
|
};
|
|
151
325
|
});
|
|
152
326
|
}
|
|
@@ -154,6 +328,59 @@ function parsePush(args, ctx) {
|
|
|
154
328
|
function stripRefPrefix(branch) {
|
|
155
329
|
return branch.startsWith("refs/heads/") ? branch.slice(11) : branch;
|
|
156
330
|
}
|
|
331
|
+
/** git pull = fetch+merge: 取最后一个非 flag 参数为远端分支名(source), 交给本地合入门禁; 无 refspec 时 source=null(同步上游语义) */
|
|
332
|
+
function parsePull(args) {
|
|
333
|
+
const nonFlags = args.filter((a) => !a.startsWith("-"));
|
|
334
|
+
const last = nonFlags[nonFlags.length - 1];
|
|
335
|
+
return [{
|
|
336
|
+
kind: "local-merge",
|
|
337
|
+
source: nonFlags.length >= 2 && last ? last.replace(/^\+/, "") : null
|
|
338
|
+
}];
|
|
339
|
+
}
|
|
340
|
+
/** git send-pack(push 的底层等价物): 首个非 flag 是 host:path, 其后 refspec 按推送语义分类 */
|
|
341
|
+
function parseSendPack(args) {
|
|
342
|
+
let force = false;
|
|
343
|
+
let all = false;
|
|
344
|
+
const nonFlag = [];
|
|
345
|
+
for (const a of args) if (a === "-f" || a === "--force") force = true;
|
|
346
|
+
else if (a === "--all") all = true;
|
|
347
|
+
else if (!a.startsWith("-")) nonFlag.push(a);
|
|
348
|
+
if (all) return [{
|
|
349
|
+
kind: "push",
|
|
350
|
+
dst: null,
|
|
351
|
+
force,
|
|
352
|
+
delete: false,
|
|
353
|
+
all: true
|
|
354
|
+
}];
|
|
355
|
+
const refspecs = nonFlag.slice(1);
|
|
356
|
+
if (refspecs.length === 0) return [{ kind: "other" }];
|
|
357
|
+
return mapRefspecs(refspecs, {
|
|
358
|
+
force,
|
|
359
|
+
delete: false
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** git update-ref 直改 refs(plumbing): 提取目标 ref(剥 refs/heads/ 前缀供角色比对); -d 为删除 */
|
|
363
|
+
function parseUpdateRef(args) {
|
|
364
|
+
let isDelete = false;
|
|
365
|
+
for (let i = 0; i < args.length; i++) {
|
|
366
|
+
const a = args[i];
|
|
367
|
+
if (a === "-d") {
|
|
368
|
+
isDelete = true;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (a === "-m" || a === "--message") {
|
|
372
|
+
i++;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (a.startsWith("-")) continue;
|
|
376
|
+
return [{
|
|
377
|
+
kind: "ref-update",
|
|
378
|
+
branch: stripRefPrefix(a),
|
|
379
|
+
delete: isDelete
|
|
380
|
+
}];
|
|
381
|
+
}
|
|
382
|
+
return [{ kind: "other" }];
|
|
383
|
+
}
|
|
157
384
|
function parseMerge(args) {
|
|
158
385
|
if (args.some((a) => a === "--abort")) return [{ kind: "other" }];
|
|
159
386
|
return [{
|
|
@@ -217,150 +444,6 @@ function guardSub(args) {
|
|
|
217
444
|
return "other";
|
|
218
445
|
}
|
|
219
446
|
//#endregion
|
|
220
|
-
//#region src/config.ts
|
|
221
|
-
const CONFIG_FILE = "gitflow-guard.config.json";
|
|
222
|
-
/** 默认配置(分支角色必须由项目显式配置, 无默认) */
|
|
223
|
-
const DEFAULT_CONFIG = {
|
|
224
|
-
enabled: false,
|
|
225
|
-
featurePattern: "feature/[\\w-]+",
|
|
226
|
-
ci: { enabled: true },
|
|
227
|
-
locale: "en"
|
|
228
|
-
};
|
|
229
|
-
const REGEX_CHARS = /[\\^$.*+?()[\]{}|]/;
|
|
230
|
-
/** 一条分支条目: 含正则元字符按正则对待, 否则精确匹配 */
|
|
231
|
-
function matchBranchSpec(branch, spec) {
|
|
232
|
-
if (REGEX_CHARS.test(spec)) try {
|
|
233
|
-
return new RegExp(`^(?:${spec})$`).test(branch);
|
|
234
|
-
} catch {
|
|
235
|
-
return false;
|
|
236
|
-
}
|
|
237
|
-
return branch === spec;
|
|
238
|
-
}
|
|
239
|
-
/** 判断分支是否命中某个角色(任一分支条目) */
|
|
240
|
-
function roleMatches(branch, role) {
|
|
241
|
-
if (!branch) return false;
|
|
242
|
-
return role.branches.some((spec) => matchBranchSpec(branch, spec));
|
|
243
|
-
}
|
|
244
|
-
/** 规范化用户输入的某个角色: 数组 或 {branches:[...], update?, mergeBy?} */
|
|
245
|
-
function normalizeRole(raw, defaultUpdate, defaultMergeBy) {
|
|
246
|
-
const errors = [];
|
|
247
|
-
let arr;
|
|
248
|
-
let update = void 0;
|
|
249
|
-
let mergeBy = void 0;
|
|
250
|
-
if (Array.isArray(raw)) arr = raw;
|
|
251
|
-
else if (typeof raw === "object" && raw !== null) {
|
|
252
|
-
const o = raw;
|
|
253
|
-
arr = o.branches;
|
|
254
|
-
update = o.update;
|
|
255
|
-
mergeBy = o.mergeBy;
|
|
256
|
-
} else return {
|
|
257
|
-
role: { branches: [] },
|
|
258
|
-
errors: ["Branch role must be an array or { branches: [...] }"]
|
|
259
|
-
};
|
|
260
|
-
if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === "string" && x !== "")) errors.push("branches must be a non-empty array of strings");
|
|
261
|
-
const role = { branches: (Array.isArray(arr) ? arr : []).filter((x) => typeof x === "string" && x !== "") };
|
|
262
|
-
if (update === void 0 || update === "pr" || update === "flexible") role.update = update === void 0 ? defaultUpdate : update;
|
|
263
|
-
else errors.push("update must be \"pr\" or \"flexible\"");
|
|
264
|
-
if (mergeBy === void 0 || mergeBy === "user" || mergeBy === "anyone") role.mergeBy = mergeBy === void 0 ? defaultMergeBy : mergeBy;
|
|
265
|
-
else errors.push("mergeBy must be \"user\" or \"anyone\"");
|
|
266
|
-
return {
|
|
267
|
-
role,
|
|
268
|
-
errors
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
/** 合并默认值并校验; 任何校验错误都会导致未启用 */
|
|
272
|
-
function mergeConfig(raw) {
|
|
273
|
-
const errors = [];
|
|
274
|
-
if (typeof raw !== "object" || raw === null) return {
|
|
275
|
-
config: null,
|
|
276
|
-
errors: ["Config file must be a JSON object"]
|
|
277
|
-
};
|
|
278
|
-
const r = raw;
|
|
279
|
-
const config = {
|
|
280
|
-
...DEFAULT_CONFIG,
|
|
281
|
-
ci: { ...DEFAULT_CONFIG.ci },
|
|
282
|
-
branches: { integration: {
|
|
283
|
-
branches: [],
|
|
284
|
-
update: "pr",
|
|
285
|
-
mergeBy: "anyone"
|
|
286
|
-
} }
|
|
287
|
-
};
|
|
288
|
-
if (typeof r.enabled === "boolean") config.enabled = r.enabled;
|
|
289
|
-
if (typeof r.featurePattern === "string" && r.featurePattern !== "") config.featurePattern = r.featurePattern;
|
|
290
|
-
if (r.locale === "en" || r.locale === "zh") config.locale = r.locale;
|
|
291
|
-
else if (r.locale !== void 0) errors.push("locale must be \"en\" or \"zh\"");
|
|
292
|
-
const b = r.branches ?? {};
|
|
293
|
-
if ("integration" in b) {
|
|
294
|
-
const { role, errors: e } = normalizeRole(b.integration, "pr", "anyone");
|
|
295
|
-
config.branches.integration = role;
|
|
296
|
-
errors.push(...e);
|
|
297
|
-
} else errors.push("branches.integration is required");
|
|
298
|
-
if (b.preview !== void 0) {
|
|
299
|
-
const { role, errors: e } = normalizeRole(b.preview, "pr", "anyone");
|
|
300
|
-
config.branches.preview = role;
|
|
301
|
-
errors.push(...e);
|
|
302
|
-
}
|
|
303
|
-
if (b.production !== void 0) {
|
|
304
|
-
const { role, errors: e } = normalizeRole(b.production, "pr", "user");
|
|
305
|
-
config.branches.production = role;
|
|
306
|
-
errors.push(...e);
|
|
307
|
-
}
|
|
308
|
-
if (b.archive !== void 0) {
|
|
309
|
-
const { role, errors: e } = normalizeRole(b.archive, "pr", "user");
|
|
310
|
-
config.branches.archive = role;
|
|
311
|
-
errors.push(...e);
|
|
312
|
-
}
|
|
313
|
-
const ci = r.ci ?? {};
|
|
314
|
-
if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
|
|
315
|
-
errors.push(...validateConfig(config));
|
|
316
|
-
return {
|
|
317
|
-
config: errors.length > 0 ? null : config,
|
|
318
|
-
errors
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
/** 配置校验: 角色分支重叠 / 正则合法等 */
|
|
322
|
-
function validateConfig(config) {
|
|
323
|
-
const errors = [];
|
|
324
|
-
if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches is required");
|
|
325
|
-
try {
|
|
326
|
-
new RegExp(config.featurePattern);
|
|
327
|
-
} catch {
|
|
328
|
-
errors.push(`featurePattern is not a valid regex: ${config.featurePattern}`);
|
|
329
|
-
}
|
|
330
|
-
const allRoles = [
|
|
331
|
-
"integration",
|
|
332
|
-
"preview",
|
|
333
|
-
"production",
|
|
334
|
-
"archive"
|
|
335
|
-
];
|
|
336
|
-
for (let i = 0; i < allRoles.length; i++) {
|
|
337
|
-
const a = config.branches[allRoles[i]];
|
|
338
|
-
if (!a) continue;
|
|
339
|
-
for (let j = i + 1; j < allRoles.length; j++) {
|
|
340
|
-
const bb = config.branches[allRoles[j]];
|
|
341
|
-
if (!bb) continue;
|
|
342
|
-
if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]} and branches.${allRoles[j]} share the same entries`);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return errors;
|
|
346
|
-
}
|
|
347
|
-
/** 从项目根加载配置; 无文件 = 未启用(opt-in) */
|
|
348
|
-
async function loadConfig(repoRoot) {
|
|
349
|
-
try {
|
|
350
|
-
const text = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
|
|
351
|
-
return mergeConfig(JSON.parse(text));
|
|
352
|
-
} catch (e) {
|
|
353
|
-
if (e.code === "ENOENT") return {
|
|
354
|
-
config: null,
|
|
355
|
-
errors: []
|
|
356
|
-
};
|
|
357
|
-
return {
|
|
358
|
-
config: null,
|
|
359
|
-
errors: [`Failed to read config file: ${e.message}`]
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
//#endregion
|
|
364
447
|
//#region src/i18n.ts
|
|
365
448
|
const en = {
|
|
366
449
|
"role.integration": () => "integration branch",
|
|
@@ -372,6 +455,8 @@ const en = {
|
|
|
372
455
|
"head.unknown": () => "current branch",
|
|
373
456
|
"denyDeleteOrForce.why": (v) => `Protected branch "${v.branch}" may not be deleted or force-pushed`,
|
|
374
457
|
"denyDeleteOrForce.next": () => "Delete/force-push on a feature branch outside the protected branches; protected branches are managed by you.",
|
|
458
|
+
"refUpdateProtected.why": (v) => `Protected branch "${v.branch}" forbids direct ref updates (update-ref)`,
|
|
459
|
+
"refUpdateProtected.next": () => "Update protected branches via PR/MR; they are managed by you.",
|
|
375
460
|
"pushAll.why": () => "--all/--mirror push would include protected branches",
|
|
376
461
|
"pushAll.next": () => "Push branch by branch with an explicit refspec.",
|
|
377
462
|
"pushDetached.why": () => "Cannot determine the push target (detached HEAD?)",
|
|
@@ -393,8 +478,6 @@ const en = {
|
|
|
393
478
|
"prMergeArchive.next": () => "Let the user do the archive merge in their terminal or UI.",
|
|
394
479
|
"prMergeUnknown.why": () => "Cannot confirm the PR/MR target branch",
|
|
395
480
|
"prMergeUnknown.next": () => "Retry once gh/glab is available, or let the user handle it.",
|
|
396
|
-
"prMergeHead.why": () => "Cannot confirm the PR/MR target, and the head is not a feature branch",
|
|
397
|
-
"prMergeHead.next": () => "Confirm the platform CLI is available, or let the user handle it.",
|
|
398
481
|
"next.integration": (v) => `Integration branch (${v.branch}) is updated via PR/MR from a feature branch: push the feature first, then \`gh pr create --base ${v.branch}\` / \`glab mr create --target-branch ${v.branch}\`.`,
|
|
399
482
|
"next.preview": (v) => `Preview branch (${v.branch}) only accepts PR/MRs: open a PR/MR into it from a feature/release branch (content of ${v.base} etc. first goes into a feature release branch).`,
|
|
400
483
|
"next.production": (v) => `Production branch (${v.branch}) is PR/MR-only, and you click the merge yourself.`,
|
|
@@ -407,6 +490,7 @@ const en = {
|
|
|
407
490
|
"cli.statusTitle": (v) => `[gitflow-guard] status — ${v.repo}`,
|
|
408
491
|
"cli.statusDisabled": () => "Config: not enabled (no gitflow-guard.config.json or enabled=false)",
|
|
409
492
|
"cli.statusConfigError": (v) => ` config error: ${v.err}`,
|
|
493
|
+
"cli.statusConfigWarning": (v) => ` config warning: ${v.warn}`,
|
|
410
494
|
"cli.statusEnabled": (v) => `Config: enabled | featurePattern: ${v.pattern}`,
|
|
411
495
|
"cli.statusIntegration": (v) => `Integration: ${v.list} (update=${v.mode})`,
|
|
412
496
|
"cli.statusPreview": (v) => `Preview: ${v.list} (update=${v.mode})`,
|
|
@@ -417,16 +501,22 @@ const en = {
|
|
|
417
501
|
"cli.statusLocalBranches": () => "Local branches (by role):",
|
|
418
502
|
"cli.auditEmpty": () => " No audit entries yet",
|
|
419
503
|
"cli.checkInternalError": (v) => `[gitflow-guard] check internal error, allowed through: ${v.msg}`,
|
|
504
|
+
"cli.guardDisabledInvalidConfig": (v) => `[gitflow-guard] guard disabled: invalid config: ${v.err}`,
|
|
505
|
+
"guardStrictConfigBroken.why": () => "Guard config is invalid while strict mode is enabled",
|
|
506
|
+
"guardStrictConfigBroken.next": () => "Fix gitflow-guard.config.json (or remove \"strict\": true) before retrying.",
|
|
507
|
+
"guardStrictInternalError.why": (v) => `Guard internal error while strict mode is enabled: ${v.msg}`,
|
|
508
|
+
"guardStrictInternalError.next": () => "Fix or remove \"strict\": true in gitflow-guard.config.json.",
|
|
420
509
|
"usage.text": () => `gitflow-guard — GitFlow guard CLI
|
|
421
510
|
|
|
422
511
|
Usage:
|
|
423
|
-
gitflow-guard status [--repo <path>]
|
|
424
|
-
gitflow-guard audit [--lines <count>] [--repo <path>]
|
|
425
|
-
gitflow-guard check [--platform <auto|claude|codex|opencode|antigravity>] [--command "<cmd>"] [--repo <path>]
|
|
512
|
+
gitflow-guard status [--repo <path>] [--locale <en|zh>]
|
|
513
|
+
gitflow-guard audit [--lines <count>] [--repo <path>] [--locale <en|zh>]
|
|
514
|
+
gitflow-guard check [--platform <auto|claude|codex|opencode|antigravity>] [--command "<cmd>"] [--repo <path>] [--locale <en|zh>]
|
|
426
515
|
gitflow-guard --help
|
|
427
516
|
|
|
428
517
|
Notes:
|
|
429
518
|
status/audit are read-only; the agent can self-inspect.
|
|
519
|
+
--locale overrides the message language for this invocation (flag > project config > English).
|
|
430
520
|
check reads the hook payload on stdin (platform-specific protocol: claude/opencode exit 2,
|
|
431
521
|
codex/antigravity JSON on stdout) and is meant for pre/post hooks of AI agents.`
|
|
432
522
|
};
|
|
@@ -440,6 +530,8 @@ const zh = {
|
|
|
440
530
|
"head.unknown": () => "当前分支",
|
|
441
531
|
"denyDeleteOrForce.why": (v) => `受保护分支「${v.branch}」禁止删除或强推`,
|
|
442
532
|
"denyDeleteOrForce.next": () => "删除/强推请到受保护分支外的 feature 分支上操作; 受保护分支由用户亲手管理",
|
|
533
|
+
"refUpdateProtected.why": (v) => `受保护分支「${v.branch}」禁止直接改写 refs(update-ref)`,
|
|
534
|
+
"refUpdateProtected.next": () => "请通过 PR/MR 更新受保护分支; 受保护分支由用户亲手管理",
|
|
443
535
|
"pushAll.why": () => "--all/--mirror 推送会包含受保护分支",
|
|
444
536
|
"pushAll.next": () => "请逐分支推送并显式指定 refspec",
|
|
445
537
|
"pushDetached.why": () => "无法确定推送目标分支(可能处于 detached HEAD)",
|
|
@@ -461,8 +553,6 @@ const zh = {
|
|
|
461
553
|
"prMergeArchive.next": () => "请让用户在自己终端或 UI 完成归档合并",
|
|
462
554
|
"prMergeUnknown.why": () => "无法确认 PR/MR 的目标分支",
|
|
463
555
|
"prMergeUnknown.next": () => "请确认 gh/glab 可用后重试, 或让用户亲手处理",
|
|
464
|
-
"prMergeHead.why": () => "无法确认 PR/MR 目标, 且 head 不是 feature 分支",
|
|
465
|
-
"prMergeHead.next": () => "请确认平台 CLI 可用, 或让用户亲手处理",
|
|
466
556
|
"next.integration": (v) => `集成分支(${v.branch})由 PR/MR 合入 feature: 先推 feature 分支, 再 gh pr create --base ${v.branch} / glab mr create --target-branch ${v.branch}`,
|
|
467
557
|
"next.preview": (v) => `预览分支(${v.branch})只收 PR/MR: 从 feature/发布分支创建指向它的 PR/MR(${v.base} 等集成分支内容先进 feature 发布分支)`,
|
|
468
558
|
"next.production": (v) => `生产分支(${v.branch})只能 PR/MR, 且合并由你亲手点击`,
|
|
@@ -475,6 +565,7 @@ const zh = {
|
|
|
475
565
|
"cli.statusTitle": (v) => `[gitflow-guard] status — ${v.repo}`,
|
|
476
566
|
"cli.statusDisabled": () => "配置: 未启用(不存在 gitflow-guard.config.json 或 enabled=false)",
|
|
477
567
|
"cli.statusConfigError": (v) => ` 配置错误: ${v.err}`,
|
|
568
|
+
"cli.statusConfigWarning": (v) => ` 配置警告: ${v.warn}`,
|
|
478
569
|
"cli.statusEnabled": (v) => `配置: 已启用 | featurePattern: ${v.pattern}`,
|
|
479
570
|
"cli.statusIntegration": (v) => `集成分支: ${v.list} (update=${v.mode})`,
|
|
480
571
|
"cli.statusPreview": (v) => `预览分支: ${v.list} (update=${v.mode})`,
|
|
@@ -485,27 +576,46 @@ const zh = {
|
|
|
485
576
|
"cli.statusLocalBranches": () => "本地分支(按角色):",
|
|
486
577
|
"cli.auditEmpty": () => " 暂无审计记录",
|
|
487
578
|
"cli.checkInternalError": (v) => `[gitflow-guard] check 内部错误, 已放行: ${v.msg}`,
|
|
579
|
+
"cli.guardDisabledInvalidConfig": (v) => `[gitflow-guard] 守卫未启用: 配置无效: ${v.err}`,
|
|
580
|
+
"guardStrictConfigBroken.why": () => "守卫配置无效, 且已启用 strict 模式",
|
|
581
|
+
"guardStrictConfigBroken.next": () => "请先修复 gitflow-guard.config.json(或移除 \"strict\": true)后重试",
|
|
582
|
+
"guardStrictInternalError.why": (v) => `守卫内部错误, 且已启用 strict 模式: ${v.msg}`,
|
|
583
|
+
"guardStrictInternalError.next": () => "请修复 gitflow-guard.config.json 或移除 \"strict\": true",
|
|
488
584
|
"usage.text": () => `gitflow-guard — GitFlow 流程守卫 CLI
|
|
489
585
|
|
|
490
586
|
用法:
|
|
491
|
-
gitflow-guard status [--repo <路径>]
|
|
492
|
-
gitflow-guard audit [--lines <数量>] [--repo <路径>]
|
|
493
|
-
gitflow-guard check [--platform <auto|claude|codex|opencode|antigravity>] [--command "<cmd>"] [--repo <路径>]
|
|
587
|
+
gitflow-guard status [--repo <路径>] [--locale <en|zh>]
|
|
588
|
+
gitflow-guard audit [--lines <数量>] [--repo <路径>] [--locale <en|zh>]
|
|
589
|
+
gitflow-guard check [--platform <auto|claude|codex|opencode|antigravity>] [--command "<cmd>"] [--repo <路径>] [--locale <en|zh>]
|
|
494
590
|
gitflow-guard --help
|
|
495
591
|
|
|
496
592
|
说明:
|
|
497
593
|
status/audit 只读, agent 可自查。
|
|
594
|
+
--locale 可临时覆盖本次调用的文案语言(旗标 > 项目配置 > 英文)。
|
|
498
595
|
check 读 stdin hook payload 做门禁(平台协议: claude/opencode exit 2, codex/antigravity stdout JSON),
|
|
499
596
|
供 Claude Code / Codex / OpenCode 等 agent 的 pre/post hook 调用。`
|
|
500
597
|
};
|
|
598
|
+
/** 内置文案注册表: en 为兜底语言; 下游可经 registerLocale 追加 */
|
|
599
|
+
const dicts = /* @__PURE__ */ new Map([["en", en], ["zh", zh]]);
|
|
501
600
|
const MESSAGE_KEYS = Object.keys(en);
|
|
502
|
-
/**
|
|
503
|
-
|
|
601
|
+
/** 字典键一致性校验(与内置 en 完全一致), 失败抛英文异常(P0-2: 异常信息遵循语言规范) */
|
|
602
|
+
function assertDictKeys(name, dict) {
|
|
603
|
+
if (Object.keys(dict).length !== MESSAGE_KEYS.length || MESSAGE_KEYS.some((k) => !(k in dict))) throw new Error(`i18n: locale "${name}" dictionary keys mismatch the built-in "en" dictionary`);
|
|
604
|
+
}
|
|
605
|
+
assertDictKeys("zh", zh);
|
|
504
606
|
/**
|
|
505
|
-
*
|
|
607
|
+
* 注册一门新语言(运行时扩展点, P2-2): key 集合必须与内置 en 完全一致, 否则抛英文异常。
|
|
608
|
+
* 注册后 makeT/resolveLocale 即接受该 locale; 未注册的 locale 一律回退英文。
|
|
609
|
+
*/
|
|
610
|
+
function registerLocale(name, dict) {
|
|
611
|
+
assertDictKeys(name, dict);
|
|
612
|
+
dicts.set(name, dict);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* 生成翻译函数。未注册 locale / 未知 key 均回退英文(开发/防御性)。
|
|
506
616
|
*/
|
|
507
617
|
function makeT(locale) {
|
|
508
|
-
const dict = locale
|
|
618
|
+
const dict = dicts.get(locale) ?? en;
|
|
509
619
|
return (key, vars = {}) => {
|
|
510
620
|
const entry = dict[key] ?? en[key];
|
|
511
621
|
if (!entry) return key;
|
|
@@ -516,9 +626,177 @@ function makeT(locale) {
|
|
|
516
626
|
}
|
|
517
627
|
};
|
|
518
628
|
}
|
|
519
|
-
/** 解析配置里的 locale 值:
|
|
629
|
+
/** 解析配置里的 locale 值: 白名单语义 = 已注册语言原样通过, 其余(含未定义)一律英文(P2-2 后白名单随注册表扩展) */
|
|
520
630
|
function resolveLocale(v) {
|
|
521
|
-
return v === "
|
|
631
|
+
return typeof v === "string" && dicts.has(v) ? v : "en";
|
|
632
|
+
}
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/config.ts
|
|
635
|
+
const CONFIG_FILE = "gitflow-guard.config.json";
|
|
636
|
+
/** 默认配置(分支角色必须由项目显式配置, 无默认) */
|
|
637
|
+
const DEFAULT_CONFIG = {
|
|
638
|
+
enabled: false,
|
|
639
|
+
featurePattern: "feature/[\\w-]+",
|
|
640
|
+
ci: { enabled: true },
|
|
641
|
+
locale: "en"
|
|
642
|
+
};
|
|
643
|
+
const REGEX_CHARS = /[\\^$.*+?()[\]{}|]/;
|
|
644
|
+
/** 一条分支条目: 含正则元字符按正则对待, 否则精确匹配 */
|
|
645
|
+
function matchBranchSpec(branch, spec) {
|
|
646
|
+
if (REGEX_CHARS.test(spec)) try {
|
|
647
|
+
return new RegExp(`^(?:${spec})$`).test(branch);
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
return branch === spec;
|
|
652
|
+
}
|
|
653
|
+
/** 判断分支是否命中某个角色(任一分支条目) */
|
|
654
|
+
function roleMatches(branch, role) {
|
|
655
|
+
if (!branch) return false;
|
|
656
|
+
return role.branches.some((spec) => matchBranchSpec(branch, spec));
|
|
657
|
+
}
|
|
658
|
+
/** 规范化用户输入的某个角色: 数组 或 {branches:[...], update?, mergeBy?} */
|
|
659
|
+
function normalizeRole(raw, defaultUpdate, defaultMergeBy) {
|
|
660
|
+
const errors = [];
|
|
661
|
+
let arr;
|
|
662
|
+
let update = void 0;
|
|
663
|
+
let mergeBy = void 0;
|
|
664
|
+
if (Array.isArray(raw)) arr = raw;
|
|
665
|
+
else if (typeof raw === "object" && raw !== null) {
|
|
666
|
+
const o = raw;
|
|
667
|
+
arr = o.branches;
|
|
668
|
+
update = o.update;
|
|
669
|
+
mergeBy = o.mergeBy;
|
|
670
|
+
} else return {
|
|
671
|
+
role: { branches: [] },
|
|
672
|
+
errors: ["Branch role must be an array or { branches: [...] }"]
|
|
673
|
+
};
|
|
674
|
+
if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === "string" && x !== "")) errors.push("branches must be a non-empty array of strings");
|
|
675
|
+
const role = { branches: (Array.isArray(arr) ? arr : []).filter((x) => typeof x === "string" && x !== "") };
|
|
676
|
+
if (update === void 0 || update === "pr" || update === "flexible") role.update = update === void 0 ? defaultUpdate : update;
|
|
677
|
+
else errors.push("update must be \"pr\" or \"flexible\"");
|
|
678
|
+
if (mergeBy === void 0 || mergeBy === "user" || mergeBy === "anyone") role.mergeBy = mergeBy === void 0 ? defaultMergeBy : mergeBy;
|
|
679
|
+
else errors.push("mergeBy must be \"user\" or \"anyone\"");
|
|
680
|
+
return {
|
|
681
|
+
role,
|
|
682
|
+
errors
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/** 合并默认值并校验; 任何校验错误都会导致未启用(strict 位仍从原文提取, 供 fail-closed 判定) */
|
|
686
|
+
function mergeConfig(raw) {
|
|
687
|
+
const errors = [];
|
|
688
|
+
const warnings = [];
|
|
689
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
690
|
+
config: null,
|
|
691
|
+
errors: ["Config file must be a JSON object"],
|
|
692
|
+
warnings
|
|
693
|
+
};
|
|
694
|
+
const r = raw;
|
|
695
|
+
const strict = r.strict === true ? true : r.strict === false ? false : void 0;
|
|
696
|
+
if (r.strict !== void 0 && typeof r.strict !== "boolean") errors.push("strict must be a boolean");
|
|
697
|
+
const config = {
|
|
698
|
+
...DEFAULT_CONFIG,
|
|
699
|
+
ci: { ...DEFAULT_CONFIG.ci },
|
|
700
|
+
branches: { integration: {
|
|
701
|
+
branches: [],
|
|
702
|
+
update: "pr",
|
|
703
|
+
mergeBy: "anyone"
|
|
704
|
+
} }
|
|
705
|
+
};
|
|
706
|
+
if (typeof r.enabled === "boolean") config.enabled = r.enabled;
|
|
707
|
+
if (typeof r.featurePattern === "string" && r.featurePattern !== "") config.featurePattern = r.featurePattern;
|
|
708
|
+
if (typeof r.locale === "string" && r.locale !== "") {
|
|
709
|
+
config.locale = r.locale;
|
|
710
|
+
if (resolveLocale(r.locale) !== r.locale) warnings.push(`unknown locale "${r.locale}"; falling back to en`);
|
|
711
|
+
} else if (r.locale !== void 0) errors.push("locale must be a string");
|
|
712
|
+
const b = r.branches ?? {};
|
|
713
|
+
if ("integration" in b) {
|
|
714
|
+
const { role, errors: e } = normalizeRole(b.integration, "pr", "anyone");
|
|
715
|
+
config.branches.integration = role;
|
|
716
|
+
errors.push(...e);
|
|
717
|
+
} else errors.push("branches.integration is required");
|
|
718
|
+
if (b.preview !== void 0) {
|
|
719
|
+
const { role, errors: e } = normalizeRole(b.preview, "pr", "anyone");
|
|
720
|
+
config.branches.preview = role;
|
|
721
|
+
errors.push(...e);
|
|
722
|
+
}
|
|
723
|
+
if (b.production !== void 0) {
|
|
724
|
+
const { role, errors: e } = normalizeRole(b.production, "pr", "user");
|
|
725
|
+
config.branches.production = role;
|
|
726
|
+
errors.push(...e);
|
|
727
|
+
}
|
|
728
|
+
if (b.archive !== void 0) {
|
|
729
|
+
const { role, errors: e } = normalizeRole(b.archive, "pr", "user");
|
|
730
|
+
config.branches.archive = role;
|
|
731
|
+
errors.push(...e);
|
|
732
|
+
}
|
|
733
|
+
const ci = r.ci ?? {};
|
|
734
|
+
if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
|
|
735
|
+
if (strict !== void 0) config.strict = strict;
|
|
736
|
+
errors.push(...validateConfig(config));
|
|
737
|
+
return {
|
|
738
|
+
config: errors.length > 0 ? null : config,
|
|
739
|
+
errors,
|
|
740
|
+
warnings,
|
|
741
|
+
...strict !== void 0 ? { strict } : {}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
/** 配置校验: 角色分支重叠 / 正则合法等 */
|
|
745
|
+
function validateConfig(config) {
|
|
746
|
+
const errors = [];
|
|
747
|
+
if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches is required");
|
|
748
|
+
try {
|
|
749
|
+
new RegExp(config.featurePattern);
|
|
750
|
+
} catch {
|
|
751
|
+
errors.push(`featurePattern is not a valid regex: ${config.featurePattern}`);
|
|
752
|
+
}
|
|
753
|
+
const allRoles = [
|
|
754
|
+
"integration",
|
|
755
|
+
"preview",
|
|
756
|
+
"production",
|
|
757
|
+
"archive"
|
|
758
|
+
];
|
|
759
|
+
for (let i = 0; i < allRoles.length; i++) {
|
|
760
|
+
const a = config.branches[allRoles[i]];
|
|
761
|
+
if (!a) continue;
|
|
762
|
+
for (let j = i + 1; j < allRoles.length; j++) {
|
|
763
|
+
const bb = config.branches[allRoles[j]];
|
|
764
|
+
if (!bb) continue;
|
|
765
|
+
if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]} and branches.${allRoles[j]} share the same entries`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return errors;
|
|
769
|
+
}
|
|
770
|
+
/** 从项目根加载配置; 无文件 = 未启用(opt-in) */
|
|
771
|
+
async function loadConfig(repoRoot) {
|
|
772
|
+
let text;
|
|
773
|
+
try {
|
|
774
|
+
text = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
|
|
775
|
+
} catch (e) {
|
|
776
|
+
if (e.code === "ENOENT") return {
|
|
777
|
+
config: null,
|
|
778
|
+
errors: [],
|
|
779
|
+
warnings: []
|
|
780
|
+
};
|
|
781
|
+
return {
|
|
782
|
+
config: null,
|
|
783
|
+
errors: [`Failed to read config file: ${e.message}`],
|
|
784
|
+
warnings: []
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
let raw;
|
|
788
|
+
try {
|
|
789
|
+
raw = JSON.parse(text);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
const strict = /"strict"\s*:\s*true/.test(text) || void 0;
|
|
792
|
+
return {
|
|
793
|
+
config: null,
|
|
794
|
+
errors: [`Failed to read config file: ${e.message}`],
|
|
795
|
+
warnings: [],
|
|
796
|
+
...strict ? { strict } : {}
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
return mergeConfig(raw);
|
|
522
800
|
}
|
|
523
801
|
//#endregion
|
|
524
802
|
//#region src/gate.ts
|
|
@@ -564,6 +842,7 @@ function decide(classified, facts, config, t = defaultT) {
|
|
|
564
842
|
case "pr-create": return decidePrCreate(classified, facts, config, t);
|
|
565
843
|
case "pr-merge": return decidePrMerge(classified, facts, config, t);
|
|
566
844
|
case "branch-delete": return isProtected(roleOfBranch(classified.branch, config)) ? deny(t("denyDeleteOrForce.why", { branch: classified.branch ?? "" }), t("denyDeleteOrForce.next")) : { kind: "allow" };
|
|
845
|
+
case "ref-update": return classified.branch != null && isProtected(roleOfBranch(classified.branch, config)) ? deny(t("refUpdateProtected.why", { branch: classified.branch }), t("refUpdateProtected.next")) : { kind: "allow" };
|
|
567
846
|
case "guard-cli": return { kind: "allow" };
|
|
568
847
|
case "checkout": return { kind: "allow" };
|
|
569
848
|
default: return { kind: "allow" };
|
|
@@ -611,18 +890,14 @@ function decidePrCreate(c, facts, config, t) {
|
|
|
611
890
|
}
|
|
612
891
|
function decidePrMerge(c, facts, config, t) {
|
|
613
892
|
const resolved = facts.resolvePrTarget?.(c.pr) ?? null;
|
|
614
|
-
|
|
615
|
-
const
|
|
893
|
+
if (!resolved) return deny(t("prMergeUnknown.why"), t("prMergeUnknown.next"));
|
|
894
|
+
const role = resolved.role;
|
|
616
895
|
if (role === "production") {
|
|
617
896
|
if (config.branches.production?.mergeBy === "user") return deny(t("prMergeProduction.why"), t("prMergeProduction.next"));
|
|
618
897
|
return { kind: "allow" };
|
|
619
898
|
}
|
|
620
899
|
if (role === "archive") return deny(t("prMergeArchive.why"), t("prMergeArchive.next"));
|
|
621
|
-
|
|
622
|
-
if (role === "feature" || role === "other") return { kind: "allow" };
|
|
623
|
-
if (head == null) return deny(t("prMergeUnknown.why"), t("prMergeUnknown.next"));
|
|
624
|
-
if (roleOfBranch(head, config) === "feature") return { kind: "allow" };
|
|
625
|
-
return deny(t("prMergeHead.why"), t("prMergeHead.next"));
|
|
900
|
+
return { kind: "allow" };
|
|
626
901
|
}
|
|
627
902
|
/** 受保护分支被拦后的下一步引导 */
|
|
628
903
|
function branchNext(branch, config, t) {
|
|
@@ -780,7 +1055,7 @@ async function evaluateCommand(command, opts) {
|
|
|
780
1055
|
segmentCount: 0,
|
|
781
1056
|
locale: "en"
|
|
782
1057
|
};
|
|
783
|
-
const locale = resolveLocale(config.locale);
|
|
1058
|
+
const locale = opts.locale != null ? resolveLocale(opts.locale) : resolveLocale(config.locale);
|
|
784
1059
|
const t = makeT(locale);
|
|
785
1060
|
const branch = opts.currentBranch ?? await currentBranch(runner, opts.repoRoot);
|
|
786
1061
|
const env = {
|
|
@@ -883,10 +1158,10 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
883
1158
|
};
|
|
884
1159
|
return next();
|
|
885
1160
|
} catch (e) {
|
|
886
|
-
ctx.logger?.warn?.(`gitflow-guard:
|
|
1161
|
+
ctx.logger?.warn?.(`gitflow-guard: gate internal error, allowed through: ${e.message}`);
|
|
887
1162
|
return next();
|
|
888
1163
|
}
|
|
889
1164
|
});
|
|
890
1165
|
}
|
|
891
1166
|
//#endregion
|
|
892
|
-
export { name as a, findRepoRoot as c,
|
|
1167
|
+
export { name as a, findRepoRoot as c, roleMatches as d, makeT as f, classify as h, formatDeny as i, gitRunner as l, resolveLocale as m, apply as n, stateDir as o, registerLocale as p, evaluateCommand as r, currentBranch as s, appendAudit as t, loadConfig as u };
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-gitflow-guard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex / OpenCode / Antigravity) — integration/preview/production/archive, each with its own update rules",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
7
8
|
"main": "lib/index.mjs",
|
|
8
9
|
"types": "lib/index.d.mts",
|
|
9
10
|
"exports": {
|
|
@@ -42,6 +43,13 @@
|
|
|
42
43
|
"type": "git",
|
|
43
44
|
"url": "https://github.com/FeatureAgents/AgentsGitFlowController.git"
|
|
44
45
|
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/FeatureAgents/AgentsGitFlowController/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/FeatureAgents/AgentsGitFlowController#readme",
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22"
|
|
52
|
+
},
|
|
45
53
|
"dsh": {
|
|
46
54
|
"bundle": {
|
|
47
55
|
"patch": "./patch.yml"
|