@feng0531/dsh-code-review 0.83.0 → 0.83.1

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 (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/RELEASE-NOTES.md +8 -7
  3. package/index.mjs +11 -12
  4. package/package.json +1 -1
  5. package/src/background-review.mjs +84 -12
  6. package/src/business-groups.mjs +1 -1
  7. package/src/class-bindings.mjs +45 -12
  8. package/src/defect-guidance.mjs +158 -16
  9. package/src/dsh-model.mjs +107 -15
  10. package/src/git-history.mjs +202 -56
  11. package/src/git.mjs +64 -34
  12. package/src/import-bindings.mjs +74 -65
  13. package/src/job-progress.mjs +21 -2
  14. package/src/model-contract.mjs +17 -1
  15. package/src/primary-input.mjs +85 -26
  16. package/src/project-report.mjs +22 -5
  17. package/src/project-review-service.mjs +177 -37
  18. package/src/project-snapshot.mjs +101 -40
  19. package/src/prompts.mjs +9 -0
  20. package/src/protocol-guidance.mjs +13 -1
  21. package/src/recovery-coverage.mjs +25 -1
  22. package/src/retrieval-loop.mjs +66 -28
  23. package/src/review.mjs +36 -33
  24. package/src/risk-plan.mjs +1 -1
  25. package/src/service.mjs +136 -5
  26. package/src/snapshot-reader.mjs +18 -5
  27. package/src/snapshot.mjs +69 -42
  28. package/src/svn-capture-budget.mjs +22 -5
  29. package/src/svn-effective-mergeinfo.mjs +59 -37
  30. package/src/svn-eol-mapping.mjs +22 -10
  31. package/src/svn-history-endpoints.mjs +18 -12
  32. package/src/svn-history-lookup.mjs +34 -26
  33. package/src/svn-keyword-config.mjs +11 -8
  34. package/src/svn-keyword-mapping.mjs +67 -36
  35. package/src/svn-mergeinfo.mjs +34 -22
  36. package/src/svn-navigation-budget.mjs +15 -4
  37. package/src/svn-special.mjs +13 -8
  38. package/src/svn-url.mjs +18 -6
  39. package/src/svn-working-identity.mjs +22 -8
  40. package/src/svn-working-scope.mjs +16 -5
  41. package/src/tool-view.mjs +5 -0
  42. 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
  }
@@ -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
  }
package/src/git.mjs CHANGED
@@ -4,55 +4,80 @@ import { run, checked } from './process.mjs';
4
4
  import { hash, decode, readLocal, relativePath } from './content.mjs';
5
5
 
6
6
  const flags = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
7
+ const STATUS_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'];
7
8
  const git = (cwd, args, options) => checked('git', [...flags, ...args], { ...options, cwd });
8
9
  const text = bytes => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
9
10
 
10
- export async function captureGit(cwd, options = {}) {
11
- const { signal, selectedPaths, maxFiles = 200, maxFileBytes = 256 * 1024 } = options;
12
- if (selectedPaths) for (const name of selectedPaths) relativePath(name);
13
- const root = await realpath(text(await git(cwd, ['rev-parse', '--show-toplevel'], options)).trim());
14
- const statusArgs = ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'];
15
- const before = await git(root, statusArgs, options);
11
+ async function resolveRoot(cwd, options) {
12
+ const top = text(await git(cwd, ['rev-parse', '--show-toplevel'], options)).trim();
13
+ return realpath(top);
14
+ }
15
+
16
+ /** HEAD commit sha, or null only for a genuinely unborn branch. */
17
+ async function resolveHead(root, options) {
16
18
  const headResult = await run('git', [...flags, 'rev-parse', '--verify', 'HEAD'], { ...options, cwd: root });
17
- // Only a genuinely unborn branch may use an empty baseline.
18
- let head = headResult.code === 0 ? text(headResult.stdout).trim() : null;
19
- if (!head) {
20
- const branch = text(await git(root, ['symbolic-ref', 'HEAD'], options)).trim();
21
- const ref = await run('git', [...flags, 'show-ref', '--verify', '--quiet', branch], { ...options, cwd: root });
22
- if (ref.code !== 1) throw new Error('Cannot resolve Git HEAD');
23
- }
24
- const base = new Map();
25
- // Resolve only reviewable changed baselines below; never enumerate the HEAD tree.
26
- const records = text(before).split('\0');
19
+ const head = headResult.code === 0 ? text(headResult.stdout).trim() : null;
20
+ if (head) return head;
21
+ const branch = text(await git(root, ['symbolic-ref', 'HEAD'], options)).trim();
22
+ const ref = await run('git', [...flags, 'show-ref', '--verify', '--quiet', branch], { ...options, cwd: root });
23
+ if (ref.code !== 1) throw new Error('Cannot resolve Git HEAD');
24
+ return null;
25
+ }
26
+
27
+ function parseChanges(bytes) {
28
+ const records = text(bytes).split('\0');
27
29
  const changes = [];
28
30
  for (let i = 0; i < records.length; i++) {
29
31
  if (!records[i]) continue;
30
- const rawStatus = records[i].slice(0, 2), name = records[i].slice(3);
32
+ const rawStatus = records[i].slice(0, 2);
33
+ const name = records[i].slice(3);
31
34
  let oldPath;
32
35
  if (/[RC]/.test(rawStatus)) oldPath = records[++i];
33
36
  changes.push({ path: name, ...(oldPath ? { oldPath } : {}), rawStatus });
34
37
  }
38
+ return changes;
39
+ }
40
+
41
+ /** Resolve only reviewable changed baselines; never enumerate the whole HEAD tree. */
42
+ async function resolveBaselines(root, head, changes, selectedPaths, options, signal) {
43
+ const base = new Map();
44
+ const requested = [...new Set(changes
45
+ .filter(change =>
46
+ !isSecretPath(change.path) &&
47
+ !isSecretPath(change.oldPath) &&
48
+ (!selectedPaths || selectedPaths.includes(change.path)) &&
49
+ (change.rawStatus !== '??' || selectedPaths?.includes(change.path)))
50
+ .map(change => relativePath(change.oldPath ?? change.path)))];
51
+ for (const name of requested) {
52
+ signal?.throwIfAborted();
53
+ const records = text(await git(root, ['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', head, '--', name], options)).split('\0').filter(Boolean);
54
+ for (const record of records) {
55
+ const m = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
56
+ if (!m || m[4] !== name || base.has(name)) throw new Error('Invalid changed baseline lookup');
57
+ base.set(name, { mode: m[1], type: m[2], oid: m[3] });
58
+ }
59
+ }
60
+ return base;
61
+ }
62
+
63
+ export async function captureGit(cwd, options = {}) {
64
+ const { signal, selectedPaths, maxFiles = 200, maxFileBytes = 256 * 1024 } = options;
65
+ if (selectedPaths) for (const name of selectedPaths) relativePath(name);
66
+ const root = await resolveRoot(cwd, options);
67
+ const before = await git(root, STATUS_ARGS, options);
68
+ const head = await resolveHead(root, options);
69
+ const changes = parseChanges(before);
35
70
  if (changes.length > maxFiles) throw new Error('Change count limit exceeded; narrow repository scope');
36
71
  const known = new Set(changes.map(c => c.path));
37
72
  if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change');
38
- if (head) {
39
- const requested = [...new Set(changes.filter(c => !isSecretPath(c.path) && !isSecretPath(c.oldPath) && (!selectedPaths || selectedPaths.includes(c.path)) && (c.rawStatus !== '??' || selectedPaths?.includes(c.path))).map(c => relativePath(c.oldPath ?? c.path)))];
40
- for (const name of requested) {
41
- signal?.throwIfAborted();
42
- const records = text(await git(root, ['--literal-pathspecs','ls-tree','-z','--full-tree',head,'--',name], options)).split('\0').filter(Boolean);
43
- for (const record of records) {
44
- const m = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
45
- if (!m || m[4] !== name || base.has(name)) throw new Error('Invalid changed baseline lookup');
46
- base.set(name,{mode:m[1],type:m[2],oid:m[3]});
47
- }
48
- }
49
- }
73
+ const base = head ? await resolveBaselines(root, head, changes, selectedPaths, options, signal) : new Map();
74
+
50
75
  const files = [];
51
76
  for (const change of changes) {
52
77
  signal?.throwIfAborted();
53
78
  const item = { ...change, id: hash(change.path), eligibility: 'reviewable', properties: [] };
54
79
  files.push(item);
55
- if (isSecretPath(change.path) || isSecretPath(change.oldPath)) { item.eligibility='excluded'; item.reason='Credential path excluded'; continue; }
80
+ if (isSecretPath(change.path) || isSecretPath(change.oldPath)) { item.eligibility = 'excluded'; item.reason = 'Credential path excluded'; continue; }
56
81
  if ((selectedPaths && !selectedPaths.includes(change.path)) || (change.rawStatus === '??' && !selectedPaths?.includes(change.path))) {
57
82
  item.eligibility = 'excluded'; item.reason = 'Not explicitly selected'; continue;
58
83
  }
@@ -64,19 +89,24 @@ export async function captureGit(cwd, options = {}) {
64
89
  if (previous && (previous.type !== 'blob' || previous.mode === '120000')) throw new Error('Submodule or symbolic link baseline');
65
90
  const left = previous ? await git(root, ['cat-file', 'blob', previous.oid], { ...options, maxBytes: maxFileBytes }) : Buffer.alloc(0);
66
91
  item.left = decode(left);
67
- try { item.right = await readLocal(root, change.path, options); item.rightExists = true; }
68
- catch (error) {
92
+ try {
93
+ item.right = await readLocal(root, change.path, options);
94
+ item.rightExists = true;
95
+ } catch (error) {
69
96
  if (error.code === 'ENOENT' && change.rawStatus.includes('D')) { item.right = decode(Buffer.alloc(0)); item.rightExists = false; }
70
97
  else throw error;
71
98
  }
72
- if (previous && item.rightExists && item.left.hash === item.right.hash && !change.oldPath) { item.eligibility = 'excluded'; item.reason = 'No net content change (mode-only changes not analyzed)'; }
99
+ if (previous && item.rightExists && item.left.hash === item.right.hash && !change.oldPath) {
100
+ item.eligibility = 'excluded'; item.reason = 'No net content change (mode-only changes not analyzed)';
101
+ }
73
102
  } catch (error) {
74
103
  signal?.throwIfAborted();
75
104
  item.eligibility = 'blocked'; item.reason = error.message;
76
105
  delete item.left; delete item.right;
77
106
  }
78
107
  }
79
- if (!(await git(root, statusArgs, options)).equals(before)) throw new Error('Repository changed during capture');
108
+
109
+ if (!(await git(root, STATUS_ARGS, options)).equals(before)) throw new Error('Repository changed during capture');
80
110
  const finalHead = await run('git', [...flags, 'rev-parse', '--verify', 'HEAD'], { ...options, cwd: root });
81
111
  if ((finalHead.code === 0 ? text(finalHead.stdout).trim() : null) !== head) throw new Error('HEAD changed during capture');
82
112
  for (const file of files.filter(f => f.right)) {