@rigour-labs/core 6.11.0 → 6.11.1-rc.2
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/gates/logic-drift-git-base.d.ts +9 -0
- package/dist/gates/logic-drift-git-base.js +36 -0
- package/dist/gates/style-drift.d.ts +8 -0
- package/dist/gates/style-drift.js +48 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/review-learning/learn-from-reviews.js +3 -2
- package/dist/review-learning/lessons.d.ts +16 -4
- package/dist/review-learning/lessons.js +74 -11
- package/dist/review-learning/review-points.d.ts +3 -1
- package/dist/review-learning/review-points.js +7 -2
- package/dist/utils/scanner.d.ts +2 -0
- package/dist/utils/scanner.js +7 -0
- package/package.json +6 -6
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export interface GitLogicBase {
|
|
2
|
+
/** The commit compared against. */
|
|
3
|
+
base: string;
|
|
4
|
+
/** Tracked files that differ from the base (added, copied, modified, renamed). Untracked files are not in it. */
|
|
2
5
|
changedFiles: Set<string>;
|
|
3
6
|
readAtBase: (file: string) => string | null;
|
|
4
7
|
}
|
|
@@ -14,3 +17,9 @@ export interface BranchBase {
|
|
|
14
17
|
export declare function branchBase(cwd: string): BranchBase | null;
|
|
15
18
|
/** Use a fixed commit for the entire scan; never move the baseline on read. */
|
|
16
19
|
export declare function resolveGitLogicBase(cwd: string): GitLogicBase | null;
|
|
20
|
+
/** Files git does not track and does not ignore: what an agent just created. */
|
|
21
|
+
export declare function untrackedFiles(cwd: string): string[];
|
|
22
|
+
/** Every file committed at `commit`, repository-relative with `/`. */
|
|
23
|
+
export declare function filesAtCommit(cwd: string, commit: string): string[];
|
|
24
|
+
/** The contents of `files` at `commit`, read by one git process; a file missing there is left out. */
|
|
25
|
+
export declare function readManyAtCommit(cwd: string, commit: string, files: string[]): Map<string, string>;
|
|
@@ -40,7 +40,43 @@ export function resolveGitLogicBase(cwd) {
|
|
|
40
40
|
if (diff === null)
|
|
41
41
|
return null;
|
|
42
42
|
return {
|
|
43
|
+
base,
|
|
43
44
|
changedFiles: new Set(diff.split('\0').filter(Boolean)),
|
|
44
45
|
readAtBase: file => git(cwd, ['show', `${base}:${file}`]),
|
|
45
46
|
};
|
|
46
47
|
}
|
|
48
|
+
/** Files git does not track and does not ignore: what an agent just created. */
|
|
49
|
+
export function untrackedFiles(cwd) {
|
|
50
|
+
return (git(cwd, ['ls-files', '--others', '--exclude-standard', '-z']) ?? '').split('\0').filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
/** Every file committed at `commit`, repository-relative with `/`. */
|
|
53
|
+
export function filesAtCommit(cwd, commit) {
|
|
54
|
+
return (git(cwd, ['ls-tree', '-r', '--name-only', '-z', commit]) ?? '').split('\0').filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
/** The contents of `files` at `commit`, read by one git process; a file missing there is left out. */
|
|
57
|
+
export function readManyAtCommit(cwd, commit, files) {
|
|
58
|
+
const contents = new Map();
|
|
59
|
+
if (files.length === 0)
|
|
60
|
+
return contents;
|
|
61
|
+
let out;
|
|
62
|
+
try {
|
|
63
|
+
out = execFileSync('git', ['cat-file', '--batch'], { cwd, input: files.map(file => `${commit}:${file}`).join('\n') + '\n', stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024 });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return contents;
|
|
67
|
+
}
|
|
68
|
+
let at = 0;
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
const end = out.indexOf(0x0a, at);
|
|
71
|
+
if (end < 0)
|
|
72
|
+
break;
|
|
73
|
+
const header = out.subarray(at, end).toString('utf8').split(' ');
|
|
74
|
+
at = end + 1;
|
|
75
|
+
if (header[1] !== 'blob')
|
|
76
|
+
continue; // "<name> missing", or not a file
|
|
77
|
+
const size = Number(header[2]);
|
|
78
|
+
contents.set(file, out.subarray(at, at + size).toString('utf8'));
|
|
79
|
+
at += size + 1;
|
|
80
|
+
}
|
|
81
|
+
return contents;
|
|
82
|
+
}
|
|
@@ -32,8 +32,16 @@ export declare class StyleDriftGate extends Gate {
|
|
|
32
32
|
constructor(config?: StyleDriftConfig);
|
|
33
33
|
protected get provenance(): Provenance;
|
|
34
34
|
run(context: GateContext): Promise<Failure[]>;
|
|
35
|
+
/**
|
|
36
|
+
* The changed and untracked files, each against a fingerprint of its language's files committed at the base.
|
|
37
|
+
* On the base branch with a clean tree nothing differs, so nothing is compared.
|
|
38
|
+
*/
|
|
39
|
+
private againstGitBase;
|
|
40
|
+
/** Each file against its own language's fingerprint. */
|
|
41
|
+
private compare;
|
|
35
42
|
private computePerLanguageBaseline;
|
|
36
43
|
private computeFingerprint;
|
|
44
|
+
private fingerprintOf;
|
|
37
45
|
private emptyFingerprint;
|
|
38
46
|
private analyzeFile;
|
|
39
47
|
private mergeIntoFingerprint;
|
|
@@ -25,6 +25,7 @@ import { Logger } from '../utils/logger.js';
|
|
|
25
25
|
import { languageAdapters } from './language-adapters/index.js';
|
|
26
26
|
import { extractComparableJsNames } from './js-style-context.js';
|
|
27
27
|
import { isScoped } from '../utils/scope.js';
|
|
28
|
+
import { filesAtCommit, isGitWorktree, readManyAtCommit, resolveGitLogicBase, untrackedFiles } from './logic-drift-git-base.js';
|
|
28
29
|
import { TRY_CATCH_PATTERN, CATCH_PATTERN, RESULT_TYPE_PATTERN, NAMED_IMPORT_PATTERN, WILDCARD_IMPORT_PATTERN, SIDE_EFFECT_IMPORT_PATTERN, DEFAULT_IMPORT_PATTERN, countQuotes, } from './style-drift-rules.js';
|
|
29
30
|
import fs from 'fs-extra';
|
|
30
31
|
import path from 'path';
|
|
@@ -43,16 +44,26 @@ export class StyleDriftGate extends Gate {
|
|
|
43
44
|
async run(context) {
|
|
44
45
|
if (!this.config.enabled)
|
|
45
46
|
return [];
|
|
46
|
-
const failures = [];
|
|
47
47
|
const baselinePath = path.join(context.cwd, this.config.baseline_path);
|
|
48
48
|
// Find source files
|
|
49
|
+
const ignore = [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/*.d.ts'];
|
|
49
50
|
const files = await FileScanner.findFiles({
|
|
50
51
|
cwd: context.cwd,
|
|
51
52
|
patterns: context.patterns || languageAdapters.getScanPatterns(),
|
|
52
|
-
ignore
|
|
53
|
+
ignore,
|
|
53
54
|
});
|
|
54
55
|
if (files.length === 0)
|
|
55
56
|
return [];
|
|
57
|
+
// In a git checkout the project's style is the code committed at the base, read fresh on every run, and
|
|
58
|
+
// only the files that differ from it are compared. Nothing is cached, so no earlier run on a dirty tree
|
|
59
|
+
// can change a verdict, and a .rigour/style-baseline.json left by an older version is never read.
|
|
60
|
+
const gitBase = resolveGitLogicBase(context.cwd);
|
|
61
|
+
if (!gitBase && isGitWorktree(context.cwd)) {
|
|
62
|
+
Logger.info('Style Drift: no main reference available; comparison unavailable');
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
if (gitBase)
|
|
66
|
+
return this.againstGitBase(context, files, ignore, gitBase);
|
|
56
67
|
// Group files by language
|
|
57
68
|
const filesByLang = new Map();
|
|
58
69
|
for (const file of files) {
|
|
@@ -94,6 +105,38 @@ export class StyleDriftGate extends Gate {
|
|
|
94
105
|
return []; // No failures on first scan
|
|
95
106
|
}
|
|
96
107
|
// Subsequent scan: compare each file against its own language's baseline
|
|
108
|
+
return this.compare(context, files, baseline);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The changed and untracked files, each against a fingerprint of its language's files committed at the base.
|
|
112
|
+
* On the base branch with a clean tree nothing differs, so nothing is compared.
|
|
113
|
+
*/
|
|
114
|
+
async againstGitBase(context, files, ignore, gitBase) {
|
|
115
|
+
const changed = new Set([...gitBase.changedFiles, ...untrackedFiles(context.cwd)]);
|
|
116
|
+
const compared = files.filter(file => changed.has(file));
|
|
117
|
+
if (compared.length === 0)
|
|
118
|
+
return [];
|
|
119
|
+
const languages = new Set(compared.map(file => languageAdapters.getAdapter(file)?.id));
|
|
120
|
+
const sampled = new Map();
|
|
121
|
+
for (const file of FileScanner.filterPaths(filesAtCommit(context.cwd, gitBase.base), { patterns: languageAdapters.getScanPatterns(), ignore })) {
|
|
122
|
+
const id = languageAdapters.getAdapter(file)?.id;
|
|
123
|
+
if (!id || !languages.has(id))
|
|
124
|
+
continue;
|
|
125
|
+
const list = sampled.get(id) ?? [];
|
|
126
|
+
if (list.length < this.config.sample_size)
|
|
127
|
+
list.push(file);
|
|
128
|
+
sampled.set(id, list);
|
|
129
|
+
}
|
|
130
|
+
const atBase = readManyAtCommit(context.cwd, gitBase.base, [...sampled.values()].flat());
|
|
131
|
+
const baseline = { languages: {}, createdAt: '', version: 3 };
|
|
132
|
+
for (const [id, list] of sampled) {
|
|
133
|
+
baseline.languages[id] = this.fingerprintOf(new Map(list.flatMap(file => atBase.has(file) ? [[file, atBase.get(file)]] : [])));
|
|
134
|
+
}
|
|
135
|
+
return this.compare(context, compared, baseline);
|
|
136
|
+
}
|
|
137
|
+
/** Each file against its own language's fingerprint. */
|
|
138
|
+
async compare(context, files, baseline) {
|
|
139
|
+
const failures = [];
|
|
97
140
|
const contents = await FileScanner.readFiles(context.cwd, files, context.fileCache);
|
|
98
141
|
for (const [file, content] of contents) {
|
|
99
142
|
const adapter = languageAdapters.getAdapter(file);
|
|
@@ -132,8 +175,10 @@ export class StyleDriftGate extends Gate {
|
|
|
132
175
|
return baseline;
|
|
133
176
|
}
|
|
134
177
|
async computeFingerprint(context, files) {
|
|
178
|
+
return this.fingerprintOf(await FileScanner.readFiles(context.cwd, files, context.fileCache));
|
|
179
|
+
}
|
|
180
|
+
fingerprintOf(contents) {
|
|
135
181
|
const fingerprint = this.emptyFingerprint();
|
|
136
|
-
const contents = await FileScanner.readFiles(context.cwd, files, context.fileCache);
|
|
137
182
|
for (const [file, content] of contents) {
|
|
138
183
|
const fileAnalysis = this.analyzeFile(content, file);
|
|
139
184
|
this.mergeIntoFingerprint(fingerprint, fileAnalysis);
|
package/dist/index.d.ts
CHANGED
|
@@ -49,7 +49,7 @@ export { decideCompiledCheck, proposeCompiledChecks, readCompiledChecks, suspens
|
|
|
49
49
|
export { ruleWriterFor } from './review/reviewer/rule-writer.js';
|
|
50
50
|
export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH, type LastReport } from './review/backtest-last.js';
|
|
51
51
|
export { buildRecord, recordLines, recordIntact, type ReviewRecord } from './review/reviewer/record.js';
|
|
52
|
-
export { readLessons, writeLessons, decideLesson, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath, type ReviewLesson, type LessonEvidence } from './review-learning/lessons.js';
|
|
52
|
+
export { readLessons, writeLessons, decideLesson, acceptSuggestedText, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath, type ReviewLesson, type LessonEvidence } from './review-learning/lessons.js';
|
|
53
53
|
export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
|
|
54
54
|
export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
|
|
55
55
|
export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ export { decideCompiledCheck, proposeCompiledChecks, readCompiledChecks, suspens
|
|
|
47
47
|
export { ruleWriterFor } from './review/reviewer/rule-writer.js';
|
|
48
48
|
export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH } from './review/backtest-last.js';
|
|
49
49
|
export { buildRecord, recordLines, recordIntact } from './review/reviewer/record.js';
|
|
50
|
-
export { readLessons, writeLessons, decideLesson, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
|
|
50
|
+
export { readLessons, writeLessons, decideLesson, acceptSuggestedText, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
|
|
51
51
|
export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
|
|
52
52
|
export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
|
|
53
53
|
export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
|
|
@@ -39,8 +39,9 @@ export async function learnFromReviews(cwd, options) {
|
|
|
39
39
|
}
|
|
40
40
|
const known = new Set(readLessons(cwd).flatMap(l => l.evidence.map(e => e.comment)));
|
|
41
41
|
const points = lessons.flatMap(l => l.evidence).filter(e => e.kind === 'point' && !known.has(e.comment));
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Comments read before go through too: each lands on its own lesson (mergeLessons matches the comment), which
|
|
43
|
+
// takes the text this version derives from it. Nothing is added twice.
|
|
44
|
+
const merged = mergeLessons(readLessons(cwd), lessons);
|
|
44
45
|
// Outcomes accrue after the merge: every candidate whose pull request was read this run is checked again.
|
|
45
46
|
if (options.mainRef) {
|
|
46
47
|
const byNumber = new Map(prs.filter(pr => pr.mergedAt).map(pr => [pr.number, pr]));
|
|
@@ -18,7 +18,7 @@ import type { Git, ReviewBody, ReviewComment } from './acted-on.js';
|
|
|
18
18
|
* until a person promotes it.
|
|
19
19
|
* A record from before evidence kinds has none: it is a `point`.
|
|
20
20
|
*/
|
|
21
|
-
export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified' | 'compiled';
|
|
21
|
+
export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified' | 'compiled' | 'reworded';
|
|
22
22
|
export interface LessonEvidence {
|
|
23
23
|
kind?: EvidenceKind;
|
|
24
24
|
pr: number;
|
|
@@ -50,6 +50,13 @@ export interface ReviewLesson {
|
|
|
50
50
|
/** Which evidence made it a lesson. */
|
|
51
51
|
promotedBy?: 'outcome' | 'correction' | 'person' | 'recurrence' | 'legacy';
|
|
52
52
|
evidence: LessonEvidence[];
|
|
53
|
+
/**
|
|
54
|
+
* A corrected wording a newer version derived from the lesson's own comment, waiting for a person. A lesson
|
|
55
|
+
* a person decided keeps the wording they decided on until they take this one (acceptSuggestedText).
|
|
56
|
+
*/
|
|
57
|
+
suggestedText?: string;
|
|
58
|
+
/** Why there is a suggestion: `parser fix`, a newer version reading the comment better. */
|
|
59
|
+
suggestedWhy?: string;
|
|
53
60
|
/** Where the point sits in the commit it was made on, for outcome evidence (inline comments only). */
|
|
54
61
|
at?: {
|
|
55
62
|
commit: string;
|
|
@@ -65,7 +72,10 @@ export interface ReviewLesson {
|
|
|
65
72
|
* evidence, never a promotion. A record from before evidence kinds keeps the state it had.
|
|
66
73
|
*/
|
|
67
74
|
export declare function lessonState(lesson: ReviewLesson): Pick<ReviewLesson, 'state' | 'promotedBy'>;
|
|
68
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* The point of a review comment: its bold title, else its first sentence and, when that only says what is
|
|
77
|
+
* wrong, the first later sentence that says what to do. Without tool output or markup.
|
|
78
|
+
*/
|
|
69
79
|
export declare function lessonText(body: string): string;
|
|
70
80
|
/** Identifiers a lesson is about: those the comment names, then those on the lines it pointed at. */
|
|
71
81
|
export declare function lessonSymbols(body: string, codeLines: string[]): string[];
|
|
@@ -78,8 +88,8 @@ export declare function lessonFromComment(git: Git, comment: ReviewComment, at?:
|
|
|
78
88
|
*/
|
|
79
89
|
export declare function lessonsFromReview(review: ReviewBody, changedAfter: string[], at?: string): ReviewLesson[];
|
|
80
90
|
/**
|
|
81
|
-
* Add lessons to the store. The same lesson again (same text,
|
|
82
|
-
* file with two shared symbols) adds evidence; acted on in enough PRs, it is
|
|
91
|
+
* Add lessons to the store. The same lesson again (isSameLesson: the same text, the same review
|
|
92
|
+
* comment, or the same file with two shared symbols) adds evidence; acted on in enough PRs, it is
|
|
83
93
|
* verified.
|
|
84
94
|
*/
|
|
85
95
|
export declare function mergeLessons(existing: ReviewLesson[], incoming: ReviewLesson[]): {
|
|
@@ -118,6 +128,8 @@ export declare function pendingDecision(lesson: ReviewLesson): LessonEvidence |
|
|
|
118
128
|
export declare function writeLessons(cwd: string, lessons: ReviewLesson[]): void;
|
|
119
129
|
/** A person's decision on a lesson, kept as evidence: accepted makes it a lesson, rejected an anti-lesson. Undefined for an unknown id. */
|
|
120
130
|
export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected' | 'dismissed', by: string, why?: string): ReviewLesson | undefined;
|
|
131
|
+
/** A person takes a decided lesson's suggested wording; who and the wording it replaced are kept as evidence. */
|
|
132
|
+
export declare function acceptSuggestedText(cwd: string, id: string, by: string): ReviewLesson | undefined;
|
|
121
133
|
/** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
|
|
122
134
|
export declare function isSpecific(symbol: string): boolean;
|
|
123
135
|
/** The meaningful words of a text or an identifier: `hasLaterAttempt` and "a later attempt" share later and attempt. */
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import crypto from 'crypto';
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import path from 'path';
|
|
18
|
-
import { bodyPoints } from './review-points.js';
|
|
18
|
+
import { bodyPoints, withoutEmphasis } from './review-points.js';
|
|
19
19
|
const STORE = path.join('.rigour', 'review-lessons.json');
|
|
20
20
|
const MAX_TEXT = 220;
|
|
21
21
|
const MAX_SYMBOLS = 8;
|
|
@@ -69,7 +69,12 @@ export function lessonState(lesson) {
|
|
|
69
69
|
const independent = reviewers >= 2 || wordings >= 2;
|
|
70
70
|
return prs >= RECUR_PRS && authors >= RECUR_AUTHORS && independent ? { state: 'verified', promotedBy: 'recurrence' } : { state: 'candidate' };
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
72
|
+
/** A sentence that tells the author what to do, not only what is wrong. */
|
|
73
|
+
const INSTRUCTION = /^(?:please\s+)?(?:use|pick|avoid|don'?t|do not|never|always|make|keep|move|read|filter|check|change|drop|add|remove|pass|split|prefer|replace|rename|derive|select|return|validate|extract|bound|lock)\b|\b(?:should|must|instead)\b/i;
|
|
74
|
+
/**
|
|
75
|
+
* The point of a review comment: its bold title, else its first sentence and, when that only says what is
|
|
76
|
+
* wrong, the first later sentence that says what to do. Without tool output or markup.
|
|
77
|
+
*/
|
|
73
78
|
export function lessonText(body) {
|
|
74
79
|
const cleaned = body
|
|
75
80
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
@@ -80,8 +85,10 @@ export function lessonText(body) {
|
|
|
80
85
|
.filter(line => !/^\s*#*\s*\**\s*(low|medium|high|critical)\s+severity\s*\**\s*$/i.test(line))
|
|
81
86
|
.join('\n');
|
|
82
87
|
const bold = /\*\*(.+?)\*\*/.exec(cleaned)?.[1]?.trim();
|
|
83
|
-
const
|
|
84
|
-
|
|
88
|
+
const sentences = cleaned.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s/);
|
|
89
|
+
const instruction = INSTRUCTION.test(sentences[0] ?? '') ? undefined : sentences.slice(1).find(s => INSTRUCTION.test(s));
|
|
90
|
+
const text = bold || [sentences[0] ?? '', instruction ?? ''].filter(Boolean).join(' ');
|
|
91
|
+
return pointText(withoutEmphasis(text)).slice(0, MAX_TEXT).trim();
|
|
85
92
|
}
|
|
86
93
|
/**
|
|
87
94
|
* A point's words without a review bot's scaffolding (`In src/a.ts around lines 10-12:`), or '' when
|
|
@@ -130,7 +137,7 @@ export function lessonFromComment(git, comment, at = new Date().toISOString()) {
|
|
|
130
137
|
*/
|
|
131
138
|
export function lessonsFromReview(review, changedAfter, at = new Date().toISOString()) {
|
|
132
139
|
return bodyPoints(review.body).flatMap((point, i) => {
|
|
133
|
-
const text = pointText(point
|
|
140
|
+
const text = pointText(withoutEmphasis(point)).slice(0, MAX_TEXT).trim();
|
|
134
141
|
if (text.length < 12)
|
|
135
142
|
return [];
|
|
136
143
|
const named = /(?:^|[\s`(])((?:[\w.-]+\/)+[\w.-]+\.\w+)/.exec(point)?.[1];
|
|
@@ -143,18 +150,15 @@ export function lessonsFromReview(review, changedAfter, at = new Date().toISOStr
|
|
|
143
150
|
});
|
|
144
151
|
}
|
|
145
152
|
/**
|
|
146
|
-
* Add lessons to the store. The same lesson again (same text,
|
|
147
|
-
* file with two shared symbols) adds evidence; acted on in enough PRs, it is
|
|
153
|
+
* Add lessons to the store. The same lesson again (isSameLesson: the same text, the same review
|
|
154
|
+
* comment, or the same file with two shared symbols) adds evidence; acted on in enough PRs, it is
|
|
148
155
|
* verified.
|
|
149
156
|
*/
|
|
150
157
|
export function mergeLessons(existing, incoming) {
|
|
151
158
|
const lessons = existing.map(l => ({ ...l, evidence: [...l.evidence] }));
|
|
152
159
|
let added = 0;
|
|
153
160
|
for (const lesson of incoming) {
|
|
154
|
-
|
|
155
|
-
const otherPr = (l) => !l.evidence.some(e => lesson.evidence.some(x => x.pr === e.pr));
|
|
156
|
-
const same = lessons.find(l => l.id === lesson.id || (l.file === lesson.file && shared(l.symbols, lesson.symbols) >= 2 && otherPr(l))
|
|
157
|
-
|| (!l.file && !lesson.file && sameWords(l.text, lesson.text)));
|
|
161
|
+
const same = lessons.find(l => isSameLesson(l, lesson));
|
|
158
162
|
if (!same) {
|
|
159
163
|
lessons.push({ ...lesson, evidence: [...lesson.evidence] });
|
|
160
164
|
added++;
|
|
@@ -163,6 +167,7 @@ export function mergeLessons(existing, incoming) {
|
|
|
163
167
|
for (const e of lesson.evidence)
|
|
164
168
|
if (!same.evidence.some(x => x.comment === e.comment && x.kind === e.kind))
|
|
165
169
|
same.evidence.push(e);
|
|
170
|
+
refreshText(same, lesson);
|
|
166
171
|
same.updatedAt = lesson.updatedAt;
|
|
167
172
|
}
|
|
168
173
|
let verified = 0;
|
|
@@ -174,6 +179,49 @@ export function mergeLessons(existing, incoming) {
|
|
|
174
179
|
}
|
|
175
180
|
return { lessons, added, verified };
|
|
176
181
|
}
|
|
182
|
+
/** Whether a lesson carries the review point `point` (the same comment on the same pull request). */
|
|
183
|
+
function hasPoint(lesson, point) {
|
|
184
|
+
return lesson.evidence.some(x => x.kind === 'point' && x.pr === point.pr && x.comment === point.comment);
|
|
185
|
+
}
|
|
186
|
+
/** A person accepted, rejected or dismissed it, or approved or took back a check compiled from it. */
|
|
187
|
+
function decidedByPerson(lesson) {
|
|
188
|
+
return lesson.evidence.some(e => e.kind === 'accepted' || e.kind === 'rejected' || e.kind === 'dismissed' || e.kind === 'compiled');
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Re-read from the comment it was learned from, an undecided lesson takes the text this version derives; its id
|
|
192
|
+
* and evidence stay. One a person decided keeps the wording they decided on, and the new one waits as a
|
|
193
|
+
* suggestion for them. A later comment merged into a lesson never rewrites it.
|
|
194
|
+
*/
|
|
195
|
+
function refreshText(stored, lesson) {
|
|
196
|
+
const origin = stored.evidence.find(e => e.kind === 'point');
|
|
197
|
+
if (stored.id === lesson.id || !origin || !hasPoint(lesson, origin))
|
|
198
|
+
return;
|
|
199
|
+
if (lesson.text === stored.text) {
|
|
200
|
+
delete stored.suggestedText;
|
|
201
|
+
delete stored.suggestedWhy;
|
|
202
|
+
}
|
|
203
|
+
else if (decidedByPerson(stored)) {
|
|
204
|
+
stored.suggestedText = lesson.text;
|
|
205
|
+
stored.suggestedWhy = 'parser fix';
|
|
206
|
+
}
|
|
207
|
+
else
|
|
208
|
+
stored.text = lesson.text;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The same lesson: the same id; the same review comment read again, whatever text an earlier version took from it;
|
|
212
|
+
* shared names on another pull request (two points on one pull request are two points); or a team standard in the
|
|
213
|
+
* same words.
|
|
214
|
+
*/
|
|
215
|
+
function isSameLesson(stored, lesson) {
|
|
216
|
+
if (stored.id === lesson.id)
|
|
217
|
+
return true;
|
|
218
|
+
if (stored.evidence.some(e => e.kind === 'point' && hasPoint(lesson, e)))
|
|
219
|
+
return true;
|
|
220
|
+
const otherPr = !stored.evidence.some(e => lesson.evidence.some(x => x.pr === e.pr));
|
|
221
|
+
if (stored.file === lesson.file && shared(stored.symbols, lesson.symbols) >= 2 && otherPr)
|
|
222
|
+
return true;
|
|
223
|
+
return !stored.file && !lesson.file && sameWords(stored.text, lesson.text);
|
|
224
|
+
}
|
|
177
225
|
/**
|
|
178
226
|
* Lessons that apply to a change, best first. A lesson applies when the change
|
|
179
227
|
* touches its file, or shares at least two specific identifiers with it; a
|
|
@@ -278,6 +326,21 @@ export function decideLesson(cwd, id, decision, by, why = '') {
|
|
|
278
326
|
writeLessons(cwd, lessons);
|
|
279
327
|
return lesson;
|
|
280
328
|
}
|
|
329
|
+
/** A person takes a decided lesson's suggested wording; who and the wording it replaced are kept as evidence. */
|
|
330
|
+
export function acceptSuggestedText(cwd, id, by) {
|
|
331
|
+
const lessons = readLessons(cwd);
|
|
332
|
+
const lesson = lessons.find(l => l.id === id);
|
|
333
|
+
if (!lesson?.suggestedText)
|
|
334
|
+
return undefined;
|
|
335
|
+
const at = new Date().toISOString();
|
|
336
|
+
lesson.evidence.push({ kind: 'reworded', pr: lesson.evidence[0]?.pr ?? 0, comment: `reworded-${at}`, author: by, detail: `${lesson.suggestedWhy ?? 'reworded'}; was: ${lesson.text}`, at });
|
|
337
|
+
lesson.text = lesson.suggestedText;
|
|
338
|
+
delete lesson.suggestedText;
|
|
339
|
+
delete lesson.suggestedWhy;
|
|
340
|
+
lesson.updatedAt = at;
|
|
341
|
+
writeLessons(cwd, lessons);
|
|
342
|
+
return lesson;
|
|
343
|
+
}
|
|
281
344
|
/** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
|
|
282
345
|
export function isSpecific(symbol) {
|
|
283
346
|
return /[a-z][A-Z]|[A-Za-z]_[A-Za-z]/.test(symbol) || symbol.length >= 10;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** The bullet and numbered lines of a review body: the points a reviewer makes outside any one line. */
|
|
2
2
|
export declare function bodyPoints(body: string): string[];
|
|
3
|
-
/**
|
|
3
|
+
/** Markdown emphasis off, never the underscores of an identifier (`RESTOCK_CHUNK`) or anything inside a code span. */
|
|
4
|
+
export declare function withoutEmphasis(text: string): string;
|
|
5
|
+
/** A comment's first non-empty line, heading marks and emphasis removed, at most 140 characters. */
|
|
4
6
|
export declare function firstLine(text: string): string;
|
|
@@ -7,8 +7,13 @@ const BULLET = /^\s*(?:[-*]|\d+[.)])\s+(.{8,})$/;
|
|
|
7
7
|
export function bodyPoints(body) {
|
|
8
8
|
return body.split('\n').map(l => BULLET.exec(l)?.[1]).filter((l) => !!l).map(firstLine);
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
10
|
+
/** Markdown emphasis off, never the underscores of an identifier (`RESTOCK_CHUNK`) or anything inside a code span. */
|
|
11
|
+
export function withoutEmphasis(text) {
|
|
12
|
+
return text.split(/(`[^`]*`)/).map(part => part.startsWith('`') ? part
|
|
13
|
+
: part.replace(/\*+/g, '').replace(/(^|[^\w])__?(?=\S)(.*?\S)__?(?=[^\w]|$)/g, '$1$2')).join('');
|
|
14
|
+
}
|
|
15
|
+
/** A comment's first non-empty line, heading marks and emphasis removed, at most 140 characters. */
|
|
11
16
|
export function firstLine(text) {
|
|
12
|
-
const line = text.split('\n').find(l => l.trim())?.replace(
|
|
17
|
+
const line = withoutEmphasis(text.split('\n').find(l => l.trim())?.replace(/^\s*#+\s*/, '') ?? '').trim();
|
|
13
18
|
return line.length > 140 ? `${line.slice(0, 137)}...` : line;
|
|
14
19
|
}
|
package/dist/utils/scanner.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare class FileScanner {
|
|
|
8
8
|
private static DEFAULT_PATTERNS;
|
|
9
9
|
private static DEFAULT_IGNORE;
|
|
10
10
|
static findFiles(options: ScannerOptions): Promise<string[]>;
|
|
11
|
+
/** The selection findFiles makes, over paths listed elsewhere (a commit's files) instead of the disk. */
|
|
12
|
+
static filterPaths(paths: string[], options: Omit<ScannerOptions, 'cwd'>): string[];
|
|
11
13
|
/**
|
|
12
14
|
* Read file contents. When a FileSystemCache is provided, uses it for
|
|
13
15
|
* shared caching across gates (avoids re-loading per gate).
|
package/dist/utils/scanner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { globby } from 'globby';
|
|
2
|
+
import micromatch from 'micromatch';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import { directoriesAsGlobs, withDirectoryForms } from './glob-paths.js';
|
|
@@ -42,6 +43,12 @@ export class FileScanner {
|
|
|
42
43
|
});
|
|
43
44
|
return files.sort();
|
|
44
45
|
}
|
|
46
|
+
/** The selection findFiles makes, over paths listed elsewhere (a commit's files) instead of the disk. */
|
|
47
|
+
static filterPaths(paths, options) {
|
|
48
|
+
const patterns = (options.patterns || this.DEFAULT_PATTERNS).map(toGlobSeparators);
|
|
49
|
+
const ignore = withDirectoryForms([...new Set([...this.DEFAULT_IGNORE, ...(options.ignore || [])])].map(toGlobSeparators));
|
|
50
|
+
return paths.filter(file => micromatch.isMatch(file, patterns, { ignore })).sort();
|
|
51
|
+
}
|
|
45
52
|
/**
|
|
46
53
|
* Read file contents. When a FileSystemCache is provided, uses it for
|
|
47
54
|
* shared caching across gates (avoids re-loading per gate).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.11.
|
|
3
|
+
"version": "6.11.1-rc.2",
|
|
4
4
|
"description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.13"
|
|
@@ -75,11 +75,11 @@
|
|
|
75
75
|
"@anthropic-ai/sdk": "^0.132.1",
|
|
76
76
|
"pg": "^8.16.3",
|
|
77
77
|
"openai": "^5.23.2",
|
|
78
|
-
"@rigour-labs/brain-
|
|
79
|
-
"@rigour-labs/brain-darwin-x64": "6.11.
|
|
80
|
-
"@rigour-labs/brain-linux-
|
|
81
|
-
"@rigour-labs/brain-
|
|
82
|
-
"@rigour-labs/brain-
|
|
78
|
+
"@rigour-labs/brain-linux-arm64": "6.11.1-rc.2",
|
|
79
|
+
"@rigour-labs/brain-darwin-x64": "6.11.1-rc.2",
|
|
80
|
+
"@rigour-labs/brain-linux-x64": "6.11.1-rc.2",
|
|
81
|
+
"@rigour-labs/brain-darwin-arm64": "6.11.1-rc.2",
|
|
82
|
+
"@rigour-labs/brain-win-x64": "6.11.1-rc.2"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/fs-extra": "^11.0.4",
|