@feng0531/dsh-code-review 0.21.0 → 0.22.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 +10 -0
- package/README.md +3 -1
- package/index.mjs +2 -2
- package/package.json +1 -1
- package/src/git-history.mjs +21 -0
- package/src/review.mjs +2 -0
- package/src/service.mjs +2 -2
- package/src/snapshot.mjs +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
- 工具注册Git指定提交/区间,预览及JSON/Markdown报告展示解析端点和精确树语义,原快照哈希/Agent再验证保留。
|
|
6
|
+
- 历史上下文/规则明确拒绝避免混入工作树,SVN明确不支持。真实服务零发送预览/脏树隔离执行、全量251及宿主离线通过。
|
|
7
|
+
|
|
8
|
+
## 0.21.1
|
|
9
|
+
|
|
10
|
+
- 新增低层不可变Git commit/range对象快照,不读工作树;根提交空侧,merge要求明确区间,端点树差异、重命名按增删表达。
|
|
11
|
+
- 严格revision参数、安全blob模式、文件/树/输出/总大小界限及取消。全量251通过;注册/上下文历史接入待完成。
|
|
12
|
+
|
|
3
13
|
## 0.21.0
|
|
4
14
|
|
|
5
15
|
- 注册入口开启唯一精确锚纠正,跨文件限定原组候选主ID,原候选/状态JSON保留及Markdown提示。
|
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
## 当前状态
|
|
6
6
|
|
|
7
|
-
当前代码版本为 0.
|
|
7
|
+
当前代码版本为 0.22.0
|
|
8
|
+
|
|
9
|
+
Git历史审查预览传 `commit`(相对唯一父提交,根提交空旧侧;merge需区间),或 `baseRevision` 与 `targetRevision`(精确端点树差异,非merge-base);两类互斥。预览/报告显示解析对象ID,执行复捕获hash验证引用变化。历史上下文/规则暂明确拒绝,不混工作树,SVN不冒充此语义。
|
|
8
10
|
|
|
9
11
|
工具对错误行锚可进行一次同文件同侧唯一完整原文纠正,须重叠原引用精确编辑;原候选/状态保留JSON,Markdown提示。纠正后仍校验窗口、跨文件编辑及生成读取证据,不增调用、不猜歧义或跨文件迁移,不证明缺陷或因果。
|
|
10
12
|
|
package/index.mjs
CHANGED
|
@@ -11,8 +11,8 @@ export function apply(ctx, config = {}) {
|
|
|
11
11
|
render: (_args, value) => [{ type: 'text', text: value.markdown || value.json }],
|
|
12
12
|
};
|
|
13
13
|
ctx.tools.register({
|
|
14
|
-
name: 'code_review_preview', description: 'Preview Git/SVN working-copy review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Preview each repository separately. Show the preview before execution.',
|
|
15
|
-
parameters: { type: 'object', additionalProperties: false, properties: { repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
|
|
14
|
+
name: 'code_review_preview', description: 'Preview Git/SVN working-copy or Git commit/range review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Preview each repository separately. Show the preview before execution.',
|
|
15
|
+
parameters: { type: 'object', additionalProperties: false, properties: { commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Historical context/rules rejected.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
|
|
16
16
|
output,
|
|
17
17
|
async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
|
|
18
18
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {realpath} from 'node:fs/promises';
|
|
2
|
+
import {checked} from './process.mjs';
|
|
3
|
+
import {hash,decode,relativePath} from './content.mjs';
|
|
4
|
+
const utf8=b=>new TextDecoder('utf-8',{fatal:true}).decode(b);
|
|
5
|
+
// Object-only capture: no checkout, worktree reads, external diff or fetch.
|
|
6
|
+
export async function captureGitHistory(cwd, options={}) {
|
|
7
|
+
const {commit,baseRevision,targetRevision,selectedPaths,signal,maxFiles=200,maxFileBytes=256*1024,maxSnapshotBytes=4*1024*1024}=options;
|
|
8
|
+
if(!Number.isSafeInteger(maxFiles)||maxFiles<1||maxFiles>200||!Number.isSafeInteger(maxFileBytes)||maxFileBytes<1||maxFileBytes>256*1024||!Number.isSafeInteger(maxSnapshotBytes)||maxSnapshotBytes<1||maxSnapshotBytes>4*1024*1024)throw new Error('Invalid historical capture limits');
|
|
9
|
+
if(selectedPaths!==undefined&&(!Array.isArray(selectedPaths)||selectedPaths.some(p=>relativePath(p)!==p)))throw new Error('Invalid selected paths');
|
|
10
|
+
const git=(root,args,limit=1024*1024)=>checked('git',['--no-optional-locks','-c','core.fsmonitor=false','-c','core.untrackedCache=false',...args],{...options,cwd:root,maxBytes:limit});
|
|
11
|
+
const root=await realpath(utf8(await git(cwd,['rev-parse','--show-toplevel'])).trim());
|
|
12
|
+
const resolve=async ref=>{if(typeof ref!=='string'||!ref||ref.length>256||ref.startsWith('-')||/[\s\0]/.test(ref))throw new Error('Invalid revision');const oid=utf8(await git(root,['rev-parse','--verify','--end-of-options',ref+'^{commit}'])).trim();if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw new Error('Invalid commit object');return oid;};
|
|
13
|
+
let base,target;
|
|
14
|
+
if(commit!==undefined){if(baseRevision!==undefined||targetRevision!==undefined)throw new Error('Commit and range are mutually exclusive');target=await resolve(commit);const ancestry=utf8(await git(root,['rev-list','--parents','-n','1',target])).trim().split(' ');if(ancestry.length>2)throw new Error('Merge commit requires explicit revision range');base=ancestry[1]??null;}
|
|
15
|
+
else {base=await resolve(baseRevision);target=await resolve(targetRevision);}
|
|
16
|
+
const tree=async oid=>{const map=new Map();if(!oid)return map;const records=utf8(await git(root,['ls-tree','-rz','--full-tree',oid])).split('\0').filter(Boolean);if(records.length>10000)throw new Error('Historical tree entry limit exceeded');for(const r of records){const m=/^(\d{6}) (blob|commit) ([a-f0-9]+)\t([\s\S]+)$/.exec(r);if(!m)throw new Error('Invalid historical tree');relativePath(m[4]);map.set(m[4],{mode:m[1],type:m[2],oid:m[3]});}return map;};
|
|
17
|
+
const left=await tree(base),right=await tree(target),names=[...new Set([...left.keys(),...right.keys()])].filter(p=>JSON.stringify(left.get(p))!==JSON.stringify(right.get(p))).sort();
|
|
18
|
+
if(names.length>maxFiles)throw new Error('Historical change count limit exceeded');if(selectedPaths?.some(p=>!names.includes(p)))throw new Error('Selected path is not a historical change');
|
|
19
|
+
const files=[];for(const name of names){signal?.throwIfAborted();const l=left.get(name),r=right.get(name),item={id:hash(name),path:name,rawStatus:!l?'A':!r?'D':'M',eligibility:'reviewable',properties:[]};files.push(item);if(selectedPaths&&!selectedPaths.includes(name)){item.eligibility='excluded';item.reason='Not explicitly selected';continue;}try{for(const e of [l,r].filter(Boolean))if(e.type!=='blob'||!['100644','100755'].includes(e.mode))throw new Error('Historical link or submodule excluded');const read=async e=>decode(e?await git(root,['cat-file','blob',e.oid],maxFileBytes):Buffer.alloc(0));item.left=await read(l);item.right=await read(r);item.rightExists=!!r;if(l&&r&&item.left.hash===item.right.hash){item.eligibility='excluded';item.reason='Mode-only historical change not analyzed';}}catch(error){signal?.throwIfAborted();item.eligibility='blocked';item.reason=error.message;delete item.left;delete item.right;}if(Buffer.byteLength(JSON.stringify(files))>maxSnapshotBytes)throw new Error('Historical snapshot limit exceeded');}
|
|
20
|
+
const snapshot={schemaVersion:1,vcs:'git',root,baseline:base,history:{mode:commit!==undefined?'commit':'range',base,target,semantics:'exact-tree-endpoints; renames represented as add/delete'},files};const serialized=JSON.stringify(snapshot);if(Buffer.byteLength(serialized)>maxSnapshotBytes)throw new Error('Historical snapshot limit exceeded');return {...snapshot,id:hash(serialized)};
|
|
21
|
+
}
|
package/src/review.mjs
CHANGED
|
@@ -93,6 +93,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
93
93
|
report.autoContext = structuredClone(input.autoContext);
|
|
94
94
|
if (input.autoContext.truncated || input.autoContext.candidates.some(c => c.status === 'blocked')) report.limitations.push({ text: '自动上下文未完全捕获:存在截断或阻塞,仅实际捕获路径可用于证据。' });
|
|
95
95
|
}
|
|
96
|
+
if (input.history) report.history = structuredClone(input.history);
|
|
96
97
|
report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
|
|
97
98
|
if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files, inferFileRelations(input.files));
|
|
98
99
|
report.interactions = [];
|
|
@@ -251,6 +252,7 @@ const safe = text => Array.from(String(text ?? '')).map(c => {
|
|
|
251
252
|
export function markdownReport(report) {
|
|
252
253
|
const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, '', '## 覆盖情况'];
|
|
253
254
|
for (const file of report.files) for (const correction of file.anchorCorrections ?? []) output.push('- 定位纠正:' + safe(correction.originalCandidate.fileId) + ' · ' + safe(correction.status) + ' · ' + safe(correction.reason) + ';原完整候选保留JSON,仅定位不证明因果');
|
|
255
|
+
if (report.history) output.push('- Git历史审查:' + safe(report.history.mode) + ' · ' + safe(report.history.base ?? 'empty') + ' → ' + safe(report.history.target) + ';精确端点树差异,重命名按增删,不含工作树');
|
|
254
256
|
if (report.businessGrouping) output.push('- 业务分组:' + safe(report.businessGrouping.status) + (report.businessGrouping.reason ? ' — ' + safe(report.businessGrouping.reason) : '') + ';仅调度假设,不证明依赖或覆盖');
|
|
255
257
|
for (const file of report.files) for (const plan of file.riskPlans ?? []) output.push('- 风险计划:' + safe(file.path) + ' · ' + safe(plan.status) + ' · ' + plan.risks.length + '项待证假设;不表示缺陷成立或覆盖完成');
|
|
256
258
|
for (const file of report.files) output.push('- ' + safe(file.path) + ':' + file.status + (file.reason ? ' — ' + safe(file.reason) : ''));
|
package/src/service.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
26
26
|
exec = bind(exec);
|
|
27
27
|
exec.signal.throwIfAborted();
|
|
28
28
|
if (busy) throw new Error('Review service busy');
|
|
29
|
-
if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext'].includes(k))) throw new Error('Unknown preview argument');
|
|
29
|
+
if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision'].includes(k))) throw new Error('Unknown preview argument');
|
|
30
30
|
if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
|
|
31
31
|
for (const key of ['selectedPaths', 'contextPaths', 'rulePaths']) if (args[key] !== undefined && (!Array.isArray(args[key]) || args[key].length > (key === 'rulePaths' ? 4 : 200) || args[key].some(p => typeof p !== 'string'))) throw new Error('Invalid path list');
|
|
32
32
|
const current = owner(exec);
|
|
@@ -42,7 +42,7 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
42
42
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
43
43
|
const previewId = randomUUID();
|
|
44
44
|
previews.set(previewId, { ...current, target, options, snapshotId: snapshot.id, expires: now() + ttlMs });
|
|
45
|
-
return { previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (enableRiskPlanning ? '执行先制定风险假设计划,再审查并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文及所选项目规则全文。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
|
|
45
|
+
return { previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason })), contextPaths: snapshot.context.map(c => c.path), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (enableRiskPlanning ? '执行先制定风险假设计划,再审查并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文及所选项目规则全文。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
|
|
46
46
|
} finally { busy = false; }
|
|
47
47
|
},
|
|
48
48
|
async execute(args, exec) {
|
package/src/snapshot.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { gitContextIndex, svnContextIndex } from './tracked-context.mjs';
|
|
|
2
2
|
import { contextCandidates } from './context-candidates.mjs';
|
|
3
3
|
import { lstat, realpath } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { captureGitHistory } from './git-history.mjs';
|
|
5
6
|
import { captureGit } from './git.mjs';
|
|
6
7
|
import { captureProjectRules } from './project-rules.mjs';
|
|
7
8
|
import { captureSvn } from './svn.mjs';
|
|
@@ -29,6 +30,12 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
29
30
|
for (const name of contextPaths) relativePath(name);
|
|
30
31
|
if (options.autoContext !== undefined && typeof options.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
|
|
31
32
|
const type = await detectVcs(cwd);
|
|
33
|
+
if (['commit','baseRevision','targetRevision'].some(k => options[k] !== undefined)) {
|
|
34
|
+
if (type !== 'git') throw new Error('Git revision review is unsupported on SVN');
|
|
35
|
+
if (!Array.isArray(rulePaths) || contextPaths.length || rulePaths.length || options.autoContext === true) throw new Error('Historical context/rules not yet supported; do not mix working-tree sources');
|
|
36
|
+
const result = { ...await captureGitHistory(cwd, options), context: [] }; delete result.id;
|
|
37
|
+
return { ...result, id: hash(JSON.stringify(result)) };
|
|
38
|
+
}
|
|
32
39
|
const capture = type === 'git' ? captureGit : captureSvn;
|
|
33
40
|
const snapshot = await capture(cwd, options);
|
|
34
41
|
if (!Array.isArray(rulePaths) || rulePaths.some(name => contextPaths.includes(name))) throw new Error('Rule paths must be separate from context paths');
|