@feng0531/dsh-code-review 0.82.3 → 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.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/RELEASE-NOTES.md +8 -7
- package/index.mjs +11 -12
- package/package.json +1 -1
- package/src/background-review.mjs +84 -11
- package/src/business-groups.mjs +1 -1
- package/src/class-bindings.mjs +45 -12
- package/src/defect-guidance.mjs +158 -16
- package/src/dsh-model.mjs +107 -15
- package/src/git-history.mjs +202 -56
- package/src/git.mjs +64 -34
- package/src/import-bindings.mjs +74 -65
- package/src/job-progress.mjs +21 -0
- package/src/model-contract.mjs +17 -1
- package/src/primary-input.mjs +85 -26
- package/src/project-report.mjs +22 -5
- package/src/project-review-service.mjs +177 -37
- package/src/project-snapshot.mjs +101 -40
- package/src/prompts.mjs +9 -0
- package/src/protocol-guidance.mjs +13 -1
- package/src/recovery-coverage.mjs +25 -1
- package/src/retrieval-loop.mjs +66 -28
- package/src/review-jobs.mjs +6 -4
- package/src/review.mjs +36 -33
- package/src/risk-plan.mjs +1 -1
- package/src/service.mjs +136 -5
- package/src/snapshot-reader.mjs +18 -5
- package/src/snapshot.mjs +69 -42
- package/src/svn-capture-budget.mjs +22 -5
- package/src/svn-effective-mergeinfo.mjs +59 -37
- package/src/svn-eol-mapping.mjs +22 -10
- package/src/svn-history-endpoints.mjs +18 -12
- package/src/svn-history-lookup.mjs +34 -26
- package/src/svn-keyword-config.mjs +11 -8
- package/src/svn-keyword-mapping.mjs +67 -36
- package/src/svn-mergeinfo.mjs +34 -22
- package/src/svn-navigation-budget.mjs +15 -4
- package/src/svn-special.mjs +13 -8
- package/src/svn-url.mjs +18 -6
- package/src/svn-working-identity.mjs +22 -8
- package/src/svn-working-scope.mjs +16 -5
- package/src/tool-view.mjs +5 -0
- 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)
|
|
6
|
-
|
|
7
|
-
|
|
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 = ''
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 (
|
|
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')
|
|
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
|
-
|
|
28
|
-
|
|
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
|
}
|
package/src/git-history.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const records = text(
|
|
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)
|
|
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
|
-
|
|
39
|
-
|
|
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 {
|
|
68
|
-
|
|
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) {
|
|
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
|
-
|
|
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)) {
|