@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.
@@ -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: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/*.d.ts'],
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
- const fresh = lessons.filter(l => !l.evidence.every(e => known.has(e.comment)));
43
- const merged = mergeLessons(readLessons(cwd), fresh);
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
- /** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
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, or the same
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
- /** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
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 text = bold || cleaned.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s/)[0] || '';
84
- return pointText(text.replace(/[*_]/g, '')).slice(0, MAX_TEXT).trim();
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.replace(/[*_]/g, '')).slice(0, MAX_TEXT).trim();
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, or the same
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
- // Shared names find the same point made again on another pull request; two points on one pull request are two points.
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
- /** A comment's first non-empty line, markup removed, at most 140 characters. */
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
- /** A comment's first non-empty line, markup removed, at most 140 characters. */
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(/[*_`#]/g, '').trim() ?? '';
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
  }
@@ -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).
@@ -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.0",
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-darwin-arm64": "6.11.0",
79
- "@rigour-labs/brain-darwin-x64": "6.11.0",
80
- "@rigour-labs/brain-linux-arm64": "6.11.0",
81
- "@rigour-labs/brain-win-x64": "6.11.0",
82
- "@rigour-labs/brain-linux-x64": "6.11.0"
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",