@feng0531/dsh-code-review 0.83.0 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/CORE-ALIGNMENT-TASKS.md +1 -1
  3. package/DEVELOPMENT.md +1 -1
  4. package/README.md +16 -2
  5. package/RELEASE-NOTES.md +8 -6
  6. package/index.mjs +11 -12
  7. package/package.json +1 -1
  8. package/src/background-review.mjs +84 -12
  9. package/src/business-groups.mjs +1 -1
  10. package/src/class-bindings.mjs +45 -12
  11. package/src/defect-guidance.mjs +158 -16
  12. package/src/dsh-model.mjs +107 -15
  13. package/src/git-change-scope.mjs +39 -0
  14. package/src/git-history.mjs +202 -56
  15. package/src/git.mjs +192 -50
  16. package/src/import-bindings.mjs +74 -65
  17. package/src/job-progress.mjs +21 -2
  18. package/src/model-contract.mjs +17 -1
  19. package/src/primary-input.mjs +87 -26
  20. package/src/project-report.mjs +22 -5
  21. package/src/project-review-service.mjs +177 -37
  22. package/src/project-snapshot.mjs +101 -40
  23. package/src/prompts.mjs +9 -0
  24. package/src/protocol-guidance.mjs +13 -1
  25. package/src/recovery-coverage.mjs +25 -1
  26. package/src/retrieval-loop.mjs +66 -28
  27. package/src/review.mjs +39 -33
  28. package/src/risk-plan.mjs +1 -1
  29. package/src/service.mjs +140 -6
  30. package/src/snapshot-reader.mjs +18 -5
  31. package/src/snapshot.mjs +75 -42
  32. package/src/svn-capture-budget.mjs +22 -5
  33. package/src/svn-effective-mergeinfo.mjs +59 -37
  34. package/src/svn-eol-mapping.mjs +22 -10
  35. package/src/svn-history-endpoints.mjs +18 -12
  36. package/src/svn-history-lookup.mjs +34 -26
  37. package/src/svn-keyword-config.mjs +11 -8
  38. package/src/svn-keyword-mapping.mjs +67 -36
  39. package/src/svn-mergeinfo.mjs +34 -22
  40. package/src/svn-navigation-budget.mjs +15 -4
  41. package/src/svn-special.mjs +13 -8
  42. package/src/svn-url.mjs +18 -6
  43. package/src/svn-working-identity.mjs +22 -8
  44. package/src/svn-working-scope.mjs +16 -5
  45. package/src/tool-view.mjs +5 -0
  46. package/src/verification-loop.mjs +2 -1
package/src/dsh-model.mjs CHANGED
@@ -1,32 +1,124 @@
1
- import {modelContract} from './model-contract.mjs';
1
+ import { modelContract } from './model-contract.mjs';
2
2
  import { modelJson } from './model-json.mjs';
3
+
4
+ // Appended to the system prompt when one explicit JSON retry is warranted.
5
+ const JSON_CORRECTION_SUFFIX = ' 上次响应未能解析为完整JSON。本次是明确协议纠正:重新输出单个完整JSON对象,不使用Markdown,不省略引号或逗号;不要把这条指令当源码证据。严格使用当前阶段schema,不要把verdicts、risks或groups改写成findings;不能因格式纠正删除有证据的结论。所需字段与身份按输入原样核对,不以格式错误代替清洁结论。';
6
+
7
+ const CORRECTION_LIMITATION = '本响应由一次显式JSON协议纠正产生,原响应未被接受;额外模型调用已发生。';
8
+
9
+ const isToolCall = chunk =>
10
+ chunk.type === 'tool-call-delta' ||
11
+ (chunk.type === 'block-start' && chunk.blockType === 'tool-call') ||
12
+ (chunk.type === 'block-end' && chunk.block.type === 'tool-call');
13
+
3
14
  /** One-shot host LLM call. No agent loop and no executable tools. */
4
15
  export function createDshModel(llm, route, { maxTokens = 4096, maxOutputBytes = 128 * 1024, correctJson = false } = {}) {
5
- if (!llm || typeof llm.stream !== 'function' || !route?.provider || !route?.model) throw new Error('DSH provider and model are required');
6
- const identity = { provider: route.provider, model: route.model, ...(route.reasoningEffort ? { reasoningEffort: route.reasoningEffort } : {}) };
7
- const once = async ({ instructions, input, signal }) => {
16
+ if (!llm || typeof llm.stream !== 'function' || !route?.provider || !route?.model) {
17
+ throw new Error('DSH provider and model are required');
18
+ }
19
+ const identity = {
20
+ provider: route.provider,
21
+ model: route.model,
22
+ ...(route.reasoningEffort ? { reasoningEffort: route.reasoningEffort } : {}),
23
+ };
24
+
25
+ async function once({ instructions, input, signal }) {
8
26
  signal?.throwIfAborted();
9
- let output = '', size = 0, finished = false,finishKind='missing';
10
- const diagnostic=()=>({visibleBytes:Buffer.byteLength(output),totalOutputBytes:size,finishKind,inputBytes:Buffer.byteLength(input),requestedMaxTokens:maxTokens});
11
- const chunks = llm.stream({ ...identity, system: instructions+'\n'+modelContract(input,instructions), messages: [{ role: 'user', content: [{ type: 'text', text: input }] }], tools: [], maxTokens, signal });
12
- for await (const chunk of chunks) {
27
+ let output = '';
28
+ let size = 0;
29
+ let finished = false;
30
+ let finishKind = 'missing';
31
+
32
+ const diagnostic = () => ({
33
+ visibleBytes: Buffer.byteLength(output),
34
+ totalOutputBytes: size,
35
+ finishKind,
36
+ inputBytes: Buffer.byteLength(input),
37
+ requestedMaxTokens: maxTokens,
38
+ });
39
+
40
+ const providerCode = reason =>
41
+ typeof reason.error?.code === 'string' ? reason.error.code
42
+ : typeof reason.code === 'string' ? reason.code
43
+ : undefined;
44
+
45
+ const stream = llm.stream({
46
+ ...identity,
47
+ system: instructions + '\n' + modelContract(input, instructions),
48
+ messages: [{ role: 'user', content: [{ type: 'text', text: input }] }],
49
+ tools: [],
50
+ maxTokens,
51
+ signal,
52
+ });
53
+
54
+ for await (const chunk of stream) {
13
55
  signal?.throwIfAborted();
14
56
  if (finished) throw new Error('Model emitted data after finish');
15
- if (chunk.type === 'tool-call-delta' || (chunk.type === 'block-start' && chunk.blockType === 'tool-call') || (chunk.type === 'block-end' && chunk.block.type === 'tool-call')) throw new Error('Review model attempted a tool call');
57
+ if (isToolCall(chunk)) throw new Error('Review model attempted a tool call');
58
+
16
59
  if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
17
60
  size += Buffer.byteLength(chunk.text);
18
61
  if (size > maxOutputBytes) throw new Error('Model output limit exceeded');
19
62
  if (chunk.type === 'text-delta') output += chunk.text;
20
63
  }
64
+
21
65
  if (chunk.type === 'finish') {
22
- finishKind=chunk.reason.kind;
23
- if (finishKind !== 'stop') throw Object.assign(new Error('Model did not complete: '+finishKind),{code:'MODEL_INCOMPLETE_OUTPUT',diagnostic:{...diagnostic(),providerCode:typeof chunk.reason.error?.code==='string'?chunk.reason.error.code:typeof chunk.reason.code==='string'?chunk.reason.code:undefined}});
66
+ finishKind = chunk.reason.kind;
67
+ if (finishKind !== 'stop') {
68
+ throw Object.assign(new Error('Model did not complete: ' + finishKind), {
69
+ code: 'MODEL_INCOMPLETE_OUTPUT',
70
+ diagnostic: { ...diagnostic(), providerCode: providerCode(chunk.reason) },
71
+ });
72
+ }
24
73
  finished = true;
25
74
  }
26
75
  }
27
- if (!finished) throw Object.assign(new Error('Model stream ended without finish'),{code:'MODEL_INCOMPLETE_OUTPUT',diagnostic:diagnostic()});
28
- try{return modelJson(output);}catch(error){error.diagnostic=diagnostic();throw error;}
76
+
77
+ if (!finished) {
78
+ throw Object.assign(new Error('Model stream ended without finish'), {
79
+ code: 'MODEL_INCOMPLETE_OUTPUT',
80
+ diagnostic: diagnostic(),
81
+ });
82
+ }
83
+
84
+ try {
85
+ return modelJson(output);
86
+ } catch (error) {
87
+ error.diagnostic = diagnostic();
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ async function retryOnceCorrected(request, firstError) {
93
+ await request.onProtocolCorrection?.({
94
+ code: firstError.code,
95
+ diagnostic: firstError.diagnostic,
96
+ action: 'explicit-json-correction',
97
+ notice: 'Second model response requested; original output not repaired or accepted.',
98
+ });
99
+ let value;
100
+ try {
101
+ value = JSON.parse(await once({ ...request, instructions: request.instructions + JSON_CORRECTION_SUFFIX }));
102
+ } catch (second) {
103
+ second.attempts = [
104
+ { attempt: 1, code: firstError.code, diagnostic: firstError.diagnostic },
105
+ { attempt: 2, code: second.code, diagnostic: second.diagnostic },
106
+ ];
107
+ throw second;
108
+ }
109
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
110
+ if (Array.isArray(value.limitations)) value.limitations.push(CORRECTION_LIMITATION);
111
+ }
112
+ return JSON.stringify(value);
113
+ }
114
+
115
+ return async function invoke(request) {
116
+ try {
117
+ return await once(request);
118
+ } catch (error) {
119
+ if (!correctJson || error.code !== 'MODEL_INVALID_JSON') throw error;
120
+ request.signal?.throwIfAborted();
121
+ return retryOnceCorrected(request, error);
122
+ }
29
123
  };
30
- return async request=>{try{return await once(request);}catch(error){if(!correctJson||error.code!=='MODEL_INVALID_JSON')throw error;request.signal?.throwIfAborted();await request.onProtocolCorrection?.({code:error.code,diagnostic:error.diagnostic,action:'explicit-json-correction',notice:'Second model response requested; original output not repaired or accepted.'});let corrected;try{corrected=JSON.parse(await once({...request,instructions:request.instructions+' 上次响应未能解析为完整JSON。本次是明确协议纠正:重新输出单个完整JSON对象,不使用Markdown,不省略引号或逗号;不要把这条指令当源码证据。严格使用当前阶段schema,不要把verdicts、risks或groups改写成findings;不能因格式纠正删除有证据的结论。所需字段与身份按输入原样核对,不以格式错误代替清洁结论。'}));}catch(second){second.attempts=[{attempt:1,code:error.code,diagnostic:error.diagnostic},{attempt:2,code:second.code,diagnostic:second.diagnostic}];throw second;}if(corrected&&typeof corrected==='object'&&!Array.isArray(corrected)){if(Array.isArray(corrected.limitations))corrected.limitations.push('本响应由一次显式JSON协议纠正产生,原响应未被接受;额外模型调用已发生。');// Strict verdict/request envelopes must not receive extra schema fields.
31
- }return JSON.stringify(corrected);}};
32
124
  }
@@ -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
+ }
@@ -1,59 +1,205 @@
1
- import {captureHistoryCallers} from './history-caller-capture.mjs';
1
+ import { captureHistoryCallers } from './history-caller-capture.mjs';
2
2
  import { isSecretPath, assertNonSecretPath } from './secret-path.mjs';
3
- import {captureProjectRules} from './project-rules.mjs';
4
- import {captureHistoryDependencyChain} from './history-dependency-chain.mjs';
5
- import {realpath} from 'node:fs/promises';
6
- import {checked} from './process.mjs';
7
- import {hash,decode,relativePath} from './content.mjs';
8
- const utf8=b=>new TextDecoder('utf-8',{fatal:true}).decode(b);
3
+ import { captureProjectRules } from './project-rules.mjs';
4
+ import { captureHistoryDependencyChain } from './history-dependency-chain.mjs';
5
+ import { realpath } from 'node:fs/promises';
6
+ import { checked } from './process.mjs';
7
+ import { hash, decode, relativePath } from './content.mjs';
8
+
9
+ const utf8 = b => new TextDecoder('utf-8', { fatal: true }).decode(b);
10
+ const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
11
+ const REGULAR_BLOB = e => e?.type === 'blob' && ['100644', '100755'].includes(e.mode);
12
+
13
+ const gitAt = (root, args, options, limit = 1024 * 1024) =>
14
+ checked('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', ...args], { ...options, cwd: root, maxBytes: limit });
15
+
16
+ async function resolveCommit(root, ref, options) {
17
+ if (typeof ref !== 'string' || !ref || ref.length > 256 || ref.startsWith('-') || /[\s\0]/.test(ref)) throw new Error('Invalid revision');
18
+ const oid = utf8(await gitAt(root, ['rev-parse', '--verify', '--end-of-options', ref + '^{commit}'], options)).trim();
19
+ if (!SHA.test(oid)) throw new Error('Invalid commit object');
20
+ return oid;
21
+ }
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));
26
+ if (parents.some(oid => !SHA.test(oid))) throw new Error('Invalid commit parent');
27
+ return parents;
28
+ }
29
+
30
+ /** Parse \`git diff-tree --raw -z\` into left/right blob maps plus the sorted path list. */
31
+ function parseDiffMeta(raw) {
32
+ if (raw.at(-1) === '') raw.pop();
33
+ if (raw.length % 2) throw new Error('Invalid historical diff metadata');
34
+ const left = new Map(), right = new Map(), names = [];
35
+ for (let i = 0; i < raw.length; i += 2) {
36
+ const m = /^:(\d{6}) (\d{6}) ([\da-f]{40}|[\da-f]{64}) ([\da-f]{40}|[\da-f]{64}) ([AMDT])$/.exec(raw[i]);
37
+ if (!m) throw new Error('Invalid historical diff metadata');
38
+ const name = relativePath(raw[i + 1]);
39
+ if (names.includes(name)) throw new Error('Duplicate historical change');
40
+ names.push(name);
41
+ for (const [map, mode, oid] of [[left, m[1], m[3]], [right, m[2], m[4]]]) {
42
+ if (mode !== '000000') map.set(name, { mode, type: mode === '160000' ? 'commit' : 'blob', oid });
43
+ }
44
+ }
45
+ names.sort();
46
+ return { left, right, names };
47
+ }
48
+
49
+ // Literal, nonrecursive lookups cannot enumerate a supplied directory or wildcard.
50
+ async function lookupPaths(root, oid, map, requested, options) {
51
+ if (!oid) return;
52
+ const pending = [...new Set(requested)].filter(p => !map.has(p));
53
+ for (let i = 0; i < pending.length; i++) {
54
+ const paths = pending.slice(i, i + 1);
55
+ paths.forEach(relativePath);
56
+ const records = utf8(await gitAt(root, ['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', oid, '--', ...paths], options)).split('\0').filter(Boolean);
57
+ for (const r of records) {
58
+ const m = /^(\d{6}) (blob|commit|tree) ([a-f0-9]+)\t([\s\S]+)$/.exec(r);
59
+ if (!m || !paths.includes(m[4])) throw new Error('Invalid historical path lookup');
60
+ map.set(m[4], { mode: m[1], type: m[2], oid: m[3] });
61
+ }
62
+ }
63
+ }
64
+
9
65
  // Object-only capture: no checkout, worktree reads, external diff or fetch.
10
- export async function captureGitHistory(cwd, options={}) {
11
- const {commit,baseRevision,targetRevision,selectedPaths,signal,maxFiles=200,maxFileBytes=256*1024,maxSnapshotBytes=4*1024*1024}=options;
12
- if(!Number.isSafeInteger(maxFiles)||maxFiles<1||maxFiles>200||!Number.isSafeInteger(maxFileBytes)||maxFileBytes<1||maxFileBytes>256*1024||!Number.isSafeInteger(maxSnapshotBytes)||maxSnapshotBytes<1||maxSnapshotBytes>4*1024*1024)throw new Error('Invalid historical capture limits');
13
- if(selectedPaths!==undefined&&(!Array.isArray(selectedPaths)||selectedPaths.some(p=>relativePath(p)!==p)))throw new Error('Invalid selected paths');
14
- const git=(root,args,limit=1024*1024)=>checked('git',['--no-optional-locks','-c','core.fsmonitor=false','-c','core.untrackedCache=false',...args],{...options,cwd:root,maxBytes:limit});
15
- const root=await realpath(utf8(await git(cwd,['rev-parse','--show-toplevel'])).trim());
16
- const resolve=async ref=>{if(typeof ref!=='string'||!ref||ref.length>256||ref.startsWith('-')||/[\s\0]/.test(ref))throw new Error('Invalid revision');const oid=utf8(await git(root,['rev-parse','--verify','--end-of-options',ref+'^{commit}'])).trim();if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw new Error('Invalid commit object');return oid;};
17
- let base,target;
18
- if(commit!==undefined){if(baseRevision!==undefined||targetRevision!==undefined)throw new Error('Commit and range are mutually exclusive');target=await resolve(commit);const object=utf8(await git(root,['cat-file','commit',target]));const header=object.split('\n\n',1)[0];const parents=header.split('\n').filter(line=>line.startsWith('parent ')).map(line=>line.slice(7));if(parents.some(oid=>!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid)))throw new Error('Invalid commit parent');if(parents.length>1)throw new Error('Merge commit requires explicit revision range');base=parents[0]??null;}
19
- else {base=await resolve(baseRevision);target=await resolve(targetRevision);}
20
- // Metadata scales with changed paths, not every tracked file in either tree.
21
- const raw=utf8(await git(root,['diff-tree','--no-abbrev','--raw','-z','-r','--no-renames','--no-ext-diff','--no-textconv','--no-commit-id',...(base?[base,target]:['--root',target]),'--'])).split('\0');
22
- if(raw.at(-1)==='')raw.pop();
23
- const left=new Map(),right=new Map(),names=[];
24
- if(raw.length%2)throw new Error('Invalid historical diff metadata');
25
- for(let i=0;i<raw.length;i+=2){const m=/^:(\d{6}) (\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([a-f0-9]{40}|[a-f0-9]{64}) ([AMDT])$/.exec(raw[i]);if(!m)throw new Error('Invalid historical diff metadata');const name=relativePath(raw[i+1]);if(names.includes(name))throw new Error('Duplicate historical change');names.push(name);for(const [map,mode,oid] of [[left,m[1],m[3]],[right,m[2],m[4]]])if(mode!=='000000')map.set(name,{mode,type:mode==='160000'?'commit':'blob',oid});}
26
- names.sort();
27
- // Literal, nonrecursive lookups cannot enumerate a supplied directory or wildcard.
28
- const lookup=async (oid,map,requested)=>{if(!oid)return;const pending=[...new Set(requested)].filter(p=>!map.has(p));for(let i=0;i<pending.length;i++){const paths=pending.slice(i,i+1);paths.forEach(relativePath);const records=utf8(await git(root,['--literal-pathspecs','ls-tree','-z','--full-tree',oid,'--',...paths])).split('\0').filter(Boolean);for(const r of records){const m=/^(\d{6}) (blob|commit|tree) ([a-f0-9]+)\t([\s\S]+)$/.exec(r);if(!m||!paths.includes(m[4]))throw new Error('Invalid historical path lookup');map.set(m[4],{mode:m[1],type:m[2],oid:m[3]});}}};
29
- if(names.length>maxFiles)throw new Error('Historical change count limit exceeded');if(selectedPaths?.some(p=>!names.includes(p)))throw new Error('Selected path is not a historical change');
30
- const files=[];for(const name of names){signal?.throwIfAborted();const l=left.get(name),r=right.get(name),item={id:hash(name),path:name,rawStatus:!l?'A':!r?'D':'M',eligibility:'reviewable',properties:[]};files.push(item);if(isSecretPath(name)){item.eligibility='excluded';item.reason='Credential path excluded';continue;}if(selectedPaths&&!selectedPaths.includes(name)){item.eligibility='excluded';item.reason='Not explicitly selected';continue;}try{for(const e of [l,r].filter(Boolean))if(e.type!=='blob'||!['100644','100755'].includes(e.mode))throw new Error('Historical link or submodule excluded');const read=async e=>decode(e?await git(root,['cat-file','blob',e.oid],maxFileBytes):Buffer.alloc(0));item.left=await read(l);item.right=await read(r);item.rightExists=!!r;if(l&&r&&item.left.hash===item.right.hash){item.eligibility='excluded';item.reason='Mode-only historical change not analyzed';}}catch(error){signal?.throwIfAborted();item.eligibility='blocked';item.reason=error.message;delete item.left;delete item.right;}if(Buffer.byteLength(JSON.stringify(files))>maxSnapshotBytes)throw new Error('Historical snapshot limit exceeded');}
31
- const rulePaths=options.rulePaths ?? [];
32
- const contextPaths=options.contextPaths ?? [], oldContextPaths=options.oldContextPaths ?? [], maxContextFiles=options.maxContextFiles ?? 20;
33
- if(!Array.isArray(oldContextPaths)||oldContextPaths.length>20||new Set(oldContextPaths).size!==oldContextPaths.length)throw new Error('Invalid old context paths');
34
- for(const name of oldContextPaths){relativePath(name);assertNonSecretPath(name);}
35
- if(oldContextPaths.length&&!base)throw new Error('Old context requires nonempty historical baseline');
36
- if(!Array.isArray(contextPaths)||!Array.isArray(rulePaths))throw new Error('Invalid historical context or rule paths');
37
- if(oldContextPaths.some(p=>contextPaths.includes(p)||rulePaths.includes(p)))throw new Error('Old context paths must be separate');
38
- if(contextPaths.length+oldContextPaths.length>maxContextFiles)throw new Error('Historical context file limit exceeded');
39
- if(!Array.isArray(contextPaths)||!Number.isSafeInteger(maxContextFiles)||maxContextFiles<1||maxContextFiles>20||contextPaths.length>maxContextFiles)throw new Error('Historical context file limit exceeded');
40
- if(options.autoContext!==undefined&&typeof options.autoContext!=='boolean')throw new Error('Invalid autoContext option');
41
- if(!Array.isArray(rulePaths)||rulePaths.length>4||rulePaths.some(p=>contextPaths.includes(p)))throw new Error('Rule paths must be separate from context paths');
42
- await lookup(target,right,[...contextPaths,...rulePaths]);await lookup(base,left,[...contextPaths,...oldContextPaths]);
43
- const context=[], regular=e=>e?.type==='blob'&&['100644','100755'].includes(e.mode);
44
- const captureContext=async name=>{relativePath(name);assertNonSecretPath(name);const changed=files.find(f=>f.path===name);if(changed){if(changed.eligibility!=='reviewable'||changed.rightExists===false)throw new Error('Historical context is excluded blocked or deleted');return {path:name,...changed.right};}const entry=right.get(name);if(!regular(entry))throw new Error('Historical context not a target regular blob');const content=decode(await git(root,['cat-file','blob',entry.oid],maxFileBytes));const previous=left.get(name);return {path:name,...content,revision:target,...(regular(previous)?{oldText:decode(await git(root,['cat-file','blob',previous.oid],maxFileBytes)).text,oldRevision:base}:{})};};
45
- for(const name of [...new Set(contextPaths)].sort())context.push(await captureContext(name));
46
- for(const name of [...oldContextPaths].sort()){
47
- const changed=files.find(f=>f.path===name);
48
- if(changed){if(changed.eligibility!=='reviewable'||!left.has(name))throw new Error('Old context conflicts with excluded blocked or absent old change');continue;}
49
- const entry=left.get(name);if(!regular(entry))throw new Error('Old context not a baseline regular blob');
50
- const content=decode(await git(root,['cat-file','blob',entry.oid],maxFileBytes));
51
- context.push({path:name,oldOnly:true,oldText:content.text,oldHash:hash(content.text),oldBlobOid:entry.oid,oldRevision:base});
52
- }
53
- const rules=await captureProjectRules(root,rulePaths,{signal,files,read:async (_root,name,{maxFileBytes:limit})=>{const entry=right.get(name);if(!regular(entry))throw new Error('Historical rule not a target regular blob');return decode(await git(root,['cat-file','blob',entry.oid],limit));}});
54
- let autoContext;
55
- if(options.autoContext===true)autoContext=await captureHistoryDependencyChain(root,files,{base,target},context,options);
56
- let callerDiscovery;
57
- if(options.callerScopePaths!==undefined){const callers=await captureHistoryCallers(root,files,{base,target},options.callerScopePaths,options);callerDiscovery=callers.discovery;for(const item of callers.context){const matches=callerDiscovery.candidates.filter(c=>c.fromPath===item.path),present=context.find(c=>c.path===item.path);if(present){if((present.text!==undefined&&present.text!==item.text)||(present.oldText!==undefined&&present.oldText!==item.oldText))throw new Error('Historical caller context identity conflict');for(const c of matches){const available=c.side==='old'?present.oldText!==undefined:present.text!==undefined;c.status=available?'reused-context':'blocked';if(!available)c.captureReason='Existing context did not approve this version side';}continue;}if(context.length>=maxContextFiles){for(const c of matches){c.status='blocked';c.captureReason='Context file limit exceeded';}continue;}context.push(item);callerDiscovery.capturedPaths.push(item.path);for(const c of matches)c.status='captured';}}
58
- const snapshot={context,...(callerDiscovery?{callerDiscovery}:{}),...(rules.length?{rules}:{}),...(autoContext?{autoContext}:{}),schemaVersion:1,vcs:'git',root,baseline:base,history:{mode:commit!==undefined?'commit':'range',base,target,semantics:'exact-tree-endpoints; renames represented as add/delete'},files};const serialized=JSON.stringify(snapshot);if(Buffer.byteLength(serialized)>maxSnapshotBytes)throw new Error('Historical snapshot limit exceeded');return {...snapshot,id:hash(serialized)};
66
+ export async function captureGitHistory(cwd, options = {}) {
67
+ const { commit, baseRevision, targetRevision, selectedPaths, signal, maxFiles = 200, maxFileBytes = 256 * 1024, maxSnapshotBytes = 4 * 1024 * 1024 } = options;
68
+ if (!Number.isSafeInteger(maxFiles) || maxFiles < 1 || maxFiles > 200 || !Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1 || maxFileBytes > 256 * 1024 || !Number.isSafeInteger(maxSnapshotBytes) || maxSnapshotBytes < 1 || maxSnapshotBytes > 4 * 1024 * 1024) throw new Error('Invalid historical capture limits');
69
+ if (selectedPaths !== undefined && (!Array.isArray(selectedPaths) || selectedPaths.some(p => relativePath(p) !== p))) throw new Error('Invalid selected paths');
70
+
71
+ const root = await realpath(utf8(await gitAt(cwd, ['rev-parse', '--show-toplevel'], options)).trim());
72
+
73
+ // Resolve the two endpoints: commit compares against its sole parent; range uses explicit endpoints.
74
+ let base, target;
75
+ if (commit !== undefined) {
76
+ if (baseRevision !== undefined || targetRevision !== undefined) throw new Error('Commit and range are mutually exclusive');
77
+ target = await resolveCommit(root, commit, options);
78
+ const object = utf8(await gitAt(root, ['cat-file', 'commit', target], options));
79
+ const parents = parseCommitParents(object);
80
+ if (parents.length > 1) throw new Error('Merge commit requires explicit revision range');
81
+ base = parents[0] ?? null;
82
+ } else {
83
+ base = await resolveCommit(root, baseRevision, options);
84
+ target = await resolveCommit(root, targetRevision, options);
85
+ }
86
+
87
+ // Metadata scales with changed paths, not every tracked file in either tree.
88
+ const raw = utf8(await gitAt(root, ['diff-tree', '--no-abbrev', '--raw', '-z', '-r', '--no-renames', '--no-ext-diff', '--no-textconv', '--no-commit-id', ...(base ? [base, target] : ['--root', target]), '--'], options)).split('\0');
89
+ const { left, right, names } = parseDiffMeta(raw);
90
+
91
+ if (names.length > maxFiles) throw new Error('Historical change count limit exceeded');
92
+ if (selectedPaths?.some(p => !names.includes(p))) throw new Error('Selected path is not a historical change');
93
+
94
+ const files = [];
95
+ for (const name of names) {
96
+ signal?.throwIfAborted();
97
+ const l = left.get(name), r = right.get(name);
98
+ const item = { id: hash(name), path: name, rawStatus: !l ? 'A' : !r ? 'D' : 'M', eligibility: 'reviewable', properties: [] };
99
+ files.push(item);
100
+ if (isSecretPath(name)) { item.eligibility = 'excluded'; item.reason = 'Credential path excluded'; continue; }
101
+ if (selectedPaths && !selectedPaths.includes(name)) { item.eligibility = 'excluded'; item.reason = 'Not explicitly selected'; continue; }
102
+ try {
103
+ for (const e of [l, r].filter(Boolean)) if (e.type !== 'blob' || !['100644', '100755'].includes(e.mode)) throw new Error('Historical link or submodule excluded');
104
+ const read = async e => decode(e ? await gitAt(root, ['cat-file', 'blob', e.oid], options, maxFileBytes) : Buffer.alloc(0));
105
+ item.left = await read(l);
106
+ item.right = await read(r);
107
+ item.rightExists = !!r;
108
+ if (l && r && item.left.hash === item.right.hash) { item.eligibility = 'excluded'; item.reason = 'Mode-only historical change not analyzed'; }
109
+ } catch (error) {
110
+ signal?.throwIfAborted();
111
+ item.eligibility = 'blocked'; item.reason = error.message;
112
+ delete item.left; delete item.right;
113
+ }
114
+ if (Buffer.byteLength(JSON.stringify(files)) > maxSnapshotBytes) throw new Error('Historical snapshot limit exceeded');
115
+ }
116
+
117
+ const rulePaths = options.rulePaths ?? [];
118
+ const contextPaths = options.contextPaths ?? [], oldContextPaths = options.oldContextPaths ?? [], maxContextFiles = options.maxContextFiles ?? 20;
119
+ if (!Array.isArray(oldContextPaths) || oldContextPaths.length > 20 || new Set(oldContextPaths).size !== oldContextPaths.length) throw new Error('Invalid old context paths');
120
+ for (const name of oldContextPaths) { relativePath(name); assertNonSecretPath(name); }
121
+ if (oldContextPaths.length && !base) throw new Error('Old context requires nonempty historical baseline');
122
+ if (!Array.isArray(contextPaths) || !Array.isArray(rulePaths)) throw new Error('Invalid historical context or rule paths');
123
+ if (oldContextPaths.some(p => contextPaths.includes(p) || rulePaths.includes(p))) throw new Error('Old context paths must be separate');
124
+ if (contextPaths.length + oldContextPaths.length > maxContextFiles) throw new Error('Historical context file limit exceeded');
125
+ if (!Array.isArray(contextPaths) || !Number.isSafeInteger(maxContextFiles) || maxContextFiles < 1 || maxContextFiles > 20 || contextPaths.length > maxContextFiles) throw new Error('Historical context file limit exceeded');
126
+ if (options.autoContext !== undefined && typeof options.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
127
+ if (!Array.isArray(rulePaths) || rulePaths.length > 4 || rulePaths.some(p => contextPaths.includes(p))) throw new Error('Rule paths must be separate from context paths');
128
+
129
+ await lookupPaths(root, target, right, [...contextPaths, ...rulePaths], options);
130
+ await lookupPaths(root, base, left, [...contextPaths, ...oldContextPaths], options);
131
+
132
+ const context = [];
133
+ const readBlob = (oid, limit = maxFileBytes) => gitAt(root, ['cat-file', 'blob', oid], options, limit);
134
+
135
+ const captureContext = async name => {
136
+ relativePath(name); assertNonSecretPath(name);
137
+ const changed = files.find(f => f.path === name);
138
+ if (changed) {
139
+ if (changed.eligibility !== 'reviewable' || changed.rightExists === false) throw new Error('Historical context is excluded blocked or deleted');
140
+ return { path: name, ...changed.right };
141
+ }
142
+ const entry = right.get(name);
143
+ if (!REGULAR_BLOB(entry)) throw new Error('Historical context not a target regular blob');
144
+ const content = decode(await readBlob(entry.oid));
145
+ const previous = left.get(name);
146
+ return { path: name, ...content, revision: target, ...(REGULAR_BLOB(previous) ? { oldText: decode(await readBlob(previous.oid)).text, oldRevision: base } : {}) };
147
+ };
148
+
149
+ for (const name of [...new Set(contextPaths)].sort()) context.push(await captureContext(name));
150
+
151
+ for (const name of [...oldContextPaths].sort()) {
152
+ const changed = files.find(f => f.path === name);
153
+ if (changed) {
154
+ if (changed.eligibility !== 'reviewable' || !left.has(name)) throw new Error('Old context conflicts with excluded blocked or absent old change');
155
+ continue;
156
+ }
157
+ const entry = left.get(name);
158
+ if (!REGULAR_BLOB(entry)) throw new Error('Old context not a baseline regular blob');
159
+ const content = decode(await readBlob(entry.oid));
160
+ context.push({ path: name, oldOnly: true, oldText: content.text, oldHash: hash(content.text), oldBlobOid: entry.oid, oldRevision: base });
161
+ }
162
+
163
+ const rules = await captureProjectRules(root, rulePaths, {
164
+ signal, files,
165
+ read: async (_root, name, { maxFileBytes: limit }) => {
166
+ const entry = right.get(name);
167
+ if (!REGULAR_BLOB(entry)) throw new Error('Historical rule not a target regular blob');
168
+ return decode(await readBlob(entry.oid, limit));
169
+ },
170
+ });
171
+
172
+ let autoContext;
173
+ if (options.autoContext === true) autoContext = await captureHistoryDependencyChain(root, files, { base, target }, context, options);
174
+
175
+ let callerDiscovery;
176
+ if (options.callerScopePaths !== undefined) {
177
+ const callers = await captureHistoryCallers(root, files, { base, target }, options.callerScopePaths, options);
178
+ callerDiscovery = callers.discovery;
179
+ for (const item of callers.context) {
180
+ const matches = callerDiscovery.candidates.filter(c => c.fromPath === item.path);
181
+ const present = context.find(c => c.path === item.path);
182
+ if (present) {
183
+ if ((present.text !== undefined && present.text !== item.text) || (present.oldText !== undefined && present.oldText !== item.oldText)) throw new Error('Historical caller context identity conflict');
184
+ for (const c of matches) {
185
+ const available = c.side === 'old' ? present.oldText !== undefined : present.text !== undefined;
186
+ c.status = available ? 'reused-context' : 'blocked';
187
+ if (!available) c.captureReason = 'Existing context did not approve this version side';
188
+ }
189
+ continue;
190
+ }
191
+ if (context.length >= maxContextFiles) {
192
+ for (const c of matches) { c.status = 'blocked'; c.captureReason = 'Context file limit exceeded'; }
193
+ continue;
194
+ }
195
+ context.push(item);
196
+ callerDiscovery.capturedPaths.push(item.path);
197
+ for (const c of matches) c.status = 'captured';
198
+ }
199
+ }
200
+
201
+ const snapshot = { context, ...(callerDiscovery ? { callerDiscovery } : {}), ...(rules.length ? { rules } : {}), ...(autoContext ? { autoContext } : {}), schemaVersion: 1, vcs: 'git', root, baseline: base, history: { mode: commit !== undefined ? 'commit' : 'range', base, target, semantics: 'exact-tree-endpoints; renames represented as add/delete' }, files };
202
+ const serialized = JSON.stringify(snapshot);
203
+ if (Buffer.byteLength(serialized) > maxSnapshotBytes) throw new Error('Historical snapshot limit exceeded');
204
+ return { ...snapshot, id: hash(serialized) };
59
205
  }