@rigour-labs/core 6.4.4 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deep/agent-review.d.ts +19 -0
- package/dist/deep/agent-review.js +19 -0
- package/dist/deep/agent-review.test.d.ts +1 -0
- package/dist/deep/agent-review.test.js +53 -0
- package/dist/deep/code-pass.d.ts +10 -0
- package/dist/deep/code-pass.js +64 -33
- package/dist/deep/code-pass.test.js +53 -12
- package/dist/deep/code-verifier.d.ts +3 -1
- package/dist/deep/code-verifier.js +11 -11
- package/dist/deep/code-verifier.test.js +8 -0
- package/dist/deep/pr-diff.d.ts +7 -0
- package/dist/deep/pr-diff.js +47 -0
- package/dist/deep/pr-review.d.ts +33 -0
- package/dist/deep/pr-review.js +81 -0
- package/dist/deep/pr-review.test.d.ts +1 -0
- package/dist/deep/pr-review.test.js +80 -0
- package/dist/deep/related-changes.d.ts +13 -0
- package/dist/deep/related-changes.js +56 -0
- package/dist/deep/related-changes.test.d.ts +1 -0
- package/dist/deep/related-changes.test.js +33 -0
- package/dist/deep/review-tools.d.ts +16 -0
- package/dist/deep/review-tools.js +111 -0
- package/dist/deep/review-tools.test.d.ts +1 -0
- package/dist/deep/review-tools.test.js +39 -0
- package/dist/deep/risk.d.ts +41 -0
- package/dist/deep/risk.js +135 -0
- package/dist/deep/risk.test.d.ts +1 -0
- package/dist/deep/risk.test.js +63 -0
- package/dist/deep/router.d.ts +27 -0
- package/dist/deep/router.js +15 -0
- package/dist/deep/self-check.d.ts +14 -9
- package/dist/deep/self-check.js +41 -32
- package/dist/deep/tool-loop.d.ts +19 -0
- package/dist/deep/tool-loop.js +23 -0
- package/dist/gates/deep-analysis.d.ts +27 -0
- package/dist/gates/deep-analysis.js +73 -2
- package/dist/gates/deep-runner.js +14 -0
- package/dist/gates/frontend-secret-exposure.d.ts +2 -0
- package/dist/gates/frontend-secret-exposure.js +8 -0
- package/dist/gates/frontend-secret-exposure.test.js +14 -0
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +27 -0
- package/dist/gates/hallucinated-imports/framework-modules.d.ts +12 -3
- package/dist/gates/hallucinated-imports/framework-modules.js +32 -6
- package/dist/gates/hallucinated-imports/index.js +2 -1
- package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports/js-resolver-types.test.js +14 -0
- package/dist/gates/hallucinated-imports/js-resolver.d.ts +6 -1
- package/dist/gates/hallucinated-imports/js-resolver.js +25 -3
- package/dist/gates/phantom-apis.js +2 -2
- package/dist/gates/phantom-apis.test.js +10 -0
- package/dist/gates/promise-safety.js +1 -1
- package/dist/gates/test-quality-matchers.js +3 -0
- package/dist/gates/test-quality.test.js +13 -0
- package/dist/hooks/checker.test.js +9 -0
- package/dist/hooks/stop-review.d.ts +2 -0
- package/dist/hooks/stop-review.js +19 -3
- package/dist/hooks/templates.d.ts +2 -0
- package/dist/hooks/templates.js +16 -2
- package/dist/hooks/templates.test.d.ts +1 -0
- package/dist/hooks/templates.test.js +16 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/inference/cloud-provider.d.ts +12 -1
- package/dist/inference/cloud-provider.js +137 -2
- package/dist/inference/cloud-provider.test.d.ts +1 -0
- package/dist/inference/cloud-provider.test.js +118 -0
- package/dist/inference/pricing.d.ts +15 -0
- package/dist/inference/pricing.js +13 -0
- package/dist/inference/types.d.ts +42 -0
- package/dist/review/changed-function-spans.d.ts +4 -0
- package/dist/review/changed-function-spans.js +48 -0
- package/dist/review/changed-lines.d.ts +9 -3
- package/dist/review/changed-lines.js +19 -2
- package/dist/review/deep-runs.d.ts +22 -0
- package/dist/review/deep-runs.js +59 -0
- package/dist/review/deep-runs.test.d.ts +1 -0
- package/dist/review/deep-runs.test.js +23 -0
- package/dist/review/generated-files.d.ts +3 -0
- package/dist/review/generated-files.js +35 -0
- package/dist/review/generated-files.test.d.ts +1 -0
- package/dist/review/generated-files.test.js +25 -0
- package/dist/review/ledger.d.ts +27 -0
- package/dist/review/ledger.js +72 -0
- package/dist/review/quiet.d.ts +14 -0
- package/dist/review/quiet.js +69 -0
- package/dist/review/quiet.test.d.ts +1 -0
- package/dist/review/quiet.test.js +27 -0
- package/dist/review/review-ack.d.ts +16 -0
- package/dist/review/review-ack.js +25 -0
- package/dist/review/review-task.d.ts +40 -0
- package/dist/review/review-task.js +50 -0
- package/dist/review/review-task.test.d.ts +1 -0
- package/dist/review/review-task.test.js +80 -0
- package/dist/review/review.d.ts +11 -1
- package/dist/review/review.js +15 -6
- package/dist/review/review.test.js +25 -0
- package/dist/review-learning/acted-on.d.ts +25 -0
- package/dist/review-learning/acted-on.js +69 -0
- package/dist/review-learning/learn-from-reviews.d.ts +34 -0
- package/dist/review-learning/learn-from-reviews.js +65 -0
- package/dist/review-learning/lessons.d.ts +52 -0
- package/dist/review-learning/lessons.js +149 -0
- package/dist/review-learning/repo-rules.d.ts +16 -0
- package/dist/review-learning/repo-rules.js +84 -0
- package/dist/review-learning/repo-rules.test.d.ts +1 -0
- package/dist/review-learning/repo-rules.test.js +39 -0
- package/dist/review-learning/review-learning.test.d.ts +1 -0
- package/dist/review-learning/review-learning.test.js +104 -0
- package/dist/review-learning/team-lessons.d.ts +4 -0
- package/dist/review-learning/team-lessons.js +24 -0
- package/dist/semantic/rules/credential-redirect.js +4 -1
- package/dist/settings.js +4 -1
- package/dist/templates/universal-config.js +7 -3
- package/dist/types/index.d.ts +276 -10
- package/dist/types/index.js +38 -5
- package/package.json +6 -6
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Git, ReviewComment } from './acted-on.js';
|
|
2
|
+
export interface ReviewLesson {
|
|
3
|
+
id: string;
|
|
4
|
+
text: string;
|
|
5
|
+
file: string;
|
|
6
|
+
symbols: string[];
|
|
7
|
+
state: 'candidate' | 'verified';
|
|
8
|
+
evidence: Array<{
|
|
9
|
+
pr: number;
|
|
10
|
+
comment: string;
|
|
11
|
+
author: string;
|
|
12
|
+
}>;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
/** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
|
|
17
|
+
export declare function lessonText(body: string): string;
|
|
18
|
+
/** Identifiers a lesson is about: those the comment names, then those on the lines it pointed at. */
|
|
19
|
+
export declare function lessonSymbols(body: string, codeLines: string[]): string[];
|
|
20
|
+
export declare function lessonFromComment(git: Git, comment: ReviewComment, at?: string): ReviewLesson | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Add lessons to the store. The same lesson again (same text, or the same
|
|
23
|
+
* file with two shared symbols) adds evidence; acted on in enough PRs, it is
|
|
24
|
+
* verified.
|
|
25
|
+
*/
|
|
26
|
+
export declare function mergeLessons(existing: ReviewLesson[], incoming: ReviewLesson[]): {
|
|
27
|
+
lessons: ReviewLesson[];
|
|
28
|
+
added: number;
|
|
29
|
+
verified: number;
|
|
30
|
+
};
|
|
31
|
+
export interface ChangeShape {
|
|
32
|
+
files: string[];
|
|
33
|
+
/** Identifiers in the changed code. */
|
|
34
|
+
symbols: Set<string>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Lessons that apply to a change, best first. A lesson applies when the change
|
|
38
|
+
* touches its file, or shares at least two specific identifiers with it; a
|
|
39
|
+
* shared directory only breaks ties. Lessons about docs, tests or config never
|
|
40
|
+
* apply to other files.
|
|
41
|
+
*/
|
|
42
|
+
export declare function matchLessons(lessons: ReviewLesson[], change: ChangeShape, options?: {
|
|
43
|
+
includeCandidates?: boolean;
|
|
44
|
+
limit?: number;
|
|
45
|
+
}): ReviewLesson[];
|
|
46
|
+
/** RIGOUR_REVIEW_LESSONS points at a lessons file outside the clone (CI, or a team's shared copy). */
|
|
47
|
+
export declare function lessonsPath(cwd: string): string;
|
|
48
|
+
export declare function readLessons(cwd: string): ReviewLesson[];
|
|
49
|
+
export declare function writeLessons(cwd: string, lessons: ReviewLesson[]): void;
|
|
50
|
+
export declare function promoteLesson(cwd: string, id: string): ReviewLesson | undefined;
|
|
51
|
+
/** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
|
|
52
|
+
export declare function isSpecific(symbol: string): boolean;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review lessons: what a team's acted-on review comments taught, ready to
|
|
3
|
+
* hand to the next agent before it opens a PR.
|
|
4
|
+
*
|
|
5
|
+
* A lesson starts as a candidate with its evidence (PR, comment, author). It
|
|
6
|
+
* becomes verified only when the same lesson was acted on in two or more PRs,
|
|
7
|
+
* or a person promotes it. Lessons stay in the repository's .rigour/ folder
|
|
8
|
+
* (ignored by git) and are sent nowhere except to the user's own model.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
const STORE = path.join('.rigour', 'review-lessons.json');
|
|
14
|
+
const MAX_TEXT = 220;
|
|
15
|
+
const MAX_SYMBOLS = 8;
|
|
16
|
+
const VERIFY_AT_PRS = 2;
|
|
17
|
+
/** Lessons from these teach about one document, test or config file, not about code that will change again. */
|
|
18
|
+
const NOT_CODE = /(\.(md|mdx|txt|ya?ml|json|lock|snap)$)|(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)(__tests__|docs?|migrations|\.github)\//i;
|
|
19
|
+
const NOT_SYMBOLS = new Set(['this', 'that', 'with', 'from', 'return', 'const', 'await', 'async', 'function', 'true', 'false', 'null', 'undefined', 'string', 'number', 'boolean', 'export', 'import', 'type', 'interface', 'else', 'when', 'then', 'should', 'line', 'lines']);
|
|
20
|
+
/** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
|
|
21
|
+
export function lessonText(body) {
|
|
22
|
+
const cleaned = body
|
|
23
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
24
|
+
.replace(/<details>[\s\S]*?<\/details>/g, '')
|
|
25
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
26
|
+
.split('\n')
|
|
27
|
+
.filter(line => !/^\s*(_[^_]+_\s*\|?\s*)+$/.test(line) && !/^\s*[🏁💡🧩🔇🧰📝⚠️🛠️]/u.test(line))
|
|
28
|
+
.filter(line => !/^\s*#*\s*\**\s*(low|medium|high|critical)\s+severity\s*\**\s*$/i.test(line))
|
|
29
|
+
.join('\n');
|
|
30
|
+
const bold = /\*\*(.+?)\*\*/.exec(cleaned)?.[1]?.trim();
|
|
31
|
+
const text = bold || cleaned.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s/)[0] || '';
|
|
32
|
+
return text.replace(/[*_]/g, '').slice(0, MAX_TEXT).trim();
|
|
33
|
+
}
|
|
34
|
+
/** Identifiers a lesson is about: those the comment names, then those on the lines it pointed at. */
|
|
35
|
+
export function lessonSymbols(body, codeLines) {
|
|
36
|
+
const named = [...body.matchAll(/`([A-Za-z_$][\w$.]*)`/g)].map(m => m[1].split('.').pop());
|
|
37
|
+
const onLines = codeLines.join('\n').match(/[A-Za-z_$][\w$]{3,}/g) ?? [];
|
|
38
|
+
const symbols = [];
|
|
39
|
+
for (const symbol of [...named, ...onLines]) {
|
|
40
|
+
if (!NOT_SYMBOLS.has(symbol.toLowerCase()) && !symbols.includes(symbol))
|
|
41
|
+
symbols.push(symbol);
|
|
42
|
+
if (symbols.length >= MAX_SYMBOLS)
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
return symbols;
|
|
46
|
+
}
|
|
47
|
+
export function lessonFromComment(git, comment, at = new Date().toISOString()) {
|
|
48
|
+
const text = lessonText(comment.body);
|
|
49
|
+
if (text.length < 12)
|
|
50
|
+
return undefined;
|
|
51
|
+
let codeLines = [];
|
|
52
|
+
try {
|
|
53
|
+
codeLines = git(['show', `${comment.commit}:${comment.path}`]).split('\n').slice(comment.start - 1, comment.end);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// The file at that commit is unavailable: the comment's own identifiers still describe it.
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
id: crypto.createHash('sha256').update(`${comment.path}\u0000${normalize(text)}`).digest('hex').slice(0, 12),
|
|
60
|
+
text, file: comment.path, symbols: lessonSymbols(comment.body, codeLines), state: 'candidate',
|
|
61
|
+
evidence: [{ pr: comment.prNumber, comment: comment.id, author: comment.author }], createdAt: at, updatedAt: at,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Add lessons to the store. The same lesson again (same text, or the same
|
|
66
|
+
* file with two shared symbols) adds evidence; acted on in enough PRs, it is
|
|
67
|
+
* verified.
|
|
68
|
+
*/
|
|
69
|
+
export function mergeLessons(existing, incoming) {
|
|
70
|
+
const lessons = existing.map(l => ({ ...l, evidence: [...l.evidence] }));
|
|
71
|
+
let added = 0;
|
|
72
|
+
for (const lesson of incoming) {
|
|
73
|
+
const same = lessons.find(l => l.id === lesson.id || (l.file === lesson.file && shared(l.symbols, lesson.symbols) >= 2));
|
|
74
|
+
if (!same) {
|
|
75
|
+
lessons.push(lesson);
|
|
76
|
+
added++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const e of lesson.evidence)
|
|
80
|
+
if (!same.evidence.some(x => x.comment === e.comment))
|
|
81
|
+
same.evidence.push(e);
|
|
82
|
+
same.updatedAt = lesson.updatedAt;
|
|
83
|
+
}
|
|
84
|
+
let verified = 0;
|
|
85
|
+
for (const lesson of lessons) {
|
|
86
|
+
if (lesson.state === 'candidate' && new Set(lesson.evidence.map(e => e.pr)).size >= VERIFY_AT_PRS) {
|
|
87
|
+
lesson.state = 'verified';
|
|
88
|
+
verified++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { lessons, added, verified };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Lessons that apply to a change, best first. A lesson applies when the change
|
|
95
|
+
* touches its file, or shares at least two specific identifiers with it; a
|
|
96
|
+
* shared directory only breaks ties. Lessons about docs, tests or config never
|
|
97
|
+
* apply to other files.
|
|
98
|
+
*/
|
|
99
|
+
export function matchLessons(lessons, change, options = {}) {
|
|
100
|
+
const dirs = new Set(change.files.map(f => path.posix.dirname(f)));
|
|
101
|
+
const scored = lessons
|
|
102
|
+
.filter(l => (options.includeCandidates || l.state === 'verified') && !NOT_CODE.test(l.file))
|
|
103
|
+
.map(l => ({
|
|
104
|
+
lesson: l,
|
|
105
|
+
score: (change.files.includes(l.file) ? 3 : 0) + (dirs.has(path.posix.dirname(l.file)) ? 0.5 : 0)
|
|
106
|
+
+ 2 * l.symbols.filter(s => isSpecific(s) && change.symbols.has(s)).length,
|
|
107
|
+
}))
|
|
108
|
+
.filter(s => s.score >= 3)
|
|
109
|
+
.sort((a, b) => b.score - a.score || b.lesson.evidence.length - a.lesson.evidence.length);
|
|
110
|
+
return scored.slice(0, options.limit ?? 5).map(s => s.lesson);
|
|
111
|
+
}
|
|
112
|
+
/** RIGOUR_REVIEW_LESSONS points at a lessons file outside the clone (CI, or a team's shared copy). */
|
|
113
|
+
export function lessonsPath(cwd) {
|
|
114
|
+
return process.env.RIGOUR_REVIEW_LESSONS?.trim() || path.join(cwd, STORE);
|
|
115
|
+
}
|
|
116
|
+
export function readLessons(cwd) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(fs.readFileSync(lessonsPath(cwd), 'utf8'));
|
|
119
|
+
return Array.isArray(parsed?.lessons) ? parsed.lessons : [];
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function writeLessons(cwd, lessons) {
|
|
126
|
+
const file = lessonsPath(cwd);
|
|
127
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
128
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, lessons }, null, 2) + '\n');
|
|
129
|
+
}
|
|
130
|
+
export function promoteLesson(cwd, id) {
|
|
131
|
+
const lessons = readLessons(cwd);
|
|
132
|
+
const lesson = lessons.find(l => l.id === id);
|
|
133
|
+
if (!lesson)
|
|
134
|
+
return undefined;
|
|
135
|
+
lesson.state = 'verified';
|
|
136
|
+
lesson.updatedAt = new Date().toISOString();
|
|
137
|
+
writeLessons(cwd, lessons);
|
|
138
|
+
return lesson;
|
|
139
|
+
}
|
|
140
|
+
/** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
|
|
141
|
+
export function isSpecific(symbol) {
|
|
142
|
+
return /[a-z][A-Z]|[A-Za-z]_[A-Za-z]/.test(symbol) || symbol.length >= 10;
|
|
143
|
+
}
|
|
144
|
+
function normalize(text) {
|
|
145
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
146
|
+
}
|
|
147
|
+
function shared(a, b) {
|
|
148
|
+
return a.filter(s => b.includes(s)).length;
|
|
149
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface RepoRule {
|
|
2
|
+
source: string;
|
|
3
|
+
text: string;
|
|
4
|
+
/** Paths the rule names (files or directories). */
|
|
5
|
+
paths: string[];
|
|
6
|
+
/** Identifiers the rule names. */
|
|
7
|
+
symbols: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function readRepoRules(cwd: string): RepoRule[];
|
|
10
|
+
/** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
|
|
11
|
+
export declare function splitRules(source: string, text: string): RepoRule[];
|
|
12
|
+
/** Rules that name a path the change touches, or a specific identifier in it; most specific first. */
|
|
13
|
+
export declare function rulesForChange(rules: RepoRule[], files: string[], symbols: Set<string>): RepoRule[];
|
|
14
|
+
export declare function rulesSection(rules: RepoRule[]): string;
|
|
15
|
+
/** The rules that apply to a diff's changed files and added identifiers. */
|
|
16
|
+
export declare function rulesForDiff(cwd: string, diff: string, enabled?: boolean): RepoRule[];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository rules that apply to a change.
|
|
3
|
+
*
|
|
4
|
+
* A team's real engineering rules usually live in its agent rules files
|
|
5
|
+
* (AGENTS.md, CLAUDE.md, Cursor rules, Copilot instructions), not in PR
|
|
6
|
+
* threads: "migrations are append-only", "every send goes through
|
|
7
|
+
* deliverOrder()". An agent loads the whole file and skims it; the reviewer
|
|
8
|
+
* is better served by the few rules that name a file or identifier the
|
|
9
|
+
* change actually touches.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { isSpecific } from './lessons.js';
|
|
14
|
+
const RULE_FILES = ['AGENTS.md', 'CLAUDE.md', '.github/copilot-instructions.md'];
|
|
15
|
+
const RULE_DIRS = ['.cursor/rules'];
|
|
16
|
+
const MAX_RULE_CHARS = 600;
|
|
17
|
+
const MAX_RULES = 5;
|
|
18
|
+
export function readRepoRules(cwd) {
|
|
19
|
+
const files = [
|
|
20
|
+
...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))),
|
|
21
|
+
...RULE_DIRS.flatMap(dir => listRuleFiles(cwd, dir)),
|
|
22
|
+
];
|
|
23
|
+
return files.flatMap(file => splitRules(file, fs.readFileSync(path.join(cwd, file), 'utf8')));
|
|
24
|
+
}
|
|
25
|
+
/** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
|
|
26
|
+
export function splitRules(source, text) {
|
|
27
|
+
const blocks = [];
|
|
28
|
+
let current = [];
|
|
29
|
+
const flush = () => {
|
|
30
|
+
const block = current.join(' ').replace(/\s+/g, ' ').trim();
|
|
31
|
+
if (block.length >= 40)
|
|
32
|
+
blocks.push(block);
|
|
33
|
+
current = [];
|
|
34
|
+
};
|
|
35
|
+
for (const line of text.split('\n')) {
|
|
36
|
+
if (/^\s*$/.test(line) || /^#{1,6}\s/.test(line) || /^@\S+$/.test(line.trim())) {
|
|
37
|
+
flush();
|
|
38
|
+
}
|
|
39
|
+
else if (/^[-*]\s/.test(line)) {
|
|
40
|
+
flush();
|
|
41
|
+
current.push(line.replace(/^[-*]\s+/, ''));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
current.push(line.trim());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
flush();
|
|
48
|
+
return blocks.map(block => ({
|
|
49
|
+
source,
|
|
50
|
+
text: block.length > MAX_RULE_CHARS ? `${block.slice(0, MAX_RULE_CHARS)}…` : block,
|
|
51
|
+
paths: [...new Set([...block.matchAll(/`([\w@.~-]+\/[\w./@*-]*)`/g)].map(m => m[1].replace(/\*.*$/, '').replace(/^\.\//, '')))].filter(Boolean),
|
|
52
|
+
symbols: [...new Set([...block.matchAll(/`([A-Za-z_$][\w$]*)(?:\(\))?`/g)].map(m => m[1]))].filter(isSpecific),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/** Rules that name a path the change touches, or a specific identifier in it; most specific first. */
|
|
56
|
+
export function rulesForChange(rules, files, symbols) {
|
|
57
|
+
const scored = rules.map(rule => {
|
|
58
|
+
const pathHits = rule.paths.filter(p => files.some(f => f === p || f.startsWith(p.endsWith('/') ? p : `${p}/`) || f.endsWith(`/${p}`))).length;
|
|
59
|
+
const symbolHits = rule.symbols.filter(s => symbols.has(s)).length;
|
|
60
|
+
return { rule, score: 3 * pathHits + 2 * symbolHits };
|
|
61
|
+
});
|
|
62
|
+
return scored.filter(s => s.score > 0).sort((a, b) => b.score - a.score).slice(0, MAX_RULES).map(s => s.rule);
|
|
63
|
+
}
|
|
64
|
+
export function rulesSection(rules) {
|
|
65
|
+
if (rules.length === 0)
|
|
66
|
+
return '';
|
|
67
|
+
return `REPOSITORY RULES THAT APPLY (from this repository's own rules files; check the change follows each):\n${rules.map(r => `- [${r.source}] ${r.text}`).join('\n')}`;
|
|
68
|
+
}
|
|
69
|
+
function listRuleFiles(cwd, dir) {
|
|
70
|
+
try {
|
|
71
|
+
return fs.readdirSync(path.join(cwd, dir)).filter(f => /\.(md|mdc)$/.test(f)).map(f => `${dir}/${f}`);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The rules that apply to a diff's changed files and added identifiers. */
|
|
78
|
+
export function rulesForDiff(cwd, diff, enabled = false) {
|
|
79
|
+
if (!enabled)
|
|
80
|
+
return [];
|
|
81
|
+
const files = [...diff.matchAll(/^\+\+\+ b\/(.+)$/gm)].map(m => m[1].trim());
|
|
82
|
+
const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
|
|
83
|
+
return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []));
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { rulesForDiff, rulesSection, splitRules } from './repo-rules.js';
|
|
6
|
+
const AGENTS = `# Conventions
|
|
7
|
+
|
|
8
|
+
@SHARED.md
|
|
9
|
+
|
|
10
|
+
- **Migrations are append-only.** Add new files under \`migrations/\`; never edit applied ones.
|
|
11
|
+
- Every outbound send goes through \`deliverOrder()\` in \`src/lib/delivery.ts\`, which checks the permit.
|
|
12
|
+
- Keep components small.
|
|
13
|
+
|
|
14
|
+
Prefer \`fetchWithTimeout\` over a bare call when talking to partner APIs; a hung request blocks the queue.
|
|
15
|
+
`;
|
|
16
|
+
let repo;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-rules-'));
|
|
19
|
+
fs.writeFileSync(path.join(repo, 'AGENTS.md'), AGENTS);
|
|
20
|
+
fs.mkdirSync(path.join(repo, '.cursor/rules'), { recursive: true });
|
|
21
|
+
fs.writeFileSync(path.join(repo, '.cursor/rules/ui.mdc'), 'Never set `fullWidth` on `<Button>`; use `maxWidth` caps instead of stretching.\n');
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
24
|
+
const diff = (file, added) => `+++ b/${file}\n@@ -1,0 +1,1 @@\n+${added}\n`;
|
|
25
|
+
describe('repository rules', () => {
|
|
26
|
+
it('splits a rules file into rules with the paths and specific identifiers they name', () => {
|
|
27
|
+
const rules = splitRules('AGENTS.md', AGENTS);
|
|
28
|
+
expect(rules.map(r => r.text.slice(0, 30))).toEqual(['**Migrations are append-only.*', 'Every outbound send goes throu', 'Prefer `fetchWithTimeout` over']);
|
|
29
|
+
expect(rules[1]).toMatchObject({ paths: ['src/lib/delivery.ts'], symbols: ['deliverOrder'] });
|
|
30
|
+
expect(rules[0].paths).toEqual(['migrations/']);
|
|
31
|
+
});
|
|
32
|
+
it('shows only the rules that name what the change touches, and nothing when disabled', () => {
|
|
33
|
+
expect(rulesForDiff(repo, diff('migrations/2026_add.sql', 'alter table x;'), true).map(r => r.text.slice(0, 20))).toEqual(['**Migrations are app']);
|
|
34
|
+
expect(rulesForDiff(repo, diff('src/jobs/send.ts', 'await deliverOrder(order);'), true).map(r => r.text.slice(0, 20))).toEqual(['Every outbound send ']);
|
|
35
|
+
expect(rulesForDiff(repo, diff('src/ui/List.tsx', 'const x = 1;'), true)).toEqual([]);
|
|
36
|
+
expect(rulesForDiff(repo, diff('migrations/2026_add.sql', 'x'), false)).toEqual([]);
|
|
37
|
+
expect(rulesSection(rulesForDiff(repo, diff('src/a.ts', 'await fetchWithTimeout(url);'), true))).toContain('[AGENTS.md] Prefer `fetchWithTimeout`');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { actedOn, gitIn } from './acted-on.js';
|
|
7
|
+
import { learnFromReviews } from './learn-from-reviews.js';
|
|
8
|
+
import { isSpecific, lessonText, matchLessons, mergeLessons, readLessons } from './lessons.js';
|
|
9
|
+
import { lessonsForDiff, lessonsSection } from './team-lessons.js';
|
|
10
|
+
let repo;
|
|
11
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
12
|
+
const write = (body) => fs.writeFileSync(path.join(repo, 'src/orders.ts'), body);
|
|
13
|
+
const commit = (message) => { git('add', '-A'); git('commit', '-qm', message); return git('rev-parse', 'HEAD'); };
|
|
14
|
+
const V1 = 'export async function save(db, order) {\n await db.insert(order);\n return order.id;\n}\n';
|
|
15
|
+
const V2 = 'export async function save(db, order) {\n await db.upsert(order, { onConflict: "id" });\n return order.id;\n}\n';
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'review-learning-'));
|
|
18
|
+
git('init', '-q', '-b', 'main');
|
|
19
|
+
git('config', 'user.email', 't@example.com');
|
|
20
|
+
git('config', 'user.name', 't');
|
|
21
|
+
git('config', 'commit.gpgsign', 'false');
|
|
22
|
+
fs.mkdirSync(path.join(repo, 'src'));
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
25
|
+
const comment = (id, sha, start, body, prNumber = 7) => ({ id, prNumber, path: 'src/orders.ts', start, end: start, commit: sha, body, author: 'reviewer' });
|
|
26
|
+
describe('actedOn', () => {
|
|
27
|
+
it('keeps a comment whose lines changed before merge, and drops one nobody acted on', () => {
|
|
28
|
+
write(V1);
|
|
29
|
+
const reviewed = commit('pr head');
|
|
30
|
+
write(V2);
|
|
31
|
+
const merged = commit('address review');
|
|
32
|
+
const acted = actedOn(gitIn(repo), { number: 7, mergeSha: merged, mergedAt: '', comments: [
|
|
33
|
+
comment('a', reviewed, 2, '**Use an upsert keyed on `id` so a retry does not duplicate the order.**'),
|
|
34
|
+
comment('b', reviewed, 3, 'Nit: return the whole order?'),
|
|
35
|
+
] });
|
|
36
|
+
expect(acted.map(c => c.id)).toEqual(['a']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('lessons', () => {
|
|
40
|
+
it('keeps the point of a review comment and drops tool output and markup', () => {
|
|
41
|
+
const body = '_⚠️ Potential issue_ | _🟠 Major_\n\n**Use an upsert keyed on `id`.**\n\n<details>\n<summary>🏁 Script executed</summary>\nrg -n insert\n</details>';
|
|
42
|
+
expect(lessonText(body)).toBe('Use an upsert keyed on `id`.');
|
|
43
|
+
expect(lessonText('Callers retry on timeout, so this insert duplicates orders. Please use upsert.')).toBe('Callers retry on timeout, so this insert duplicates orders.');
|
|
44
|
+
expect(lessonText('### Medium Severity\n\nThe retry loop never resets `attempt`, so a second failure gives up at once.')).toBe('The retry loop never resets `attempt`, so a second failure gives up at once.');
|
|
45
|
+
});
|
|
46
|
+
it('verifies a lesson only when it was acted on in two PRs', () => {
|
|
47
|
+
const lesson = (pr, comment) => ({
|
|
48
|
+
id: 'x', text: 'Use upsert', file: 'src/orders.ts', symbols: ['save', 'insert'], state: 'candidate',
|
|
49
|
+
evidence: [{ pr, comment, author: 'r' }], createdAt: '', updatedAt: '',
|
|
50
|
+
});
|
|
51
|
+
const once = mergeLessons([], [lesson(1, 'c1'), lesson(1, 'c2')]);
|
|
52
|
+
expect(once.lessons[0].state).toBe('candidate');
|
|
53
|
+
const twice = mergeLessons(once.lessons, [lesson(2, 'c3')]);
|
|
54
|
+
expect(twice).toMatchObject({ added: 0, verified: 1 });
|
|
55
|
+
expect(twice.lessons[0].evidence).toHaveLength(3);
|
|
56
|
+
});
|
|
57
|
+
it('matches lessons by file, symbols and directory, verified only unless asked', () => {
|
|
58
|
+
const base = { symbols: ['insert'], evidence: [{ pr: 1, comment: 'c', author: 'r' }], createdAt: '', updatedAt: '' };
|
|
59
|
+
const lessons = [
|
|
60
|
+
{ ...base, id: '1', text: 'same file', file: 'src/orders.ts', state: 'verified' },
|
|
61
|
+
{ ...base, id: '2', text: 'other dir, two shared symbols', file: 'lib/x.ts', state: 'verified', symbols: ['insertOrder', 'orderId'] },
|
|
62
|
+
{ ...base, id: '6', text: 'a lesson about a runbook', file: 'docs/runbook.md', state: 'verified', symbols: ['insertOrder', 'orderId'] },
|
|
63
|
+
{ ...base, id: '5', text: 'other dir, one generic shared symbol', file: 'lib/z.ts', state: 'verified' },
|
|
64
|
+
{ ...base, id: '3', text: 'candidate', file: 'src/orders.ts', state: 'candidate' },
|
|
65
|
+
{ ...base, id: '4', text: 'unrelated', file: 'lib/y.ts', state: 'verified', symbols: ['other'] },
|
|
66
|
+
];
|
|
67
|
+
const change = { files: ['src/orders.ts'], symbols: new Set(['insert', 'insertOrder', 'orderId']) };
|
|
68
|
+
expect(matchLessons(lessons, change).map(l => l.id)).toEqual(['2', '1']); // two specific shared names outrank a same-file lesson with only generic ones
|
|
69
|
+
expect(matchLessons(lessons, change, { includeCandidates: true }).map(l => l.id)).toEqual(['2', '1', '3']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('isSpecific', () => {
|
|
73
|
+
it('counts identifiers that name something, not common words', () => {
|
|
74
|
+
expect(['orderId', 'max_rows', 'renderTransactionalMessage'].map(isSpecific)).toEqual([true, true, true]);
|
|
75
|
+
expect(['data', 'error', 'route', 'insert'].map(isSpecific)).toEqual([false, false, false, false]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('learnFromReviews', () => {
|
|
79
|
+
it('reads merged PRs from GitHub, learns from acted-on comments, and shows them for the next change', async () => {
|
|
80
|
+
write(V1);
|
|
81
|
+
const reviewed = commit('pr head');
|
|
82
|
+
write(V2);
|
|
83
|
+
const merged = commit('address review');
|
|
84
|
+
const api = 'https://api.github.com/repos/acme/app';
|
|
85
|
+
const pages = {
|
|
86
|
+
[`${api}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=1`]: [
|
|
87
|
+
{ number: 7, merged_at: '2026-09-01T00:00:00Z', merge_commit_sha: merged },
|
|
88
|
+
{ number: 8, merged_at: null, merge_commit_sha: null },
|
|
89
|
+
],
|
|
90
|
+
[`${api}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=2`]: [],
|
|
91
|
+
[`${api}/pulls/7/comments?per_page=100`]: [
|
|
92
|
+
{ id: 11, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: '**Use an upsert keyed on `id` so retries do not duplicate orders.**', user: { login: 'priya' } },
|
|
93
|
+
{ id: 12, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: 'agreed', in_reply_to_id: 11, user: { login: 'sam' } },
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
const fetchImpl = async (url) => ({ ok: url in pages, status: url in pages ? 200 : 404, json: async () => pages[url] });
|
|
97
|
+
const result = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl });
|
|
98
|
+
expect(result).toMatchObject({ prs: 1, comments: 1, actedOn: 1, added: 1, total: 1 });
|
|
99
|
+
expect(readLessons(repo)[0]).toMatchObject({ file: 'src/orders.ts', text: 'Use an upsert keyed on `id` so retries do not duplicate orders.', evidence: [{ pr: 7, author: 'priya' }] });
|
|
100
|
+
const diff = '+++ b/src/orders.ts\n@@ -1,0 +1,1 @@\n+ await db.insert(other);\n';
|
|
101
|
+
expect(lessonsForDiff(repo, diff, 'verified')).toEqual([]); // one PR is not enough to verify
|
|
102
|
+
expect(lessonsSection(lessonsForDiff(repo, diff, 'all'))).toContain('src/orders.ts: Use an upsert keyed on `id` so retries do not duplicate orders. (acted on in PR #7)');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ReviewLesson } from './lessons.js';
|
|
2
|
+
export type LessonMode = 'verified' | 'all' | 'off';
|
|
3
|
+
export declare function lessonsForDiff(cwd: string, diff: string, mode?: LessonMode): ReviewLesson[];
|
|
4
|
+
export declare function lessonsSection(lessons: ReviewLesson[]): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The team's review lessons that apply to a change, for the reviewer (an
|
|
3
|
+
* agent or a model) to check before the PR: "your team has asked for this
|
|
4
|
+
* before".
|
|
5
|
+
*/
|
|
6
|
+
import { parseDiff } from '../utils/diff.js';
|
|
7
|
+
import { matchLessons, readLessons } from './lessons.js';
|
|
8
|
+
export function lessonsForDiff(cwd, diff, mode = 'off') {
|
|
9
|
+
if (mode === 'off')
|
|
10
|
+
return [];
|
|
11
|
+
const lessons = readLessons(cwd);
|
|
12
|
+
if (lessons.length === 0)
|
|
13
|
+
return [];
|
|
14
|
+
const files = Object.keys(parseDiff(diff));
|
|
15
|
+
const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
|
|
16
|
+
const symbols = new Set(added.match(/[A-Za-z_$][\w$]{3,}/g) ?? []);
|
|
17
|
+
return matchLessons(lessons, { files, symbols }, { includeCandidates: mode === 'all' });
|
|
18
|
+
}
|
|
19
|
+
export function lessonsSection(lessons) {
|
|
20
|
+
if (lessons.length === 0)
|
|
21
|
+
return '';
|
|
22
|
+
const lines = lessons.map(l => `- ${l.file}: ${l.text} (acted on in PR ${[...new Set(l.evidence.map(e => `#${e.pr}`))].join(', ')})`);
|
|
23
|
+
return `TEAM LESSONS (this team asked for these in past reviews; check whether the change repeats any):\n${lines.join('\n')}`;
|
|
24
|
+
}
|
|
@@ -15,6 +15,8 @@ import { originsOf, propertyValues } from '../origins.js';
|
|
|
15
15
|
const CREDENTIAL_HEADER = /^(?:x-[\w-]*(?:token|secret|key|signature|password|auth[\w-]*)|[\w-]*(?:api[-_]?key|secret|access[-_]?token|private[-_]?token))$/i;
|
|
16
16
|
/** Removed by fetch (or not settable) on a cross-origin redirect. */
|
|
17
17
|
const STRIPPED_ON_REDIRECT = /^(?:authorization|proxy-authorization|cookie)$/i;
|
|
18
|
+
/** Named like a key but not a secret: identifiers for deduplication, routing, caching and tracing. */
|
|
19
|
+
const NOT_A_CREDENTIAL = /idempoten|request[-_]?id|correlation|trace|dedup|cache[-_]?key|partition[-_]?key|routing[-_]?key|public[-_]?key|key[-_]?id$/i;
|
|
18
20
|
const SECRET_ENV = /SECRET|TOKEN|API_?KEY|PASSWORD|PRIVATE_KEY/i;
|
|
19
21
|
export const credentialRedirect = {
|
|
20
22
|
id: 'credential-redirect',
|
|
@@ -155,7 +157,8 @@ function credentialHeader(checker, headers) {
|
|
|
155
157
|
const header = propertyNameText(prop.name);
|
|
156
158
|
if (!header || STRIPPED_ON_REDIRECT.test(header))
|
|
157
159
|
continue;
|
|
158
|
-
|
|
160
|
+
const named = CREDENTIAL_HEADER.test(header) && !NOT_A_CREDENTIAL.test(header);
|
|
161
|
+
if (named || readsSecretEnv(prop.initializer))
|
|
159
162
|
return { header, node: prop };
|
|
160
163
|
}
|
|
161
164
|
}
|
package/dist/settings.js
CHANGED
|
@@ -123,10 +123,13 @@ export function resolveDeepOptions(cliOptions) {
|
|
|
123
123
|
result.modelName = cliOptions.modelName;
|
|
124
124
|
}
|
|
125
125
|
// 4. Resolve API key
|
|
126
|
-
// CLI flag takes highest precedence
|
|
126
|
+
// CLI flag takes highest precedence, then RIGOUR_API_KEY (CI secrets: never on a command line)
|
|
127
127
|
if (cliOptions.apiKey) {
|
|
128
128
|
result.apiKey = cliOptions.apiKey;
|
|
129
129
|
}
|
|
130
|
+
else if (process.env.RIGOUR_API_KEY?.trim()) {
|
|
131
|
+
result.apiKey = process.env.RIGOUR_API_KEY.trim();
|
|
132
|
+
}
|
|
130
133
|
else if (settings.providers) {
|
|
131
134
|
// Otherwise look up provider key in settings.providers
|
|
132
135
|
const normalizedProvider = normalizeProviderName(selectedProvider);
|
|
@@ -154,7 +154,7 @@ export const UNIVERSAL_CONFIG = {
|
|
|
154
154
|
promise_safety: {
|
|
155
155
|
enabled: true,
|
|
156
156
|
check_unhandled_then: true,
|
|
157
|
-
check_unsafe_parse:
|
|
157
|
+
check_unsafe_parse: false,
|
|
158
158
|
check_async_without_await: true,
|
|
159
159
|
check_unsafe_fetch: true,
|
|
160
160
|
ignore_patterns: [],
|
|
@@ -191,8 +191,8 @@ export const UNIVERSAL_CONFIG = {
|
|
|
191
191
|
},
|
|
192
192
|
governance: {
|
|
193
193
|
enabled: true,
|
|
194
|
-
enforce_memory:
|
|
195
|
-
enforce_skills:
|
|
194
|
+
enforce_memory: false,
|
|
195
|
+
enforce_skills: false,
|
|
196
196
|
block_native_memory: true,
|
|
197
197
|
protected_memory_paths: [
|
|
198
198
|
'CLAUDE.md', '.claude/CLAUDE.md',
|
|
@@ -275,11 +275,15 @@ export const UNIVERSAL_CONFIG = {
|
|
|
275
275
|
fast_gates: ['hallucinated-imports', 'phantom-apis', 'deprecated-apis', 'promise-safety', 'security-patterns', 'file-size'],
|
|
276
276
|
timeout_ms: 5000,
|
|
277
277
|
block_on_failure: false,
|
|
278
|
+
require_review_ack: false,
|
|
278
279
|
dlp: true,
|
|
279
280
|
},
|
|
280
281
|
output: {
|
|
281
282
|
report_path: 'rigour-report.json',
|
|
282
283
|
},
|
|
284
|
+
review: {
|
|
285
|
+
include_heuristics: false,
|
|
286
|
+
},
|
|
283
287
|
planned: [],
|
|
284
288
|
ignore: [],
|
|
285
289
|
};
|