@feng0531/dsh-code-review 0.85.5 → 0.85.7

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 CHANGED
@@ -2,6 +2,22 @@
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.7 — 大仓库项目选择预览修复
6
+
7
+ - Git 项目模式显式选择文件时只查询所选精确索引,不再因未选文件导致全仓索引输出或节点数超限;空选择不枚举索引,目录及通配符不展开。
8
+ - 初次捕获、执行前及末尾复核使用同一局部范围,保留所选索引/正文变动拒绝、冲突及链接限制;未选索引变化不再使局部快照失效。
9
+ - 局部预览与报告明确显示清单不完整、全仓总数未知及所选清单数,不冒充全项目覆盖。未指定选择和 SVN 清单上限保持不变。
10
+ - 这是当前源码项目预览修复,不新增历史重命名配对,也不将项目审核当作重构前后功能等价证明。
11
+
12
+ ## 0.85.6 — 快照正确性、任务隔离与取消修复
13
+
14
+ - Git 工作区撤销暂存重命名后按 HEAD 到当前原路径的净内容比较,不再误报不存在的删除;历史提交允许非 UTF-8 消息,父对象标识仍严格验证。
15
+ - 实例方法绑定保留与 Object 原型同名的合法方法,并拒绝同一构造行附加语句或可执行参数造成的错误唯一性。
16
+ - 后台任务卸载等待启动持久化到终态的完整生命周期,取消后不再启动审核;报告目录按真实路径隔离,拒绝祖先 junction/符号链接别名绕过。
17
+ - SVN 标准关键字按原生别名组收缩,固定字段保持 UTF-8 字节宽度;显式上下文末尾同时复核元数据与原始正文哈希,并累计来源预算。
18
+ - 本地文件以只读非阻塞方式打开后验证句柄类型,避免 Linux FIFO 替换挂起;进程取消时关闭自身输出管道并等待直接子进程结束,正常输出仍完整读取,不承诺终止全部后代。
19
+ - 注册表元数据及安装包正文网络中断使用有界重试,格式、地址、超限与完整性失败仍立即拒绝;测试临时 SVN 工作副本及时清理。
20
+
5
21
  ## 0.85.5 — 凭据来源保护与审核边界修复
6
22
 
7
23
  - SVN 独立替换树在读取正文前检查复制来源及祖先来源,阻断从凭据路径复制到普通目标的内容;调用方扫描不再把带源码扩展名的目录当作文件读取。
package/DEVELOPMENT.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  服务按DSH session对象隔离缓存和锁,同一会话changes/project两种模式共用互斥锁;不同会话可并行,模型调用与取消由各请求生命周期约束。Git工作区默认比较HEAD到工作区;changeScope=staged/unstaged分别只比较HEAD→index和index→工作区,执行前复核对应内容。宿主dispose终止全部会话服务。全项目是跟踪文件的当前源码分批,不是多仓库汇总或跨批次语义分析。
18
18
 
19
- 项目捕获最多10000清单节点、1000选择节点、16MiB,不设默认总模型调用次数或执行秒表,初次捕获/复核均遵循宿主与用户取消。SVN项目模式拒绝不支持节点属性,不隐式继承固定历史审查的网络权限。
19
+ 项目捕获完整清单最多 10000 节点、1000 选择节点、16 MiB。Git 显式选择以精确字面路径查询所选索引,单次查询最多 64 KiB、总计 2 MiB,不受未选索引数量影响;初次、执行前及末尾复核使用相同范围。局部清单不宣称全仓完整性或总数,只绑定所选索引和正文,未选索引变动不使其失效。公开工具的显式列表仍最多 200 项。不设默认总模型调用次数或执行秒表,初次捕获/复核均遵循宿主与用户取消。SVN 仍枚举本地完整已观察清单,项目模式拒绝不支持节点属性,不隐式继承固定历史审查的网络权限。
20
20
 
21
21
  ## 全项目并发与离线速度对照
22
22
 
package/README.md CHANGED
@@ -254,11 +254,11 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
254
254
 
255
255
  项目模式只接受reviewMode、repositoryPath和可选selectedPaths,不与历史revision、autoContext、规则或调用方参数混用。默认全部跟踪节点;不读取未跟踪/忽略文件、子模块、外部仓库及链接目标。目录只作清单记录,源码按批次审查;跨批次不宣称完整调用图或全程序语义分析。
256
256
 
257
- 清单上限10000节点,单次选择最多1000节点/16MiB,12节点一批,单文件256KiB;超限明确拒绝,缩小选择后必须重新预览。单次采集共享8MiB元数据内存保护,不按命令条数、插件秒表或模型调用次数中断;耗时受宿主/模型提供方限制,用户可随时取消。SVN为本地已观察清单,稀疏/外部/链接及不支持属性显示限制;不证明仓库远端完整覆盖。
257
+ 未指定选择时,清单上限 10000 节点,Git 索引输出上限 2 MiB。Git 显式 `selectedPaths` 只查询所选精确文件,不先枚举全仓,因此大仓库可用小范围预览;空数组不枚举索引,目录与通配符不会展开。此时 `inventoryScope:"selected"`、`inventoryComplete:false`、`selectionComplete:false`,`totalTrackedNodes:null` 表示全仓总数未知,`inventoryNodes` 仅为所选清单数;只绑定所选索引及正文,未选文件的变化不使该局部快照失效。单次选择运行时最多 1000 节点(公开工具参数显式列表最多 200 项)/16 MiB,12 节点一批,单文件 256 KiB;超限必须重新预览。SVN 仍枚举本地已观察清单,显式选择不绕过清单上限,稀疏/外部/链接及不支持属性显示限制,不证明远端完整覆盖。SVN 单次采集共享 8 MiB 元数据保护;Git 所选索引单次查询最多 64 KiB、合计 2 MiB。不按命令条数、插件秒表或模型调用次数中断;耗时受宿主/模型提供方限制,用户可随时取消。
258
258
 
259
259
  全项目审查默认最多 **8 个独立批次并行**,每批内主审、检索、复核仍按依赖顺序执行;预览和报告的 `execution.batchConcurrency` 显示实际配置。插件配置 `projectConcurrency` 可设为 1~8,设 1 回退串行;遇到模型提供方并发限制或 429,应降低并发。该设置不是 preview 参数,不扩大来源或共享模型调用预算,也不减少模型调用量或承诺 token 成本下降。仅一个批次时不会获得批间加速;实际提速受提供方吞吐、批次均衡及检查点写入影响。
260
260
 
261
- 对外只交付一份统一最终报告,给出总数、completed、excluded、blocked、pending、去重后的发现及未完成综合阶段;内部批次快照仅供证据追溯,不需要用户拼接报告。completed只表示所选捕获文件已处理,不代表代码正确;排除与阻断从不算完成。代码或清单变化会拒绝旧预览,运行后无法复核则标记outdated。
261
+ 对外只交付一份统一最终报告,给出总数、completed、excluded、blocked、pending、去重后的发现及未完成综合阶段;内部批次快照仅供证据追溯,不需要用户拼接报告。completed只表示所选捕获文件已处理,不代表代码正确;排除与阻断从不算完成。所绑定的代码或清单变化会拒绝旧预览,运行后无法复核则标记outdated。
262
262
 
263
263
  ## 工具调用流程(给需要精确指定参数的人)
264
264
 
package/RELEASE-NOTES.md CHANGED
@@ -1,21 +1,15 @@
1
- # v0.85.5 发布说明
1
+ # v0.85.7 发布说明
2
2
 
3
3
  使用方式见 [README](README.md),详细变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
4
4
 
5
- ## 安全修复
5
+ ## 大仓库也能按文件预览
6
6
 
7
- SVN 独立替换树现在在读取正文前检查复制来源及复制祖先来源,阻断把凭据文件复制到普通目标后绕过来源过滤的路径。限定调用方扫描也会区分目录与普通文件,带 `.js` 等源码扩展名的目录不再令预览整体失败。
8
-
9
- ## 审核正确性与可用性
10
-
11
- - Git 默认工作区审核合并暂存删除后重建的同路径记录,保持文件身份唯一。
12
- - 自动上下文的目录索引导入不再被误判为越界;仍只读取精确选中的候选正文,不授权目录下任意文件。
13
- - 已选暂存变更同时作为显式上下文时,复用 index 内容并按同侧版本验证,不把尚未暂存的正文混入变更侧。
14
- - 长编辑分次读取后,综合校验按同一快照、来源及版本侧计算连续覆盖;跨来源、有缺口或错误版本的读取仍不能作为有效证据。
15
- - 文件分成超过 20 个窗口批次时,主窗口继续审核并保留已有成果;综合受限单独披露,整体仍为部分完成,不再以异常丢失整轮报告。
7
+ - 修复 Git 项目模式只选少量文件,仍因全仓索引超过 2 MiB 或 10000 节点而预览失败的问题。显式 `selectedPaths` 现在只查询所选精确索引;空选择不枚举索引,目录和通配符不展开。
8
+ - 初次捕获、执行前及末尾复核保持相同选择范围;所选索引或正文变化仍使快照失效,冲突、链接、子模块及凭据路径保护不放宽。未选索引变化不影响局部快照。
9
+ - 预览、批次和报告标明局部清单范围;全仓总数为未知值,不把所选数量或局部完成当成全仓覆盖。
16
10
 
17
11
  ## 验证与边界
18
12
 
19
- 本版为六项已复现问题添加回归测试,并执行完整本地测试及隔离安装验证;正式发布须通过同一冻结标签提交的 Ubuntu/Windows × Node.js 22/24 四组 CI、安装包验证和 npm 实际工件一致性门禁。具体执行结果以该标签 CI 和发布工作流为准。
13
+ 新增 15 项大索引、精确路径、空选择、失效快照和范围披露回归。本地 Windows 完整测试 765 项:761 通过、4 项平台限定跳过、0 失败;隔离安装入口及本地化导出验证通过。正式发布须通过冻结标签同一提交的 Ubuntu/Windows × Node.js 22/24 四组 CI、隔离安装以及原始包、npm 与 GitHub Release 工件一致性门禁;最终结果以对应 CI 和发布工作流为准。
20
14
 
21
- 本版本不保证模型始终输出合法 JSON,不扩大源码读取权限,不自动安装、升级或重启 DSH;更新并重新加载插件后生效。
15
+ 未指定选择的完整索引捕获及 SVN 项目清单仍保留原上限;历史变更范围仍最多 200 个变更路径。本版不新增跨路径旧/新实现配对,不将当前源码项目审核替代重构前后功能对照,也不以空发现证明功能等价。发布不自动安装、升级或重启 DSH;更新并重新加载插件后生效。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.85.5",
3
+ "version": "0.85.7",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -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[d.name] = (names[d.name] ?? 0) + 1;
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[d.name] !== 1) continue;
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 = /^\s*(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/.exec(lines[i]);
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
- const handle = await open(canonical, 'r');
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');
@@ -20,9 +20,17 @@ async function resolveCommit(root, ref, options) {
20
20
  return oid;
21
21
  }
22
22
 
23
- function parseCommitParents(objectText) {
24
- const header = objectText.split('\n\n', 1)[0];
25
- const parents = header.split('\n').filter(line => line.startsWith('parent ')).map(line => line.slice(7));
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 = utf8(await gitAt(root, ['cat-file', 'commit', target], options));
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
@@ -45,8 +45,15 @@ function changesInScope(changes, scope) {
45
45
  // Worktree compares HEAD to the current file once, retaining the tracked entry
46
46
  // (and its baseline/selection authority), not a second untracked identity.
47
47
  if (scope === 'worktree') {
48
- const tracked = new Set(changes.filter(change => change.rawStatus !== '??').map(change => change.path));
49
- return changes.filter(change => change.rawStatus !== '??' || !tracked.has(change.path));
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));
50
57
  }
51
58
  if (scope === 'staged') return changes.filter(change => change.rawStatus[0] !== ' ' && change.rawStatus[0] !== '?');
52
59
  return changes.filter(change => change.rawStatus[1] !== ' ' || change.rawStatus === '??');
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?.();
@@ -31,7 +31,7 @@ function makeCaptureBudget(signal) {
31
31
 
32
32
  async function captureProject(cwd, args, signal) {
33
33
  const captureBudget = makeCaptureBudget(signal);
34
- const captureOptions = { signal, svnCaptureBudget: captureBudget };
34
+ const captureOptions = { signal, svnCaptureBudget: captureBudget, selectedPaths: args.selectedPaths };
35
35
  const root = await realpath(await resolveWorkspaceRepository(cwd, args.repositoryPath));
36
36
  const vcs = await detectVcs(root);
37
37
  const inventory = await projectInventory(root, vcs, captureOptions);
@@ -59,9 +59,10 @@ async function captureProject(cwd, args, signal) {
59
59
  }
60
60
 
61
61
  const result = {
62
- root, vcs, inventoryId: inventory.id, inventoryComplete: inventory.vcs === 'git',
63
- totalTrackedNodes: inventory.records.length, selectedFiles: paths.length,
64
- selectionComplete: paths.length === inventory.records.length, notice: inventory.notice, batches,
62
+ root, vcs, inventoryId: inventory.id, inventoryComplete: inventory.inventoryComplete,
63
+ inventoryScope: inventory.inventoryScope, inventoryNodes: inventory.records.length,
64
+ totalTrackedNodes: inventory.totalTrackedNodes, selectedFiles: paths.length,
65
+ selectionComplete: inventory.inventoryScope !== 'selected' && paths.length === inventory.records.length, notice: inventory.notice, batches,
65
66
  };
66
67
  return { ...result, id: hash(JSON.stringify(result)) };
67
68
  }
@@ -96,7 +97,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
96
97
  previewId, snapshotId: s.id, reviewMode: 'project', repositoryRoot: s.root, vcs: s.vcs,
97
98
  model: current.route, modelSendingEnabled: allowModelSending,
98
99
  totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
99
- inventoryComplete: s.inventoryComplete, batches: s.batches.length,
100
+ inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes, batches: s.batches.length,
100
101
  execution: { batchConcurrency: projectConcurrency },
101
102
  files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
102
103
  notice: s.notice + PREVIEW_NOTICE_SUFFIX,
@@ -160,7 +161,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
160
161
  : results.every(r => r.status === 'completed') && files.every(f => ['completed', 'excluded'].includes(f.status)) ? 'completed'
161
162
  : 'partial',
162
163
  repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
163
- totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete,
164
+ totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes,
164
165
  coverage, files, findings, batches: results,
165
166
  execution: { batchConcurrency: projectConcurrency },
166
167
  notice: s.notice + REPORT_NOTICE_SUFFIX,
@@ -15,7 +15,29 @@ async function inventoryGit(root, options) {
15
15
  const args = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
16
16
  const actual = text(await checked('git', [...args, 'rev-parse', '--show-toplevel'], { ...options, cwd: root })).replace(/\r?\n$/, '');
17
17
  if (await realpath(actual) !== root) throw new Error('Full project requires explicit repository root');
18
- const index = await checked('git', [...args, 'ls-files', '--stage', '-z'], { ...options, cwd: root, maxBytes: 2 * 1024 * 1024 });
18
+ const selected = options.selectedPaths;
19
+ let index;
20
+ if (selected === undefined) {
21
+ index = await checked('git', [...args, 'ls-files', '--stage', '-z'], { ...options, cwd: root, maxBytes: 2 * 1024 * 1024 });
22
+ } else {
23
+ const chunks = [];
24
+ let bytes = 0;
25
+ for (const name of [...selected].sort()) {
26
+ // Literal pathspecs still recurse. Exclude descendants, not the node:
27
+ // a trailing-slash literal exclusion would also hide an exact gitlink.
28
+ const escaped = Array.from(name, c => '*?[]'.includes(c) ? String.fromCharCode(92) + c : c).join('');
29
+ const chunk = await checked('git', [...args, 'ls-files', '--stage', '-z', '--',
30
+ ':(top,literal)' + name, ':(top,glob,exclude)' + escaped + '/**'],
31
+ { ...options, cwd: root, maxBytes: 64 * 1024 });
32
+ for (const row of text(chunk).split(String.fromCharCode(0)).filter(Boolean)) {
33
+ if (row.slice(row.indexOf(String.fromCharCode(9)) + 1) !== name) throw new Error('Git project selection expanded beyond literal path');
34
+ }
35
+ bytes += chunk.length;
36
+ if (bytes > 2 * 1024 * 1024) throw new Error('Selected project inventory exceeds shared 2MiB limit');
37
+ chunks.push(chunk);
38
+ }
39
+ index = Buffer.concat(chunks);
40
+ }
19
41
  const records = [];
20
42
  for (const row of text(index).split(String.fromCharCode(0)).filter(Boolean)) {
21
43
  const m = /^(\d{6}) ([a-f0-9]+) ([0-3])\t([\s\S]+)$/.exec(row);
@@ -47,6 +69,11 @@ async function inventorySvn(root, options) {
47
69
  }
48
70
 
49
71
  export async function projectInventory(cwd, vcs, options = {}) {
72
+ const selected = options.selectedPaths;
73
+ if (selected !== undefined) {
74
+ if (!Array.isArray(selected) || selected.length > 1000 || new Set(selected).size !== selected.length) throw new Error('Invalid tracked project selection');
75
+ selected.forEach(relativePath);
76
+ }
50
77
  const root = await realpath(cwd);
51
78
  const source = vcs === 'git' ? await inventoryGit(root, options)
52
79
  : vcs === 'svn' ? await inventorySvn(root, options)
@@ -58,7 +85,14 @@ export async function projectInventory(cwd, vcs, options = {}) {
58
85
  const grouped = new Map();
59
86
  for (const r of records) { if (grouped.has(r.path)) { grouped.get(r.path).duplicate = true; } else grouped.set(r.path, r); }
60
87
  records = [...grouped.values()];
61
- const inventory = { root, vcs, records, identity: source.identity, notice: INVENTORY_NOTICE };
88
+ const scoped = vcs === 'git' && selected !== undefined;
89
+ const inventory = {
90
+ root, vcs, records, identity: source.identity,
91
+ inventoryScope: scoped ? 'selected' : 'repository',
92
+ inventoryComplete: vcs === 'git' && !scoped,
93
+ totalTrackedNodes: scoped ? null : records.length,
94
+ notice: INVENTORY_NOTICE + (scoped ? ' Explicit Git selection only: repository total is unknown; unrelated index entries are not enumerated or bound to this snapshot.' : ''),
95
+ };
62
96
  return { ...inventory, id: hash(JSON.stringify(canonical(inventory))) };
63
97
  }
64
98
 
@@ -100,7 +134,7 @@ export async function captureProjectBatch(inventory, paths, options = {}) {
100
134
  delete file.left; delete file.right;
101
135
  }
102
136
  }
103
- const scope = { mode: 'project', inventoryId: inventory.id, totalTrackedNodes: inventory.records.length, batchPaths: paths, untrackedIncluded: false, inventoryComplete: inventory.vcs === 'git', scopeCompleteness: inventory.vcs === 'svn' ? 'observed-working-copy-only' : 'tracked-index-only', notice: inventory.notice };
137
+ const scope = { mode: 'project', inventoryId: inventory.id, inventoryScope: inventory.inventoryScope, inventoryNodes: inventory.records.length, totalTrackedNodes: inventory.totalTrackedNodes, batchPaths: paths, untrackedIncluded: false, inventoryComplete: inventory.inventoryComplete, scopeCompleteness: inventory.vcs === 'svn' ? 'observed-working-copy-only' : inventory.inventoryScope === 'selected' ? 'selected-index-only' : 'tracked-index-only', notice: inventory.notice };
104
138
  const snapshot = { schemaVersion: 1, vcs: inventory.vcs, root: inventory.root, files, context: [], projectScope: scope };
105
139
  return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
106
140
  }
@@ -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
- async function safeDirectory(){await mkdir(directory,{recursive:true,mode:0o700});const stat=await lstat(directory),resolved=await realpath(directory);if(stat.isSymbolicLink()||!stat.isDirectory()||(canonicalDirectory!==undefined&&resolved!==canonicalDirectory))throw new Error('Unsafe report directory');canonicalDirectory??=resolved;}
16
- async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));changed(job.id);}
17
- 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();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;}
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){if(disposed)throw new Error('Review job service disposed');exec.signal?.throwIfAborted();const who=identity(exec);const relative=path.relative(path.resolve(who.cwd),directory);if(relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)))throw new Error('Report store must be outside reviewed workspace');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');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()};jobs.set(id,job);try{await save(job);}catch(error){jobs.delete(id);throw error;}
20
- // Keep the serialization tail usable after failure, but return the original
21
- // rejecting operation so checkpoint callers still observe durability failures.
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
- // Own cancellation explicitly. The short start RPC ending is NOT task cancellation.
24
- job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));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);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));jobs.delete(id);}catch(error){job.record.status='failed';job.record.persistenceError=error?.message??String(error);}finally{changed(id);}}})();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.'};
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};},
@@ -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
- const verify=async()=>{for(const node of identities)if((await svnCallerNode(root,node.path,bounded)).fingerprint!==node.fingerprint)throw new Error('SVN explicit context identity changed during capture');signal.throwIfAborted();};
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
- const STANDARD = new Set(['Date', 'LastChangedDate', 'Revision', 'Rev', 'LastChangedRevision', 'Author', 'LastChangedBy', 'HeadURL', 'URL', 'Id', 'Header']);
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 (enabled.has(token)) throw new Error('Duplicate SVN keyword configuration');
30
- enabled.add(token);
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); shrink values without changing width.
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.length) + '$';
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++;