@feng0531/dsh-code-review 0.7.0 → 0.8.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 +7 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/context-relations.mjs +16 -0
- package/src/file-relations.mjs +7 -0
- package/src/import-call-sites.mjs +28 -0
- package/src/import-relations.mjs +46 -0
- package/src/primary-input.mjs +8 -1
- package/src/review.mjs +2 -2
- package/src/service.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- 批准快照内JS/TS单行相对静态import/export加入有界分组,并记录旧新侧和原始行号;显式context增加方向解释,不新增读取或审查覆盖。
|
|
6
|
+
- 简单default/named alias导入支持有界独立调用语法位置;每源码侧一次索引,不是绑定/作用域/可达性证明。
|
|
7
|
+
- 歧义、阻断/排除目标、旧路径重命名竞争、畸形引号、块注释/模板/续行字符串保守跳过;不支持完整语言依赖图。
|
|
8
|
+
- 延续四文件组及输入预算回退,不改变审批、当前Agent模型或只读边界。
|
|
9
|
+
|
|
3
10
|
## 0.7.0
|
|
4
11
|
|
|
5
12
|
- 复核支持本次批准read结果生成的receiptId,减少模型重新抄写完整证据;兼容原六字段证据。
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 当前状态
|
|
6
6
|
|
|
7
|
-
当前代码版本为 0.
|
|
7
|
+
当前代码版本为 0.8.0,包含有界覆盖预检、显式项目规则及候选证据与反证复核;发布状态以 npm 和 GitHub Release 为准。已通过真实临时 Git/SVN 仓库测试,以及隔离进程中的真实 DSH ToolRuntime 注册验证。当前开发会话的实际预览已返回0.6.0的plan/rules字段,空变更预览与默认关闭外发已验证;已进行经宿主审批的六次小规模真实试跑,累计12次模型调用;四组合成正负例与历史修复对照符合预期,历史引入方向检出候选但引用复核未完成(partial)。不是独立盲测或整体质量证明。详情见 [试跑结果](evaluation/PILOT-RESULTS.md)。
|
|
8
8
|
|
|
9
9
|
## 证据引用编号(0.7.0)
|
|
10
10
|
|
|
@@ -56,13 +56,17 @@ dsh plugin add https://github.com/f-e-n-g-0531/dsh-code-review.git
|
|
|
56
56
|
|
|
57
57
|
安装版本号不等于当前进程已重新加载。升级后可先调用本地预览:0.6.0应返回 `plan` 和 `rules`(未选择规则时为空数组)。若缺失,核对当前profile的依赖和加载来源,必要时经用户确认重启DSH,再重新预览;不要因此开启外发或重复安装。`ready`仅表示初始输入可容纳,不代表已审查。
|
|
58
58
|
|
|
59
|
+
## 必要关联上下文(0.8.0)
|
|
60
|
+
|
|
61
|
+
仅使用批准快照数据,静态相对导入线索记录方向、旧新侧及行号;显式context仍原样作为上下文发送,其关系说明不额外读取或计为审查完成。简单默认/命名别名导入可提示独立调用语法位置(每导入最多20处),可能存在局部遮蔽,不证明绑定、可达性或因果。仅支持保守单行JS/TS语法;动态/包导入、歧义目标、旧路径重命名竞争不猜测,含块注释、模板、续行字符串文件保守跳过。不是完整依赖图,不自动扫描仓库。
|
|
62
|
+
|
|
59
63
|
## 支持与限制
|
|
60
64
|
|
|
61
65
|
- Git:HEAD 到工作区净变化,含暂存和未暂存变化;支持未出生 HEAD、重命名、新增和删除。没有独立 staged/unstaged 审查模式。
|
|
62
66
|
- SVN:本地每节点 BASE 到工作副本,包含目录属性,无需连接远端。复制、替换、switched、externals、冲突及目录结构变化目前阻断并说明,不宣称完整 SVN 历史支持。
|
|
63
67
|
- 二进制、不能解码的内容、链接、非普通文件和超限文件明确阻断。不会转码写回。
|
|
64
68
|
- SVN 配置 `svn:keywords` 的文件目前明确阻断,避免把关键字展开误认为代码变化;CRLF/LF 保留原始文本并提示模型不应仅因换行差异报缺陷。
|
|
65
|
-
- 当前逐文件审查;模型可在已批准的可审查文件两侧及显式上下文中搜索和读取行段,不访问范围外文件,不开放 shell 或模型工具调用。JS/TS
|
|
69
|
+
- 当前逐文件审查;模型可在已批准的可审查文件两侧及显式上下文中搜索和读取行段,不访问范围外文件,不开放 shell 或模型工具调用。JS/TS实现与测试可按唯一命名关系及批准变更文件内单行相对静态import/export线索分组,每组最多4个文件,同组两侧作为主文件上下文;仍逐主文件审查,不是一次联合审查或通用依赖图。组输入超预算回退单文件并报告限制;大文件超预算不截断,标记未完成。
|
|
66
70
|
- v0.2.0 提供有界 diff/hunk 与紧凑编辑引用。发现的归因引用可校验,但因果解释仍未经独立验证;缺失归因保留发现并标记 partial。检索截断同样进入报告限制。
|
|
67
71
|
- JSON 报告包含检索来源目录和本地已成功读取的审计元数据,不保存重复源码;retrieved-locally 不表示模型已接收或采用证据。
|
|
68
72
|
- 快照双次采集和内容复验用于发现普通并发修改,不是文件系统原子快照,不承诺防御恶意并发链接置换。
|
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { inferImportRelations } from './import-relations.mjs';
|
|
2
|
+
|
|
3
|
+
// Context entries are already sent with every primary. These annotations add
|
|
4
|
+
// explanations only, never reads, group members, or reviewed coverage.
|
|
5
|
+
export function inferContextRelations(input, file) {
|
|
6
|
+
const paths = new Set(input.files.map(f => f.path));
|
|
7
|
+
const contexts = (input.context ?? []).filter(c => !paths.has(c.path));
|
|
8
|
+
const primary = { ...file, id: 'primary' };
|
|
9
|
+
const entries = contexts.map((c, i) => ({ id: 'context-' + i, path: c.path, eligibility: 'reviewable', left: { text: '' }, right: { text: c.text } }));
|
|
10
|
+
// Other selected paths participate as opaque competitors, not context.
|
|
11
|
+
const competitors = input.files.filter(f => f.id !== file.id).map((f, i) => ({ id: 'selected-' + i, path: f.path, eligibility: 'blocked' }));
|
|
12
|
+
const all = [primary, ...entries, ...competitors];
|
|
13
|
+
if (all.length > 200) return [];
|
|
14
|
+
const byId = new Map(all.map(f => [f.id, f.path]));
|
|
15
|
+
return inferImportRelations(all).filter(e => e.specifier && (e.from === 'primary' || e.to === 'primary')).map(e => ({ fromPath: byId.get(e.from), toPath: byId.get(e.to), side: e.from === 'primary' ? e.side : 'context', line: e.line, specifier: e.specifier, callSites: e.callSites, reason: 'relative-import:approved-context' }));
|
|
16
|
+
}
|
package/src/file-relations.mjs
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { inferImportRelations } from './import-relations.mjs';
|
|
2
|
+
|
|
3
|
+
export function inferFileRelations(files) {
|
|
4
|
+
// Keep the existing group edge budget; naming hints have priority at the cap.
|
|
5
|
+
return [...inferTestRelations(files), ...inferImportRelations(files)].slice(0, 2000);
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
// Conservative naming hints only. No filesystem access or dependency claims.
|
|
2
9
|
export function inferTestRelations(files) {
|
|
3
10
|
if (!Array.isArray(files) || files.length > 200) throw new Error('Invalid relation input');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Syntactic occurrences only, never binding resolution or reachability.
|
|
2
|
+
export function indexStandaloneCalls(lines) {
|
|
3
|
+
const index = new Map();
|
|
4
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5
|
+
const call = /^\s*(?:await\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^;"'\/]*\)\s*;?\s*$/.exec(lines[i]);
|
|
6
|
+
if (!call) continue;
|
|
7
|
+
const positions = index.get(call[1]) ?? [];
|
|
8
|
+
if (positions.length < 20) positions.push(i + 1);
|
|
9
|
+
index.set(call[1], positions);
|
|
10
|
+
}
|
|
11
|
+
return index;
|
|
12
|
+
}
|
|
13
|
+
export function importedCallSites(lines, importLine, callIndex = indexStandaloneCalls(lines)) {
|
|
14
|
+
const match = /^\s*import\s+(.+?)\s+from\s+(['"])([^'"\\]+)\2\s*;?\s*(?:\/\/.*)?$/.exec(lines[importLine - 1]);
|
|
15
|
+
if (!match || match[1].startsWith('type ')) return [];
|
|
16
|
+
const names = [];
|
|
17
|
+
const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
18
|
+
if (identifier.test(match[1])) names.push(match[1]);
|
|
19
|
+
else if (match[1].startsWith('{') && match[1].endsWith('}')) {
|
|
20
|
+
for (const part of match[1].slice(1, -1).split(',')) {
|
|
21
|
+
const binding = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?\s*$/.exec(part);
|
|
22
|
+
if (!binding) return [];
|
|
23
|
+
names.push(binding[2] ?? binding[1]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const calls = names.flatMap(local => (callIndex.get(local) ?? []).map(line => ({ local, line }))).sort((a, b) => a.line - b.line).slice(0, 20);
|
|
27
|
+
return calls;
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { importedCallSites, indexStandaloneCalls } from './import-call-sites.mjs';
|
|
3
|
+
|
|
4
|
+
const extensions = ['.mjs', '.cjs', '.js', '.jsx', '.ts', '.tsx'];
|
|
5
|
+
// Conservative syntax hints, not a module resolver or a call graph. No I/O.
|
|
6
|
+
// Deliberately skip files containing block comments/templates rather than
|
|
7
|
+
// invent edges from text inside those constructs without a language parser.
|
|
8
|
+
export function inferImportRelations(files) {
|
|
9
|
+
if (!Array.isArray(files) || files.length > 200) throw new Error('Invalid import relation input');
|
|
10
|
+
const byPath = new Map(), ids = new Set();
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
if (!file || typeof file.id !== 'string' || !file.id || ids.has(file.id) || typeof file.path !== 'string' || !file.path || file.path.includes('\\') || file.path.startsWith('/') || file.path.includes(':') || file.path.split('/').some(p => !p || p === '.' || p === '..') || byPath.has(file.path) || !['reviewable', 'blocked', 'excluded'].includes(file.eligibility)) throw new Error('Invalid import relation file');
|
|
13
|
+
ids.add(file.id); byPath.set(file.path, file);
|
|
14
|
+
}
|
|
15
|
+
const edges = [];
|
|
16
|
+
for (const file of [...files].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
|
|
17
|
+
if (file.eligibility !== 'reviewable' || !extensions.some(ext => file.path.endsWith(ext))) continue;
|
|
18
|
+
for (const side of ['old', 'new']) {
|
|
19
|
+
// Old-path module resolution for renames needs a separate path index.
|
|
20
|
+
if (side === 'old' && file.oldPath && file.oldPath !== file.path) continue;
|
|
21
|
+
const text = (side === 'old' ? file.left : file.right)?.text;
|
|
22
|
+
if (typeof text !== 'string') throw new Error('Missing import relation source');
|
|
23
|
+
if (Buffer.byteLength(text) > 256 * 1024 || text.includes('/*') || text.includes(String.fromCharCode(96)) || text.includes(String.fromCharCode(92, 10)) || text.includes(String.fromCharCode(92, 13, 10))) continue;
|
|
24
|
+
const lines = text.split(String.fromCharCode(10));
|
|
25
|
+
const callIndex = indexStandaloneCalls(lines);
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const match = /^\s*(?:import\s+(?:(?:[^'";]+)\s+from\s+)?|export\s+(?:[^'";]+)\s+from\s+)(['"])([^'"\\]+)\1\s*;?\s*(?:\/\/.*)?$/.exec(lines[i]);
|
|
28
|
+
if (!match || !(match[2].startsWith('./') || match[2].startsWith('../'))) continue;
|
|
29
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(file.path), match[2]));
|
|
30
|
+
if (target === '..' || target.startsWith('../') || target.includes(':')) continue;
|
|
31
|
+
const paths = path.posix.extname(target) ? [target] : [target, ...extensions.map(ext => target + ext), ...extensions.map(ext => target + '/index' + ext)];
|
|
32
|
+
// Blocked/excluded matches still create ambiguity, never silently pick another.
|
|
33
|
+
const candidates = new Set(paths);
|
|
34
|
+
// A renamed old target may collide or disappear from the new-path index.
|
|
35
|
+
if (side === 'old' && files.some(f => f.oldPath && f.oldPath !== f.path && (candidates.has(f.oldPath) || candidates.has(f.path)))) continue;
|
|
36
|
+
const matches = [...candidates].map(p => byPath.get(p)).filter(Boolean);
|
|
37
|
+
if (matches.length !== 1 || matches[0].eligibility !== 'reviewable' || matches[0].id === file.id) continue;
|
|
38
|
+
const callSites = importedCallSites(lines, i + 1, callIndex);
|
|
39
|
+
edges.push({ from: file.id, to: matches[0].id, reason: 'relative-import:' + side + ':L' + (i + 1), side, line: i + 1, specifier: match[2], callSites });
|
|
40
|
+
if (callSites.length && edges.length < 2000) edges.push({ from: file.id, to: matches[0].id, reason: 'imported-call-syntax:' + side + ':L' + callSites.map(c => c.line).join(','), side, callSites });
|
|
41
|
+
if (edges.length >= 2000) return edges;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return edges.sort((a, b) => a.from.localeCompare(b.from, 'en') || a.to.localeCompare(b.to, 'en') || a.side.localeCompare(b.side, 'en') || a.line - b.line);
|
|
46
|
+
}
|
package/src/primary-input.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inferContextRelations } from './context-relations.mjs';
|
|
1
2
|
import { changeMap } from './change-map.mjs';
|
|
2
3
|
import { initialInputBudget } from './input-budget.mjs';
|
|
3
4
|
|
|
@@ -5,6 +6,11 @@ import { initialInputBudget } from './input-budget.mjs';
|
|
|
5
6
|
export function preparePrimaryInput(input, file, { instructions, scope, grouping, maxInputBytes }) {
|
|
6
7
|
const changes = changeMap(file.left.text, file.right.text);
|
|
7
8
|
const base = { snapshotId: input.id, file, changes, context: input.context ?? [], rules: input.rules ?? [] };
|
|
9
|
+
const contextRelations = inferContextRelations(input, file);
|
|
10
|
+
if (contextRelations.length) {
|
|
11
|
+
base.contextRelations = contextRelations;
|
|
12
|
+
base.contextRelationNotice = '导入与调用位置仅为语法线索;可能被同名局部变量遮蔽,不证明绑定、可达性或因果。';
|
|
13
|
+
}
|
|
8
14
|
let payload = JSON.stringify(base);
|
|
9
15
|
const measure = value => initialInputBudget({ instructions, input: value }, scope, maxInputBytes);
|
|
10
16
|
const metadata = {};
|
|
@@ -13,7 +19,8 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
|
|
|
13
19
|
metadata.groupId = group.id;
|
|
14
20
|
const relatedFiles = input.files.filter(f => f.id !== file.id && f.eligibility === 'reviewable' && group.fileIds.includes(f.id));
|
|
15
21
|
if (relatedFiles.length) {
|
|
16
|
-
const
|
|
22
|
+
const relations = grouping.links.filter(link => !link.split && group.fileIds.includes(link.from) && group.fileIds.includes(link.to));
|
|
23
|
+
const grouped = JSON.stringify({ ...base, relatedFiles, relations, relationNotice: '命名和相对导入关系仅为源码线索,可能被同名局部变量遮蔽,不证明绑定、调用可达或因果;只报告主file的问题,不为relatedFiles重复生成发现。' });
|
|
17
24
|
if (measure(grouped).fits) { payload = grouped; metadata.relatedFileIds = relatedFiles.map(f => f.id); }
|
|
18
25
|
else metadata.groupFallback = 'input-budget';
|
|
19
26
|
}
|
package/src/review.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { hash } from './content.mjs';
|
|
2
2
|
import { coverageFollowup } from './coverage-followup.mjs';
|
|
3
3
|
import { verificationLoop } from './verification-loop.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { inferFileRelations } from './file-relations.mjs';
|
|
5
5
|
import { buildReviewGroups } from './review-groups.mjs';
|
|
6
6
|
import { changeMap } from './change-map.mjs';
|
|
7
7
|
import { createRetrievalScope } from './retrieval-scope.mjs';
|
|
@@ -80,7 +80,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
80
80
|
const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { signal }) : null;
|
|
81
81
|
const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
|
|
82
82
|
report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
|
|
83
|
-
if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files,
|
|
83
|
+
if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files, inferFileRelations(input.files));
|
|
84
84
|
for (const file of input.files) {
|
|
85
85
|
const state = { fileId: file.id, path: file.path, status: file.eligibility === 'reviewable' ? 'pending' : file.eligibility, reason: file.reason };
|
|
86
86
|
report.files.push(state);
|
package/src/service.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { buildCoveragePlan } from './coverage-plan.mjs';
|
|
3
3
|
import { createRetrievalScope } from './retrieval-scope.mjs';
|
|
4
4
|
import { buildReviewGroups } from './review-groups.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { inferFileRelations } from './file-relations.mjs';
|
|
6
6
|
import { captureSnapshot } from './snapshot.mjs';
|
|
7
7
|
import { reviewSnapshot, markdownReport, REVIEW_INSTRUCTIONS } from './review.mjs';
|
|
8
8
|
import { createDshModel } from './dsh-model.mjs';
|
|
@@ -34,7 +34,7 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
|
|
|
34
34
|
const snapshot = await capture(current.cwd, { ...options, signal: exec.signal });
|
|
35
35
|
exec.signal?.throwIfAborted();
|
|
36
36
|
if (owner(exec).cwd !== current.cwd) throw new Error('Agent directory changed; preview again');
|
|
37
|
-
const plan = buildCoveragePlan(snapshot, { instructions: REVIEW_INSTRUCTIONS, scope: createRetrievalScope(snapshot, { signal: exec.signal }), grouping: buildReviewGroups(snapshot.files,
|
|
37
|
+
const plan = buildCoveragePlan(snapshot, { instructions: REVIEW_INSTRUCTIONS, scope: createRetrievalScope(snapshot, { signal: exec.signal }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
|
|
38
38
|
prune();
|
|
39
39
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
40
40
|
const previewId = randomUUID();
|