@rowansenne/workflow-run 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +67 -31
- package/bin/lib/core.mjs +3 -12
- package/bin/lib/docnexus.mjs +86 -42
- package/bin/lib/docsrepo.mjs +2 -2
- package/bin/lib/doctor.mjs +10 -11
- package/bin/lib/install.mjs +1 -1
- package/bin/lib/uninstall.mjs +5 -6
- package/bin/seed-templates.mjs +8 -7
- package/bin/workflow-run.mjs +3 -1
- package/create-release-md-doc/SKILL.md.tpl +2 -2
- package/create-tech-md-doc/SKILL.md.tpl +2 -2
- package/dev/SKILL.md.tpl +10 -7
- package/dev/references/delivery-template.md +1 -1
- package/dev/references/plan-template.md +1 -1
- package/package.json +1 -1
- package/project-review/SKILL.md.tpl +8 -8
- package/project-review/references/diff-strategy.md +4 -2
- package/project-review/references/report.md.tpl +2 -2
- package/workflow/SKILL.md.tpl +1 -1
- package/workflow/references/docnexus.md +10 -14
- package/workflow/references/profile.md +1 -0
- package/workflow/scripts/docnexus-layout.mjs +24 -0
- package/workflow/scripts/docnexus.mjs +7 -8
- package/workflow/scripts/knowledge.mjs +7 -19
- package/workflow/scripts/locate.mjs +113 -0
- package/workflow/scripts/resolve.mjs +13 -18
- package/workflow/scripts/review-scope.mjs +359 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 给审查发现定位行号:每条发现带上问题所在的原代码片段(code),由脚本在文件里找到它、算出起止行,不靠模型自己数行号。
|
|
3
|
+
// project-review 第 6 步复核前使用。片段先精确匹配,再按忽略空白匹配;单行片段还可以是某一行的一部分。
|
|
4
|
+
// 有多处匹配时优先本次改动的行,再取离模型给的 line 最近的一处,并标 ambiguous。找不到的标 anchored: false,要回去重读代码改片段。
|
|
5
|
+
// 范围参数和 review-scope.mjs 一样,决定读哪个版本的文件、哪些行算本次改动(in_change)。
|
|
6
|
+
//
|
|
7
|
+
// 用法(在项目根执行;--root 可指定项目根):
|
|
8
|
+
// node .agents/skills/workflow/scripts/locate.mjs <发现.json> [--diff <base>...<head> | --base <分支>]
|
|
9
|
+
// <发现.json> 是数组:[{ "file": "src/A.java", "code": "原代码片段(可多行)", "line": 42(可选,模型估的行号), …其他字段原样保留 }]
|
|
10
|
+
// 输出 JSON:{ ok, ref, anchored, total, findings: [{ …原字段, start_line, end_line, anchored, match, in_change, ambiguous? }] }。
|
|
11
|
+
// 退出码:0 正常(有没定位到的也是 0,看 anchored);1 参数错误、文件读不到或 git 失败
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { loadDiff, readAt } from './review-scope.mjs';
|
|
17
|
+
|
|
18
|
+
const loose = (s) => s.replace(/\s+/g, ' ').trim();
|
|
19
|
+
|
|
20
|
+
// 片段 → 行数组:去掉首尾空行、行尾空白;整段都是从 diff 里复制来的(每行以 + 或空格开头且至少一行是 +)时去掉这一列
|
|
21
|
+
export function snippetLines(code) {
|
|
22
|
+
let lines = String(code ?? '').replace(/\r/g, '').split('\n').map((l) => l.replace(/\s+$/, ''));
|
|
23
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
24
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
25
|
+
const body = lines.filter((l) => l.length);
|
|
26
|
+
if (body.length && body.every((l) => /^[+ ]/.test(l)) && body.some((l) => l.startsWith('+'))) lines = lines.map((l) => l.slice(1));
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 在 fileLines 里找 snip 的所有位置(1 起的行号)
|
|
31
|
+
export function findAll(fileLines, snip) {
|
|
32
|
+
const n = snip.length;
|
|
33
|
+
if (!n) return { match: 'none', hits: [] };
|
|
34
|
+
const scan = (eq) => {
|
|
35
|
+
const hits = [];
|
|
36
|
+
for (let i = 0; i + n <= fileLines.length; i++) {
|
|
37
|
+
let ok = true;
|
|
38
|
+
for (let j = 0; j < n && ok; j++) ok = eq(fileLines[i + j], snip[j]);
|
|
39
|
+
if (ok) hits.push(i + 1);
|
|
40
|
+
}
|
|
41
|
+
return hits;
|
|
42
|
+
};
|
|
43
|
+
let hits = scan((a, b) => a.replace(/\s+$/, '') === b);
|
|
44
|
+
if (hits.length) return { match: 'exact', hits };
|
|
45
|
+
hits = scan((a, b) => loose(a) === loose(b));
|
|
46
|
+
if (hits.length) return { match: 'loose', hits };
|
|
47
|
+
// 单行片段:允许是某一行的一部分(至少 8 个非空白字符,避免太短的片段到处都能匹配)
|
|
48
|
+
if (n === 1 && loose(snip[0]).replace(/ /g, '').length >= 8) {
|
|
49
|
+
hits = fileLines.map((l, i) => (loose(l).includes(loose(snip[0])) ? i + 1 : 0)).filter(Boolean);
|
|
50
|
+
if (hits.length) return { match: 'partial', hits };
|
|
51
|
+
}
|
|
52
|
+
return { match: 'none', hits: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const overlaps = (s, e, hunks) => hunks.some(([a, b]) => s <= b && e >= a);
|
|
56
|
+
|
|
57
|
+
export function locateOne(finding, { read, hunksOf }) {
|
|
58
|
+
const out = { ...finding };
|
|
59
|
+
const file = String(finding.file ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
60
|
+
const text = file ? read(file) : null;
|
|
61
|
+
if (text == null) return { ...out, anchored: false, match: 'none', reason: file ? '文件读不到(路径写错或不在这个版本里)' : '缺少 file' };
|
|
62
|
+
const snip = snippetLines(finding.code);
|
|
63
|
+
if (!snip.length) return { ...out, anchored: false, match: 'none', reason: '缺少 code(问题所在的原代码片段)' };
|
|
64
|
+
const fileLines = text.replace(/\r/g, '').split('\n');
|
|
65
|
+
const { match, hits } = findAll(fileLines, snip);
|
|
66
|
+
if (!hits.length) return { ...out, anchored: false, match: 'none', reason: '片段在文件里找不到:回去重读代码,照原样复制片段' };
|
|
67
|
+
const hunks = hunksOf(file);
|
|
68
|
+
const span = snip.length - 1;
|
|
69
|
+
const inChange = hits.filter((s) => overlaps(s, s + span, hunks));
|
|
70
|
+
const pool = inChange.length ? inChange : hits;
|
|
71
|
+
const guess = Number(finding.line); // 模型可能写成字符串 "42"
|
|
72
|
+
const near = Number.isInteger(guess) && guess > 0 ? [...pool].sort((a, b) => Math.abs(a - guess) - Math.abs(b - guess)) : pool;
|
|
73
|
+
const start = near[0];
|
|
74
|
+
return {
|
|
75
|
+
...out, start_line: start, end_line: start + span, anchored: true, match,
|
|
76
|
+
in_change: overlaps(start, start + span, hunks),
|
|
77
|
+
...(pool.length > 1 ? { ambiguous: true, candidates: pool.slice(0, 10) } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function locate(root, findings, opts = {}) {
|
|
82
|
+
const d = loadDiff(root, opts);
|
|
83
|
+
if (!d.ok) return d;
|
|
84
|
+
const byPath = new Map(d.files.map((f) => [f.path, f.hunks]));
|
|
85
|
+
const cache = new Map();
|
|
86
|
+
const read = (f) => { if (!cache.has(f)) cache.set(f, readAt(root, d.ref, f)); return cache.get(f); };
|
|
87
|
+
const res = findings.map((f) => locateOne(f, { read, hunksOf: (p) => byPath.get(p) ?? [] }));
|
|
88
|
+
return { ok: true, ref: d.ref, range: d.range, total: res.length, anchored: res.filter((x) => x.anchored).length, findings: res };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main(argv) {
|
|
92
|
+
let root = process.cwd();
|
|
93
|
+
const o = {};
|
|
94
|
+
const rest = [];
|
|
95
|
+
for (let i = 0; i < argv.length; i++) {
|
|
96
|
+
if (argv[i] === '--root') root = path.resolve(argv[++i] ?? '.');
|
|
97
|
+
else if (argv[i] === '--diff') o.diff = argv[++i] ?? null;
|
|
98
|
+
else if (argv[i] === '--base') o.base = argv[++i] ?? null;
|
|
99
|
+
else if (argv[i] === '--exclude') i++; // 和 review-scope.mjs 用同一组参数时带过来的,定位用不到
|
|
100
|
+
else rest.push(argv[i]);
|
|
101
|
+
}
|
|
102
|
+
if (rest.length !== 1) { console.error('用法: locate.mjs <发现.json> [--diff <base>...<head> | --base <分支>]'); return 1; }
|
|
103
|
+
let findings;
|
|
104
|
+
try { findings = JSON.parse(fs.readFileSync(path.resolve(root, rest[0]), 'utf8')); } catch (e) { console.error(`读不了 ${rest[0]}:${e.message}`); return 1; }
|
|
105
|
+
if (!Array.isArray(findings)) { console.error(`${rest[0]} 要是数组:[{ "file": …, "code": … }]`); return 1; }
|
|
106
|
+
const r = locate(root, findings, o);
|
|
107
|
+
console.log(JSON.stringify(r, null, 2));
|
|
108
|
+
return r.ok ? 0 : 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { main };
|
|
112
|
+
const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
113
|
+
if (invoked) process.exit(main(process.argv.slice(2)));
|
|
@@ -18,6 +18,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { inferProfile } from './infer-profile.mjs';
|
|
20
20
|
import { layout, HOME, TEMPLATES_DIR } from './requirement.mjs';
|
|
21
|
+
import { docnexusWorkspace, docnexusConnected, docnexusBinPath } from './docnexus-layout.mjs';
|
|
22
|
+
export { docnexusRoot, docnexusWorkspace, docnexusConnected } from './docnexus-layout.mjs';
|
|
21
23
|
|
|
22
24
|
const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
25
|
const PROFILE_FIELDS = ['main_branch', 'build', 'test', 'test_single', 'lint', 'config_center', 'deploy.platform', 'deploy.regions', 'job_annotation'];
|
|
@@ -37,19 +39,13 @@ function skillMetas() {
|
|
|
37
39
|
|
|
38
40
|
export const skillMeta = (name) => skillMetas().find((m) => m.name === name);
|
|
39
41
|
|
|
40
|
-
//
|
|
41
|
-
export const docnexusRoot = (root) => path.resolve(root, HOME);
|
|
42
|
-
|
|
43
|
-
// DocNexus CLI 的位置(相对项目根):workflow-run/docnexus/node_modules/.bin;没有返回 null。bin/lib/core.mjs 的 docnexusBin 与此一致
|
|
44
|
-
export const DOCNEXUS_BIN_DIRS = [['docnexus', 'node_modules', '.bin']];
|
|
42
|
+
// CLI 位于项目根的 .docnexus/,对 Agent 返回项目相对路径。
|
|
45
43
|
export function docnexusBin(root) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
for (const dir of DOCNEXUS_BIN_DIRS) if (fs.existsSync(path.join(dn, ...dir, name))) return path.relative(path.resolve(root), path.join(dn, ...dir, name));
|
|
49
|
-
return null;
|
|
44
|
+
const bin = docnexusBinPath(root);
|
|
45
|
+
return bin ? path.relative(path.resolve(root), bin) : null;
|
|
50
46
|
}
|
|
51
|
-
// 给 agent
|
|
52
|
-
const cmdPath = (p) => { const s = p.split(path.sep).join('/'); return s.startsWith('.') ? s : `./${s}`; };
|
|
47
|
+
// 给 agent 的命令写法(相对项目根):./.docnexus/...
|
|
48
|
+
const cmdPath = (p) => { const s = p.split(path.sep).join('/'); return s.startsWith('./') || s.startsWith('../') ? s : `./${s}`; };
|
|
53
49
|
|
|
54
50
|
// CodeGraph 代码图谱:本项目建过索引(.codegraph/)且 codegraph 命令可用。索引是否建立由用户决定,skill 不自行 codegraph init
|
|
55
51
|
export function codegraphStatus(root) {
|
|
@@ -62,16 +58,12 @@ export function codegraphStatus(root) {
|
|
|
62
58
|
};
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
export function docnexusConnected(root) {
|
|
66
|
-
return fs.existsSync(path.join(docnexusRoot(root), 'docnexus', 'project.json')) && docnexusBin(root) !== null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
61
|
export function resolveTemplate(root, skill, id) {
|
|
70
62
|
const meta = skillMeta(skill);
|
|
71
63
|
if (!meta) return { ok: false, error: 'unknown', message: `skill「${skill}」没有安装(${rel(root, SKILLS_DIR)} 下找不到 ${skill}/skill.json);重跑安装并在 --skills 里带上它` };
|
|
72
64
|
const t = meta.templates.find((x) => x.id === id);
|
|
73
65
|
if (!t) return { ok: false, error: 'unknown', message: `skill「${skill}」没有登记模板「${id}」(见 ${skill}/skill.json 的 templates)` };
|
|
74
|
-
const lib = path.join(
|
|
66
|
+
const lib = path.join(docnexusWorkspace(root), 'library', 'templates', skill, id);
|
|
75
67
|
const project = path.join(root, TEMPLATES_DIR, skill, id);
|
|
76
68
|
// 没有 src 的模板(技术方案、上线方案)不随 skills 提供,缺失时按「创建模板」由用户给样例生成
|
|
77
69
|
const builtin = t.src ? path.join(SKILLS_DIR, skill, t.src.replace(/\.tpl$/, '')) : null;
|
|
@@ -185,8 +177,11 @@ export function status(root) {
|
|
|
185
177
|
ok: dn,
|
|
186
178
|
// 调用 DocNexus CLI 时用这个路径(在项目根执行)
|
|
187
179
|
bin: dn ? cmdPath(docnexusBin(root)) : null,
|
|
188
|
-
//
|
|
189
|
-
root:
|
|
180
|
+
// CLI、草稿及返回的文档路径均相对业务项目根。
|
|
181
|
+
root: '.',
|
|
182
|
+
internal: '.docnexus',
|
|
183
|
+
workspace: `${HOME}/docnexus`,
|
|
184
|
+
output_dir: HOME,
|
|
190
185
|
// 运行 DocNexus 命令的写法(在项目根执行,任何终端都一样):后面接原命令的参数;node 脚本前加 --node
|
|
191
186
|
run: `node ${(rel(root, path.join(SKILLS_DIR, 'workflow', 'scripts', 'docnexus.mjs')))}`,
|
|
192
187
|
missing_templates: dn ? templates.filter((t) => !t.ok && !t.on_demand).map((t) => t.expected) : [],
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 审查范围:由脚本决定审哪些文件、跳过哪些(带原因)、每个文件看审查清单的哪几节,不让模型自己挑着看。
|
|
3
|
+
// project-review 第 1 步与 dev 自审(F7)使用;报告里每个文件都要有交代(已审 / 跳过的原因 / 没审完)。
|
|
4
|
+
// - 范围:分支对比 `--diff <base>...<head>`(读 <head>),或 `--base <分支>`:相对合并基点的已提交 + 未提交 + 未跟踪改动(读工作区)
|
|
5
|
+
// - 跳过:删除、二进制、敏感文件(不读内容)、依赖目录、生成文件与锁文件、文档、本工具的产物;画像 review.include 可以把后三类加回来
|
|
6
|
+
// - 风险 P0 / P1 / P2 与分批:规则同 project-review/references/diff-strategy.md
|
|
7
|
+
// - 清单路由:按文件类型和改动内容给出审查清单的小节号;画像 review.rules 里按路径匹配的项目规则一起给出
|
|
8
|
+
// `rules <文件…>` 打印这些文件要看的清单小节原文和项目规则:清单用 resolveTemplate 取 project-review 的 checklist.md(团队在 DocNexus 改过的版本也适用)。
|
|
9
|
+
// 只有 project-review 和 dev 自审会调用 rules,project-review 没装时它报错退出(resolveTemplate 提示没有安装)。
|
|
10
|
+
//
|
|
11
|
+
// 用法(在项目根执行;--root 可指定项目根):
|
|
12
|
+
// node .agents/skills/workflow/scripts/review-scope.mjs --diff <base>...<head> [--exclude a,b]
|
|
13
|
+
// node .agents/skills/workflow/scripts/review-scope.mjs [--base <分支>] [--exclude a,b] # 缺省基线取画像 main_branch(再缺省 main)
|
|
14
|
+
// node .agents/skills/workflow/scripts/review-scope.mjs rules <文件> [<文件>…]
|
|
15
|
+
// 输出 JSON(rules 输出 Markdown)。退出码:0 正常;1 参数错误或 git 失败
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { readProfile, resolveTemplate } from './resolve.mjs';
|
|
22
|
+
|
|
23
|
+
const git = (root, args) => spawnSync('git', ['-c', 'core.quotepath=false', ...args], { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
24
|
+
const toPosix = (p) => p.split(path.sep).join('/');
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- glob
|
|
27
|
+
// ** 跨目录、* 不跨 /、? 单个字符、{a,b} 展开(括号里同样支持这些写法);不含 / 的模式匹配文件名(同 .gitignore)。不区分大小写。
|
|
28
|
+
// 没有闭合的 { 按普通字符处理(画像里写错也不会卡住)
|
|
29
|
+
function globSource(glob) {
|
|
30
|
+
let re = '';
|
|
31
|
+
for (let i = 0; i < glob.length; i++) {
|
|
32
|
+
const c = glob[i];
|
|
33
|
+
const end = c === '{' ? glob.indexOf('}', i) : -1;
|
|
34
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
35
|
+
const slash = glob[i + 2] === '/';
|
|
36
|
+
re += slash ? '(?:.*/)?' : '.*';
|
|
37
|
+
i += slash ? 2 : 1;
|
|
38
|
+
} else if (c === '*') re += '[^/]*';
|
|
39
|
+
else if (c === '?') re += '[^/]';
|
|
40
|
+
else if (end > i) { re += `(?:${glob.slice(i + 1, end).split(',').map(globSource).join('|')})`; i = end; }
|
|
41
|
+
else re += c.replace(/[.+^$(){}|[\]\\]/g, '\\$&');
|
|
42
|
+
}
|
|
43
|
+
return re;
|
|
44
|
+
}
|
|
45
|
+
export const globToRegExp = (glob) => new RegExp(`^${globSource(glob)}$`, 'i');
|
|
46
|
+
export const matchGlob = (p, glob) => globToRegExp(glob).test(glob.includes('/') ? p : p.split('/').pop());
|
|
47
|
+
const matchAny = (p, globs = []) => globs.find((g) => matchGlob(p, g)) ?? null;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- 跳过规则
|
|
50
|
+
const SECRET = ['**/.env', '**/.env.*', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx', '**/*.jks', '**/*.keystore', '**/id_rsa*', '**/id_dsa*', '**/id_ecdsa*', '**/id_ed25519*', '**/.ssh/**', '**/.netrc', '**/.npmrc', '**/.pypirc', '**/database-query/config.json', '**/wiki-config.json'];
|
|
51
|
+
const SECRET_OK = ['**/.env.example', '**/.env.sample', '**/.env.template'];
|
|
52
|
+
const DEPENDENCY = ['**/node_modules/**', '**/vendor/**', '**/bower_components/**', '**/target/**', '**/.gradle/**', '**/__pycache__/**', '**/.venv/**', '**/site-packages/**', '**/.next/**', '**/.nuxt/**'];
|
|
53
|
+
const GENERATED = ['**/package-lock.json', '**/yarn.lock', '**/pnpm-lock.yaml', '**/go.sum', '**/Cargo.lock', '**/poetry.lock', '**/composer.lock', '**/Gemfile.lock', '**/*.min.js', '**/*.min.css', '**/*.map', '**/*.pb.go', '**/*.gen.go', '**/*.generated.*', '**/generated/**', '**/__snapshots__/**', '**/*.snap'];
|
|
54
|
+
const DOCS = ['**/*.md', '**/*.md.tpl', '**/*.txt', '**/*.rst', '**/*.adoc', '**/LICENSE', '**/CHANGELOG*'];
|
|
55
|
+
const TOOLING = ['workflow-run/**', '.agents/skills/**', '.claude/skills/**', '.qwen/skills/**', '.codebuddy/skills/**', '.codegraph/**'];
|
|
56
|
+
const NOTE = {
|
|
57
|
+
deleted: '文件已删除,不审内容;影响面里确认没有调用方还在用它',
|
|
58
|
+
binary: '二进制文件',
|
|
59
|
+
secret: '敏感文件,不读内容;它出现在改动里就提醒用户确认是否误提交了凭据',
|
|
60
|
+
dependency: '依赖目录',
|
|
61
|
+
generated: '生成文件或锁文件;依赖变化看 pom.xml / package.json / go.mod 等清单文件',
|
|
62
|
+
docs: '文档',
|
|
63
|
+
tooling: 'workflow-run 的产物或已安装的 skills',
|
|
64
|
+
user_exclude: '用户指定不审(--exclude 或画像 review.exclude)',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function skipReason(file, { include = [], exclude = [] } = {}) {
|
|
68
|
+
if (file.status === 'D') return 'deleted';
|
|
69
|
+
if (file.binary) return 'binary';
|
|
70
|
+
const p = file.path;
|
|
71
|
+
if (matchAny(p, SECRET) && !matchAny(p, SECRET_OK)) return 'secret';
|
|
72
|
+
if (matchAny(p, exclude)) return 'user_exclude';
|
|
73
|
+
if (matchAny(p, include)) return null;
|
|
74
|
+
if (matchAny(p, DEPENDENCY)) return 'dependency';
|
|
75
|
+
if (matchAny(p, GENERATED)) return 'generated';
|
|
76
|
+
if (matchAny(p, DOCS)) return 'docs';
|
|
77
|
+
if (matchAny(p, TOOLING)) return 'tooling';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- 分类、风险、清单小节
|
|
82
|
+
const LANG = [
|
|
83
|
+
[/\.(java|kt|kts|scala|groovy)$/i, 'Java / Kotlin'], [/\.go$/i, 'Go'], [/\.(ts|tsx|js|jsx|mjs|cjs|vue)$/i, 'TypeScript / JavaScript'],
|
|
84
|
+
[/\.(py|pyi)$/i, 'Python'], [/\.sql$/i, 'SQL / MyBatis'],
|
|
85
|
+
];
|
|
86
|
+
const TEST = /(^|\/)(src\/test\/|tests?\/|__tests__\/)|(Test|Tests|Spec)\.(java|kt|scala|groovy|swift)$|[._-](test|spec)\.[jt]sx?$|_test\.(go|py|rs)$|(^|\/)test_[^/]*\.py$/i;
|
|
87
|
+
const CONFIG = /(^|\/)(application|bootstrap)[^/]*\.(ya?ml|properties)$|\.(properties|conf|ini|toml)$|(^|\/)config\/[^/]*\.(ya?ml|json)$|(^|\/)\.env\.(example|sample|template)$/i;
|
|
88
|
+
const BUILD = /(^|\/)(pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|package\.json|go\.mod|requirements[^/]*\.txt|pyproject\.toml|Cargo\.toml|composer\.json|Gemfile)$/i;
|
|
89
|
+
// 迁移目录只认 SQL 和 Flyway / Liquibase 的变更集格式;db/ 下的代码文件照常按语言判断
|
|
90
|
+
const SQLFILE = /\.sql$|(^|\/)(db|migration|migrations|flyway|liquibase)\/(?:[^/]+\/)*[^/]+\.(xml|ya?ml|json)$/i;
|
|
91
|
+
const MAPPER = /mapper[^/]*\.xml$|dao[^/]*\.xml$/i;
|
|
92
|
+
const DATA_OBJ = /(^|\/)(entity|entities|dto|vo|po|bo|domain|model|models|pojo|converter|convert|assembler)(\/|$)|(Entity|DTO|Dto|VO|Vo|PO|BO|Converter|Assembler|Mapping)\.(java|kt|ts|go|py)$/;
|
|
93
|
+
const ENTRY = /(^|\/)(controller|controllers|api|apis|endpoint|resource|resources|handler|handlers|router|routes)\/|Controller\.(java|kt)$/i;
|
|
94
|
+
const P1_PATH = /(^|\/)(service|services|impl|biz|manager|job|jobs|task|tasks|listener|consumer|consumers|handler|handlers|domain|entity|dto|vo|converter|repository|dao)\//i;
|
|
95
|
+
|
|
96
|
+
// 只看增删的行。MyBatis 的 ${} 不放这里(JS 模板字符串也是 ${}),Mapper XML 和 .sql 按文件类型已经是 P0
|
|
97
|
+
const KW = {
|
|
98
|
+
p0: /@Transactional|synchronized|\.(?:try)?lock\(|ReentrantLock|@Version|BigDecimal|amount|balance|@KafkaListener|KafkaTemplate|RabbitTemplate|RocketMQ|@PreAuthorize|@Secured|ALTER TABLE|CREATE TABLE|DELETE FROM|UPDATE\s+\S+\s+SET/i,
|
|
99
|
+
concurrency: /synchronized|\.(?:try)?lock\(|ReentrantLock|Atomic[A-Z]|ConcurrentHashMap|volatile|@Async|ThreadPool|Executor|CompletableFuture|go func|sync\.(Mutex|RWMutex|WaitGroup)|@Version|setnx|redisson/i,
|
|
100
|
+
mq: /@KafkaListener|@RabbitListener|@RocketMQMessageListener|@JmsListener|KafkaTemplate|RabbitTemplate|RocketMQTemplate|\.send\(|callback|notify|@Transactional/i,
|
|
101
|
+
sql: /\b(SELECT|INSERT|UPDATE|DELETE)\b.+\b(FROM|INTO|SET|WHERE)\b|@Select|@Update|@Insert|@Delete|createQuery|nativeQuery|jdbcTemplate/i,
|
|
102
|
+
config: /@Value\s*\(|@ConfigurationProperties|@NacosValue|@ApolloConfig|getProperty\(|os\.Getenv|process\.env/,
|
|
103
|
+
security: /@PreAuthorize|@Secured|permission|auth|token|password|secret|role/i,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// 赋值、参数、对象值等位置后面的 /…/flags 换成占位,避免模式里的关键字被当成改动内容
|
|
107
|
+
// 行注释(// 前要有空白或在行首,避免把 http:// 当成注释)和以 * 开头的块注释行
|
|
108
|
+
export const stripComments = (text) => text.split('\n').map((l) => (/^\s*(\*|\/\*)/.test(l) ? '' : l.replace(/(^|\s)\/\/.*$/, '$1'))).join('\n');
|
|
109
|
+
export const stripRegexLiterals = (text) => text.replace(/([=(:,[!&|?{};]\s*|return\s+)\/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n[])+\/[a-z]*/g, '$1/re/');
|
|
110
|
+
|
|
111
|
+
export function classify(file, profileRules = []) {
|
|
112
|
+
const p = file.path;
|
|
113
|
+
// 注释不算;JS / TS 里的正则字面量也不算(规则脚本自己写着匹配关键字的模式,不是业务代码)
|
|
114
|
+
let changed = file.changed_text ?? '';
|
|
115
|
+
if (/\.(java|kt|kts|scala|groovy|go|m?[jt]sx?|cjs|vue)$/i.test(p)) changed = stripComments(changed);
|
|
116
|
+
if (/\.(m?[jt]sx?|cjs|vue)$/i.test(p)) changed = stripRegexLiterals(changed);
|
|
117
|
+
const hit = (k) => KW[k].test(changed);
|
|
118
|
+
const lang = LANG.find(([re]) => re.test(p))?.[1] ?? (MAPPER.test(p) ? 'SQL / MyBatis' : null);
|
|
119
|
+
// 构建文件先于配置判断:pyproject.toml / Cargo.toml 也是 .toml
|
|
120
|
+
const kind = TEST.test(p) ? 'test' : MAPPER.test(p) || SQLFILE.test(p) ? 'sql' : BUILD.test(p) ? 'build' : CONFIG.test(p) ? 'config' : lang ? 'code' : 'other';
|
|
121
|
+
const s = new Set();
|
|
122
|
+
if (kind === 'test') { s.add(1); s.add(13); }
|
|
123
|
+
else if (kind === 'sql') { [1, 6, 7, 12].forEach((n) => s.add(n)); }
|
|
124
|
+
else if (kind === 'config') { [6, 10].forEach((n) => s.add(n)); }
|
|
125
|
+
else if (kind === 'build') { s.add(6); }
|
|
126
|
+
else { [1, 2, 5, 6, 7].forEach((n) => s.add(n)); }
|
|
127
|
+
if (kind === 'code' || kind === 'other') {
|
|
128
|
+
if (DATA_OBJ.test(p)) s.add(3);
|
|
129
|
+
if (hit('concurrency')) s.add(4);
|
|
130
|
+
if (hit('mq')) s.add(11);
|
|
131
|
+
if (hit('sql')) s.add(12);
|
|
132
|
+
if (hit('config')) s.add(10);
|
|
133
|
+
}
|
|
134
|
+
if (lang && kind !== 'config' && kind !== 'build') s.add(14);
|
|
135
|
+
const risk = kind === 'test' ? 'P2'
|
|
136
|
+
: kind === 'sql' || kind === 'config' || ENTRY.test(p) || (kind === 'code' && hit('p0')) ? 'P0'
|
|
137
|
+
: P1_PATH.test(p) || DATA_OBJ.test(p) || kind === 'build' ? 'P1' : 'P2';
|
|
138
|
+
const project_rules = profileRules.filter((r) => r?.path && r?.rule && matchGlob(p, r.path)).map((r) => ({ path: r.path, rule: r.rule }));
|
|
139
|
+
return { kind, lang, risk, sections: [...s].sort((a, b) => a - b), ...(project_rules.length ? { project_rules } : {}) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------- diff
|
|
143
|
+
// git diff -U0 的输出 → [{ path, old_path?, status, binary, added, deleted, hunks: [[start, end]](新文件里新增 / 改动的行), changed_text }]
|
|
144
|
+
export function parseDiff(text) {
|
|
145
|
+
const files = [];
|
|
146
|
+
let f = null;
|
|
147
|
+
let newLine = 0;
|
|
148
|
+
const strip = (s) => s.replace(/^"|"$/g, '').replace(/^[ab]\//, '');
|
|
149
|
+
for (const line of text.split('\n')) {
|
|
150
|
+
if (line.startsWith('diff --git ')) {
|
|
151
|
+
const m = /^diff --git (?:"?a\/(.+?)"?) (?:"?b\/(.+?)"?)$/.exec(line);
|
|
152
|
+
f = { path: m?.[2] ?? '', old: m?.[1] ?? '', status: 'M', binary: false, added: 0, deleted: 0, hunks: [], changed: [], inHunk: false };
|
|
153
|
+
files.push(f);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!f) continue;
|
|
157
|
+
// 进入 hunk 之后以 +/- 开头的都是内容(被删的 SQL 注释「-- x」在 diff 里就是「--- x」)
|
|
158
|
+
if (f.inHunk) {
|
|
159
|
+
if (line.startsWith('@@')) { /* 下面统一处理 */ }
|
|
160
|
+
else if (line.startsWith('+')) { f.added++; f.changed.push(line.slice(1)); continue; }
|
|
161
|
+
else if (line.startsWith('-')) { f.deleted++; f.changed.push(line.slice(1)); continue; }
|
|
162
|
+
else continue;
|
|
163
|
+
}
|
|
164
|
+
if (line.startsWith('new file mode')) f.status = 'A';
|
|
165
|
+
else if (line.startsWith('deleted file mode')) f.status = 'D';
|
|
166
|
+
else if (line.startsWith('rename from ')) { f.old = line.slice(12); f.status = 'R'; }
|
|
167
|
+
else if (line.startsWith('rename to ')) f.path = line.slice(10);
|
|
168
|
+
else if (line.startsWith('Binary files ')) f.binary = true;
|
|
169
|
+
else if (line.startsWith('--- ')) { if (line !== '--- /dev/null') f.old = strip(line.slice(4)); }
|
|
170
|
+
else if (line.startsWith('+++ ')) { if (line !== '+++ /dev/null') f.path = strip(line.slice(4)); }
|
|
171
|
+
else if (line.startsWith('@@')) {
|
|
172
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
173
|
+
newLine = Number(m?.[1] ?? 0);
|
|
174
|
+
const count = m?.[2] === undefined ? 1 : Number(m[2]);
|
|
175
|
+
if (count > 0) f.hunks.push([newLine, newLine + count - 1]);
|
|
176
|
+
f.inHunk = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return files.map(({ old, changed, inHunk, ...x }) => ({ ...x, ...(x.status === 'R' && old !== x.path ? { old_path: old } : {}), changed_text: changed.join('\n') }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 范围 → { ok, mode, range, ref('WORKTREE' 或读文件用的提交), files }
|
|
183
|
+
export function loadDiff(root, { diff = null, base = null } = {}) {
|
|
184
|
+
if (diff) {
|
|
185
|
+
if (!/\.{2,3}/.test(diff)) return { ok: false, error: '--diff 写成 <base>...<head>' };
|
|
186
|
+
const head = diff.split(/\.{2,3}/)[1] || 'HEAD';
|
|
187
|
+
const r = git(root, ['diff', '-U0', '-M', '--no-color', '--no-ext-diff', diff]);
|
|
188
|
+
if (r.status !== 0) return { ok: false, error: r.stderr.trim() || `git diff ${diff} 失败` };
|
|
189
|
+
return { ok: true, mode: 'diff', range: diff, ref: head, files: parseDiff(r.stdout) };
|
|
190
|
+
}
|
|
191
|
+
const b = base || readProfile(root).profile?.main_branch || 'main';
|
|
192
|
+
const mb = git(root, ['merge-base', b, 'HEAD']);
|
|
193
|
+
if (mb.status !== 0) return { ok: false, error: mb.stderr.trim() || `找不到 ${b} 与 HEAD 的合并基点` };
|
|
194
|
+
const from = mb.stdout.trim();
|
|
195
|
+
const r = git(root, ['diff', '-U0', '-M', '--no-color', '--no-ext-diff', from]);
|
|
196
|
+
if (r.status !== 0) return { ok: false, error: r.stderr.trim() || `git diff ${b} 失败` };
|
|
197
|
+
const files = parseDiff(r.stdout);
|
|
198
|
+
// 未跟踪的新文件:git diff 看不到,按整文件新增处理
|
|
199
|
+
for (const p of git(root, ['ls-files', '--others', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean)) {
|
|
200
|
+
const abs = path.join(root, p);
|
|
201
|
+
let buf;
|
|
202
|
+
try { buf = fs.readFileSync(abs); } catch { continue; }
|
|
203
|
+
const binary = buf.subarray(0, 8000).includes(0);
|
|
204
|
+
const text = binary ? '' : buf.toString('utf8');
|
|
205
|
+
const n = binary || !text ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
206
|
+
files.push({ path: toPosix(p), status: 'A', untracked: true, binary, added: n, deleted: 0, hunks: n ? [[1, n]] : [], changed_text: text });
|
|
207
|
+
}
|
|
208
|
+
return { ok: true, mode: 'base', range: `${b}...(含未提交与未跟踪)`, base: b, ref: 'WORKTREE', files };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 文件在 ref 上的内容(WORKTREE 读磁盘);读不到返回 null
|
|
212
|
+
export function readAt(root, ref, file) {
|
|
213
|
+
if (ref === 'WORKTREE') { try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; } }
|
|
214
|
+
const r = git(root, ['show', `${ref}:${file}`]);
|
|
215
|
+
return r.status === 0 ? r.stdout : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------- 分批(同 diff-strategy.md)
|
|
219
|
+
const FULL_FILES = 30, FULL_LINES = 1500, BATCH_FILES = 15, BATCH_LINES = 800, TOO_MANY = 500;
|
|
220
|
+
export function plan(review) {
|
|
221
|
+
const lines = review.reduce((a, f) => a + f.added + f.deleted, 0);
|
|
222
|
+
if (review.length > TOO_MANY) return { size: '过大', batches: [], hint: `排除后还有 ${review.length} 个文件:停下,请用户按模块或提交区间缩小范围` };
|
|
223
|
+
const order = { P0: 0, P1: 1, P2: 2 };
|
|
224
|
+
const sorted = [...review].sort((a, b) => order[a.risk] - order[b.risk] || a.path.localeCompare(b.path));
|
|
225
|
+
if (review.length <= FULL_FILES && lines <= FULL_LINES) return { size: '全量精读', batches: [sorted.map((f) => f.path)] };
|
|
226
|
+
const batches = [];
|
|
227
|
+
let cur = [], n = 0;
|
|
228
|
+
for (const f of sorted) {
|
|
229
|
+
const l = f.added + f.deleted;
|
|
230
|
+
if (cur.length && (cur.length >= BATCH_FILES || n + l > BATCH_LINES || cur.at(-1).risk !== f.risk)) { batches.push(cur); cur = []; n = 0; }
|
|
231
|
+
cur.push(f); n += l;
|
|
232
|
+
}
|
|
233
|
+
if (cur.length) batches.push(cur);
|
|
234
|
+
return { size: '分批精读', batches: batches.map((b) => b.map((f) => f.path)), hint: '按批次顺序精读(P0 → P1 → P2),P0 不能跳过;没读完的批次写进报告「未覆盖与待确认」' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function reviewConfig(root) {
|
|
238
|
+
const r = readProfile(root).profile?.review ?? {};
|
|
239
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()) : []);
|
|
240
|
+
return { include: list(r.include), exclude: list(r.exclude), rules: Array.isArray(r.rules) ? r.rules : [] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function scope(root, opts = {}) {
|
|
244
|
+
const d = loadDiff(root, opts);
|
|
245
|
+
if (!d.ok) return d;
|
|
246
|
+
const cfg = opts.config ?? reviewConfig(root);
|
|
247
|
+
const exclude = [...cfg.exclude, ...(opts.exclude ?? [])];
|
|
248
|
+
const review = [], skipped = [];
|
|
249
|
+
for (const f of d.files) {
|
|
250
|
+
const reason = skipReason(f, { include: cfg.include, exclude });
|
|
251
|
+
const base = { path: f.path, ...(f.old_path ? { old_path: f.old_path } : {}), status: f.status, ...(f.untracked ? { untracked: true } : {}), added: f.added, deleted: f.deleted };
|
|
252
|
+
if (reason) { skipped.push({ ...base, reason, note: NOTE[reason] }); continue; }
|
|
253
|
+
review.push({ ...base, ...classify(f, cfg.rules), ...(f.added + f.deleted > BATCH_LINES ? { large: true } : {}) });
|
|
254
|
+
}
|
|
255
|
+
const p = plan(review);
|
|
256
|
+
const count = (xs, k) => xs.reduce((m, x) => ((m[x[k]] = (m[x[k]] ?? 0) + 1), m), {});
|
|
257
|
+
return {
|
|
258
|
+
ok: true, mode: d.mode, range: d.range, ref: d.ref,
|
|
259
|
+
summary: {
|
|
260
|
+
files: d.files.length, review: review.length, skipped: skipped.length,
|
|
261
|
+
lines: review.reduce((a, f) => a + f.added + f.deleted, 0), risk: count(review, 'risk'), skipped_by_reason: count(skipped, 'reason'), size: p.size,
|
|
262
|
+
},
|
|
263
|
+
overall_sections: review.length ? [9, 13] : [],
|
|
264
|
+
review, skipped, batches: p.batches,
|
|
265
|
+
...(p.hint ? { hint: p.hint } : {}),
|
|
266
|
+
next: '每个文件按 sections 过清单(rules <文件…> 打印对应小节原文和项目规则);整个改动再过一次 overall_sections(9 影响面、13 测试)。报告「未覆盖与待确认」按 reason 汇总 skipped,没读完的文件逐个列出',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------- rules:打印清单小节
|
|
271
|
+
export function checklistSections(text) {
|
|
272
|
+
const out = {};
|
|
273
|
+
const parts = text.replace(/\r/g, '').split(/^(?=## )/m);
|
|
274
|
+
for (const part of parts) {
|
|
275
|
+
const m = /^## (\d+)[.、.]/.exec(part);
|
|
276
|
+
if (m) out[Number(m[1])] = part.trimEnd();
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 语言专项(14)只取对应语言那一条
|
|
282
|
+
function languageBullet(section, lang) {
|
|
283
|
+
if (!section || !lang) return null;
|
|
284
|
+
const head = section.split('\n')[0];
|
|
285
|
+
const bullet = section.split('\n').find((l) => l.includes(`**${lang}**`));
|
|
286
|
+
return bullet ? `${head}\n${bullet}` : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function readChecklist(root) {
|
|
290
|
+
const t = resolveTemplate(root, 'project-review', 'checklist.md');
|
|
291
|
+
if (!t.ok) return { ok: false, error: t.hint ?? t.message ?? '取不到审查清单' };
|
|
292
|
+
const abs = path.resolve(root, t.path);
|
|
293
|
+
for (const f of [abs, `${abs}.tpl`]) { try { return { ok: true, path: t.path, text: fs.readFileSync(f, 'utf8') }; } catch { /* 下一个 */ } }
|
|
294
|
+
return { ok: false, error: `读不到审查清单 ${t.path}` };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 文件在本次范围里时沿用 scope 的分类(按改动内容判断的小节一致);不在范围里的按整个文件内容判断
|
|
298
|
+
export function rulesFor(root, files, { config = reviewConfig(root), checklist = readChecklist(root), inScope = [] } = {}) {
|
|
299
|
+
if (!checklist.ok) return checklist;
|
|
300
|
+
const secs = checklistSections(checklist.text);
|
|
301
|
+
const parsed = Object.keys(secs).length > 0;
|
|
302
|
+
const known = new Map(inScope.map((x) => [x.path, x]));
|
|
303
|
+
const per = files.map((p) => {
|
|
304
|
+
const key = toPosix(p).replace(/^\.\//, '');
|
|
305
|
+
if (known.has(key)) {
|
|
306
|
+
const { project_rules, ...x } = known.get(key);
|
|
307
|
+
const pr = config.rules.filter((r) => r?.path && r?.rule && matchGlob(key, r.path)).map((r) => ({ path: r.path, rule: r.rule }));
|
|
308
|
+
return { ...x, ...(pr.length ? { project_rules: pr } : {}) };
|
|
309
|
+
}
|
|
310
|
+
const f = { path: key, changed_text: (() => { try { return fs.readFileSync(path.join(root, key), 'utf8'); } catch { return ''; } })() };
|
|
311
|
+
return { path: f.path, ...classify(f, config.rules) };
|
|
312
|
+
});
|
|
313
|
+
const nums = [...new Set(per.flatMap((x) => x.sections))].sort((a, b) => a - b);
|
|
314
|
+
const langs = [...new Set(per.map((x) => x.lang).filter(Boolean))];
|
|
315
|
+
const L = [`# 审查要点:${per.map((x) => x.path).join('、')}`, '', `清单:${checklist.path}`, ''];
|
|
316
|
+
for (const x of per) L.push(`- ${x.path}:${x.kind},风险 ${x.risk},清单 ${x.sections.join(' / ')}`);
|
|
317
|
+
L.push('');
|
|
318
|
+
if (!parsed) L.push('(清单没有「## <编号>.」格式的小节,按整份清单检查)', '', checklist.text.trim());
|
|
319
|
+
for (const n of nums) {
|
|
320
|
+
if (!secs[n]) { L.push(`(清单里没有第 ${n} 节)`, ''); continue; }
|
|
321
|
+
if (n === 14) { for (const lang of langs) { const b = languageBullet(secs[14], lang); if (b) L.push(b, ''); } continue; }
|
|
322
|
+
L.push(secs[n], '');
|
|
323
|
+
}
|
|
324
|
+
const pr = per.flatMap((x) => (x.project_rules ?? []).map((r) => ({ ...r, file: x.path })));
|
|
325
|
+
if (pr.length) {
|
|
326
|
+
L.push('## 项目规则(workflow/profile.json 的 review.rules,和上面的清单一起检查)', '');
|
|
327
|
+
for (const r of pr) L.push(`- \`${r.file}\`(匹配 \`${r.path}\`):${r.rule}`);
|
|
328
|
+
}
|
|
329
|
+
return { ok: true, text: L.join('\n').trimEnd() + '\n' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function main(argv) {
|
|
333
|
+
let root = process.cwd();
|
|
334
|
+
const o = { exclude: [] };
|
|
335
|
+
const rest = [];
|
|
336
|
+
for (let i = 0; i < argv.length; i++) {
|
|
337
|
+
if (argv[i] === '--root') root = path.resolve(argv[++i] ?? '.');
|
|
338
|
+
else if (argv[i] === '--diff') o.diff = argv[++i] ?? null;
|
|
339
|
+
else if (argv[i] === '--base') o.base = argv[++i] ?? null;
|
|
340
|
+
else if (argv[i] === '--exclude') o.exclude.push(...(argv[++i] ?? '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
341
|
+
else rest.push(argv[i]);
|
|
342
|
+
}
|
|
343
|
+
if (rest[0] === 'rules') {
|
|
344
|
+
if (rest.length < 2) { console.error('用法: review-scope.mjs rules <文件> [<文件>…]'); return 1; }
|
|
345
|
+
const sc = scope(root, o);
|
|
346
|
+
const r = rulesFor(root, rest.slice(1), { inScope: sc.ok ? sc.review : [] });
|
|
347
|
+
if (!r.ok) { console.error(r.error); return 1; }
|
|
348
|
+
process.stdout.write(r.text);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
if (rest.length) { console.error('用法: review-scope.mjs [--diff <base>...<head> | --base <分支>] [--exclude a,b] | rules <文件…>'); return 1; }
|
|
352
|
+
const r = scope(root, o);
|
|
353
|
+
console.log(JSON.stringify(r, null, 2));
|
|
354
|
+
return r.ok ? 0 : 1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export { main };
|
|
358
|
+
const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
359
|
+
if (invoked) process.exit(main(process.argv.slice(2)));
|