@orion-agents/orion-code 0.3.25 → 0.3.27
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 +74 -9
- package/README.md +10 -8
- package/README.zh-CN.md +7 -6
- package/dist/runtime/legacy-thread-materializer.d.ts +13 -0
- package/dist/runtime/legacy-thread-materializer.js +72 -0
- package/dist/services/code-review/constraints.d.ts +25 -0
- package/dist/services/code-review/constraints.js +79 -0
- package/dist/services/code-review/contract.d.ts +172 -0
- package/dist/services/code-review/contract.js +184 -0
- package/dist/services/code-review/snapshot.d.ts +64 -0
- package/dist/services/code-review/snapshot.js +321 -0
- package/dist/services/session-storage.js +21 -0
- package/dist/web/git-diff-document.d.ts +19 -0
- package/dist/web/git-diff-document.js +46 -2
- package/dist/web/git-read-model-service.js +76 -5
- package/npm-shrinkwrap.json +12 -12
- package/package.json +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FINDING_TEXT_LIMITS = exports.DEFAULT_REVIEW_BUDGETS = void 0;
|
|
4
|
+
exports.validateReviewFinding = validateReviewFinding;
|
|
5
|
+
exports.resolveReviewConclusion = resolveReviewConclusion;
|
|
6
|
+
exports.resolveReviewBudgets = resolveReviewBudgets;
|
|
7
|
+
exports.reviewApplicability = reviewApplicability;
|
|
8
|
+
exports.computeReceiptDigest = computeReceiptDigest;
|
|
9
|
+
exports.verifyReceiptDigest = verifyReceiptDigest;
|
|
10
|
+
/**
|
|
11
|
+
* v0.3.27 PR0 — the shared code-review contract, frozen before any runtime
|
|
12
|
+
* wiring. This module is the single source of truth for review selectors,
|
|
13
|
+
* states, conclusions, findings, budgets and receipt digests; CLI and Web both
|
|
14
|
+
* consume it and neither may redefine the semantics locally (goal G3: one
|
|
15
|
+
* engine, one contract).
|
|
16
|
+
*
|
|
17
|
+
* Design sources: docs/plan/v0.3.27-plan.md §4 (identity, state, conclusions),
|
|
18
|
+
* §4.3 (bounded resources), §5 (read-only execution). The conclusion mapping
|
|
19
|
+
* encodes the plan's rule that tool success is not review success: a partial,
|
|
20
|
+
* failed, cancelled or untrustworthy output can never surface as a green
|
|
21
|
+
* "no findings".
|
|
22
|
+
*/
|
|
23
|
+
const canonical_1 = require("../../runtime/protocol/canonical");
|
|
24
|
+
exports.DEFAULT_REVIEW_BUDGETS = Object.freeze({
|
|
25
|
+
maxFiles: 50,
|
|
26
|
+
maxBodyBytes: 2 * 1024 * 1024,
|
|
27
|
+
maxDiffBytes: 1024 * 1024,
|
|
28
|
+
maxReadToolCalls: 50,
|
|
29
|
+
maxDurationMs: 5 * 60 * 1000,
|
|
30
|
+
maxFindings: 100,
|
|
31
|
+
maxResultBytes: 256 * 1024,
|
|
32
|
+
});
|
|
33
|
+
/** Text size caps for finding fields, in characters. Chosen so 100 maximal
|
|
34
|
+
* findings stay well inside the 256 KiB result bound. */
|
|
35
|
+
exports.FINDING_TEXT_LIMITS = Object.freeze({
|
|
36
|
+
title: 200,
|
|
37
|
+
trigger: 2000,
|
|
38
|
+
impact: 2000,
|
|
39
|
+
evidence: 4000,
|
|
40
|
+
fileKey: 1024,
|
|
41
|
+
});
|
|
42
|
+
const FINDING_ID_PATTERN = /^[0-9a-f]{8,64}$/iu;
|
|
43
|
+
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
44
|
+
/** Validates one model-produced finding against the frozen shape. Returns the
|
|
45
|
+
* finding unchanged when valid; a typed reason when not. Membership against
|
|
46
|
+
* the snapshot manifest is a separate Host check (PR1+). */
|
|
47
|
+
function validateReviewFinding(value) {
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
49
|
+
return { valid: false, reason: 'not_an_object' };
|
|
50
|
+
}
|
|
51
|
+
const candidate = value;
|
|
52
|
+
const text = (field) => typeof candidate[field] === 'string' ? candidate[field] : '';
|
|
53
|
+
if (typeof candidate.findingId !== 'string' || !FINDING_ID_PATTERN.test(candidate.findingId)) {
|
|
54
|
+
return { valid: false, reason: 'finding_id_invalid' };
|
|
55
|
+
}
|
|
56
|
+
if (candidate.priority !== 'P0' &&
|
|
57
|
+
candidate.priority !== 'P1' &&
|
|
58
|
+
candidate.priority !== 'P2' &&
|
|
59
|
+
candidate.priority !== 'P3') {
|
|
60
|
+
return { valid: false, reason: 'priority_invalid' };
|
|
61
|
+
}
|
|
62
|
+
const title = text('title').trim();
|
|
63
|
+
if (!title || title.length > exports.FINDING_TEXT_LIMITS.title) {
|
|
64
|
+
return { valid: false, reason: 'title_invalid' };
|
|
65
|
+
}
|
|
66
|
+
const trigger = text('trigger').trim();
|
|
67
|
+
if (!trigger || trigger.length > exports.FINDING_TEXT_LIMITS.trigger) {
|
|
68
|
+
return { valid: false, reason: 'trigger_invalid' };
|
|
69
|
+
}
|
|
70
|
+
const impact = text('impact').trim();
|
|
71
|
+
if (!impact || impact.length > exports.FINDING_TEXT_LIMITS.impact) {
|
|
72
|
+
return { valid: false, reason: 'impact_invalid' };
|
|
73
|
+
}
|
|
74
|
+
const fileKey = text('fileKey');
|
|
75
|
+
if (!fileKey ||
|
|
76
|
+
fileKey.length > exports.FINDING_TEXT_LIMITS.fileKey ||
|
|
77
|
+
fileKey.startsWith('/') ||
|
|
78
|
+
fileKey.includes('\0') ||
|
|
79
|
+
fileKey.split('/').includes('..')) {
|
|
80
|
+
return { valid: false, reason: 'file_key_invalid' };
|
|
81
|
+
}
|
|
82
|
+
if (candidate.side !== 'old' && candidate.side !== 'new') {
|
|
83
|
+
return { valid: false, reason: 'side_invalid' };
|
|
84
|
+
}
|
|
85
|
+
const startLine = candidate.startLine;
|
|
86
|
+
const endLine = candidate.endLine;
|
|
87
|
+
if (!Number.isSafeInteger(startLine) ||
|
|
88
|
+
startLine < 1 ||
|
|
89
|
+
!Number.isSafeInteger(endLine) ||
|
|
90
|
+
endLine < startLine) {
|
|
91
|
+
return { valid: false, reason: 'line_range_invalid' };
|
|
92
|
+
}
|
|
93
|
+
const evidence = text('evidence').trim();
|
|
94
|
+
if (!evidence || evidence.length > exports.FINDING_TEXT_LIMITS.evidence) {
|
|
95
|
+
return { valid: false, reason: 'evidence_invalid' };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
valid: true,
|
|
99
|
+
finding: Object.freeze({
|
|
100
|
+
findingId: candidate.findingId,
|
|
101
|
+
priority: candidate.priority,
|
|
102
|
+
title,
|
|
103
|
+
trigger,
|
|
104
|
+
impact,
|
|
105
|
+
fileKey,
|
|
106
|
+
side: candidate.side,
|
|
107
|
+
startLine: startLine,
|
|
108
|
+
endLine: endLine,
|
|
109
|
+
evidence,
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* v0.3.27 §4.1 — the conclusion mapping, frozen as code so CLI and Web cannot
|
|
115
|
+
* diverge. The invariants:
|
|
116
|
+
* - `queued`/`running` results carry no conclusion (callers must not call this).
|
|
117
|
+
* - A state that is not `completed` can never conclude `no_findings`.
|
|
118
|
+
* - Invalid findings make the output untrustworthy: zero valid findings with
|
|
119
|
+
* invalid ones present is `inconclusive`, never a green pass.
|
|
120
|
+
* - Zero findings without complete coverage is `inconclusive`, never a green
|
|
121
|
+
* pass ("不静默截断后宣布全部完成").
|
|
122
|
+
*/
|
|
123
|
+
function resolveReviewConclusion(input) {
|
|
124
|
+
if (input.state === 'queued' || input.state === 'running') {
|
|
125
|
+
throw new Error('A queued or running review cannot have a terminal conclusion.');
|
|
126
|
+
}
|
|
127
|
+
if (input.state === 'failed' || input.state === 'cancelled') {
|
|
128
|
+
return 'inconclusive';
|
|
129
|
+
}
|
|
130
|
+
if (input.validFindingCount > 0) {
|
|
131
|
+
return 'findings';
|
|
132
|
+
}
|
|
133
|
+
if (input.state === 'partial') {
|
|
134
|
+
return 'inconclusive';
|
|
135
|
+
}
|
|
136
|
+
// state === 'completed'
|
|
137
|
+
if (input.invalidFindingCount > 0) {
|
|
138
|
+
return 'inconclusive';
|
|
139
|
+
}
|
|
140
|
+
return input.coverageComplete ? 'no_findings' : 'inconclusive';
|
|
141
|
+
}
|
|
142
|
+
/** Applies the budgets with the tighten-only rule: every override must be a
|
|
143
|
+
* safe integer at or below the frozen default, otherwise the run is refused
|
|
144
|
+
* rather than silently widened. */
|
|
145
|
+
function resolveReviewBudgets(overrides = {}) {
|
|
146
|
+
const resolved = {};
|
|
147
|
+
for (const [field, ceiling] of Object.entries(exports.DEFAULT_REVIEW_BUDGETS)) {
|
|
148
|
+
const requested = overrides[field];
|
|
149
|
+
if (requested === undefined) {
|
|
150
|
+
resolved[field] = ceiling;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!Number.isSafeInteger(requested) || requested < 1 || requested > ceiling) {
|
|
154
|
+
return { ok: false, field };
|
|
155
|
+
}
|
|
156
|
+
resolved[field] = requested;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
budgets: Object.freeze(resolved),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Marks a stored result's applicability against the current input snapshot and
|
|
164
|
+
* rules digests. The receipt itself is never rewritten. */
|
|
165
|
+
function reviewApplicability(result, current) {
|
|
166
|
+
return result.inputSnapshotDigest === current.inputSnapshotDigest &&
|
|
167
|
+
result.rulesDigest === current.rulesDigest
|
|
168
|
+
? 'current'
|
|
169
|
+
: 'stale';
|
|
170
|
+
}
|
|
171
|
+
/** The receipt digest covers the canonical form of the result with the digest
|
|
172
|
+
* field itself excluded, so verification is a recomputation, not a lookup. */
|
|
173
|
+
function computeReceiptDigest(result) {
|
|
174
|
+
const content = { ...result };
|
|
175
|
+
delete content.receiptDigest;
|
|
176
|
+
const digest = (0, canonical_1.digestRuntimeValue)(content);
|
|
177
|
+
return DIGEST_PATTERN.test(digest) ? digest : digest.toLowerCase();
|
|
178
|
+
}
|
|
179
|
+
/** Verifies a stored receipt by recomputing its digest. */
|
|
180
|
+
function verifyReceiptDigest(result) {
|
|
181
|
+
const { receiptDigest, ...content } = result;
|
|
182
|
+
return computeReceiptDigest(content) === receiptDigest;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ReviewBudgetsV1, type ReviewSelectorV1 } from './contract';
|
|
2
|
+
export interface ReviewSnapshotFileSideV1 {
|
|
3
|
+
readonly source: 'staged' | 'unstaged' | 'base' | 'head';
|
|
4
|
+
readonly oid: string | null;
|
|
5
|
+
readonly contentDigest: string;
|
|
6
|
+
readonly byteSize: number;
|
|
7
|
+
readonly truncated: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface ReviewSnapshotHunkV1 {
|
|
10
|
+
readonly oldStart: number;
|
|
11
|
+
readonly oldLines: number;
|
|
12
|
+
readonly newStart: number;
|
|
13
|
+
readonly newLines: number;
|
|
14
|
+
readonly header: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ReviewSnapshotFileV1 {
|
|
17
|
+
readonly fileKey: string;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly oldPath: string | null;
|
|
20
|
+
readonly sensitive: boolean;
|
|
21
|
+
readonly sides: readonly ReviewSnapshotFileSideV1[];
|
|
22
|
+
readonly hunks: readonly ReviewSnapshotHunkV1[];
|
|
23
|
+
}
|
|
24
|
+
export interface ReviewSnapshotSkipV1 {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly reason: 'over_budget_files' | 'over_budget_bytes' | 'binary' | 'sensitive' | 'unreadable' | 'changed_during_review' | 'other';
|
|
27
|
+
readonly detail?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface CodeReviewSnapshotV1 {
|
|
30
|
+
readonly schemaVersion: 1;
|
|
31
|
+
readonly snapshotId: string;
|
|
32
|
+
readonly kind: 'worktree' | 'base';
|
|
33
|
+
readonly baseOid: string | null;
|
|
34
|
+
readonly headOid: string | null;
|
|
35
|
+
readonly requestedPaths: readonly string[];
|
|
36
|
+
readonly files: readonly ReviewSnapshotFileV1[];
|
|
37
|
+
readonly inputSnapshotDigest: string;
|
|
38
|
+
readonly skipped: readonly ReviewSnapshotSkipV1[];
|
|
39
|
+
}
|
|
40
|
+
export declare class ReviewSnapshotError extends Error {
|
|
41
|
+
readonly code: 'not_a_repository' | 'ref_unresolved' | 'no_merge_base' | 'path_invalid' | 'worktree_changed';
|
|
42
|
+
constructor(message: string, code: 'not_a_repository' | 'ref_unresolved' | 'no_merge_base' | 'path_invalid' | 'worktree_changed');
|
|
43
|
+
}
|
|
44
|
+
/** Runs a read-only git command with argument arrays. The caller injects this,
|
|
45
|
+
* so tests drive real temporary repositories without spawning beyond git. */
|
|
46
|
+
export type ReviewGitRunner = (args: readonly string[]) => Promise<string>;
|
|
47
|
+
export interface ReviewSnapshotOptionsV1 {
|
|
48
|
+
readonly root: string;
|
|
49
|
+
readonly selector: ReviewSelectorV1;
|
|
50
|
+
readonly budgets?: Partial<ReviewBudgetsV1>;
|
|
51
|
+
readonly runGit: ReviewGitRunner;
|
|
52
|
+
/** Reads one working-tree file relative to the root, or null when missing. */
|
|
53
|
+
readonly readWorktreeFile?: (relativePath: string) => Promise<string | null>;
|
|
54
|
+
readonly snapshotIdPrefix?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Resolves the selector to a frozen snapshot. Every OID and digest in the
|
|
57
|
+
* returned object was taken at admission time; callers persist the returned
|
|
58
|
+
* digest as the review's input identity. */
|
|
59
|
+
export declare function captureReviewSnapshot(options: ReviewSnapshotOptionsV1): Promise<CodeReviewSnapshotV1>;
|
|
60
|
+
/** Reads one working-tree file for review purposes, refusing to leave the
|
|
61
|
+
* repository root (same containment discipline as the v0.3.25 conflict
|
|
62
|
+
* reader). Returns null when the file is missing or unreadable. */
|
|
63
|
+
export declare function createContainedWorktreeReader(rootInput: string): (relativePath: string) => Promise<string | null>;
|
|
64
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ReviewSnapshotError = void 0;
|
|
4
|
+
exports.captureReviewSnapshot = captureReviewSnapshot;
|
|
5
|
+
exports.createContainedWorktreeReader = createContainedWorktreeReader;
|
|
6
|
+
/**
|
|
7
|
+
* v0.3.27 PR1 — the minimal shared Git snapshot reader for code review
|
|
8
|
+
* (plan §4.2). One reader, two scopes:
|
|
9
|
+
*
|
|
10
|
+
* - `worktree`: staged, unstaged and untracked changes are recorded as
|
|
11
|
+
* separate sources for the same path and are never merged into one
|
|
12
|
+
* composite diff.
|
|
13
|
+
* - `base`: the ref, HEAD and merge-base are resolved to immutable OIDs at
|
|
14
|
+
* admission; later ref movement cannot change the frozen input.
|
|
15
|
+
*
|
|
16
|
+
* Security posture (§4.2.7): git runs with argument arrays through the read
|
|
17
|
+
* profile, never a shell; external diff drivers and textconv are disabled;
|
|
18
|
+
* blobs are read by OID; untracked working files are read directly under the
|
|
19
|
+
* same sensitive/binary/boundary policies as everything else. No optional
|
|
20
|
+
* locks, no arbitrary subcommands exposed to the model.
|
|
21
|
+
*/
|
|
22
|
+
const crypto_1 = require("crypto");
|
|
23
|
+
const fs_1 = require("fs");
|
|
24
|
+
const promises_1 = require("fs/promises");
|
|
25
|
+
const path_1 = require("path");
|
|
26
|
+
const redaction_1 = require("../redaction");
|
|
27
|
+
const contract_1 = require("./contract");
|
|
28
|
+
class ReviewSnapshotError extends Error {
|
|
29
|
+
constructor(message, code) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.name = 'ReviewSnapshotError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.ReviewSnapshotError = ReviewSnapshotError;
|
|
36
|
+
function sha256(value) {
|
|
37
|
+
return (0, crypto_1.createHash)('sha256').update(value, 'utf8').digest('hex');
|
|
38
|
+
}
|
|
39
|
+
function assertSafePath(value) {
|
|
40
|
+
if (!value ||
|
|
41
|
+
value.length > 1024 ||
|
|
42
|
+
value.startsWith('/') ||
|
|
43
|
+
value.includes('\0') ||
|
|
44
|
+
value.split('/').includes('..')) {
|
|
45
|
+
throw new ReviewSnapshotError(`Unsafe review path: ${value}`, 'path_invalid');
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/** Parses `git diff --name-status -z` records into status/path groups. */
|
|
50
|
+
function parseNameStatusZ(records) {
|
|
51
|
+
const parsed = [];
|
|
52
|
+
let index = 0;
|
|
53
|
+
while (index < records.length) {
|
|
54
|
+
const record = records[index];
|
|
55
|
+
if (!record) {
|
|
56
|
+
index += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const status = record.slice(0, 1);
|
|
60
|
+
const paths = [];
|
|
61
|
+
if (status === 'R' || status === 'C') {
|
|
62
|
+
paths.push(records[index + 1] ?? '', records[index + 2] ?? '');
|
|
63
|
+
index += 3;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
paths.push(records[index + 1] ?? '');
|
|
67
|
+
index += 2;
|
|
68
|
+
}
|
|
69
|
+
parsed.push({ status, paths });
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
function parseDiffHunks(diffText) {
|
|
74
|
+
const files = [];
|
|
75
|
+
let current;
|
|
76
|
+
const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
77
|
+
for (const line of diffText.split('\n')) {
|
|
78
|
+
if (line.startsWith('diff --git ')) {
|
|
79
|
+
const rest = line.slice('diff --git '.length);
|
|
80
|
+
const split = rest.indexOf(' b/');
|
|
81
|
+
current = {
|
|
82
|
+
path: split >= 0 ? rest.slice(split + 3) : rest,
|
|
83
|
+
hunks: [],
|
|
84
|
+
diffBytes: 0,
|
|
85
|
+
};
|
|
86
|
+
files.push(current);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!current)
|
|
90
|
+
continue;
|
|
91
|
+
current.diffBytes += line.length + 1;
|
|
92
|
+
const header = HUNK.exec(line);
|
|
93
|
+
if (header) {
|
|
94
|
+
current.hunks.push({
|
|
95
|
+
oldStart: Number(header[1]),
|
|
96
|
+
oldLines: header[2] === undefined ? 1 : Number(header[2]),
|
|
97
|
+
newStart: Number(header[3]),
|
|
98
|
+
newLines: header[4] === undefined ? 1 : Number(header[4]),
|
|
99
|
+
header: line,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
function pathMatches(path, requestedPaths) {
|
|
106
|
+
if (requestedPaths.length === 0)
|
|
107
|
+
return true;
|
|
108
|
+
return requestedPaths.some(filter => path === filter || path.startsWith(`${filter}/`));
|
|
109
|
+
}
|
|
110
|
+
/** Resolves the selector to a frozen snapshot. Every OID and digest in the
|
|
111
|
+
* returned object was taken at admission time; callers persist the returned
|
|
112
|
+
* digest as the review's input identity. */
|
|
113
|
+
async function captureReviewSnapshot(options) {
|
|
114
|
+
const budgets = { ...contract_1.DEFAULT_REVIEW_BUDGETS, ...options.budgets };
|
|
115
|
+
const { runGit } = options;
|
|
116
|
+
const requestedPaths = (options.selector.paths ?? []).map(assertSafePath);
|
|
117
|
+
let baseOid = null;
|
|
118
|
+
let headOid = null;
|
|
119
|
+
let nameStatusArgs;
|
|
120
|
+
let unifiedArgs;
|
|
121
|
+
if (options.selector.kind === 'base') {
|
|
122
|
+
headOid = (await runGit(['rev-parse', '--verify', 'HEAD'])).trim();
|
|
123
|
+
const refOid = (await runGit(['rev-parse', '--verify', `${options.selector.ref}^{commit}`]).catch(() => '')).trim();
|
|
124
|
+
if (!refOid) {
|
|
125
|
+
throw new ReviewSnapshotError(`Review base ref could not be resolved: ${options.selector.ref}`, 'ref_unresolved');
|
|
126
|
+
}
|
|
127
|
+
baseOid = (await runGit(['merge-base', refOid, headOid]).catch(() => '')).trim();
|
|
128
|
+
if (!baseOid) {
|
|
129
|
+
throw new ReviewSnapshotError(`No merge base between ${options.selector.ref} and HEAD`, 'no_merge_base');
|
|
130
|
+
}
|
|
131
|
+
nameStatusArgs = [
|
|
132
|
+
'diff',
|
|
133
|
+
'--no-ext-diff',
|
|
134
|
+
'--no-textconv',
|
|
135
|
+
'--find-renames',
|
|
136
|
+
'--name-status',
|
|
137
|
+
'-z',
|
|
138
|
+
baseOid,
|
|
139
|
+
headOid,
|
|
140
|
+
];
|
|
141
|
+
unifiedArgs = ['diff', '--no-ext-diff', '--no-textconv', baseOid, headOid];
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
nameStatusArgs = [
|
|
145
|
+
'diff',
|
|
146
|
+
'--no-ext-diff',
|
|
147
|
+
'--no-textconv',
|
|
148
|
+
'--find-renames',
|
|
149
|
+
'--name-status',
|
|
150
|
+
'-z',
|
|
151
|
+
'HEAD',
|
|
152
|
+
];
|
|
153
|
+
unifiedArgs = ['diff', '--no-ext-diff', '--no-textconv', 'HEAD'];
|
|
154
|
+
}
|
|
155
|
+
const nameStatusRaw = await runGit(nameStatusArgs);
|
|
156
|
+
const changed = parseNameStatusZ(nameStatusRaw.split('\0')).filter(entry => {
|
|
157
|
+
const path = entry.paths.at(-1);
|
|
158
|
+
return Boolean(path) && pathMatches(path, requestedPaths);
|
|
159
|
+
});
|
|
160
|
+
const files = [];
|
|
161
|
+
const skipped = [];
|
|
162
|
+
const seenPaths = new Set();
|
|
163
|
+
for (const entry of changed) {
|
|
164
|
+
const path = assertSafePath(entry.paths.at(-1));
|
|
165
|
+
if (seenPaths.has(path))
|
|
166
|
+
continue;
|
|
167
|
+
if (files.length >= budgets.maxFiles) {
|
|
168
|
+
skipped.push({ path, reason: 'over_budget_files' });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
seenPaths.add(path);
|
|
172
|
+
const oldPath = entry.status === 'R' || entry.status === 'C' ? (entry.paths[0] ?? null) : null;
|
|
173
|
+
const sensitive = (0, redaction_1.isSensitiveFilePath)(path) || (oldPath ? (0, redaction_1.isSensitiveFilePath)(oldPath) : false);
|
|
174
|
+
if (sensitive) {
|
|
175
|
+
skipped.push({ path, reason: 'sensitive' });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (entry.status === 'B') {
|
|
179
|
+
skipped.push({ path, reason: 'binary' });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
files.push({
|
|
183
|
+
fileKey: sha256(`${entry.status}:${oldPath ?? ''}:${path}`).slice(0, 16),
|
|
184
|
+
path,
|
|
185
|
+
oldPath,
|
|
186
|
+
sensitive: false,
|
|
187
|
+
sides: [],
|
|
188
|
+
hunks: [],
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const parsedFiles = parseDiffHunks(await runGit(unifiedArgs));
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const match = parsedFiles.find(candidate => candidate.path === file.path);
|
|
194
|
+
if (match)
|
|
195
|
+
file.hunks.push(...match.hunks);
|
|
196
|
+
}
|
|
197
|
+
for (const file of files) {
|
|
198
|
+
const totalDiff = file.hunks.reduce((sum, hunk) => sum + hunk.oldLines + hunk.newLines, 0);
|
|
199
|
+
if (totalDiff * 128 > budgets.maxDiffBytes) {
|
|
200
|
+
skipped.push({ path: file.path, reason: 'over_budget_bytes' });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (options.selector.kind === 'base') {
|
|
204
|
+
const headOidForPath = (await runGit(['rev-parse', '--verify', `${headOid}:${file.path}`]).catch(() => '')).trim();
|
|
205
|
+
const baseOidForPath = (await runGit([
|
|
206
|
+
'rev-parse',
|
|
207
|
+
'--verify',
|
|
208
|
+
`${baseOid}:${file.oldPath ?? file.path}`,
|
|
209
|
+
]).catch(() => '')).trim();
|
|
210
|
+
if (headOidForPath) {
|
|
211
|
+
file.sides.push({
|
|
212
|
+
source: 'head',
|
|
213
|
+
oid: headOidForPath,
|
|
214
|
+
contentDigest: headOidForPath,
|
|
215
|
+
byteSize: Number((await runGit(['cat-file', '-s', headOidForPath])).trim()) || 0,
|
|
216
|
+
truncated: false,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (baseOidForPath) {
|
|
220
|
+
file.sides.push({
|
|
221
|
+
source: 'base',
|
|
222
|
+
oid: baseOidForPath,
|
|
223
|
+
contentDigest: baseOidForPath,
|
|
224
|
+
byteSize: Number((await runGit(['cat-file', '-s', baseOidForPath])).trim()) || 0,
|
|
225
|
+
truncated: false,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const indexOid = (await runGit(['ls-files', '-s', '-z', '--', file.path]).then(raw => {
|
|
231
|
+
const record = raw.split('\0').find(item => item.length > 0);
|
|
232
|
+
return record ? (record.split(' ')[1] ?? '') : '';
|
|
233
|
+
})).trim();
|
|
234
|
+
if (indexOid) {
|
|
235
|
+
file.sides.push({
|
|
236
|
+
source: 'staged',
|
|
237
|
+
oid: indexOid,
|
|
238
|
+
contentDigest: indexOid,
|
|
239
|
+
byteSize: Number((await runGit(['cat-file', '-s', indexOid])).trim()) || 0,
|
|
240
|
+
truncated: false,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (options.readWorktreeFile) {
|
|
244
|
+
const before = await options.readWorktreeFile(file.path);
|
|
245
|
+
if (before === null) {
|
|
246
|
+
file.sides.push({
|
|
247
|
+
source: 'unstaged',
|
|
248
|
+
oid: null,
|
|
249
|
+
contentDigest: sha256(''),
|
|
250
|
+
byteSize: 0,
|
|
251
|
+
truncated: false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const beforeDigest = sha256(before);
|
|
256
|
+
const after = await options.readWorktreeFile(file.path);
|
|
257
|
+
if (after === null || sha256(after) !== beforeDigest) {
|
|
258
|
+
throw new ReviewSnapshotError(`Working file changed while snapshotting: ${file.path}`, 'worktree_changed');
|
|
259
|
+
}
|
|
260
|
+
file.sides.push({
|
|
261
|
+
source: 'unstaged',
|
|
262
|
+
oid: null,
|
|
263
|
+
contentDigest: beforeDigest,
|
|
264
|
+
byteSize: Buffer.byteLength(before, 'utf8'),
|
|
265
|
+
truncated: before.length > budgets.maxBodyBytes,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const manifest = {
|
|
272
|
+
kind: options.selector.kind,
|
|
273
|
+
baseOid,
|
|
274
|
+
headOid,
|
|
275
|
+
requestedPaths,
|
|
276
|
+
files: files.map(file => ({
|
|
277
|
+
fileKey: file.fileKey,
|
|
278
|
+
path: file.path,
|
|
279
|
+
oldPath: file.oldPath,
|
|
280
|
+
sides: file.sides.map(side => ({ source: side.source, contentDigest: side.contentDigest })),
|
|
281
|
+
})),
|
|
282
|
+
};
|
|
283
|
+
const inputSnapshotDigest = sha256(JSON.stringify(manifest));
|
|
284
|
+
const snapshotId = `${options.snapshotIdPrefix ?? 'rev'}-${inputSnapshotDigest.slice(0, 16)}`;
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
schemaVersion: 1,
|
|
287
|
+
snapshotId,
|
|
288
|
+
kind: options.selector.kind,
|
|
289
|
+
baseOid,
|
|
290
|
+
headOid,
|
|
291
|
+
requestedPaths,
|
|
292
|
+
files: Object.freeze(files.map(file => Object.freeze({
|
|
293
|
+
...file,
|
|
294
|
+
sides: Object.freeze(file.sides),
|
|
295
|
+
hunks: Object.freeze(file.hunks),
|
|
296
|
+
}))),
|
|
297
|
+
inputSnapshotDigest,
|
|
298
|
+
skipped: Object.freeze(skipped),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** Reads one working-tree file for review purposes, refusing to leave the
|
|
302
|
+
* repository root (same containment discipline as the v0.3.25 conflict
|
|
303
|
+
* reader). Returns null when the file is missing or unreadable. */
|
|
304
|
+
function createContainedWorktreeReader(rootInput) {
|
|
305
|
+
const root = (0, fs_1.realpathSync)(rootInput);
|
|
306
|
+
return async (relativePath) => {
|
|
307
|
+
try {
|
|
308
|
+
const absolute = (0, path_1.resolve)(root, relativePath);
|
|
309
|
+
const fileReal = (0, fs_1.realpathSync)(absolute);
|
|
310
|
+
if (fileReal !== root && !fileReal.startsWith(root + path_1.sep))
|
|
311
|
+
return null;
|
|
312
|
+
if (!(0, fs_1.statSync)(fileReal).isFile())
|
|
313
|
+
return null;
|
|
314
|
+
return await (0, promises_1.readFile)(fileReal, 'utf8');
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -1858,6 +1858,21 @@ function updateSessionSummary(sessionId, _messages) {
|
|
|
1858
1858
|
// Callers may hold a stale transcript snapshot. Re-read under the same lock
|
|
1859
1859
|
// used by append/delete so reconciliation can never roll metadata backwards.
|
|
1860
1860
|
withLockedSession(sessionId, session => {
|
|
1861
|
+
// v0.3.26 #277 — a cutover Session's summary comes from the authoritative
|
|
1862
|
+
// v2 Thread. Its legacy JSONL is frozen at cutover, so summarizing from it
|
|
1863
|
+
// (as /exit and the shutdown path do) would move messageCount and
|
|
1864
|
+
// historySizeBytes backwards while the Thread history is intact. Reopening
|
|
1865
|
+
// the Session repairs the read model from v2; this makes the exit path
|
|
1866
|
+
// agree instead of regressing it.
|
|
1867
|
+
const threadView = (0, thread_session_view_1.loadThreadSessionViewV1)(session.projectPath, session.id);
|
|
1868
|
+
if (threadView) {
|
|
1869
|
+
session.messageCount = threadView.messageCount;
|
|
1870
|
+
session.historySizeBytes = threadView.historySizeBytes;
|
|
1871
|
+
session.updatedAt = threadView.updatedAt;
|
|
1872
|
+
session.updatedAtIso = new Date(session.updatedAt).toISOString();
|
|
1873
|
+
writeSessionMetaUnlocked(session);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1861
1876
|
const summary = summarizeSessionMessages(readSessionMessagesForSession(session));
|
|
1862
1877
|
session.toolsUsed = summary.toolsUsed;
|
|
1863
1878
|
session.filesModified = summary.filesModified;
|
|
@@ -2553,6 +2568,12 @@ function deleteSession(sessionId) {
|
|
|
2553
2568
|
updateSessionCatalog(catalog => {
|
|
2554
2569
|
delete catalog.sessions[sessionId];
|
|
2555
2570
|
});
|
|
2571
|
+
// v0.3.26 #276 — a cutover Session's v2 records are the authoritative
|
|
2572
|
+
// history; permanent deletion must remove them with the legacy files.
|
|
2573
|
+
// The Web deletion dialog promises exactly that.
|
|
2574
|
+
if ((0, legacy_thread_materializer_1.deleteSessionThreadRecordsV1)(session.projectPath, sessionId)) {
|
|
2575
|
+
deleted = true;
|
|
2576
|
+
}
|
|
2556
2577
|
return deleted;
|
|
2557
2578
|
}) ?? false);
|
|
2558
2579
|
}
|
|
@@ -41,6 +41,18 @@ export interface GitHunkV2 {
|
|
|
41
41
|
readonly complete: boolean;
|
|
42
42
|
}
|
|
43
43
|
export type GitDiffDocumentKindV2 = 'text' | 'binary' | 'symlink' | 'submodule' | 'metadata';
|
|
44
|
+
/**
|
|
45
|
+
* v0.3.26 #275 — parser state for a hunk that a page boundary split. Carried in
|
|
46
|
+
* the continuation cursor so the next page re-opens the hunk with its stable
|
|
47
|
+
* identity (`index:oldStart:newStart`) and correct running line cursors instead
|
|
48
|
+
* of demoting the continuation rows to document meta.
|
|
49
|
+
*/
|
|
50
|
+
export interface GitDiffHunkResumeV1 {
|
|
51
|
+
readonly header: string;
|
|
52
|
+
readonly index: number;
|
|
53
|
+
readonly oldCursor: number;
|
|
54
|
+
readonly newCursor: number;
|
|
55
|
+
}
|
|
44
56
|
export interface GitDiffCapabilitiesV2 {
|
|
45
57
|
readonly stageFile: boolean;
|
|
46
58
|
readonly unstageFile: boolean;
|
|
@@ -63,6 +75,11 @@ export interface GitDiffDocumentV2 {
|
|
|
63
75
|
readonly hunks: readonly GitHunkV2[];
|
|
64
76
|
/** Metadata lines that are not part of any hunk (file headers, mode changes, …). */
|
|
65
77
|
readonly meta: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* v0.3.26 #275 — the hunk still open when this page ended, for the
|
|
80
|
+
* continuation cursor. Null when the page ends on a hunk boundary.
|
|
81
|
+
*/
|
|
82
|
+
readonly pendingHunk: GitDiffHunkResumeV1 | null;
|
|
66
83
|
readonly completeness: 'complete' | 'paged' | 'limited';
|
|
67
84
|
readonly additions: number;
|
|
68
85
|
readonly deletions: number;
|
|
@@ -79,5 +96,7 @@ export declare function parseGitDiffDocument(input: {
|
|
|
79
96
|
readonly hasMore: boolean;
|
|
80
97
|
/** Host-signed continuation cursor, or null. Never synthesised here. */
|
|
81
98
|
readonly nextCursor: string | null;
|
|
99
|
+
/** v0.3.26 #275 — hunk state from the previous page's `pendingHunk`. */
|
|
100
|
+
readonly resume?: GitDiffHunkResumeV1 | null;
|
|
82
101
|
}): GitDiffDocumentV2;
|
|
83
102
|
//# sourceMappingURL=git-diff-document.d.ts.map
|