@rigour-labs/core 6.7.2 → 6.7.5
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/dist/hooks/index.d.ts +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/session-state.d.ts +3 -0
- package/dist/hooks/session-state.js +10 -0
- package/dist/hooks/stop-review.d.ts +8 -15
- package/dist/hooks/stop-review.js +36 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/review/backtest-init.js +13 -11
- package/dist/review/backtest-init.test.js +26 -2
- package/dist/review/backtest.js +35 -3
- package/dist/review/backtest.test.js +44 -2
- package/dist/review/dead-code.test.js +13 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/quiet.d.ts +1 -1
- package/dist/review/quiet.js +3 -1
- package/dist/review/review-task.d.ts +2 -6
- package/dist/review/review-task.js +2 -2
- package/dist/review/reviewer/adapters.d.ts +21 -0
- package/dist/review/reviewer/adapters.js +70 -15
- package/dist/review/reviewer/adapters.test.js +22 -0
- package/dist/review/reviewer/background.test.js +1 -1
- package/dist/review/reviewer/context.d.ts +3 -0
- package/dist/review/reviewer/context.js +14 -5
- package/dist/review/reviewer/context.test.js +26 -1
- package/dist/review/reviewer/exec.d.ts +9 -0
- package/dist/review/reviewer/exec.js +28 -1
- package/dist/review/reviewer/exec.test.js +38 -2
- package/dist/review/reviewer/inputs.d.ts +6 -0
- package/dist/review/reviewer/inputs.js +26 -0
- package/dist/review/reviewer/judge-env.d.ts +3 -0
- package/dist/review/reviewer/judge-env.js +10 -0
- package/dist/review/reviewer/prompt.js +68 -12
- package/dist/review/reviewer/rule-writer.d.ts +10 -0
- package/dist/review/reviewer/rule-writer.js +30 -0
- package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
- package/dist/review/reviewer/rule-writer.test.js +52 -0
- package/dist/review/reviewer/settings.d.ts +4 -0
- package/dist/review/reviewer/settings.js +2 -1
- package/dist/review/reviewer/verdict.d.ts +71 -9
- package/dist/review/reviewer/verdict.js +102 -10
- package/dist/review/reviewer.d.ts +5 -2
- package/dist/review/reviewer.js +56 -41
- package/dist/review/reviewer.test.js +170 -17
- package/dist/review/typed/constant-inputs.d.ts +35 -0
- package/dist/review/typed/constant-inputs.js +168 -0
- package/dist/review/typed/dead-null-guards.d.ts +34 -0
- package/dist/review/typed/dead-null-guards.js +147 -0
- package/dist/review/typed/redundancy.js +6 -0
- package/dist/review/typed/redundancy.test.js +136 -0
- package/dist/review/unused-exports.js +3 -2
- package/dist/review-learning/acted-on.d.ts +23 -3
- package/dist/review-learning/acted-on.js +25 -13
- package/dist/review-learning/human-edits.d.ts +7 -0
- package/dist/review-learning/human-edits.js +99 -0
- package/dist/review-learning/human-edits.test.d.ts +1 -0
- package/dist/review-learning/human-edits.test.js +47 -0
- package/dist/review-learning/learn-from-reviews.d.ts +37 -4
- package/dist/review-learning/learn-from-reviews.js +128 -12
- package/dist/review-learning/lessons.d.ts +62 -9
- package/dist/review-learning/lessons.js +132 -21
- package/dist/review-learning/outcomes.d.ts +26 -0
- package/dist/review-learning/outcomes.js +73 -0
- package/dist/review-learning/review-learning.test.js +158 -22
- package/dist/review-learning/review-points.d.ts +4 -0
- package/dist/review-learning/review-points.js +14 -0
- package/dist/review-learning/rules-from-reviews.d.ts +14 -0
- package/dist/review-learning/rules-from-reviews.js +86 -0
- package/dist/review-learning/team-lessons.d.ts +17 -1
- package/dist/review-learning/team-lessons.js +29 -5
- package/dist/templates/universal-config.js +3 -0
- package/dist/types/index.d.ts +63 -0
- package/dist/types/index.js +11 -0
- package/dist/utils/profile.d.ts +5 -1
- package/dist/utils/profile.js +7 -1
- package/dist/utils/profile.test.js +10 -0
- package/package.json +6 -6
package/dist/hooks/index.d.ts
CHANGED
|
@@ -13,6 +13,6 @@ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearned
|
|
|
13
13
|
export type { DLPFeedbackEntry, DLPFeedbackStore, DLPBlockManifest } from './dlp-feedback.js';
|
|
14
14
|
export { generateDLPHookFiles } from './dlp-templates.js';
|
|
15
15
|
export type { GeneratedDLPHookFile } from './dlp-templates.js';
|
|
16
|
-
export { stopReview, stopMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
17
|
-
export type { StopDecision } from './stop-review.js';
|
|
18
|
-
export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
|
|
16
|
+
export { stopReview, stopMessage, teamMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
17
|
+
export type { StopDecision, TeamGuidance } from './stop-review.js';
|
|
18
|
+
export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed, untaught, recordTaught } from './session-state.js';
|
package/dist/hooks/index.js
CHANGED
|
@@ -9,5 +9,5 @@ export { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry } from './
|
|
|
9
9
|
export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearnedAllowCount, loadDLPFeedbackStore, loadDLPFeedbackStoreSync, fingerprintDetection, } from './dlp-feedback.js';
|
|
10
10
|
export { generateDLPHookFiles } from './dlp-templates.js';
|
|
11
11
|
// Stop review ("before you say done")
|
|
12
|
-
export { stopReview, stopMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
13
|
-
export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
|
|
12
|
+
export { stopReview, stopMessage, teamMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
13
|
+
export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed, untaught, recordTaught } from './session-state.js';
|
|
@@ -15,3 +15,6 @@ export declare function recordReviewed(cwd: string, session: string, fingerprint
|
|
|
15
15
|
export declare function nextStopAttempt(cwd: string, session: string): number;
|
|
16
16
|
/** The agent finished cleanly: the next stop starts counting again. The baseline stays. */
|
|
17
17
|
export declare function clearStopAttempts(cwd: string, session: string): void;
|
|
18
|
+
/** Of these team-knowledge keys, the ones this session has not been shown yet. */
|
|
19
|
+
export declare function untaught(cwd: string, session: string, keys: string[]): string[];
|
|
20
|
+
export declare function recordTaught(cwd: string, session: string, keys: string[]): void;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* - baseline: the commit HEAD was at when the session first edited a file. The stop review
|
|
5
5
|
* covers everything since then, so committing mid-session hides nothing.
|
|
6
6
|
* - attempts: how many times the stop review has blocked this session.
|
|
7
|
+
* - taught: the team lessons and rules the stop review already put to the agent, so each is asked once.
|
|
7
8
|
*/
|
|
8
9
|
import { spawnSync } from 'child_process';
|
|
9
10
|
import { createHash } from 'crypto';
|
|
@@ -96,3 +97,12 @@ export function clearStopAttempts(cwd, session) {
|
|
|
96
97
|
if (readStore(cwd)[session]?.attempts)
|
|
97
98
|
update(cwd, session, entry => ({ ...entry, attempts: 0 }));
|
|
98
99
|
}
|
|
100
|
+
/** Of these team-knowledge keys, the ones this session has not been shown yet. */
|
|
101
|
+
export function untaught(cwd, session, keys) {
|
|
102
|
+
const taught = new Set(readStore(cwd)[session]?.taught ?? []);
|
|
103
|
+
return keys.filter(key => !taught.has(key));
|
|
104
|
+
}
|
|
105
|
+
export function recordTaught(cwd, session, keys) {
|
|
106
|
+
if (keys.length)
|
|
107
|
+
update(cwd, session, entry => ({ ...entry, taught: [...new Set([...(entry.taught ?? []), ...keys])] }));
|
|
108
|
+
}
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The "before you say done" review: when an agent is about to stop, review the
|
|
3
|
-
* whole branch against the commit it left main at (committed, uncommitted and new
|
|
4
|
-
* files alike; on main itself, what the session changed since its baseline) and,
|
|
5
|
-
* if it introduced serious findings, tell the agent what to fix instead of
|
|
6
|
-
* letting it finish. A review with nothing to look at says so; it is never
|
|
7
|
-
* reported as a clean pass.
|
|
8
|
-
*
|
|
9
|
-
* What blocks is what the review itself reports (review/quiet.ts `mustFix`: proven, critical, or
|
|
10
|
-
* high and verified or from a security gate), plus on a branch a merge conflict with main or a
|
|
11
|
-
* mention of a file the branch deleted (review/branch-checks.ts). The same rule decides the push
|
|
12
|
-
* gate, so a stop, a review and a push never disagree about a finding. A high heuristic (a regex
|
|
13
|
-
* that sees `fetch` without `.catch`) is not enough to hold an agent back; callers also cap the
|
|
14
|
-
* number of attempts.
|
|
15
|
-
*/
|
|
16
1
|
import type { Config, Failure } from '../types/index.js';
|
|
17
2
|
import { type ReviewTaskItem } from '../review/review-task.js';
|
|
18
3
|
export declare const STOP_MAX_ATTEMPTS = 3;
|
|
@@ -26,8 +11,16 @@ export interface StopDecision {
|
|
|
26
11
|
reviewedFiles: string[];
|
|
27
12
|
/** What the review was measured against, e.g. `origin/main @ 1a2b3c4` or `uncommitted work`. */
|
|
28
13
|
against: string;
|
|
14
|
+
/** What this team learned that applies to the change (verified lessons; rules when `gates.deep.repo_rules` is on), keyed for asking once. */
|
|
15
|
+
guidance: TeamGuidance[];
|
|
16
|
+
}
|
|
17
|
+
export interface TeamGuidance {
|
|
18
|
+
key: string;
|
|
19
|
+
text: string;
|
|
29
20
|
}
|
|
30
21
|
/** `sessionBaseline`: the commit the session started from, used on main (session-state.ts). */
|
|
31
22
|
export declare function stopReview(cwd: string, config: Config, attempt: number, sessionBaseline?: string): Promise<StopDecision>;
|
|
23
|
+
/** Asked once per new lesson or rule: the senior's question at the end of the task, never a loop. */
|
|
24
|
+
export declare function teamMessage(guidance: TeamGuidance[]): string;
|
|
32
25
|
export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
|
|
33
26
|
export declare function stopMessage(findings: Failure[], attempt: number): string;
|
|
@@ -1,8 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "before you say done" review: when an agent is about to stop, review the
|
|
3
|
+
* whole branch against the commit it left main at (committed, uncommitted and new
|
|
4
|
+
* files alike; on main itself, what the session changed since its baseline) and,
|
|
5
|
+
* if it introduced serious findings, tell the agent what to fix instead of
|
|
6
|
+
* letting it finish. A review with nothing to look at says so; it is never
|
|
7
|
+
* reported as a clean pass.
|
|
8
|
+
*
|
|
9
|
+
* What blocks is what the review itself reports (review/quiet.ts `mustFix`: proven, critical, or
|
|
10
|
+
* high and verified or from a security gate), plus on a branch a merge conflict with main or a
|
|
11
|
+
* mention of a file the branch deleted (review/branch-checks.ts). The same rule decides the push
|
|
12
|
+
* gate, so a stop, a review and a push never disagree about a finding. A high heuristic (a regex
|
|
13
|
+
* that sees `fetch` without `.catch`) is not enough to hold an agent back; callers also cap the
|
|
14
|
+
* number of attempts.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'crypto';
|
|
1
17
|
import { reviewChange } from '../review/review.js';
|
|
2
18
|
import { buildReviewTask } from '../review/review-task.js';
|
|
3
19
|
import { diffFromGit } from '../review/git-diff.js';
|
|
4
20
|
import { branchBase } from '../gates/logic-drift-git-base.js';
|
|
5
21
|
import { branchFailures } from '../review/branch-checks.js';
|
|
22
|
+
import { describeLesson } from '../review-learning/team-lessons.js';
|
|
6
23
|
export const STOP_MAX_ATTEMPTS = 3;
|
|
7
24
|
const MAX_LISTED = 8;
|
|
8
25
|
/** The branch since it left main; on main, what the session changed since its baseline (session-state.ts). */
|
|
@@ -27,8 +44,9 @@ export async function stopReview(cwd, config, attempt, sessionBaseline) {
|
|
|
27
44
|
? result.gateErrors.map(id => ({ id, title: 'A check could not run', details: id === 'typed-checks-unavailable' && result.typedError ? result.typedError : `${id} crashed instead of running`, severity: 'high', files: [], hint: 'Fix the environment (dependencies, generated config), then try again.' }))
|
|
28
45
|
: [];
|
|
29
46
|
const blocking = [...result.findings, ...whole, ...crashed];
|
|
30
|
-
const
|
|
31
|
-
const
|
|
47
|
+
const task = buildReviewTask(cwd, diff, config.gates.deep?.router, config.gates.deep?.review_lessons, config.gates.deep?.repo_rules ?? false);
|
|
48
|
+
const unreviewed = config.hooks?.require_review_ack ? task.items : [];
|
|
49
|
+
const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines), against, guidance: teamGuidance(task) };
|
|
32
50
|
if (blocking.length === 0 && unreviewed.length === 0)
|
|
33
51
|
return { block: false, message: '', blocking: 0, ...reviewed };
|
|
34
52
|
const message = [
|
|
@@ -37,6 +55,22 @@ export async function stopReview(cwd, config, attempt, sessionBaseline) {
|
|
|
37
55
|
].join('\n\n');
|
|
38
56
|
return { block: true, message, blocking: blocking.length + unreviewed.length, ...reviewed };
|
|
39
57
|
}
|
|
58
|
+
/** Each lesson and rule once, keyed by its text so the same one is never asked twice in a session. */
|
|
59
|
+
function teamGuidance(task) {
|
|
60
|
+
const key = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
61
|
+
return [
|
|
62
|
+
...task.lessons.map(l => ({ key: key(`lesson\u0000${l.file}\u0000${l.text}`), text: describeLesson(l) })),
|
|
63
|
+
...task.rules.map(r => ({ key: key(`rule\u0000${r.source}\u0000${r.text}`), text: `${r.source}: ${r.text}` })),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
/** Asked once per new lesson or rule: the senior's question at the end of the task, never a loop. */
|
|
67
|
+
export function teamMessage(guidance) {
|
|
68
|
+
return [
|
|
69
|
+
'Before you finish, check this change against what this team learned on earlier work that applies to it.',
|
|
70
|
+
'If the change repeats one, fix it; if none applies, say so in one line and finish:',
|
|
71
|
+
...guidance.slice(0, MAX_LISTED).map(g => `- ${g.text}`),
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
40
74
|
export function reviewAckMessage(items, attempt) {
|
|
41
75
|
const listed = items.slice(0, MAX_LISTED).map(i => `- ${i.file}:${i.start} \`${i.function}\`: ${i.questions[0]}`);
|
|
42
76
|
return [
|
package/dist/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/
|
|
|
26
26
|
export { buildQualityReceipt, receiptReport, type QualityReceipt, type ReceiptGap, type ReceiptOptions } from './review/receipt.js';
|
|
27
27
|
export { runToolchain, type ToolResult, type ToolStatus } from './review/toolchain.js';
|
|
28
28
|
export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
|
|
29
|
-
export { runReviewer, reviewerBlocks, itemLine, type ReviewerOptions, type ReviewerResult, type ReviewerOutcome, type OpenItem } from './review/reviewer.js';
|
|
29
|
+
export { runReviewer, reviewerBlocks, itemLine, defaultExec, githubToken, type ReviewerOptions, type ReviewerResult, type ReviewerOutcome, type OpenItem } from './review/reviewer.js';
|
|
30
30
|
export { startBackgroundReview, backgroundReview, reviewStatus, type ReviewStatus } from './review/reviewer/background.js';
|
|
31
31
|
export { dismissReviewerFinding, REVIEW_DISMISSALS, reviewerInputs } from './review/reviewer/context.js';
|
|
32
32
|
export { resolveReviewer, saveUserReviewer, type ResolvedReviewer, type RunChoice, type UserReviewerPatch } from './review/reviewer/settings.js';
|
|
@@ -38,8 +38,10 @@ export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type Funct
|
|
|
38
38
|
export { routeFiles, type RouterPolicy, type RouterStats } from './deep/router.js';
|
|
39
39
|
export { appendDeepRun, readDeepRuns, summarizeDeepRuns, type DeepRun, type DeepRunSummary } from './review/deep-runs.js';
|
|
40
40
|
export { learnFromReviews, type LearnFromReviewsOptions, type LearnFromReviewsResult } from './review-learning/learn-from-reviews.js';
|
|
41
|
-
export {
|
|
41
|
+
export { ruleWriterFor } from './review/reviewer/rule-writer.js';
|
|
42
|
+
export { readLessons, writeLessons, decideLesson, lessonState, matchLessons, lessonText, lessonsPath, type ReviewLesson, type LessonEvidence } from './review-learning/lessons.js';
|
|
42
43
|
export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
|
|
44
|
+
export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
|
|
43
45
|
export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
|
|
44
46
|
export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
|
|
45
47
|
export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export { buildReviewTask } from './review/review-task.js';
|
|
|
26
26
|
export { buildQualityReceipt, receiptReport } from './review/receipt.js';
|
|
27
27
|
export { runToolchain } from './review/toolchain.js';
|
|
28
28
|
export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
|
|
29
|
-
export { runReviewer, reviewerBlocks, itemLine } from './review/reviewer.js';
|
|
29
|
+
export { runReviewer, reviewerBlocks, itemLine, defaultExec, githubToken } from './review/reviewer.js';
|
|
30
30
|
export { startBackgroundReview, backgroundReview, reviewStatus } from './review/reviewer/background.js';
|
|
31
31
|
export { dismissReviewerFinding, REVIEW_DISMISSALS, reviewerInputs } from './review/reviewer/context.js';
|
|
32
32
|
export { resolveReviewer, saveUserReviewer } from './review/reviewer/settings.js';
|
|
@@ -38,8 +38,10 @@ export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './d
|
|
|
38
38
|
export { routeFiles } from './deep/router.js';
|
|
39
39
|
export { appendDeepRun, readDeepRuns, summarizeDeepRuns } from './review/deep-runs.js';
|
|
40
40
|
export { learnFromReviews } from './review-learning/learn-from-reviews.js';
|
|
41
|
-
export {
|
|
41
|
+
export { ruleWriterFor } from './review/reviewer/rule-writer.js';
|
|
42
|
+
export { readLessons, writeLessons, decideLesson, lessonState, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
|
|
42
43
|
export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
|
|
44
|
+
export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
|
|
43
45
|
export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
|
|
44
46
|
export { diffFromGit, GitDiffError } from './review/git-diff.js';
|
|
45
47
|
export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { branchBase } from '../gates/logic-drift-git-base.js';
|
|
11
|
+
import { bodyPoints, firstLine } from '../review-learning/review-points.js';
|
|
11
12
|
import { LEDGER_PATH, LedgerSchema } from './backtest.js';
|
|
12
13
|
import { defaultExec, githubEnv, parseJsonArrays } from './reviewer.js';
|
|
13
14
|
const GH_TIMEOUT_MS = 60_000;
|
|
14
15
|
/** Lines either side of an inline comment that a finding for the same point may land on. */
|
|
15
16
|
const LINE_SLACK = 10;
|
|
16
|
-
const BULLET = /^\s*(?:[-*]|\d+[.)])\s+(.{8,})$/;
|
|
17
17
|
export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
|
|
18
18
|
const env = await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec);
|
|
19
19
|
const gh = (args) => exec('gh', args, { cwd, timeoutMs: GH_TIMEOUT_MS, env });
|
|
@@ -38,11 +38,11 @@ export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
|
|
|
38
38
|
for (const [i, line] of bodyPoints(String(review.body ?? '')).entries()) {
|
|
39
39
|
points.push({ id: `R${round}-B${i + 1}`, point: line, file: '', needs: 'a file pattern and a text pattern (this point was made in the review body, not on a line)' });
|
|
40
40
|
}
|
|
41
|
-
const base = await
|
|
41
|
+
const base = await baseAt(cwd, String(review.commit_id), review.submitted_at, mainRef, exec);
|
|
42
42
|
rounds.push({
|
|
43
43
|
id: `pr${pr}-r${round}`,
|
|
44
44
|
commit: String(review.commit_id),
|
|
45
|
-
base
|
|
45
|
+
base,
|
|
46
46
|
reviewed_at: String(review.submitted_at),
|
|
47
47
|
pr,
|
|
48
48
|
points,
|
|
@@ -66,14 +66,6 @@ function inlinePoint(id, comment) {
|
|
|
66
66
|
...(line > 0 ? { lines: [Math.max(1, line - LINE_SLACK), line + LINE_SLACK] } : { needs: 'a line window or a text pattern (the comment has no line)' }),
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
|
-
/** The bullet and numbered lines of a review body: the points a reviewer makes outside any one line. */
|
|
70
|
-
function bodyPoints(body) {
|
|
71
|
-
return body.split('\n').map(l => BULLET.exec(l)?.[1]).filter((l) => !!l).map(firstLine);
|
|
72
|
-
}
|
|
73
|
-
function firstLine(text) {
|
|
74
|
-
const line = text.split('\n').find(l => l.trim())?.replace(/[*_`#]/g, '').trim() ?? '';
|
|
75
|
-
return line.length > 140 ? `${line.slice(0, 137)}...` : line;
|
|
76
|
-
}
|
|
77
69
|
function escape(text) {
|
|
78
70
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
79
71
|
}
|
|
@@ -85,3 +77,13 @@ function existingLedger(file) {
|
|
|
85
77
|
throw new Error(`${LEDGER_PATH} exists but is not a ledger; fix or remove it first`);
|
|
86
78
|
return parsed.data;
|
|
87
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* What the reviewer saw the change against: the merge-base of the reviewed commit and main as of the review.
|
|
82
|
+
* Not main itself: a branch that merged main in already has main's newer commits, and diffing from an older
|
|
83
|
+
* main would review them as the branch's own. Main as of the review when the commit is not fetched here.
|
|
84
|
+
*/
|
|
85
|
+
async function baseAt(cwd, commit, reviewedAt, mainRef, exec) {
|
|
86
|
+
const main = (await exec('git', ['rev-list', '-1', `--before=${reviewedAt}`, mainRef], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim() || mainRef;
|
|
87
|
+
const mergeBase = await exec('git', ['merge-base', commit, main], { cwd, timeoutMs: GH_TIMEOUT_MS });
|
|
88
|
+
return (mergeBase.exitCode === 0 && mergeBase.stdout.trim()) || main;
|
|
89
|
+
}
|
|
@@ -20,8 +20,14 @@ const INLINE = [
|
|
|
20
20
|
function fakeExec(calls) {
|
|
21
21
|
return async (command, args, options) => {
|
|
22
22
|
calls.push([command, ...args]);
|
|
23
|
-
if (command === 'git')
|
|
24
|
-
|
|
23
|
+
if (command === 'git') {
|
|
24
|
+
try {
|
|
25
|
+
return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8', stdio: 'pipe' }), stderr: '' };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return { exitCode: error.status ?? 1, stdout: '', stderr: String(error.stderr ?? '') };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
25
31
|
if (args[0] === 'auth')
|
|
26
32
|
return { exitCode: 0, stdout: 'token\n', stderr: '' };
|
|
27
33
|
if (args[0] === 'pr')
|
|
@@ -43,6 +49,24 @@ beforeEach(() => {
|
|
|
43
49
|
});
|
|
44
50
|
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
45
51
|
describe('rigour backtest init', () => {
|
|
52
|
+
it('takes the base from where the reviewed commit left main, not from main as of the review', async () => {
|
|
53
|
+
const forked = git('rev-parse', 'HEAD');
|
|
54
|
+
git('checkout', '-q', '-b', 'feature');
|
|
55
|
+
fs.writeFileSync(path.join(repo, 'b.ts'), 'export const b = 1;\n');
|
|
56
|
+
git('add', '-A');
|
|
57
|
+
execFileSync('git', ['-C', repo, 'commit', '-qm', 'the branch'], { env: { ...process.env, GIT_COMMITTER_DATE: '2026-04-12T09:30:00Z', GIT_AUTHOR_DATE: '2026-04-12T09:30:00Z' } });
|
|
58
|
+
const reviewed = git('rev-parse', 'HEAD');
|
|
59
|
+
git('checkout', '-q', 'main');
|
|
60
|
+
fs.writeFileSync(path.join(repo, 'c.ts'), 'export const c = 1;\n');
|
|
61
|
+
git('add', '-A');
|
|
62
|
+
execFileSync('git', ['-C', repo, 'commit', '-qm', 'main moves on before the review'], { env: { ...process.env, GIT_COMMITTER_DATE: '2026-04-12T11:00:00Z', GIT_AUTHOR_DATE: '2026-04-12T11:00:00Z' } });
|
|
63
|
+
const realCommit = (calls) => async (command, args, options) => {
|
|
64
|
+
const answer = await fakeExec(calls)(command, args, options);
|
|
65
|
+
return command === 'gh' && args[1]?.endsWith('/reviews') ? { ...answer, stdout: JSON.stringify(REVIEWS.map(r => ({ ...r, commit_id: reviewed }))) } : answer;
|
|
66
|
+
};
|
|
67
|
+
const { rounds } = await scaffoldLedger(repo, 212, config, realCommit([]));
|
|
68
|
+
expect(rounds[0].base).toBe(forked);
|
|
69
|
+
});
|
|
46
70
|
it('writes one round per human review: inline comments become file-and-window points, body bullets need a pattern', async () => {
|
|
47
71
|
const calls = [];
|
|
48
72
|
const mainAtReview = git('rev-parse', 'HEAD');
|
package/dist/review/backtest.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
+
import { branchBase } from '../gates/logic-drift-git-base.js';
|
|
16
17
|
import { reviewChange } from './review.js';
|
|
17
18
|
import { defaultExec, runReviewer } from './reviewer.js';
|
|
18
19
|
import { reviewerInputs } from './reviewer/context.js';
|
|
@@ -91,6 +92,9 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
|
|
|
91
92
|
const worktree = await worktreeFor(cwd, round.commit, exec);
|
|
92
93
|
const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
93
94
|
progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round.reviewed_at}` : ''}`);
|
|
95
|
+
const stale = await staleBase(worktree, head, round.base, exec);
|
|
96
|
+
if (stale)
|
|
97
|
+
progress(`backtest ${round.id}: warning: ${stale}`);
|
|
94
98
|
const collect = options.collect ?? ((tree, r, c) => collectItems(tree, r, c, !!options.reviewer, exec, progress));
|
|
95
99
|
const { items, reviewerError, judged } = await collect(worktree, round, config);
|
|
96
100
|
const result = { ...score(round, head, items, Date.now() - started, reviewerError), ...(judged ? { judges: judgeCatches(round, judged) } : {}) };
|
|
@@ -99,6 +103,25 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
|
|
|
99
103
|
}
|
|
100
104
|
return results;
|
|
101
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Why a round's base would review main's own commits as the branch's: the head already contains a newer
|
|
108
|
+
* main than the base (the branch merged main in), so the diff from the base carries everything main did since.
|
|
109
|
+
*/
|
|
110
|
+
async function staleBase(worktree, head, base, exec) {
|
|
111
|
+
const mainRef = branchBase(worktree)?.mainRef;
|
|
112
|
+
if (!mainRef)
|
|
113
|
+
return undefined;
|
|
114
|
+
const git = async (...args) => (await exec('git', args, { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
115
|
+
const [mergeBase, baseSha] = await Promise.all([git('merge-base', head, mainRef), git('rev-parse', `${base}^{commit}`)]);
|
|
116
|
+
// A merged head is on main already: its merge-base with main is itself, and says nothing about the base.
|
|
117
|
+
if (!mergeBase || !baseSha || mergeBase === baseSha || mergeBase === head)
|
|
118
|
+
return undefined;
|
|
119
|
+
// Only a base strictly older than that main is stale; a newer one means the local main ref is the stale thing.
|
|
120
|
+
const older = await exec('git', ['merge-base', '--is-ancestor', baseSha, mergeBase], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS });
|
|
121
|
+
if (older.exitCode !== 0)
|
|
122
|
+
return undefined;
|
|
123
|
+
return `the head already contains ${mainRef} up to ${mergeBase.slice(0, 9)}, so diffing from ${base} reviews main's own commits as the branch's; set the base to ${mergeBase.slice(0, 9)}`;
|
|
124
|
+
}
|
|
102
125
|
/** Every point caught, nothing the reviewer called good flagged, and a verdict when the reviewer ran. */
|
|
103
126
|
export function backtestPassed(results) {
|
|
104
127
|
return results.every(r => r.points.every(p => p.caught) && r.falseBlocks.length === 0 && !r.reviewerError);
|
|
@@ -131,13 +154,22 @@ async function worktreeFor(cwd, commit, exec) {
|
|
|
131
154
|
const reason = resolved.stderr.trim().split('\n').at(-1);
|
|
132
155
|
throw new Error(`commit ${commit} is not in this repository (fetch the branch it was reviewed on)${reason ? `: git said "${reason}"` : ''}`);
|
|
133
156
|
}
|
|
134
|
-
|
|
157
|
+
// One worktree, moved from round to round: a checkout per commit filled the disk on a backtest of dozens of pull requests.
|
|
158
|
+
const dir = path.join(common, 'rigour-backtest', 'checkout');
|
|
159
|
+
const sha = resolved.stdout.trim();
|
|
135
160
|
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
136
161
|
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
137
|
-
const added = await exec('git', ['worktree', 'add', '--detach', dir,
|
|
162
|
+
const added = await exec('git', ['worktree', 'add', '--detach', dir, sha], { cwd, timeoutMs: 5 * GIT_TIMEOUT_MS });
|
|
138
163
|
if (added.exitCode !== 0)
|
|
139
164
|
throw new Error(`could not check out ${commit} for the backtest: ${added.stderr.trim()}`);
|
|
140
165
|
}
|
|
166
|
+
else {
|
|
167
|
+
const moved = await exec('git', ['checkout', '-q', '--detach', '--force', sha], { cwd: dir, timeoutMs: 5 * GIT_TIMEOUT_MS });
|
|
168
|
+
if (moved.exitCode !== 0)
|
|
169
|
+
throw new Error(`could not check out ${commit} for the backtest: ${moved.stderr.trim()}`);
|
|
170
|
+
// What the last round left behind (untracked files) must not reach this one; the linked dependencies stay.
|
|
171
|
+
await exec('git', ['clean', '-q', '-fdx', '-e', 'node_modules'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
172
|
+
}
|
|
141
173
|
shareDependencies(cwd, dir);
|
|
142
174
|
return dir;
|
|
143
175
|
}
|
|
@@ -160,7 +192,7 @@ async function collectItems(worktree, round, config, reviewer, exec, progress) {
|
|
|
160
192
|
];
|
|
161
193
|
if (!reviewer)
|
|
162
194
|
return { items };
|
|
163
|
-
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, trigger: 'backtest', force: true, ...reviewerInputs(review) });
|
|
195
|
+
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, blind: round.pr === undefined, trigger: 'backtest', force: true, ...reviewerInputs(review) });
|
|
164
196
|
if (result.outcome === 'unavailable' || result.outcome === 'skipped')
|
|
165
197
|
return { items, reviewerError: result.reason ?? result.outcome };
|
|
166
198
|
// The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
|
|
@@ -104,7 +104,7 @@ describe('rigour backtest on a repository', () => {
|
|
|
104
104
|
],
|
|
105
105
|
must_not_flag: [{ file: 'app\\.ts', lines: [1, 1] }],
|
|
106
106
|
}] }));
|
|
107
|
-
const config = ConfigSchema.parse({ version: 1 });
|
|
107
|
+
const config = ConfigSchema.parse({ version: 1, gates: { unused_exports: { block: true } } });
|
|
108
108
|
const results = await runBacktest(repo, config, loadLedger(repo), { progress: () => undefined });
|
|
109
109
|
expect(results).toHaveLength(1);
|
|
110
110
|
expect(results[0].points).toMatchObject([{ id: 'R1-1', caught: true, by: 'unused-export src/lib/util.ts:1' }, { id: 'R1-2', caught: false, noted: false }]);
|
|
@@ -113,12 +113,54 @@ describe('rigour backtest on a repository', () => {
|
|
|
113
113
|
expect(backtestPassed(results)).toBe(false);
|
|
114
114
|
expect(git('branch', '--show-current')).toBe('main');
|
|
115
115
|
expect(fs.existsSync(path.join(repo, 'src/lib/util.ts'))).toBe(false); // the checkout did not move
|
|
116
|
-
expect(fs.existsSync(path.join(repo, '.git/rigour-backtest
|
|
116
|
+
expect(fs.existsSync(path.join(repo, '.git/rigour-backtest/checkout/src/lib/util.ts'))).toBe(true);
|
|
117
117
|
expect(fs.readdirSync(path.join(repo, '.rigour/backtest'))).toEqual([`r1-${reviewed.slice(0, 12)}.json`]);
|
|
118
118
|
// The worktree is reused on the next run.
|
|
119
119
|
const again = await runBacktest(repo, config, loadLedger(repo), { round: 'r1', progress: () => undefined });
|
|
120
120
|
expect(again[0].points[0].caught).toBe(true);
|
|
121
121
|
}, 60_000); // a real worktree and a typed review: over 10s on a Windows runner
|
|
122
|
+
it('warns when the base is older than the main the head already merged in', async () => {
|
|
123
|
+
const forked = git('rev-parse', 'main');
|
|
124
|
+
git('checkout', '-q', 'main');
|
|
125
|
+
write('src/main-only.ts', 'export const later = 1;\n');
|
|
126
|
+
git('add', '-A');
|
|
127
|
+
git('commit', '-qm', 'main moves on');
|
|
128
|
+
const merged = git('rev-parse', 'HEAD');
|
|
129
|
+
git('checkout', '-q', 'feature');
|
|
130
|
+
git('merge', '-q', '--no-edit', 'main');
|
|
131
|
+
const reviewed = git('rev-parse', 'HEAD');
|
|
132
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
133
|
+
const run = async (base) => {
|
|
134
|
+
const lines = [];
|
|
135
|
+
const ledger = { rounds: [{ id: 'r1', commit: reviewed, base, points: [], must_not_flag: [] }] };
|
|
136
|
+
await runBacktest(repo, config, ledger, { progress: line => lines.push(line), collect: async () => ({ items: [] }) });
|
|
137
|
+
return lines.filter(line => line.includes('warning'));
|
|
138
|
+
};
|
|
139
|
+
expect(await run(forked)).toEqual([expect.stringContaining(`set the base to ${merged.slice(0, 9)}`)]);
|
|
140
|
+
expect(await run(merged)).toEqual([]);
|
|
141
|
+
git('update-ref', 'refs/heads/main', forked); // a local main older than the round's base: the ref is stale, not the base
|
|
142
|
+
expect(await run(merged)).toEqual([]);
|
|
143
|
+
git('update-ref', 'refs/heads/main', merged);
|
|
144
|
+
git('checkout', '-q', 'main');
|
|
145
|
+
git('merge', '-q', '--no-edit', 'feature'); // the pull request is merged: its head is on main now
|
|
146
|
+
expect(await run(merged)).toEqual([]);
|
|
147
|
+
}, 60_000);
|
|
148
|
+
it('moves one worktree from round to round, leaving nothing of the last round behind', async () => {
|
|
149
|
+
const reviewed = git('rev-parse', 'HEAD');
|
|
150
|
+
const base = git('rev-parse', 'main');
|
|
151
|
+
const seen = [];
|
|
152
|
+
const ledger = { rounds: [
|
|
153
|
+
{ id: 'after', commit: reviewed, base, points: [], must_not_flag: [] },
|
|
154
|
+
{ id: 'before', commit: base, base, points: [], must_not_flag: [] },
|
|
155
|
+
] };
|
|
156
|
+
await runBacktest(repo, ConfigSchema.parse({ version: 1 }), ledger, { progress: () => undefined, collect: async (tree) => {
|
|
157
|
+
seen.push({ tree, util: fs.existsSync(path.join(tree, 'src/lib/util.ts')), leftover: fs.existsSync(path.join(tree, 'scratch.txt')) });
|
|
158
|
+
fs.writeFileSync(path.join(tree, 'scratch.txt'), 'left by a round');
|
|
159
|
+
return { items: [] };
|
|
160
|
+
} });
|
|
161
|
+
expect(seen.map(s => s.tree)).toEqual([seen[0].tree, seen[0].tree]);
|
|
162
|
+
expect(seen.map(s => [s.util, s.leftover])).toEqual([[true, false], [false, false]]);
|
|
163
|
+
}, 60_000);
|
|
122
164
|
it('names a round or a commit it cannot find', async () => {
|
|
123
165
|
const config = ConfigSchema.parse({ version: 1 });
|
|
124
166
|
const ledger = { rounds: [{ id: 'r1', commit: '0000000000000000000000000000000000000000', base: 'main', points: [{ id: 'P', point: 'p', file: 'x', text: 'x' }], must_not_flag: [] }] };
|
|
@@ -5,6 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
6
|
import { ConfigSchema } from '../types/index.js';
|
|
7
7
|
import { diffFromGit } from './git-diff.js';
|
|
8
|
+
import { mustFix } from './quiet.js';
|
|
8
9
|
import { orphanFileFailures } from './orphan-files.js';
|
|
9
10
|
import { unusedExportFailures } from './unused-exports.js';
|
|
10
11
|
let repo;
|
|
@@ -87,6 +88,18 @@ describe('unused exports', () => {
|
|
|
87
88
|
write('rigour-report.json', '{"failures":[{"details":"orphanName"}]}\n');
|
|
88
89
|
expect(unusedExportFailures(repo, diffFromGit(repo), config())).toHaveLength(1);
|
|
89
90
|
});
|
|
91
|
+
it('reports dead code as a note unless the team blocks on it', () => {
|
|
92
|
+
write('src/app.ts', 'export function start() {}\nexport function lonely() { return 1; }\n');
|
|
93
|
+
write('src/new-tool.ts', 'export const tool = 1;\n');
|
|
94
|
+
const found = (gates = {}) => [
|
|
95
|
+
...unusedExportFailures(repo, diffFromGit(repo), config(gates)),
|
|
96
|
+
...orphanFileFailures(repo, diffFromGit(repo), config(gates)),
|
|
97
|
+
];
|
|
98
|
+
expect(found().map(f => [f.id, mustFix(f)])).toEqual(expect.arrayContaining([['unused-export', false], ['orphan-file', false]]));
|
|
99
|
+
const strict = found({ unused_exports: { block: true }, orphan_files: { block: true } });
|
|
100
|
+
expect(strict.length).toBeGreaterThan(0);
|
|
101
|
+
expect(strict.every(mustFix)).toBe(true);
|
|
102
|
+
});
|
|
90
103
|
it('is off when disabled', () => {
|
|
91
104
|
write('src/app.ts', 'export function start() {}\nexport const x = 1;\n');
|
|
92
105
|
expect(unusedExportFailures(repo, diffFromGit(repo), config({ unused_exports: { enabled: false } }))).toEqual([]);
|
|
@@ -41,7 +41,7 @@ export function orphanFileFailures(cwd, diff, config) {
|
|
|
41
41
|
if (!files)
|
|
42
42
|
return []; // git could not answer: say nothing rather than guess
|
|
43
43
|
const referrers = referrersOf(cwd, added, files);
|
|
44
|
-
return [...unreferenced(added, referrers)].map(orphan);
|
|
44
|
+
return [...unreferenced(added, referrers)].map(file => orphan(file, !settings.block));
|
|
45
45
|
}
|
|
46
46
|
/** Tracked and new (untracked, not ignored) files. */
|
|
47
47
|
function repositoryFiles(cwd) {
|
|
@@ -116,9 +116,10 @@ function unreferenced(added, referrers) {
|
|
|
116
116
|
}
|
|
117
117
|
return suspects;
|
|
118
118
|
}
|
|
119
|
-
function orphan(file) {
|
|
119
|
+
function orphan(file, advisory) {
|
|
120
120
|
return {
|
|
121
121
|
id: 'orphan-file',
|
|
122
|
+
...(advisory ? { advisory } : {}),
|
|
122
123
|
title: 'Orphaned file',
|
|
123
124
|
details: `\`${file}\` is new, but nothing outside the change's new files imports or runs it.`,
|
|
124
125
|
severity: 'medium',
|
package/dist/review/quiet.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export declare const DISMISSED_FILE: string;
|
|
|
3
3
|
export declare function isProven(failure: Failure): boolean;
|
|
4
4
|
/**
|
|
5
5
|
* What a change must fix: a proven finding, a critical one, or a high one the semantic engine
|
|
6
|
-
* verified or a security gate found. The one rule behind the review's verdict, the stop hook and
|
|
6
|
+
* verified or a security gate found. Never one the team keeps as a note (`advisory`, from a check's `block: false`). The one rule behind the review's verdict, the stop hook and
|
|
7
7
|
* the push gate, so the three never disagree about the same finding.
|
|
8
8
|
*/
|
|
9
9
|
export declare function mustFix(failure: Failure): boolean;
|
package/dist/review/quiet.js
CHANGED
|
@@ -28,10 +28,12 @@ export function isProven(failure) {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* What a change must fix: a proven finding, a critical one, or a high one the semantic engine
|
|
31
|
-
* verified or a security gate found. The one rule behind the review's verdict, the stop hook and
|
|
31
|
+
* verified or a security gate found. Never one the team keeps as a note (`advisory`, from a check's `block: false`). The one rule behind the review's verdict, the stop hook and
|
|
32
32
|
* the push gate, so the three never disagree about the same finding.
|
|
33
33
|
*/
|
|
34
34
|
export function mustFix(failure) {
|
|
35
|
+
if (failure.advisory)
|
|
36
|
+
return false;
|
|
35
37
|
const severity = failure.severity ?? 'medium';
|
|
36
38
|
return isProven(failure) || severity === 'critical' || (severity === 'high' && (failure.verified === true || failure.provenance === 'security'));
|
|
37
39
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { type RouterPolicy } from '../deep/router.js';
|
|
12
12
|
import { type ReviewedKey } from './ledger.js';
|
|
13
|
-
import { type LessonMode } from '../review-learning/team-lessons.js';
|
|
13
|
+
import { type LessonMode, type LessonView } from '../review-learning/team-lessons.js';
|
|
14
14
|
export interface ReviewTaskItem {
|
|
15
15
|
file: string;
|
|
16
16
|
function: string;
|
|
@@ -26,11 +26,7 @@ export interface ReviewTask {
|
|
|
26
26
|
/** Risky functions already reviewed at their current content. */
|
|
27
27
|
alreadyReviewed: number;
|
|
28
28
|
/** The team's past review lessons that apply to this change. */
|
|
29
|
-
lessons:
|
|
30
|
-
file: string;
|
|
31
|
-
text: string;
|
|
32
|
-
prs: number[];
|
|
33
|
-
}>;
|
|
29
|
+
lessons: LessonView[];
|
|
34
30
|
/** The repository's own rules (AGENTS.md, …) that name what this change touches. */
|
|
35
31
|
rules: Array<{
|
|
36
32
|
source: string;
|
|
@@ -12,7 +12,7 @@ import { DEFAULT_MAX_FUNCTIONS, DEFAULT_MIN_SCORE } from '../deep/router.js';
|
|
|
12
12
|
import { rankChangedFunctions } from '../deep/risk.js';
|
|
13
13
|
import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
|
|
14
14
|
import { isReviewed, reviewedKeys } from './ledger.js';
|
|
15
|
-
import { activeLessons, DEFAULT_LESSON_MODE, lessonsForDiff } from '../review-learning/team-lessons.js';
|
|
15
|
+
import { activeLessons, DEFAULT_LESSON_MODE, lessonsForDiff, lessonView } from '../review-learning/team-lessons.js';
|
|
16
16
|
import { rulesForDiff } from '../review-learning/repo-rules.js';
|
|
17
17
|
const QUESTIONS = {
|
|
18
18
|
'data-write': 'Is the write safe under concurrent calls (no check-then-act), idempotent on retry, and does an upsert/conflict clause do what the caller expects?',
|
|
@@ -28,7 +28,7 @@ export function buildReviewTask(cwd, diff, policy = {}, lessonMode = DEFAULT_LES
|
|
|
28
28
|
const ranked = rankChangedFunctions(cwd, changedLinesByFile(changed), removedByFile(diff), activeLessons(cwd, lessonMode));
|
|
29
29
|
const risky = ranked.filter(f => f.score >= (policy.min_score ?? DEFAULT_MIN_SCORE)).slice(0, policy.max_functions ?? DEFAULT_MAX_FUNCTIONS);
|
|
30
30
|
const pending = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
|
|
31
|
-
const lessons = lessonsForDiff(cwd, diff, lessonMode).map(
|
|
31
|
+
const lessons = lessonsForDiff(cwd, diff, lessonMode).map(lessonView);
|
|
32
32
|
return {
|
|
33
33
|
items: pending.map(toItem),
|
|
34
34
|
alreadyReviewed: risky.length - pending.length,
|
|
@@ -16,6 +16,27 @@ export interface Adapter {
|
|
|
16
16
|
export interface Spend {
|
|
17
17
|
costUsd?: number;
|
|
18
18
|
tokens?: Tokens;
|
|
19
|
+
trace?: RunTrace;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Where a run's tokens went, turn by turn, and what each tool call read: measurement only, never a
|
|
23
|
+
* decision. `category` is filled in by the reviewer, which knows its own input files and the change.
|
|
24
|
+
*/
|
|
25
|
+
export interface RunTrace {
|
|
26
|
+
turns: number;
|
|
27
|
+
usage: {
|
|
28
|
+
input: number;
|
|
29
|
+
cacheRead: number;
|
|
30
|
+
cacheWrite: number;
|
|
31
|
+
output: number;
|
|
32
|
+
};
|
|
33
|
+
calls: Array<{
|
|
34
|
+
turn: number;
|
|
35
|
+
tool: string;
|
|
36
|
+
target: string;
|
|
37
|
+
resultChars: number;
|
|
38
|
+
category?: 'rigour-input' | 'changed-file' | 'other-file' | 'git' | 'search' | 'other';
|
|
39
|
+
}>;
|
|
19
40
|
}
|
|
20
41
|
export interface Tokens {
|
|
21
42
|
input: number;
|