@feng0531/dsh-code-review 0.83.1 → 0.84.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 CHANGED
@@ -2,6 +2,10 @@
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.84.0 — 暂存区与未暂存审查范围
6
+
7
+ 新增 Git `changeScope`:`staged` 只审查 HEAD 到索引的已暂存内容,工作区里尚未暂存的修改不作为变更正文参与或发送(上下文与规则文件仍按工作区读取);`unstaged` 只审查索引到工作区的未暂存改动,已暂存内容仅作旧侧基线。省略时仍是 HEAD 到工作区的净变化。两个范围只用于 Git 工作副本,不能与历史版本、SVN 参数或全项目模式混用;未跟踪文件仍默认排除。预览与报告会标明实际比较范围,执行前重新核验对应内容。
8
+
5
9
  ## 0.83.1 — 代码结构整理
6
10
 
7
11
  整理内部结构,不改变行为:模型指令集中到 src/prompts.mjs 并由原模块继续重导出;重复的工具输出 schema 抽到 src/tool-view.mjs;缺陷/协议指引的扩展名判定改为优先级查找表;超长单行与巨型函数拆成具名小函数,覆盖 service、dsh-model、git、git-history、snapshot、review 渲染、项目模式、会话/后台任务与 SVN 纯辅助模块。工具参数、协议字段和报告语义不变,升级前后输出一致。
@@ -6,7 +6,7 @@
6
6
 
7
7
  | 能力 | 用途 | 不能据此推断 |
8
8
  |---|---|---|
9
- | Git/SVN变更采集 | 工作副本、固定历史端点和同仓库SVN分支比较 | 任意SVN状态均可审查、完整提交逐一覆盖 |
9
+ | Git/SVN变更采集 | Git工作副本净变化/暂存区/未暂存范围、固定历史端点、同仓库SVN分支比较 | 任意SVN状态均可审查、完整提交逐一覆盖 |
10
10
  | 范围与来源保护 | 先预览后确认,固定来源与哈希,执行前重新采集 | 普通源码一定没有秘密、模型提供方不保存数据 |
11
11
  | 上下文 | 显式文件、旧侧文件、最多三层字面依赖、限定目录引用方 | 完整语义依赖图、调用图或动态执行可达性 |
12
12
  | 输入与调度 | 差异窗口、分组和失败回退,共享预算 | 大仓库一定能在预算内完成 |
package/DEVELOPMENT.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## 会话与模式
16
16
 
17
- 服务按DSH session对象隔离缓存和锁,同一会话changes/project两种模式共用互斥锁;不同会话可并行,模型调用与取消由各请求生命周期约束。宿主dispose终止全部会话服务。全项目是跟踪文件的当前源码分批,不是多仓库汇总或跨批次语义分析。
17
+ 服务按DSH session对象隔离缓存和锁,同一会话changes/project两种模式共用互斥锁;不同会话可并行,模型调用与取消由各请求生命周期约束。Git工作区默认比较HEAD到工作区;changeScope=staged/unstaged分别只比较HEAD→index和index→工作区,执行前复核对应内容。宿主dispose终止全部会话服务。全项目是跟踪文件的当前源码分批,不是多仓库汇总或跨批次语义分析。
18
18
 
19
19
  项目捕获最多10000清单节点、1000选择节点、16MiB,不设默认总模型调用次数或执行秒表,初次捕获/复核均遵循宿主与用户取消。SVN项目模式拒绝不支持节点属性,不隐式继承固定历史审查的网络权限。
20
20
 
package/README.md CHANGED
@@ -104,6 +104,20 @@ dsh plugin add https://github.com/f-e-n-g-0531/dsh-code-review.git
104
104
 
105
105
  比较两个版本的文件树,不自动计算合并基点,也不会把工作区中的未提交正文当作历史正文。`commit` 不能与这两个参数混用。
106
106
 
107
+ ### 只审查 Git 暂存区或未暂存改动
108
+
109
+ 提交前只看已暂存内容,或只想确认工作区里还没暂存的修改:
110
+
111
+ ```json
112
+ {"changeScope":"staged"}
113
+ ```
114
+
115
+ `staged` 比较 HEAD 与索引(相当于 `git diff --cached`):右侧是已暂存内容,工作区里尚未 `git add` 的修改不作为变更内容,也不会作为变更正文发送给模型。把值换成 `"unstaged"` 则比较索引与工作区(相当于 `git diff`):左侧是已暂存内容,仅作为旧侧基线,右侧是工作区文件。省略 `changeScope` 时默认仍是 HEAD 到工作区的净变化。
116
+
117
+ 这两个范围只用于 Git 工作副本,不能与 `commit`、`baseRevision`、`targetRevision`、SVN 参数或 `reviewMode:"project"` 同时使用。未跟踪文件仍默认排除;`worktree` / `unstaged` 下用 `selectedPaths` 显式选择后才会纳入。预览和报告都会标明实际比较的是哪两个内容。
118
+
119
+ `staged` 下显式上下文、自动依赖、调用方候选和规则文件仍按工作区当前内容读取,可能包含尚未暂存的修改;预览会列出这些文件。
120
+
107
121
  ### 审查 SVN 单个 revision
108
122
 
109
123
  例如审查 r7315 引入的净变化,即 **r7314 → r7315**:
@@ -280,13 +294,13 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
280
294
  | SVN 历史提示需要网络许可 | 确认可以访问仓库后,预览参数增加 `svnAllowNetwork:true` |
281
295
  | SVN 登录、权限或版本不存在 | 检查 SVN 本身的访问权限和版本号;不要把密码写进对话或仓库 URL |
282
296
  | 文件被阻断或报告 partial | 阅读具体原因;缩小变更批次、补充允许的上下文,或处理真实冲突后重新预览。不能把排除文件视为通过 |
283
- | 想审查 Git 暂存区,排除未暂存改动 | 当前没有单独 staged / unstaged 模式;默认是 HEAD 到工作区的净变化 |
297
+ | 想只审查 Git 暂存区或只审查未暂存改动 | 预览参数设置 `changeScope:"staged"`(HEAD → 索引)或 `changeScope:"unstaged"`(索引 → 工作区);默认仍是 HEAD 到工作区的净变化 |
284
298
 
285
299
  ## 支持范围与安全边界
286
300
 
287
301
  - 执行会把批准范围内的代码、上下文、所选规则及业务要求发送给当前模型提供方;敏感项目请先检查预览和模型。常见凭据路径过滤不能保证源码没有秘密。
288
302
  - 插件遵循 DSH 工具权限;确认预览不等于绕过宿主权限。不执行项目代码、构建脚本或模型生成的 shell 命令。
289
- - Git 支持工作区、单提交、两版本比较。SVN 支持工作副本和固定历史端点;**尚未完整支持所有 SVN 状态**。
303
+ - Git 支持工作区净变化、暂存区/未暂存范围、单提交、两版本比较。SVN 支持工作副本和固定历史端点;**尚未完整支持所有 SVN 状态**。
290
304
  - SVN 根目录属性可预览;同仓库复制新增以空目标旧侧审查,复制来源只作信息;移动的新增/删除分别覆盖。目录后代须逐项纳入,选择父目录不自动批准所有后代。
291
305
  - SVN 标准、固定宽度和受支持的复合关键字展开,以及工作副本变更/自动依赖/命中调用方、固定历史变更及上下文的换行元数据保留原文证据;不以规范化结果证明行为等价。自定义/固定宽度关键字、未提交替换、冲突、特殊节点、切换/外部工作副本及跨仓库比较等仍有限制。同仓库跨分支与独立基线替换需要下文列出的显式参数。已提交固定历史替换与未提交替换的支持不同,详见 [SVN 支持矩阵](SVN-SUPPORT-MATRIX.md)。
292
306
  - 二进制、不能解码的文件、普通源码路径中的链接和超限内容会明确阻断;已跟踪的 SVN 链接只审查 `link 目标` 记录,不读取目标正文,不静默截断后声称完成。文本导航不证明语义绑定、执行可达或完整调用覆盖。
package/RELEASE-NOTES.md CHANGED
@@ -1,12 +1,13 @@
1
- # v0.83.1 发布说明
1
+ # v0.84.0 发布说明
2
2
 
3
- ## 内部结构整理(不改变行为)
3
+ ## 新增 Git 暂存区 / 未暂存审查范围
4
4
 
5
- 本次发布只整理代码结构,不新增、删除或修改任何用户可见功能、工具参数、协议字段或报告语义;升级前后行为一致。
5
+ 本次新增 `changeScope` 参数,用于把 Git 工作副本审查限定到更精确的比较范围:
6
6
 
7
- - 模型指令集中到 `src/prompts.mjs`,原模块继续重导出,既有导入路径不变。
8
- - 重复的工具输出 schema 抽到 `src/tool-view.mjs`。
9
- - 缺陷/协议指引的扩展名判定改为优先级查找表;超长单行与巨型函数拆成具名小函数,覆盖 service、dsh-model、git、git-history、snapshot、review 渲染、项目模式与会话/后台任务。
10
- - SVN 纯辅助模块(URL、EOL、关键字、mergeinfo、预算等)按同一风格整理。
7
+ - `staged`:比较 HEAD 与索引,只审查已暂存内容。工作区里尚未 `git add` 的修改不会出现在右侧,也不会作为变更正文发送给模型。
8
+ - `unstaged`:比较索引与工作区,只审查未暂存改动。已暂存内容只作为左侧基线,不代表由本次改动引入。
9
+ - 省略该参数时行为不变,仍是 HEAD 到工作区的净变化。
11
10
 
12
- 每个改动模块都以旧/新差分对比确认行为一致;非 SVN 测试全量通过。SVN 相关测试需要本机或 CI 安装 SVN 工具链后运行,未在本发布步骤本地执行。用法与限制不变,见 [README](README.md)。
11
+ 预览会展示实际比较范围与目标模型;报告会注明审查范围。`staged` 模式下上下文、自动依赖、调用方候选和规则文件仍按工作区当前内容读取,可能包含未暂存修改,预览会列出这些文件。执行前重新采集并核对两侧内容,预览之后暂存区或相关工作区内容变化会使旧预览失效。两个新值只支持 Git 工作副本,不能与 `commit`、`baseRevision`、`targetRevision`、SVN 参数或 `reviewMode:"project"` 混用;未跟踪文件仍默认排除,只有显式 `selectedPaths` 才会在 `worktree` / `unstaged` 中纳入。
12
+
13
+ 用法和参数示例见 [README](README.md)。本版本不改变默认审查范围,也不提供真实模型缺陷率保证。
package/index.mjs CHANGED
@@ -5,8 +5,8 @@ import { reviewToolOutput } from './src/tool-view.mjs';
5
5
  export const name = 'dsh-code-review';
6
6
  export const inject = ['tools', 'llm'];
7
7
 
8
- const PREVIEW_DESCRIPTION = 'Preview Git/SVN working-copy or Git commit/range review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Each session reviews one selected repository independently; reviewMode=project selects tracked current source instead of changes. Show the preview before execution.';
9
- const PREVIEW_PARAMETERS = { type: 'object', additionalProperties: false, properties: { reviewMode: {type:'string',enum:['changes','project'],description:'project reviews all tracked current files, including unchanged files, in bounded batches with shared session budget. Does not include ignored/untracked/external contents. Each DSH session owns independent preview, lock and model budget.'}, svnReplacementBaseRevision: {type:'string',description:'Working-copy file replacement only: explicitly compare to this fixed repository revision at the target URL. Not local BASE and not proof of the removed-node revision. Requires separate remote opt-in when applicable; identity and properties frozen in preview.'}, svnReplacementAllowNetwork: {type:'boolean',description:'Explicit permission to read the selected working-copy target URL at svnReplacementBaseRevision. Does not permit arbitrary URLs, updates, merges or writes.'}, svnBasePath: {type:'string',description:'Explicit old endpoint path relative to the current SVN repository root; requires svnTargetPath and fixed revision range. Not an arbitrary URL. Same repository UUID required.'}, svnTargetPath: {type:'string',description:'Explicit new endpoint repository-relative path paired with svnBasePath. Each side content/context stays at its URL/revision; shared capture budgets and remote opt-in apply.'}, svnEffectiveMergeinfo: { type: 'boolean', description: 'Explicit SVN opt-in to bounded BASE/working or fixed-history ancestor mergeinfo property reads on each selected side. Offline for working copies; history uses existing remote permission and budgets. Mixed BASE revisions, unsafe ancestors or incomplete URL boundaries remain unknown. No branch source reads or merge attribution.' }, svnRevision: { type: 'string', description: 'SVN fixed numeric revision reviewed against revision minus one. Requires SVN working-copy root; no HEAD/date aliases.' }, svnBaseRevision: { type: 'string', description: 'SVN fixed numeric old endpoint; requires svnTargetRevision, mutually exclusive with svnRevision and Git revision parameters.' }, svnTargetRevision: { type: 'string', description: 'SVN fixed numeric new endpoint, greater than old endpoint. Fixed repository objects, not working-copy text. Supports explicitly selected target context with verified baseline provenance and target rules; explicit old-only context is also supported with fixed SVN provenance; autoContext follows at most three independent fixed-side dependency layers with bounded parent listings and shared capture budgets; callerScopePaths supports bounded fixed-side SVN historical literal discovery; only matched sides captured, never complete caller coverage.' }, svnAllowNetwork: { type: 'boolean', description: 'Explicit opt-in to read the working-copy repository URL at fixed SVN revisions during preview/execute; preview sends no source to a model. Required for non-file repository URLs; default denies remote access. Does not allow checkout/update/merge or arbitrary URLs.' }, reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git/SVN historical baseline-only context paths. Git uses fixed blobs; SVN uses UUID/URL/peg/operative revision/hash. Shares 20 context slots. No target text is inferred; selected old changes reuse original identity, excluded changes cannot reenter. Unsupported on working reviews.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git/SVN working review or fixed Git/SVN historical review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to tracked literal import/include context shown in preview. Working Git follows changed and explicitly selected context sources at most 3 dependency layers under shared 512 exact probes, 20 candidates/context slots, 1MiB source and 30s verification budget. Git history follows at most 3 baseline/target dependency layers with shared limits, object reads only; SVN follows at most 3 safe offline current-side layers with exact path probes and node/ancestor identity verification, sharing limits; no SVN historical review. Not semantic binding or complete definitions.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } };
8
+ const PREVIEW_DESCRIPTION = 'Preview Git/SVN working-copy or Git commit/range review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Each session reviews one selected repository independently; reviewMode=project selects tracked current source instead of changes; changeScope=staged or unstaged narrows a Git working-copy review to the index or to unstaged edits. Show the preview before execution.';
9
+ const PREVIEW_PARAMETERS = { type: 'object', additionalProperties: false, properties: { reviewMode: {type:'string',enum:['changes','project'],description:'project reviews all tracked current files, including unchanged files, in bounded batches with shared session budget. Does not include ignored/untracked/external contents. Each DSH session owns independent preview, lock and model budget.'}, changeScope: {type:'string',enum:['worktree','staged','unstaged'],description:'Git working-copy comparison scope. worktree (default) compares HEAD to the working tree (net change); staged compares HEAD to the index so only staged content is reviewed; unstaged compares the index to the working tree so only unstaged edits are reviewed and staged content is the old side. Requires a Git working copy; mutually exclusive with Git history, SVN parameters and project mode.'}, svnReplacementBaseRevision: {type:'string',description:'Working-copy file replacement only: explicitly compare to this fixed repository revision at the target URL. Not local BASE and not proof of the removed-node revision. Requires separate remote opt-in when applicable; identity and properties frozen in preview.'}, svnReplacementAllowNetwork: {type:'boolean',description:'Explicit permission to read the selected working-copy target URL at svnReplacementBaseRevision. Does not permit arbitrary URLs, updates, merges or writes.'}, svnBasePath: {type:'string',description:'Explicit old endpoint path relative to the current SVN repository root; requires svnTargetPath and fixed revision range. Not an arbitrary URL. Same repository UUID required.'}, svnTargetPath: {type:'string',description:'Explicit new endpoint repository-relative path paired with svnBasePath. Each side content/context stays at its URL/revision; shared capture budgets and remote opt-in apply.'}, svnEffectiveMergeinfo: { type: 'boolean', description: 'Explicit SVN opt-in to bounded BASE/working or fixed-history ancestor mergeinfo property reads on each selected side. Offline for working copies; history uses existing remote permission and budgets. Mixed BASE revisions, unsafe ancestors or incomplete URL boundaries remain unknown. No branch source reads or merge attribution.' }, svnRevision: { type: 'string', description: 'SVN fixed numeric revision reviewed against revision minus one. Requires SVN working-copy root; no HEAD/date aliases.' }, svnBaseRevision: { type: 'string', description: 'SVN fixed numeric old endpoint; requires svnTargetRevision, mutually exclusive with svnRevision and Git revision parameters.' }, svnTargetRevision: { type: 'string', description: 'SVN fixed numeric new endpoint, greater than old endpoint. Fixed repository objects, not working-copy text. Supports explicitly selected target context with verified baseline provenance and target rules; explicit old-only context is also supported with fixed SVN provenance; autoContext follows at most three independent fixed-side dependency layers with bounded parent listings and shared capture budgets; callerScopePaths supports bounded fixed-side SVN historical literal discovery; only matched sides captured, never complete caller coverage.' }, svnAllowNetwork: { type: 'boolean', description: 'Explicit opt-in to read the working-copy repository URL at fixed SVN revisions during preview/execute; preview sends no source to a model. Required for non-file repository URLs; default denies remote access. Does not allow checkout/update/merge or arbitrary URLs.' }, reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git/SVN historical baseline-only context paths. Git uses fixed blobs; SVN uses UUID/URL/peg/operative revision/hash. Shares 20 context slots. No target text is inferred; selected old changes reuse original identity, excluded changes cannot reenter. Unsupported on working reviews.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git/SVN working review or fixed Git/SVN historical review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to tracked literal import/include context shown in preview. Working Git follows changed and explicitly selected context sources at most 3 dependency layers under shared 512 exact probes, 20 candidates/context slots, 1MiB source and 30s verification budget. Git history follows at most 3 baseline/target dependency layers with shared limits, object reads only; SVN follows at most 3 safe offline current-side layers with exact path probes and node/ancestor identity verification, sharing limits; no SVN historical review. Not semantic binding or complete definitions.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } };
10
10
  const EXECUTE_DESCRIPTION = 'Execute a previewed read-only code review using the current DSH model. Sends the approved snapshot to the current Agent model under DSH tool execution policy; no separate plugin approval prompt. Never modifies the reviewed repository.';
11
11
  const EXECUTE_PARAMETERS = { type: 'object', additionalProperties: false, properties: { previewId: { type: 'string' }, confirmed: { type: 'boolean' } }, required: ['previewId', 'confirmed'] };
12
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.83.1",
3
+ "version": "0.84.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -0,0 +1,39 @@
1
+ // Git working-copy comparison scopes: whole working tree (default), staged index or unstaged edits.
2
+ export const CHANGE_SCOPES = ['worktree', 'staged', 'unstaged'];
3
+
4
+ /** Normalize the requested scope; only the three documented values are accepted. */
5
+ export function changeScope(value) {
6
+ if (value === undefined) return 'worktree';
7
+ if (typeof value !== 'string' || !CHANGE_SCOPES.includes(value)) throw new Error('Invalid changeScope; expected worktree, staged or unstaged');
8
+ return value;
9
+ }
10
+
11
+ /** Model-facing note: what the two captured sides actually are for this scope. */
12
+ export const CHANGE_SCOPE_NOTICE = {
13
+ staged: '本次比较为 Git 暂存区(HEAD → index):变更文件左侧为 HEAD 内容,右侧为已暂存内容;工作区未暂存修改不作为变更正文发送。显式上下文、自动依赖、调用方候选与规则文件按工作区当前内容读取,可能包含未暂存修改。',
14
+ unstaged: '本次比较为 Git 未暂存改动(index → 工作区):左侧为已暂存内容,仅作旧侧基线;右侧为工作区文件。已暂存内容不代表由本次未暂存改动引入。',
15
+ };
16
+
17
+ /** Preview/report label: disclose the exact comparison and its exclusions. */
18
+ export const CHANGE_SCOPE_LABEL = {
19
+ staged: 'Git 暂存区(HEAD → index),不包含工作区未暂存修改;上下文与规则文件按工作区读取',
20
+ unstaged: 'Git 未暂存改动(index → 工作区),已暂存内容仅作旧侧基线',
21
+ };
22
+
23
+ /** Preview notice: tell the user what will be sent before confirmation. */
24
+ export const CHANGE_SCOPE_PREVIEW_NOTICE = {
25
+ staged: '本次审查范围是 Git 暂存区(HEAD → index):只发送已暂存变更内容,工作区未暂存修改不作为变更正文;如选择上下文、自动依赖、调用方或规则文件,它们按工作区当前内容读取。',
26
+ unstaged: '本次审查范围是 Git 未暂存改动(index → 工作区):已暂存内容只作为旧侧基线。',
27
+ };
28
+
29
+ export function changeScopeNotice(scope) {
30
+ return CHANGE_SCOPE_NOTICE[scope];
31
+ }
32
+
33
+ export function changeScopeLabel(scope) {
34
+ return CHANGE_SCOPE_LABEL[scope];
35
+ }
36
+
37
+ export function changeScopePreviewNotice(scope) {
38
+ return CHANGE_SCOPE_PREVIEW_NOTICE[scope];
39
+ }
package/src/git.mjs CHANGED
@@ -2,6 +2,7 @@ import { isSecretPath } from './secret-path.mjs';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import { run, checked } from './process.mjs';
4
4
  import { hash, decode, readLocal, relativePath } from './content.mjs';
5
+ import { changeScope } from './git-change-scope.mjs';
5
6
 
6
7
  const flags = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
7
8
  const STATUS_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'];
@@ -38,6 +39,29 @@ function parseChanges(bytes) {
38
39
  return changes;
39
40
  }
40
41
 
42
+ /** Keep only the entries the requested comparison actually observes. */
43
+ function changesInScope(changes, scope) {
44
+ if (scope === 'worktree') return changes;
45
+ if (scope === 'staged') return changes.filter(change => change.rawStatus[0] !== ' ' && change.rawStatus[0] !== '?');
46
+ return changes.filter(change => change.rawStatus[1] !== ' ' || change.rawStatus === '??');
47
+ }
48
+
49
+ /** Report only the status column this scope observes so identity follows the compared sides. */
50
+ function scopeStatus(rawStatus, scope) {
51
+ if (scope === 'worktree') return rawStatus;
52
+ if (scope === 'staged') return rawStatus[0] + ' ';
53
+ return rawStatus === '??' ? '??' : ' ' + rawStatus[1];
54
+ }
55
+
56
+ /**
57
+ * Serialize one change for the snapshot. An unstaged entry compares index to worktree, so a
58
+ * staged rename's old path is not a side of that comparison and must not reach the model.
59
+ */
60
+ function scopeEntry(change, scope) {
61
+ const oldPath = scope === 'unstaged' ? undefined : change.oldPath;
62
+ return { path: change.path, ...(oldPath ? { oldPath } : {}), rawStatus: scopeStatus(change.rawStatus, scope) };
63
+ }
64
+
41
65
  /** Resolve only reviewable changed baselines; never enumerate the whole HEAD tree. */
42
66
  async function resolveBaselines(root, head, changes, selectedPaths, options, signal) {
43
67
  const base = new Map();
@@ -60,22 +84,104 @@ async function resolveBaselines(root, head, changes, selectedPaths, options, sig
60
84
  return base;
61
85
  }
62
86
 
87
+ /**
88
+ * Resolve index entries for the selected paths only. Stage entries above zero mean an
89
+ * unresolved conflict; callers block those files instead of reading a partial side.
90
+ */
91
+ async function resolveIndexEntries(root, names, options) {
92
+ const entries = new Map();
93
+ for (let i = 0; i < names.length; i += 64) {
94
+ const chunk = names.slice(i, i + 64);
95
+ const records = text(await git(root, ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', ...chunk], options)).split('\0');
96
+ for (const record of records) {
97
+ if (!record) continue;
98
+ const m = /^(\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([\s\S]+)$/.exec(record);
99
+ if (!m || !chunk.includes(m[4])) throw new Error('Invalid index entry lookup');
100
+ if (entries.has(m[4])) entries.set(m[4], { conflict: true });
101
+ else entries.set(m[4], { mode: m[1], oid: m[2], stage: m[3] });
102
+ }
103
+ }
104
+ return entries;
105
+ }
106
+
107
+ /** Stable identity of the index entries a capture depends on; mode and content changes are caught even when status text is unchanged. */
108
+ function indexIdentity(entries, names) {
109
+ return names.map(name => {
110
+ const entry = entries.get(name);
111
+ return name + '\0' + (entry ? (entry.conflict ? 'conflict' : entry.mode + ':' + entry.oid) : '');
112
+ }).join('\n');
113
+ }
114
+
115
+ function assertReviewableIndexEntry(entry) {
116
+ if (!entry) throw new Error('Missing index baseline');
117
+ if (entry.conflict || entry.stage !== '0') throw new Error('Unresolved conflict');
118
+ if (entry.mode === '120000' || entry.mode === '160000') throw new Error('Submodule or symbolic link baseline');
119
+ }
120
+
121
+ async function readIndexBlob(root, entry, options, maxFileBytes) {
122
+ let bytes;
123
+ try {
124
+ bytes = await git(root, ['cat-file', 'blob', entry.oid], { ...options, maxBytes: maxFileBytes });
125
+ } catch (error) {
126
+ if (/output limit exceeded/i.test(error.message)) throw new Error('File size limit exceeded');
127
+ throw error;
128
+ }
129
+ return decode(bytes);
130
+ }
131
+
132
+ /** A staged deletion has no index entry; every other staged path reads its index blob. */
133
+ async function readStagedRight(root, entry, options, maxFileBytes) {
134
+ if (!entry) return { right: decode(Buffer.alloc(0)), rightExists: false };
135
+ assertReviewableIndexEntry(entry);
136
+ return { right: await readIndexBlob(root, entry, options, maxFileBytes), rightExists: true };
137
+ }
138
+
139
+ /** Unstaged left side is the index entry; untracked files have no index baseline. */
140
+ async function readUnstagedLeft(root, change, entries, options, maxFileBytes) {
141
+ if (change.rawStatus === '??') return decode(Buffer.alloc(0));
142
+ const entry = entries.get(change.path);
143
+ assertReviewableIndexEntry(entry);
144
+ return readIndexBlob(root, entry, options, maxFileBytes);
145
+ }
146
+
147
+ /** Worktree and staged captures read the same HEAD baseline; unstaged uses the index instead. */
148
+ async function readHeadLeft(root, change, baselines, options, maxFileBytes) {
149
+ const previous = baselines.get(change.oldPath ?? change.path);
150
+ if (previous && (previous.type !== 'blob' || previous.mode === '120000')) throw new Error('Submodule or symbolic link baseline');
151
+ return previous ? decode(await git(root, ['cat-file', 'blob', previous.oid], { ...options, maxBytes: maxFileBytes })) : decode(Buffer.alloc(0));
152
+ }
153
+
154
+ async function readWorktreeRight(root, change, options) {
155
+ try {
156
+ return { right: await readLocal(root, change.path, options), rightExists: true };
157
+ } catch (error) {
158
+ if (error.code === 'ENOENT' && change.rawStatus.includes('D')) return { right: decode(Buffer.alloc(0)), rightExists: false };
159
+ throw error;
160
+ }
161
+ }
162
+
63
163
  export async function captureGit(cwd, options = {}) {
64
164
  const { signal, selectedPaths, maxFiles = 200, maxFileBytes = 256 * 1024 } = options;
165
+ const scope = changeScope(options.changeScope);
65
166
  if (selectedPaths) for (const name of selectedPaths) relativePath(name);
66
167
  const root = await resolveRoot(cwd, options);
67
168
  const before = await git(root, STATUS_ARGS, options);
68
169
  const head = await resolveHead(root, options);
69
- const changes = parseChanges(before);
70
- if (changes.length > maxFiles) throw new Error('Change count limit exceeded; narrow repository scope');
170
+ const changes = changesInScope(parseChanges(before), scope);
171
+ const counted = changes.filter(change => change.rawStatus !== '??' || selectedPaths?.includes(change.path));
172
+ if (counted.length > maxFiles) throw new Error('Change count limit exceeded; narrow repository scope');
71
173
  const known = new Set(changes.map(c => c.path));
72
- if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change');
73
- const base = head ? await resolveBaselines(root, head, changes, selectedPaths, options, signal) : new Map();
174
+ if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change in the selected scope');
175
+ const projectedBefore = scope === 'worktree' ? null : JSON.stringify(changes.map(change => scopeEntry(change, scope)));
176
+ const baselines = scope !== 'unstaged' && head ? await resolveBaselines(root, head, changes, selectedPaths, options, signal) : new Map();
177
+ const indexNames = scope === 'worktree' ? [] : [...new Set(changes.filter(change => change.rawStatus !== '??').map(change => change.path))];
178
+ const indexEntries = scope === 'worktree' ? new Map() : await resolveIndexEntries(root, indexNames, options);
179
+ const indexBefore = indexIdentity(indexEntries, indexNames);
74
180
 
75
181
  const files = [];
76
182
  for (const change of changes) {
77
183
  signal?.throwIfAborted();
78
- const item = { ...change, id: hash(change.path), eligibility: 'reviewable', properties: [] };
184
+ const item = { ...scopeEntry(change, scope), id: hash(change.path), eligibility: 'reviewable', properties: [] };
79
185
  files.push(item);
80
186
  if (isSecretPath(change.path) || isSecretPath(change.oldPath)) { item.eligibility = 'excluded'; item.reason = 'Credential path excluded'; continue; }
81
187
  if ((selectedPaths && !selectedPaths.includes(change.path)) || (change.rawStatus === '??' && !selectedPaths?.includes(change.path))) {
@@ -85,18 +191,14 @@ export async function captureGit(cwd, options = {}) {
85
191
  relativePath(change.path);
86
192
  if (change.oldPath) relativePath(change.oldPath);
87
193
  if (/U/.test(change.rawStatus) || ['AA', 'DD'].includes(change.rawStatus)) throw new Error('Unresolved conflict');
88
- const previous = base.get(change.oldPath ?? change.path);
89
- if (previous && (previous.type !== 'blob' || previous.mode === '120000')) throw new Error('Submodule or symbolic link baseline');
90
- const left = previous ? await git(root, ['cat-file', 'blob', previous.oid], { ...options, maxBytes: maxFileBytes }) : Buffer.alloc(0);
91
- item.left = decode(left);
92
- try {
93
- item.right = await readLocal(root, change.path, options);
94
- item.rightExists = true;
95
- } catch (error) {
96
- if (error.code === 'ENOENT' && change.rawStatus.includes('D')) { item.right = decode(Buffer.alloc(0)); item.rightExists = false; }
97
- else throw error;
98
- }
99
- if (previous && item.rightExists && item.left.hash === item.right.hash && !change.oldPath) {
194
+ const baselineExists = scope === 'unstaged' ? change.rawStatus !== '??' : baselines.has(change.oldPath ?? change.path);
195
+ item.left = scope === 'unstaged' ? await readUnstagedLeft(root, change, indexEntries, options, maxFileBytes) : await readHeadLeft(root, change, baselines, options, maxFileBytes);
196
+ const right = scope === 'staged'
197
+ ? await readStagedRight(root, change.rawStatus[0] === 'D' ? undefined : indexEntries.get(change.path), options, maxFileBytes)
198
+ : await readWorktreeRight(root, change, options);
199
+ item.right = right.right;
200
+ item.rightExists = right.rightExists;
201
+ if (baselineExists && item.rightExists && item.left.hash === item.right.hash && !item.oldPath) {
100
202
  item.eligibility = 'excluded'; item.reason = 'No net content change (mode-only changes not analyzed)';
101
203
  }
102
204
  } catch (error) {
@@ -106,18 +208,28 @@ export async function captureGit(cwd, options = {}) {
106
208
  }
107
209
  }
108
210
 
109
- if (!(await git(root, STATUS_ARGS, options)).equals(before)) throw new Error('Repository changed during capture');
211
+ const after = await git(root, STATUS_ARGS, options);
212
+ const scopeUnchanged = scope === 'worktree'
213
+ ? after.equals(before)
214
+ : JSON.stringify(changesInScope(parseChanges(after), scope).map(change => scopeEntry(change, scope))) === projectedBefore;
215
+ if (!scopeUnchanged) throw new Error('Repository changed during capture');
110
216
  const finalHead = await run('git', [...flags, 'rev-parse', '--verify', 'HEAD'], { ...options, cwd: root });
111
217
  if ((finalHead.code === 0 ? text(finalHead.stdout).trim() : null) !== head) throw new Error('HEAD changed during capture');
112
- for (const file of files.filter(f => f.right)) {
113
- if (file.rightExists) {
114
- if ((await readLocal(root, file.path, options)).hash !== file.right.hash) throw new Error('Content changed during capture');
115
- } else {
116
- try { await readLocal(root, file.path, options); }
117
- catch (error) { if (error.code === 'ENOENT') continue; throw error; }
118
- throw new Error('Deleted path reappeared during capture');
218
+ if (scope !== 'worktree') {
219
+ const entriesAfter = await resolveIndexEntries(root, indexNames, options);
220
+ if (indexIdentity(entriesAfter, indexNames) !== indexBefore) throw new Error('Index changed during capture');
221
+ }
222
+ if (scope !== 'staged') {
223
+ for (const file of files.filter(f => f.right)) {
224
+ if (file.rightExists) {
225
+ if ((await readLocal(root, file.path, options)).hash !== file.right.hash) throw new Error('Content changed during capture');
226
+ } else {
227
+ try { await readLocal(root, file.path, options); }
228
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
229
+ throw new Error('Deleted path reappeared during capture');
230
+ }
119
231
  }
120
232
  }
121
- const snapshot = { schemaVersion: 1, vcs: 'git', root, baseline: head, files };
233
+ const snapshot = { schemaVersion: 1, vcs: 'git', root, baseline: head, ...(scope === 'worktree' ? {} : { changeScope: scope }), files };
122
234
  return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
123
235
  }
@@ -13,6 +13,7 @@ import { planWindowBatches } from './window-batches.mjs';
13
13
  import { changeWindows } from './change-windows.mjs';
14
14
  import { changeMap } from './change-map.mjs';
15
15
  import { initialInputBudget } from './input-budget.mjs';
16
+ import { changeScopeNotice } from './git-change-scope.mjs';
16
17
 
17
18
  const CPP_FILE = /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/;
18
19
  const RELATION_NOTICE = '命名和相对导入关系仅为源码线索,可能被同名局部变量遮蔽,不证明绑定、调用可达或因果;只报告主file的问题,不为relatedFiles重复生成发现。';
@@ -35,6 +36,7 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
35
36
 
36
37
  const base = {
37
38
  ...(input.projectScope ? { projectScope: input.projectScope, reviewNotice: 'Full current-file review; empty left is a sentinel, not a historical baseline. changes are transport coordinates only, do not infer introduced regression.' } : {}),
39
+ ...(input.changeScope ? { changeScope: input.changeScope, changeScopeNotice: changeScopeNotice(input.changeScope) } : {}),
38
40
  ...(input.history ? { contextSideNotice: CONTEXT_SIDE_NOTICE } : {}),
39
41
  ...(input.businessRequirement !== undefined ? { businessRequirement: input.businessRequirement, requirementNotice: REQUIREMENT_NOTICE } : {}),
40
42
  defectGuidance: defectGuidance(file.path, file.rightExists === false ? file.left.text : file.right.text),
package/src/review.mjs CHANGED
@@ -21,6 +21,7 @@ import { createRetrievalScope } from './retrieval-scope.mjs';
21
21
  import { retrievalLoop } from './retrieval-loop.mjs';
22
22
  import { preparePrimaryInput } from './primary-input.mjs';
23
23
  import { validateAttribution } from './attribution.mjs';
24
+ import { changeScopeLabel } from './git-change-scope.mjs';
24
25
 
25
26
  export { REVIEW_INSTRUCTIONS };
26
27
  export { PROJECT_REVIEW_INSTRUCTIONS };
@@ -111,6 +112,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
111
112
  }
112
113
  if(input.projectScope)report.projectScope=structuredClone(input.projectScope);
113
114
  if (input.history) report.history = structuredClone(input.history);
115
+ if (input.changeScope) report.changeScope = input.changeScope;
114
116
  if (input.mergeSources) report.mergeSources = structuredClone(input.mergeSources);
115
117
  if(input.workingScope)report.workingScope=structuredClone(input.workingScope);
116
118
  if(input.effectiveMergeSources)report.effectiveMergeSources=structuredClone(input.effectiveMergeSources);
@@ -367,6 +369,7 @@ export function markdownReport(report) {
367
369
  if(report.businessRequirement)output.push('- 业务需求:SHA256 '+safe(report.businessRequirement.hash)+' · '+report.businessRequirement.bytes+' bytes;不可信约束,不是证据或权限,全文不在报告重复。');
368
370
  for (const file of report.files) for (const correction of file.anchorCorrections ?? []) output.push('- 定位纠正:' + safe(correction.originalCandidate.fileId) + ' · ' + safe(correction.status) + ' · ' + safe(correction.reason) + ';原完整候选保留JSON,仅定位不证明因果');
369
371
  if (report.history) output.push('- Git历史审查:' + safe(report.history.mode) + ' · ' + safe(report.history.base ?? 'empty') + ' → ' + safe(report.history.target) + ';精确端点树差异,重命名按增删,不含工作树');
372
+ if (report.changeScope) output.push('- Git审查范围:' + safe(changeScopeLabel(report.changeScope)) + ';其他范围未审查,未跟踪文件仍默认排除。');
370
373
  if (report.businessGrouping) output.push('- 业务分组:' + safe(report.businessGrouping.status) + (report.businessGrouping.reason ? ' — ' + safe(report.businessGrouping.reason) : '') + ';仅调度假设,不证明依赖或覆盖');
371
374
  for (const file of report.files) for (const plan of file.riskPlans ?? []) output.push('- 风险计划:' + safe(file.path) + ' · ' + safe(plan.status) + ' · ' + plan.risks.length + '项待证假设;不表示缺陷成立或覆盖完成');
372
375
  for (const file of report.files) if(file.reviewPasses)output.push('- 独立审查轮次 '+safe(file.path)+':'+file.reviewPasses.filter(p=>p.status==='completed').length+'/'+file.reviewPasses.length+'阶段完成;同一生命周期预算,后续轮无首轮计划;不证明代码正确。');
package/src/service.mjs CHANGED
@@ -5,6 +5,7 @@ import { reviewRounds as validateReviewRounds } from './review-rounds.mjs';
5
5
  import { validateCallerScopes } from './caller-index.mjs';
6
6
  import { validateBusinessRequirement } from './business-requirement.mjs';
7
7
  import { hash } from './content.mjs';
8
+ import { changeScopePreviewNotice } from './git-change-scope.mjs';
8
9
  import { resolveWorkspaceRepository } from './workspace-repository.mjs';
9
10
  import { randomUUID } from 'node:crypto';
10
11
  import { buildCoveragePlan } from './coverage-plan.mjs';
@@ -44,7 +45,8 @@ function describeFile(file, snapshot) {
44
45
 
45
46
  /** Compose the user-facing preview notice; text is verbatim from the original rules. */
46
47
  function buildPreviewNotice(snapshot, options, enableRiskPlanning) {
47
- return (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,不设插件内部默认时间或总模型调用次数截止,遵循宿主/用户取消;输入字节和检索轮次保护仍保留;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。';
48
+ const scopeNotice = options.changeScope !== undefined && options.changeScope !== 'worktree' ? changeScopePreviewNotice(options.changeScope) : '';
49
+ return scopeNotice + (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,不设插件内部默认时间或总模型调用次数截止,遵循宿主/用户取消;输入字节和检索轮次保护仍保留;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。';
48
50
  }
49
51
 
50
52
  function createSessionReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, authorize = async () => false } = {}) {
@@ -59,7 +61,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
59
61
  exec = bind(exec);
60
62
  exec.signal.throwIfAborted();
61
63
  if (busy) throw new Error('Review service busy');
62
- if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork'].includes(k))) throw new Error('Unknown preview argument');
64
+ if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork','changeScope'].includes(k))) throw new Error('Unknown preview argument');
63
65
  if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
64
66
  for (const key of ['selectedPaths', 'contextPaths', 'rulePaths', 'oldContextPaths']) if (args[key] !== undefined && (!Array.isArray(args[key]) || args[key].length > (key === 'rulePaths' ? 4 : key === 'oldContextPaths' ? 20 : 200) || args[key].some(p => typeof p !== 'string'))) throw new Error('Invalid path list');
65
67
  const rounds=validateReviewRounds(args.reviewRounds === undefined ? reviewRounds : args.reviewRounds);
@@ -87,6 +89,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
87
89
  snapshotId: snapshot.id,
88
90
  repositoryRoot: snapshot.root,
89
91
  vcs: snapshot.vcs,
92
+ ...(snapshot.changeScope ? { changeScope: snapshot.changeScope } : options.changeScope !== undefined ? { changeScope: options.changeScope } : {}),
90
93
  ...(snapshot.workingScope ? { workingScope: snapshot.workingScope } : {}),
91
94
  ...(snapshot.history ? { history: snapshot.history } : {}),
92
95
  ...(snapshot.mergeSources ? { mergeSources: snapshot.mergeSources } : {}),
package/src/snapshot.mjs CHANGED
@@ -16,6 +16,7 @@ import { captureProjectRules } from './project-rules.mjs';
16
16
  import { captureSvn } from './svn.mjs';
17
17
  import { captureSvnHistory } from './svn-history.mjs';
18
18
  import { readLocal, relativePath, hash } from './content.mjs';
19
+ import { CHANGE_SCOPES } from './git-change-scope.mjs';
19
20
 
20
21
  export async function detectVcs(cwd) {
21
22
  let current = await realpath(cwd);
@@ -50,6 +51,11 @@ export async function captureSnapshot(cwd, options = {}) {
50
51
  const { rulePaths, contextPaths, maxContextFiles, maxSnapshotBytes } = validateSnapshotOptions(options);
51
52
 
52
53
  const type = await detectVcs(cwd);
54
+ if (options.changeScope !== undefined) {
55
+ if (typeof options.changeScope !== 'string' || !CHANGE_SCOPES.includes(options.changeScope)) throw new Error('Invalid changeScope; expected worktree, staged or unstaged');
56
+ if (['commit', 'baseRevision', 'targetRevision', 'svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnBasePath', 'svnTargetPath'].some(key => options[key] !== undefined)) throw new Error('changeScope applies to Git working-copy comparison only');
57
+ if (type !== 'git') throw new Error('changeScope requires a Git working copy');
58
+ }
53
59
  const replacementOptions = options.svnReplacementBaseRevision !== undefined || options.svnReplacementAllowNetwork !== undefined;
54
60
  if (replacementOptions) {
55
61
  if (type !== 'svn' || ['svnRevision', 'svnBaseRevision', 'svnTargetRevision', 'svnBasePath', 'svnTargetPath', 'commit', 'baseRevision', 'targetRevision'].some(key => options[key] !== undefined)) throw new Error('Replacement repository baseline requires SVN working-copy mode');