@feng0531/dsh-code-review 0.85.4 → 0.85.6
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/RELEASE-NOTES.md +13 -19
- package/package.json +1 -1
- package/src/class-bindings.mjs +11 -4
- package/src/content.mjs +4 -1
- package/src/coverage-followup.mjs +1 -1
- package/src/coverage-plan.mjs +1 -1
- package/src/git-history.mjs +12 -4
- package/src/git.mjs +14 -1
- package/src/primary-input.mjs +3 -2
- package/src/process.mjs +5 -0
- package/src/review-jobs.mjs +60 -9
- package/src/review.mjs +3 -3
- package/src/snapshot.mjs +7 -1
- package/src/svn-caller-index.mjs +8 -1
- package/src/svn-explicit-context.mjs +13 -1
- package/src/svn-keyword-mapping.mjs +12 -6
- package/src/svn-replacement-tree.mjs +12 -2
- package/src/synthesis-evidence.mjs +20 -4
- package/src/tracked-context.mjs +4 -1
- package/src/window-synthesis.mjs +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
|
|
4
4
|
|
|
5
|
+
## 0.85.6 — 快照正确性、任务隔离与取消修复
|
|
6
|
+
|
|
7
|
+
- Git 工作区撤销暂存重命名后按 HEAD 到当前原路径的净内容比较,不再误报不存在的删除;历史提交允许非 UTF-8 消息,父对象标识仍严格验证。
|
|
8
|
+
- 实例方法绑定保留与 Object 原型同名的合法方法,并拒绝同一构造行附加语句或可执行参数造成的错误唯一性。
|
|
9
|
+
- 后台任务卸载等待启动持久化到终态的完整生命周期,取消后不再启动审核;报告目录按真实路径隔离,拒绝祖先 junction/符号链接别名绕过。
|
|
10
|
+
- SVN 标准关键字按原生别名组收缩,固定字段保持 UTF-8 字节宽度;显式上下文末尾同时复核元数据与原始正文哈希,并累计来源预算。
|
|
11
|
+
- 本地文件以只读非阻塞方式打开后验证句柄类型,避免 Linux FIFO 替换挂起;进程取消时关闭自身输出管道并等待直接子进程结束,正常输出仍完整读取,不承诺终止全部后代。
|
|
12
|
+
- 注册表元数据及安装包正文网络中断使用有界重试,格式、地址、超限与完整性失败仍立即拒绝;测试临时 SVN 工作副本及时清理。
|
|
13
|
+
|
|
14
|
+
## 0.85.5 — 凭据来源保护与审核边界修复
|
|
15
|
+
|
|
16
|
+
- SVN 独立替换树在读取正文前检查复制来源及祖先来源,阻断从凭据路径复制到普通目标的内容;调用方扫描不再把带源码扩展名的目录当作文件读取。
|
|
17
|
+
- Git 默认工作区范围合并暂存删除后重建的同路径记录,避免重复文件身份;自动上下文允许精确目录索引导入探测,但不扩大后代正文授权。
|
|
18
|
+
- 暂存区审核复用已选变更作为上下文时,按相同 index 版本验证,不再误与未暂存工作区内容比较。
|
|
19
|
+
- 综合证据接受同一快照、来源和版本侧连续或重叠读取的完整区间覆盖;仍拒绝有缺口、错来源或错版本的证据。
|
|
20
|
+
- 超过 20 个窗口批次时保留全部可执行主窗口及此前成果,单独披露综合受限并保持未完整审核状态,不再使整轮异常退出。
|
|
21
|
+
|
|
5
22
|
## 0.85.4 — 审查正确性、失败恢复与全项目提速
|
|
6
23
|
|
|
7
24
|
`v0.85.2` 因发布说明缺少导航链接、`v0.85.3` 因 Windows 临时目录短路径别名未被新增测试 mock 规范化而未通过 CI,均未发布 npm 或 GitHub Release。本版本补齐链接、规范化测试匹配路径并重新执行完整门禁,不覆盖旧冻结标签、不削弱拒绝断言。
|
package/RELEASE-NOTES.md
CHANGED
|
@@ -1,28 +1,22 @@
|
|
|
1
|
-
# v0.85.
|
|
1
|
+
# v0.85.6 发布说明
|
|
2
2
|
|
|
3
3
|
使用方式见 [README](README.md),详细变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 审核正确性
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- Git 撤销暂存重命名后按实际工作区净内容比较;历史审核不再因合法非 UTF-8 提交消息失败。
|
|
8
|
+
- 方法绑定不再遗漏 `toString`、`valueOf` 等合法名称;构造行附加语句、原型替换及可执行参数不再被错误认证。
|
|
9
|
+
- SVN 标准关键字别名与固定字段 UTF-8 字节宽度保持原生语义;显式上下文增加原始正文哈希重验,验证读取继续累计预算。
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
- 后台保存队列在一次失败后仍可保存终态;成功落盘后释放已结束任务的内存,最终保存失败也会唤醒等待者并保留诊断。
|
|
11
|
-
- 修复模型拒绝值为 null/undefined 时丢失报告、非法分段参数导致死循环、SVN 历史正文大小低报影响累计预算等问题。
|
|
12
|
-
- 补全进程终止的 SIGKILL 回退及清理、注册表网络异常重试、Git 根路径空格保留、锚点越界重定位、具名导入尾随逗号,以及 JavaScript/C#/C++ 导航边界。
|
|
11
|
+
## 隔离、取消与发布验证
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
- 后台任务卸载覆盖首次持久化到终态,避免卸载完成后才启动审核;报告目录以真实路径验证工作区隔离,阻断路径别名绕过。
|
|
14
|
+
- 本地文件以只读非阻塞方式打开并验证句柄,避免 Linux FIFO 替换导致等待;取消时关闭自身捕获管道,仍等待直接子进程退出并保留终止升级机制。正常成功路径继续完整收集输出。
|
|
15
|
+
- npm 元数据及安装包正文网络中断按预算重试;无效 JSON、非法工件地址、输出超限及完整性不一致仍立即失败。
|
|
16
|
+
- 补齐测试临时 SVN 仓库与工作副本清理。
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
## 验证与边界
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
新增 43 项回归。本地完整测试:Windows 750 项中 746 通过、4 项平台限定跳过;Linux 750 项全部通过。正式发布仍须通过冻结标签同一提交的 Ubuntu/Windows × Node.js 22/24 四组 CI、隔离安装以及原始包、npm 与 GitHub Release 工件一致性门禁。最终结果以对应 CI 和发布工作流为准。
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
## 验证与安全边界
|
|
23
|
-
|
|
24
|
-
本地完整测试 685 项:684 通过、1 项 Windows 平台跳过、0 失败、0 取消;另对新增流式、持久化及导航回归进行独立验证。正式发布必须经过冻结标签提交的 Linux/Windows × Node 22/24 四组 CI,以及隔离安装和注册表工件一致性门禁。
|
|
25
|
-
|
|
26
|
-
文件及祖先身份检查是路径竞态检测加固,不是原子目录相对打开;不能保证抵御恶意进程精确交换并恢复路径。严格隔离场景应在不可并发修改的独立快照中审核。
|
|
27
|
-
|
|
28
|
-
本版本不自动安装、升级或重启 DSH;需更新并重新加载插件后生效。
|
|
22
|
+
只读非阻塞打开不是原子防路径替换,不保证任意设备或网络文件系统无阻塞;进程取消不承诺杀死全部后代。本版不保证模型始终输出合法 JSON,也不代表补齐此前自动审核失败的文件。发布不自动安装、升级或重启 DSH;更新并重新加载插件后生效。
|
package/package.json
CHANGED
package/src/class-bindings.mjs
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
const BS = String.fromCharCode(96);
|
|
5
5
|
const NL = String.fromCharCode(10);
|
|
6
6
|
|
|
7
|
+
// Only a whole declaration with inert primitive arguments is proven here. In
|
|
8
|
+
// particular, never exempt a constructor-line suffix or executable argument
|
|
9
|
+
// from the class/instance mutation checks below. Strings are matched as tokens,
|
|
10
|
+
// not stripped as comments (a quoted `);` must not end the declaration).
|
|
11
|
+
const primitiveArgument = /(?:null|true|false|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|"(?:[^"\\\r\n]|\\[^\r\n])*"|'(?:[^'\\\r\n]|\\[^\r\n])*')/.source;
|
|
12
|
+
const constructionDeclaration = new RegExp('^\\s*(?:const|let|var)\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*=\\s*new\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*\\(\\s*(?:' + primitiveArgument + '(?:\\s*,\\s*' + primitiveArgument + ')*\\s*,?)?\\s*\\)\\s*;?\\s*$');
|
|
13
|
+
|
|
7
14
|
const escapeDollar = name => name.replaceAll('$', '\\$');
|
|
8
15
|
function boundaryPattern(name, suffix, flags = '') {
|
|
9
16
|
return new RegExp('(^|[^A-Za-z0-9_$])' + escapeDollar(name) + suffix, flags);
|
|
@@ -94,11 +101,11 @@ export function instanceMethodBindings(lines, importLine, targetText) {
|
|
|
94
101
|
const body = classBody(targets, classLine);
|
|
95
102
|
if (!body) return [];
|
|
96
103
|
const decls = directMethods(body);
|
|
97
|
-
const names =
|
|
98
|
-
for (const d of decls) names
|
|
104
|
+
const names = new Map();
|
|
105
|
+
for (const d of decls) names.set(d.name, (names.get(d.name) ?? 0) + 1);
|
|
99
106
|
const result = [];
|
|
100
107
|
for (const d of decls) {
|
|
101
|
-
if (names
|
|
108
|
+
if (names.get(d.name) !== 1) continue;
|
|
102
109
|
if (d.name === 'constructor') continue;
|
|
103
110
|
const member = d.name;
|
|
104
111
|
const token = boundaryPattern(member, '(?=[^A-Za-z0-9_$]|$)', 'g');
|
|
@@ -115,7 +122,7 @@ export function instanceMethodBindings(lines, importLine, targetText) {
|
|
|
115
122
|
if (/[=,:.*#\[\]()]|\b(?:new|function|return|yield|case|throw|typeof|void|delete|instanceof)\b/.test(seg)) continue;
|
|
116
123
|
let assign = null, inst = null;
|
|
117
124
|
for (let i = 0; i < lines.length; i++) {
|
|
118
|
-
const a =
|
|
125
|
+
const a = constructionDeclaration.exec(lines[i]);
|
|
119
126
|
if (a && a[2] === local) { if (assign !== null) return []; assign = i + 1; inst = a[1]; }
|
|
120
127
|
}
|
|
121
128
|
if (assign === null || inst === member || inst === klass || inst === local) continue;
|
package/src/content.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { assertNonSecretPath } from './secret-path.mjs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { lstat, realpath, open } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
5
6
|
|
|
6
7
|
export const hash = value => createHash('sha256').update(value).digest('hex');
|
|
@@ -44,7 +45,9 @@ export async function readLocal(root, name, { maxFileBytes = 256 * 1024, signal
|
|
|
44
45
|
if (rel.startsWith('..' + path.sep) || rel === '..' || path.isAbsolute(rel)) throw new Error('Path escapes repository');
|
|
45
46
|
const opened = await lstat(filename);
|
|
46
47
|
if (opened.isSymbolicLink() || !opened.isFile()) throw new Error(opened.isSymbolicLink() ? 'Symbolic links and junctions are excluded' : 'Not a regular file');
|
|
47
|
-
|
|
48
|
+
// A regular file may become a FIFO after lstat. Do not wait for a writer;
|
|
49
|
+
// validate the opened descriptor before reading. These flags never write/create.
|
|
50
|
+
const handle = await open(canonical, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
48
51
|
try {
|
|
49
52
|
const before = await handle.stat();
|
|
50
53
|
if (!before.isFile() || before.dev !== expected.dev || before.ino !== expected.ino || before.dev !== opened.dev || before.ino !== opened.ino) throw new Error('File changed during capture');
|
|
@@ -5,7 +5,7 @@ export function coverageFollowup(report) {
|
|
|
5
5
|
const items = report.files.map(file => {
|
|
6
6
|
if (!file || typeof file.fileId !== 'string' || !file.fileId || ids.has(file.fileId) || typeof file.path !== 'string' || !file.path || paths.has(file.path) || !['completed', 'excluded', 'blocked', 'failed', 'cancelled', 'pending'].includes(file.status)) throw new Error('Invalid coverage file');
|
|
7
7
|
ids.add(file.fileId); paths.add(file.path);
|
|
8
|
-
const incompleteSynthesis=(file.stageFailures??[]).length>0
|
|
8
|
+
const incompleteSynthesis=(file.stageFailures??[]).length>0||!!file.synthesisStatus&&file.synthesisStatus!=='completed';
|
|
9
9
|
const incompleteVerification = report.findings.some(f => f.fileId === file.fileId && ['pending', 'incomplete'].includes(f.verification?.status));
|
|
10
10
|
return { fileId: file.fileId, path: file.path, status: file.status, incompleteVerification,incompleteSynthesis,
|
|
11
11
|
followup: file.status !== 'excluded' && (file.status !== 'completed' || incompleteVerification || incompleteSynthesis) };
|
package/src/coverage-plan.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export function buildCoveragePlan(input, options) {
|
|
|
21
21
|
if (file.eligibility !== 'reviewable') return item;
|
|
22
22
|
const prepared = preparePrimaryInput(input, file, options);
|
|
23
23
|
const riskPlanning = options.enableRiskPlanning === true ? options.skipSmallRiskPlans === true ? riskPlanningDecision([file]) : {required:true,reason:'unconditional'} : {required:false,reason:'disabled'};
|
|
24
|
-
return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? ((prepared.batches?.length ?? 1) * (rounds + (riskPlanning.required ? 1 : 0)) + (prepared.synthesis?.
|
|
24
|
+
return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? ((prepared.batches?.length ?? 1) * (rounds + (riskPlanning.required ? 1 : 0)) + (prepared.synthesis?.status === 'ready' ? rounds : 0)) : 0 };
|
|
25
25
|
});
|
|
26
26
|
const local = options.enableBusinessGrouping === true ? localBusinessGroups(input.files) : null;
|
|
27
27
|
const groups = [...(options.grouping?.groups ?? [])];
|
package/src/git-history.mjs
CHANGED
|
@@ -20,9 +20,17 @@ async function resolveCommit(root, ref, options) {
|
|
|
20
20
|
return oid;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function parseCommitParents(
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
function parseCommitParents(object) {
|
|
24
|
+
// Commit messages (and identities) need not be UTF-8. Decode only the header
|
|
25
|
+
// losslessly; parent object IDs themselves must still be strictly ASCII hex.
|
|
26
|
+
const end = object.indexOf(Buffer.from('\n\n'));
|
|
27
|
+
if (end < 0) throw new Error('Invalid commit header');
|
|
28
|
+
const lines = object.subarray(0, end).toString('latin1').split('\n');
|
|
29
|
+
if (!/^tree (?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(lines[0]) ||
|
|
30
|
+
lines.some(line => line.includes('\0') || !/^(?:[a-z][a-z0-9-]* | )/.test(line))) {
|
|
31
|
+
throw new Error('Invalid commit header');
|
|
32
|
+
}
|
|
33
|
+
const parents = lines.filter(line => line.startsWith('parent ')).map(line => line.slice(7));
|
|
26
34
|
if (parents.some(oid => !SHA.test(oid))) throw new Error('Invalid commit parent');
|
|
27
35
|
return parents;
|
|
28
36
|
}
|
|
@@ -75,7 +83,7 @@ export async function captureGitHistory(cwd, options = {}) {
|
|
|
75
83
|
if (commit !== undefined) {
|
|
76
84
|
if (baseRevision !== undefined || targetRevision !== undefined) throw new Error('Commit and range are mutually exclusive');
|
|
77
85
|
target = await resolveCommit(root, commit, options);
|
|
78
|
-
const object =
|
|
86
|
+
const object = await gitAt(root, ['cat-file', 'commit', target], options);
|
|
79
87
|
const parents = parseCommitParents(object);
|
|
80
88
|
if (parents.length > 1) throw new Error('Merge commit requires explicit revision range');
|
|
81
89
|
base = parents[0] ?? null;
|
package/src/git.mjs
CHANGED
|
@@ -41,7 +41,20 @@ function parseChanges(bytes) {
|
|
|
41
41
|
|
|
42
42
|
/** Keep only the entries the requested comparison actually observes. */
|
|
43
43
|
function changesInScope(changes, scope) {
|
|
44
|
-
|
|
44
|
+
// Porcelain reports a staged deletion and its untracked recreation separately.
|
|
45
|
+
// Worktree compares HEAD to the current file once, retaining the tracked entry
|
|
46
|
+
// (and its baseline/selection authority), not a second untracked identity.
|
|
47
|
+
if (scope === 'worktree') {
|
|
48
|
+
// A staged rename whose destination was removed compares HEAD's source to
|
|
49
|
+
// that source's current worktree path, not to the absent index destination.
|
|
50
|
+
// Its source remains HEAD-authorized even if porcelain calls it untracked.
|
|
51
|
+
const trackedPaths = new Set(changes.filter(change => change.rawStatus !== '??').map(change => change.path));
|
|
52
|
+
const net = changes.flatMap(change => change.rawStatus === 'RD' && change.oldPath
|
|
53
|
+
? (trackedPaths.has(change.oldPath) ? [] : [{ path: change.oldPath, rawStatus: 'D ' }])
|
|
54
|
+
: [change]);
|
|
55
|
+
const tracked = new Set(net.filter(change => change.rawStatus !== '??').map(change => change.path));
|
|
56
|
+
return net.filter(change => change.rawStatus !== '??' || !tracked.has(change.path));
|
|
57
|
+
}
|
|
45
58
|
if (scope === 'staged') return changes.filter(change => change.rawStatus[0] !== ' ' && change.rawStatus[0] !== '?');
|
|
46
59
|
return changes.filter(change => change.rawStatus[1] !== ' ' || change.rawStatus === '??');
|
|
47
60
|
}
|
package/src/primary-input.mjs
CHANGED
|
@@ -167,8 +167,9 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
|
|
|
167
167
|
|
|
168
168
|
if (batches?.length > 1) {
|
|
169
169
|
synthesis = prepareWindowSynthesis(batches.map(batch => batch.payload), measure);
|
|
170
|
-
metadata.synthesisStatus = synthesis.
|
|
171
|
-
metadata.
|
|
170
|
+
metadata.synthesisStatus = synthesis.status === 'ready' ? 'ready' : 'blocked';
|
|
171
|
+
if (synthesis.reason) metadata.synthesisBlockedReason = synthesis.reason;
|
|
172
|
+
if (synthesis.budget) metadata.synthesisInputBytes = synthesis.budget.bytes;
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
const budget = measure(payload);
|
package/src/process.mjs
CHANGED
|
@@ -19,6 +19,11 @@ export function run(command, args, { cwd, signal, timeoutMs = 0, maxBytes = 8 *
|
|
|
19
19
|
failure ??= error;
|
|
20
20
|
if (stopped) return;
|
|
21
21
|
stopped = true;
|
|
22
|
+
// Descendants may retain these pipes after the direct child exits. On
|
|
23
|
+
// failure discard owned capture streams, but still join the child via close.
|
|
24
|
+
// Successful commands keep draining to EOF so no output is lost.
|
|
25
|
+
child.stdout?.destroy?.();
|
|
26
|
+
child.stderr?.destroy?.();
|
|
22
27
|
child.kill();
|
|
23
28
|
killer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 1000);
|
|
24
29
|
killer.unref?.();
|
package/src/review-jobs.mjs
CHANGED
|
@@ -11,17 +11,68 @@ export function createReviewJobs({directory}={}){
|
|
|
11
11
|
const identity=exec=>{const a=exec?.agent,s=a?.session;if(!s||!a.options?.provider||!a.options?.model||!s.header?.cwd)throw new Error('Review job requires session identity');const id=s.header.id??s.id;if(typeof id!=='string'||!id)throw new Error('Durable session id required');return {sessionId:id,cwd:s.header.cwd,provider:a.options.provider,model:a.options.model,reasoningEffort:a.options.reasoningEffort??null};};
|
|
12
12
|
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
13
13
|
const pageCheckpoint=(c,offset,limit)=>({...c,...(c.files?{files:c.files.slice(offset,offset+limit),totalFiles:c.files.length}:{}),...(c.findings?{findings:c.findings.slice(offset,offset+limit),totalFindings:c.findings.length}: {})});
|
|
14
|
-
const filename=id=>{if(typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))throw new Error('Invalid review job id');return path.join(directory,id+'.json');};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
async function
|
|
14
|
+
const filename=id=>{if(typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))throw new Error('Invalid review job id');return path.join(canonicalDirectory??directory,id+'.json');};
|
|
15
|
+
// Resolve existing ancestors before appending a not-yet-created suffix. Lexical
|
|
16
|
+
// paths alone do not identify a workspace reached through a junction or symlink.
|
|
17
|
+
async function projectedRealpath(name){
|
|
18
|
+
let current=path.resolve(name);const suffix=[];
|
|
19
|
+
for(;;){
|
|
20
|
+
try{return path.join(await realpath(current),...suffix);}
|
|
21
|
+
catch(error){
|
|
22
|
+
if(error.code!=='ENOENT')throw error;
|
|
23
|
+
// A dangling link is not an ordinary missing directory.
|
|
24
|
+
try{if((await lstat(current)).isSymbolicLink())throw new Error('Unsafe report directory');}catch(statError){if(statError.code!=='ENOENT')throw statError;}
|
|
25
|
+
const parent=path.dirname(current);if(parent===current)throw error;
|
|
26
|
+
suffix.unshift(path.basename(current));current=parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function checkDirectory(who){
|
|
31
|
+
const [workspace,resolved]=await Promise.all([projectedRealpath(who.cwd),projectedRealpath(directory)]);
|
|
32
|
+
const relative=path.relative(workspace,resolved);
|
|
33
|
+
if(relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)))throw new Error('Report store must be outside reviewed workspace');
|
|
34
|
+
if(canonicalDirectory!==undefined&&resolved!==canonicalDirectory)throw new Error('Unsafe report directory');
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
37
|
+
async function safeDirectory(who){
|
|
38
|
+
const expected=await checkDirectory(who); // Do not even create directories inside the workspace.
|
|
39
|
+
// Use the resolved destination, not a mutable ancestor junction, for creation.
|
|
40
|
+
await mkdir(expected,{recursive:true,mode:0o700});
|
|
41
|
+
const stat=await lstat(directory),resolved=await checkDirectory(who);
|
|
42
|
+
if(stat.isSymbolicLink()||!stat.isDirectory()||resolved!==expected)throw new Error('Unsafe report directory');
|
|
43
|
+
canonicalDirectory??=resolved;
|
|
44
|
+
}
|
|
45
|
+
async function save(job){await safeDirectory(job.record.owner);const tmp=path.join(canonicalDirectory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await safeDirectory(job.record.owner);await rename(tmp,filename(job.id));changed(job.id);}
|
|
46
|
+
async function owned(id,exec){const who=identity(exec),job=jobs.get(id);if(job){if(!same(who,job.record.owner))throw new Error('Foreign review job');return job.record;}await safeDirectory(who);const name=filename(id),stat=await lstat(name);if(stat.isSymbolicLink()||!stat.isFile())throw new Error('Unsafe report file');const r=JSON.parse(await readFile(name,'utf8'));if(!same(who,r.owner))throw new Error('Foreign review job');if(['queued','running'].includes(r.status)){r.status='interrupted';r.notice='Host restarted or plugin disposed. Saved checkpoints are available; resending requires a fresh preview and approval.';}return r;}
|
|
18
47
|
return {
|
|
19
|
-
async start(exec,work){
|
|
20
|
-
|
|
21
|
-
|
|
48
|
+
async start(exec,work){
|
|
49
|
+
if(disposed)throw new Error('Review job service disposed');
|
|
50
|
+
exec.signal?.throwIfAborted();const who=identity(exec);
|
|
51
|
+
if([...jobs.values()].some(j=>j.record.owner.sessionId===who.sessionId&&['queued','running'].includes(j.record.status)))throw new Error('Session review job already active');
|
|
52
|
+
const id=randomUUID(),controller=new AbortController(),job={id,controller,record:{jobId:id,owner:who,status:'queued',stage:'queued',checkpoints:[],createdAt:new Date().toISOString()},pending:Promise.resolve()};
|
|
53
|
+
// Register the entire lifetime before the first await: dispose must also join
|
|
54
|
+
// startup persistence. The start RPC observes startup errors; done never rejects.
|
|
55
|
+
const startup=Promise.resolve().then(()=>save(job));
|
|
22
56
|
const enqueue=fn=>{const operation=job.pending.then(fn);job.pending=operation.catch(()=>{});return operation;};
|
|
23
|
-
|
|
24
|
-
|
|
57
|
+
job.done=startup.then(async()=>{
|
|
58
|
+
try{
|
|
59
|
+
controller.signal.throwIfAborted();
|
|
60
|
+
job.record.status='running';await enqueue(()=>save(job));
|
|
61
|
+
controller.signal.throwIfAborted();
|
|
62
|
+
const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});
|
|
63
|
+
job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';
|
|
64
|
+
}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}
|
|
65
|
+
finally{
|
|
66
|
+
job.record.finishedAt=new Date().toISOString();
|
|
67
|
+
try{await enqueue(()=>save(job));jobs.delete(id);}
|
|
68
|
+
catch(error){job.record.status='failed';job.record.persistenceError=error?.message??String(error);}
|
|
69
|
+
finally{changed(id);}
|
|
70
|
+
}
|
|
71
|
+
},()=>{jobs.delete(id);changed(id);});
|
|
72
|
+
jobs.set(id,job);
|
|
73
|
+
// Own cancellation explicitly. The short start RPC ending is NOT cancellation.
|
|
74
|
+
await startup;
|
|
75
|
+
return {jobId:id,status:'queued',nextAction:'Call code_review_wait until terminal status; do not finish the user request at start.',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
|
|
25
76
|
},
|
|
26
77
|
async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait; do not end the user review request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
|
|
27
78
|
async wait(id,exec,{after,timeoutMs=25000,signal}={}){const r=await owned(id,exec);if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');if(after!==undefined&&(!Number.isSafeInteger(after)||after<0))throw new Error('Invalid wait revision');const revision=r.checkpoints.length;if(!['queued','running'].includes(r.status)||(after!==undefined&&after!==revision))return {changed:true};signal?.throwIfAborted();await new Promise((resolve,reject)=>{let timer;const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);const waiting=listeners.get(id);waiting?.delete(done);if(waiting?.size===0)listeners.delete(id);};const done=()=>{cleanup();resolve();},abort=()=>{cleanup();reject(signal.reason);};if(!listeners.has(id))listeners.set(id,new Set());listeners.get(id).add(done);signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(done,timeoutMs);const latest=jobs.get(id)?.record;if(!latest||latest.checkpoints.length!==revision||!['queued','running'].includes(latest.status))done();if(signal?.aborted)abort();});return {changed:true};},
|
package/src/review.mjs
CHANGED
|
@@ -136,8 +136,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
136
136
|
const { changes, payload } = prepared;
|
|
137
137
|
Object.assign(state, prepared.metadata);
|
|
138
138
|
if (state.contextMode === 'approved-retrieval') report.limitations.push({ fileId: file.id, text: '窗口初始输入仅带上下文身份目录,全文须按需读取;不声明所有上下文已核查。' });
|
|
139
|
-
if (prepared.synthesis) state.synthesisStatus = prepared.synthesis.
|
|
140
|
-
if (prepared.synthesis
|
|
139
|
+
if (prepared.synthesis) state.synthesisStatus = prepared.synthesis.status === 'ready' ? 'pending' : 'blocked';
|
|
140
|
+
if (prepared.synthesis?.status === 'blocked') report.limitations.push({ fileId: file.id, text: (prepared.synthesis.reason === 'batch-limit' ? '跨窗口综合批次数超过上限(20),未发送;全部主窗口仍独立审查' : '跨窗口综合输入超限,未发送') + ';单窗口完成不表示交互影响已检查' });
|
|
141
141
|
if (state.sourceMode === 'change-windows') state.windowCoverage = { completed: [], pending: [...state.windowIds] };
|
|
142
142
|
if(changes.precision==='envelope')report.limitations.push({fileId:file.id,text:'精确差异不可得,按保守包络分段检查;包络包含可能未变的行,不证明精确编辑或回归因果。'});
|
|
143
143
|
if (changes.status === 'limited') report.limitations.push({ fileId: file.id, text: '精确变更分析受限:' + changes.reason + ';仍按完整两侧内容审查' });
|
|
@@ -192,7 +192,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
|
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
const batches = [...(prepared.batches ?? [{ payload, windowIds: state.windowIds ?? [] }])];
|
|
195
|
-
if (prepared.synthesis?.
|
|
195
|
+
if (prepared.synthesis?.status === 'ready') batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
|
|
196
196
|
const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
|
|
197
197
|
for (const interactionPlan of matchingInteractionPlans.filter(p => p.prepared.status === 'ready')) batches.push({ payload: interactionPlan.prepared.payload, windowIds: [], interaction: interactionPlan.group });
|
|
198
198
|
const scheduled = Array.from({length:rounds},(_,i)=>batches.map(b=>({...b,round:i+1}))).flat();
|
package/src/snapshot.mjs
CHANGED
|
@@ -147,7 +147,13 @@ export async function captureSnapshot(cwd, options = {}) {
|
|
|
147
147
|
|
|
148
148
|
// Re-capture verifies both selected sides while collecting explicit context.
|
|
149
149
|
if ((await capture(cwd, indexOptions)).id !== snapshot.id) throw new Error('Snapshot changed while collecting context');
|
|
150
|
-
for (const item of context)
|
|
150
|
+
for (const item of context) {
|
|
151
|
+
// Overlapping staged context reuses the approved index side, not local bytes.
|
|
152
|
+
// The same-side re-capture above verifies its content and index identity,
|
|
153
|
+
// including when the working file differs or no longer exists.
|
|
154
|
+
if (type === 'git' && snapshot.changeScope === 'staged' && snapshot.files.some(file => file.path === item.path)) continue;
|
|
155
|
+
if ((await readLocal(snapshot.root, item.path, indexOptions)).hash !== item.hash) throw new Error('Context changed during capture');
|
|
156
|
+
}
|
|
151
157
|
if (index && (await (type === 'git' ? gitContextIndex : svnContextIndex)(snapshot.root, indexOptions)).fingerprint !== index.fingerprint) throw new Error('Tracked context index changed during capture');
|
|
152
158
|
const verifiedRules = await captureProjectRules(snapshot.root, rulePaths, { signal: options.signal, files: snapshot.files });
|
|
153
159
|
if (JSON.stringify(verifiedRules) !== JSON.stringify(rules)) throw new Error('Rules changed during capture');
|
package/src/svn-caller-index.mjs
CHANGED
|
@@ -7,4 +7,11 @@ import {lstat} from 'node:fs/promises';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
const canonical=v=>Array.isArray(v)?v.map(canonical):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
|
|
9
9
|
// Offline scoped status only, never svn list or repository-wide enumeration.
|
|
10
|
-
|
|
10
|
+
// Status XML has no node kind: bind local kinds and omit directories from body candidates.
|
|
11
|
+
// Links and other unsafe non-directory nodes still reach the strict source validator.
|
|
12
|
+
export async function svnCallerIndex(root,scopes,options={}){const approved=validateCallerScopes(scopes),entries=[],chunks=[];let bytes=0;const seen=new Set();for(const scope of approved){const ancestors=await svnCallerAncestors(root,scope,options);chunks.push(ancestors.fingerprint);let cursor=root;for(const part of scope.split('/')){cursor=path.join(cursor,part);const s=await lstat(cursor);if(s.isSymbolicLink()||!s.isDirectory())throw new Error('SVN caller scope requires real directories');}
|
|
13
|
+
const raw=await checked('svn',['--non-interactive','status','--xml','--verbose','--ignore-externals','--',scope+'@'],{...options,cwd:root,maxBytes:64*1024});bytes+=raw.length;if(bytes>64*1024)throw new Error('SVN caller index byte limit exceeded');const found=(xml(raw).status?.target??[]).flatMap(t=>t.entry??[]);
|
|
14
|
+
for(const e of found){const name=relativePath(e['@_path'].replaceAll('\\','/'));if(name!==scope&&!name.startsWith(scope+'/'))throw new Error('SVN caller index escaped scope');if(seen.has(name))throw new Error('Duplicate SVN caller index path');seen.add(name);if(seen.size>256)throw new Error('SVN caller index path limit exceeded');entries.push({...e,'@_path':name});}if(!found.some(e=>e['@_path'].replaceAll('\\','/')===scope))throw new Error('SVN caller scope missing from status');chunks.push(scope);}
|
|
15
|
+
entries.sort((a,b)=>a['@_path']<b['@_path']?-1:1);const unsafe=s=>!s||s['@_item']!=='normal'||!['none','normal'].includes(s['@_props'])||['@_switched','@_copied','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true');for(const scope of approved){const e=entries.find(e=>e['@_path']===scope);if(unsafe(e?.['wc-status']))throw new Error('Unsafe SVN caller directory state');}
|
|
16
|
+
const nodes=[];for(const e of entries.filter(e=>!approved.includes(e['@_path'])&&!unsafe(e['wc-status']))){options.signal?.throwIfAborted();const stat=await lstat(path.join(root,e['@_path']));nodes.push({path:e['@_path'],kind:stat.isDirectory()?'dir':stat.isSymbolicLink()?'link':stat.isFile()?'file':'other'});}
|
|
17
|
+
return {scopes:approved,paths:nodes.filter(n=>n.kind!=='dir').map(n=>n.path),entries,records:entries.length,bytes,fingerprint:hash(JSON.stringify(canonical({scopes:chunks,entries,nodes})))};}
|
|
@@ -20,6 +20,18 @@ export async function captureSvnExplicitContext(root,names,options={}){
|
|
|
20
20
|
}
|
|
21
21
|
context.push({path:name,...content,...(keywordMapping?{keywordMapping}:{}),...(node.eolStyle?{eolMapping:svnEolMapping(content.text,node.eolStyle)}:{})});identities.push({path:name,fingerprint:node.fingerprint,revision:node.revision});
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
// SVN metadata can remain identical while raw keyword values change: status
|
|
24
|
+
// compares contracted text, but the original expanded body is our evidence.
|
|
25
|
+
let verifiedBytes=bytes; // sourceBytes below remains the initial capture cost.
|
|
26
|
+
const verify=async()=>{
|
|
27
|
+
for(let i=0;i<identities.length;i++){
|
|
28
|
+
const node=identities[i];
|
|
29
|
+
if((await svnCallerNode(root,node.path,bounded)).fingerprint!==node.fingerprint)throw new Error('SVN explicit context identity changed during capture');
|
|
30
|
+
const current=await readLocal(root,node.path,bounded);bounded.svnNavigationBudget?.charge(current.bytes);verifiedBytes+=current.bytes;
|
|
31
|
+
if(verifiedBytes>1024*1024)throw new Error('SVN explicit context source byte limit exceeded');
|
|
32
|
+
if(current.hash!==context[i].hash)throw new Error('SVN explicit context content changed during capture');
|
|
33
|
+
}
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
};
|
|
24
36
|
return {context,verify,options:bounded,sourceBytes:bytes,identities};
|
|
25
37
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { hash } from './content.mjs';
|
|
2
2
|
import { keywordConfigurationTokens } from './svn-keyword-config.mjs';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Each standard property spelling enables its complete native SVN alias group.
|
|
5
|
+
const STANDARD_GROUPS = [
|
|
6
|
+
['Date', 'LastChangedDate'], ['Revision', 'Rev', 'LastChangedRevision'],
|
|
7
|
+
['Author', 'LastChangedBy'], ['HeadURL', 'URL'], ['Id'], ['Header'],
|
|
8
|
+
];
|
|
9
|
+
const STANDARD = new Map(STANDARD_GROUPS.flatMap(group => group.map(name => [name, group])));
|
|
5
10
|
const CUSTOM_DEFINITION = /^([A-Za-z][A-Za-z0-9]*)=(.+)$/;
|
|
6
11
|
const ALLOWED_FORMAT_FLAGS = 'radDuRPb_HI%';
|
|
7
12
|
const ALLOWED_LITERAL = /[A-Za-z0-9_.:/ ()-]/;
|
|
@@ -23,11 +28,12 @@ function parseCustomFormat(format) {
|
|
|
23
28
|
// never reimplemented here; raw source remains the only evidence.
|
|
24
29
|
export function svnKeywordConfiguration(configuration) {
|
|
25
30
|
if (typeof configuration !== 'string' || Buffer.byteLength(configuration) > 4096) throw new Error('Invalid SVN keyword configuration');
|
|
26
|
-
const enabled = new Set(), custom = [];
|
|
31
|
+
const enabled = new Set(), configured = new Set(), custom = [];
|
|
27
32
|
for (const token of keywordConfigurationTokens(configuration)) {
|
|
28
33
|
if (STANDARD.has(token)) {
|
|
29
|
-
if (
|
|
30
|
-
|
|
34
|
+
if (configured.has(token)) throw new Error('Duplicate SVN keyword configuration');
|
|
35
|
+
configured.add(token);
|
|
36
|
+
for (const alias of STANDARD.get(token)) enabled.add(alias);
|
|
31
37
|
continue;
|
|
32
38
|
}
|
|
33
39
|
const match = CUSTOM_DEFINITION.exec(token);
|
|
@@ -47,11 +53,11 @@ export function svnKeywordMapping(text, configuration) {
|
|
|
47
53
|
if (!enabled.has(name)) return literal;
|
|
48
54
|
if (separator === '::') {
|
|
49
55
|
// SVN reserves the complete field width, including the initial space and
|
|
50
|
-
// final space (or # truncation marker);
|
|
56
|
+
// final space (or # truncation marker); retain UTF-8 byte width, not UTF-16 length.
|
|
51
57
|
if (value.length < 2 || value[0] !== ' ' || ![' ', '#'].includes(value.at(-1))) throw new Error('Ambiguous SVN fixed-width keyword expansion');
|
|
52
58
|
fixedWidth++;
|
|
53
59
|
if (/[^ ]/.test(value)) replacements++;
|
|
54
|
-
return '$' + name + '::' + ' '.repeat(value
|
|
60
|
+
return '$' + name + '::' + ' '.repeat(Buffer.byteLength(value, 'utf8')) + '$';
|
|
55
61
|
}
|
|
56
62
|
if (!/^ .+ $/.test(value)) throw new Error('Ambiguous SVN keyword expansion');
|
|
57
63
|
replacements++;
|
|
@@ -6,11 +6,19 @@ import {readReplacementSource} from './svn-replacement-source.mjs';
|
|
|
6
6
|
import {svnWorkingIdentity} from './svn-working-identity.mjs';
|
|
7
7
|
import {hash,decode,relativePath,readLocal} from './content.mjs';
|
|
8
8
|
import {isSecretPath} from './secret-path.mjs';
|
|
9
|
+
import {canonicalSvnUrl} from './svn-url.mjs';
|
|
9
10
|
import {svnKeywordMapping} from './svn-keyword-mapping.mjs';
|
|
10
11
|
import {svnEolMapping} from './svn-eol-mapping.mjs';
|
|
11
12
|
import {svnSpecialData} from './svn-special.mjs';
|
|
12
13
|
import {readWorkingSvnSpecial} from './svn-working-special.mjs';
|
|
13
14
|
|
|
15
|
+
// Copy provenance is not permission to capture credential bytes at a safe target name.
|
|
16
|
+
function assertCopySource(source,suffix=''){
|
|
17
|
+
if(source===null||source===undefined)return;
|
|
18
|
+
const url=canonicalSvnUrl(source),name=decodeURIComponent(new URL(url).pathname);
|
|
19
|
+
if(isSecretPath(name)||isSecretPath(name+(suffix?'/'+suffix:'')))throw new Error('Credential replacement copy source excluded');
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
// Fixed repository tree versus explicitly selected working replacement tree.
|
|
15
23
|
// Every descendant is a separate source/coverage unit, never an implicit approval.
|
|
16
24
|
export async function captureReplacementTree(root,rootInfo,name,options={}){
|
|
@@ -26,13 +34,14 @@ export async function captureReplacementTree(root,rootInfo,name,options={}){
|
|
|
26
34
|
const ancestors=async p=>{
|
|
27
35
|
const parts=p.split('/').slice(0,-1),names=['.',...parts.map((_,i)=>parts.slice(0,i+1).join('/'))],records=[];
|
|
28
36
|
for(const name of names){
|
|
29
|
-
if(!verifying&&ancestorReceipts.has(name)){
|
|
37
|
+
if(!verifying&&ancestorReceipts.has(name)){const record=ancestorReceipts.get(name);assertCopySource(record.copyFromUrl,name==='.'?p:p.slice(name.length+1));records.push(record);continue;}
|
|
30
38
|
const stat=await lstat(name==='.'?root:path.join(root,name));if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('Replacement ancestor is not a real directory');
|
|
31
39
|
const node=xml(await run(['info','--xml','--',name+'@'])).info?.entry?.[0],identity=svnWorkingIdentity(node,rootInfo,name);
|
|
32
40
|
const entries=xml(await run(['status','--xml','--verbose','--ignore-externals','--depth','empty','--',name+'@'])).status?.target?.flatMap(t=>t.entry??[])??[],s=entries[0]?.['wc-status'];
|
|
33
41
|
if(entries.length!==1||!s||!['normal','modified','added','replaced'].includes(s['@_item'])||s['@_props']==='conflicted'||['@_switched','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true'))throw new Error('Unsafe replacement ancestor state');
|
|
42
|
+
assertCopySource(identity.source.copyFromUrl,name==='.'?p:p.slice(name.length+1));
|
|
34
43
|
const properties=await props(name);if(Object.hasOwn(properties,'svn:externals')||Object.hasOwn(properties,'svn:special'))throw new Error('Unsafe replacement ancestor properties');
|
|
35
|
-
const record={path:name,fingerprint:hash(JSON.stringify([identity.fingerprint,properties]))};
|
|
44
|
+
const record={path:name,copyFromUrl:identity.source.copyFromUrl,fingerprint:hash(JSON.stringify([identity.fingerprint,properties]))};
|
|
36
45
|
if(verifying){if(ancestorReceipts.get(name)?.fingerprint!==record.fingerprint)throw new Error('Replacement ancestor changed during capture');}else ancestorReceipts.set(name,record);records.push(record);
|
|
37
46
|
}return records;
|
|
38
47
|
};
|
|
@@ -40,6 +49,7 @@ export async function captureReplacementTree(root,rootInfo,name,options={}){
|
|
|
40
49
|
if(['@_switched','@_file-external','@_tree-conflicted'].some(k=>s[k]==='true')||!['normal','modified','added','replaced'].includes(s['@_item'])||s['@_props']==='conflicted')throw new Error('Unsafe replacement descendant state');
|
|
41
50
|
const ancestorEvidence=await ancestors(p);
|
|
42
51
|
const node=xml(await run(['info','--xml','--',p+'@'])).info?.entry?.[0],identity=svnWorkingIdentity(node,rootInfo,p);if(!['file','dir'].includes(node['@_kind']))throw new Error('Unsupported replacement descendant kind');
|
|
52
|
+
assertCopySource(identity.source.copyFromUrl);
|
|
43
53
|
if(node['@_kind']==='dir'&&identity.source.depth!=='infinity')throw new Error('Sparse replacement target cannot prove descendant inventory');
|
|
44
54
|
const properties=await props(p);if(Object.hasOwn(properties,'svn:externals'))throw new Error('Replacement externals require independent scope');
|
|
45
55
|
const content=node['@_kind']==='dir'?decode(Buffer.alloc(0)):Object.hasOwn(properties,'svn:special')?(await readWorkingSvnSpecial(root,p,properties,options)).content:await readLocal(root,p,options);
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
// Audit only successful reads from this synthesis generation, never prior batches/searches.
|
|
2
2
|
export function validateSynthesisReads(findings, changes, reads, catalog, fileId, snapshotId) {
|
|
3
3
|
const sources = new Map(catalog.map(s => [s.id, s]));
|
|
4
|
+
const intervals = new Map();
|
|
5
|
+
for (const read of reads) {
|
|
6
|
+
const source = sources.get(read.sourceId);
|
|
7
|
+
if (read.kind !== 'read' || read.snapshotId !== snapshotId || source?.fileId !== fileId || !['old', 'new'].includes(source.side)) continue;
|
|
8
|
+
if (!Number.isSafeInteger(read.start) || read.start < 1 || !Number.isSafeInteger(read.count) || read.count < 1 || !Number.isSafeInteger(read.start + read.count)) continue;
|
|
9
|
+
const ranges = intervals.get(read.sourceId) ?? [];
|
|
10
|
+
ranges.push({ start: read.start, end: read.start + read.count });
|
|
11
|
+
intervals.set(read.sourceId, ranges);
|
|
12
|
+
}
|
|
13
|
+
// Never join different source identities, even when their file and side match.
|
|
14
|
+
for (const [id, ranges] of intervals) {
|
|
15
|
+
const merged = [];
|
|
16
|
+
for (const range of ranges.sort((a, b) => a.start - b.start)) {
|
|
17
|
+
const last = merged.at(-1);
|
|
18
|
+
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
|
|
19
|
+
else merged.push({ ...range });
|
|
20
|
+
}
|
|
21
|
+
intervals.set(id, merged);
|
|
22
|
+
}
|
|
4
23
|
for (const finding of findings) {
|
|
5
24
|
for (const id of finding.attribution.editIds) {
|
|
6
25
|
const edit = changes.edits.find(e => e.id === id);
|
|
7
26
|
const covered = ['old', 'new'].some(side => {
|
|
8
27
|
const range = edit?.[side];
|
|
9
|
-
return range?.count > 0 &&
|
|
10
|
-
const source = sources.get(r.sourceId);
|
|
11
|
-
return r.kind === 'read' && r.snapshotId === snapshotId && source?.fileId === fileId && source.side === side && r.start <= range.start && r.start + r.count >= range.start + range.count;
|
|
12
|
-
});
|
|
28
|
+
return range?.count > 0 && [...intervals].some(([sourceId, ranges]) => sources.get(sourceId).side === side && ranges.some(r => r.start <= range.start && r.end >= range.start + range.count));
|
|
13
29
|
});
|
|
14
30
|
if (!covered) throw new Error('Synthesis referenced edit was not read in this generation');
|
|
15
31
|
}
|
package/src/tracked-context.mjs
CHANGED
|
@@ -16,7 +16,10 @@ export async function gitContextIndex(root, options = {}) {
|
|
|
16
16
|
let bytes;
|
|
17
17
|
if(options.probePaths !== undefined){
|
|
18
18
|
if(!Array.isArray(options.probePaths)||options.probePaths.length>512||new Set(options.probePaths).size!==options.probePaths.length)throw new Error('Invalid Git context probes');
|
|
19
|
-
|
|
19
|
+
// A literal pathspec alone still recursively matches a directory. Exclude
|
|
20
|
+
// its literal trailing-slash subtree before output, so directory probes are
|
|
21
|
+
// exact misses without enumerating or authorizing unrequested descendants.
|
|
22
|
+
const chunks=[];let totalBytes=0;for(const name of [...options.probePaths].sort()){relativePath(name);const chunk=await checked('git',['--no-optional-locks','-c','core.fsmonitor=false','-c','core.untrackedCache=false','ls-files','--stage','-z','--',':(top,literal)'+name,':(top,literal,exclude)'+name+'/'],{...options,cwd:root,maxBytes:64*1024});const records=new TextDecoder('utf-8',{fatal:true}).decode(chunk).split('\0').filter(Boolean);for(const record of records)if(record.slice(record.indexOf('\t')+1)!==name)throw new Error('Git context probe expanded beyond literal path');totalBytes+=chunk.length;if(totalBytes>1024*1024)throw new Error('Tracked context index limit exceeded');chunks.push(chunk);}
|
|
20
23
|
bytes=Buffer.concat(chunks);if(bytes.length>1024*1024)throw new Error('Tracked context index limit exceeded');
|
|
21
24
|
}else bytes = await checked('git', ['--no-optional-locks','-c','core.fsmonitor=false','-c','core.untrackedCache=false','ls-files','--stage','-z'], {...options,cwd:root,maxBytes:1024*1024});
|
|
22
25
|
const records = new TextDecoder('utf-8',{fatal:true}).decode(bytes).split('\0').filter(Boolean);
|
package/src/window-synthesis.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Pure approved-data preparation, no reads or sends. No source text is silently clipped.
|
|
2
2
|
export function prepareWindowSynthesis(batchPayloads, measure) {
|
|
3
|
-
if (!Array.isArray(batchPayloads) || batchPayloads.length < 2
|
|
3
|
+
if (!Array.isArray(batchPayloads) || batchPayloads.length < 2) throw new Error('Invalid synthesis batches');
|
|
4
|
+
// A synthesis scheduling limit must not discard any independently reviewable batch.
|
|
5
|
+
if (batchPayloads.length > 20) return { status: 'blocked', reason: 'batch-limit', batchCount: batchPayloads.length };
|
|
4
6
|
const batches = batchPayloads.map(payload => JSON.parse(payload));
|
|
5
7
|
const base = batches[0];
|
|
6
8
|
const windows = batches.flatMap(batch => batch.windows);
|
|
@@ -8,5 +10,5 @@ export function prepareWindowSynthesis(batchPayloads, measure) {
|
|
|
8
10
|
const windowIndex = windows.map(w => ({ id: w.id, editIds: w.editIds, old: { start: w.old.start, count: w.old.count }, new: { start: w.new.start, count: w.new.count } }));
|
|
9
11
|
const payload = JSON.stringify({ ...base, windows: undefined, windowIndex, sourceMode: 'window-synthesis', windowNotice: '本请求综合检查所有窗口的共同影响,不重报单窗口问题;windowIndex仅为原始定位目录,不含源码,必须在批准catalog中读取相关旧新片段,不能仅凭目录认定缺陷。输出仍仅主file的发现,归因至少关联两个不同窗口的编辑;证据不足明确限制。' });
|
|
10
12
|
const budget = measure(payload);
|
|
11
|
-
return { payload, windows, windowIds: windows.map(w => w.id), budget };
|
|
13
|
+
return { status: budget.fits ? 'ready' : 'blocked', ...(budget.fits ? {} : { reason: 'input-budget' }), payload, windows, windowIds: windows.map(w => w.id), budget };
|
|
12
14
|
}
|