@rigour-labs/core 6.7.2 → 6.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/hooks/index.d.ts +3 -3
  2. package/dist/hooks/index.js +2 -2
  3. package/dist/hooks/session-state.d.ts +3 -0
  4. package/dist/hooks/session-state.js +10 -0
  5. package/dist/hooks/stop-review.d.ts +8 -15
  6. package/dist/hooks/stop-review.js +36 -2
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +4 -2
  9. package/dist/review/backtest-init.js +13 -11
  10. package/dist/review/backtest-init.test.js +26 -2
  11. package/dist/review/backtest.js +35 -3
  12. package/dist/review/backtest.test.js +44 -2
  13. package/dist/review/dead-code.test.js +13 -0
  14. package/dist/review/orphan-files.js +3 -2
  15. package/dist/review/quiet.d.ts +1 -1
  16. package/dist/review/quiet.js +3 -1
  17. package/dist/review/review-task.d.ts +2 -6
  18. package/dist/review/review-task.js +2 -2
  19. package/dist/review/reviewer/adapters.d.ts +21 -0
  20. package/dist/review/reviewer/adapters.js +70 -15
  21. package/dist/review/reviewer/adapters.test.js +22 -0
  22. package/dist/review/reviewer/background.test.js +1 -1
  23. package/dist/review/reviewer/context.d.ts +3 -0
  24. package/dist/review/reviewer/context.js +14 -5
  25. package/dist/review/reviewer/context.test.js +26 -1
  26. package/dist/review/reviewer/exec.d.ts +9 -0
  27. package/dist/review/reviewer/exec.js +28 -1
  28. package/dist/review/reviewer/exec.test.js +38 -2
  29. package/dist/review/reviewer/inputs.d.ts +6 -0
  30. package/dist/review/reviewer/inputs.js +26 -0
  31. package/dist/review/reviewer/judge-env.d.ts +3 -0
  32. package/dist/review/reviewer/judge-env.js +10 -0
  33. package/dist/review/reviewer/prompt.js +68 -12
  34. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  35. package/dist/review/reviewer/rule-writer.js +30 -0
  36. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  37. package/dist/review/reviewer/rule-writer.test.js +52 -0
  38. package/dist/review/reviewer/settings.d.ts +4 -0
  39. package/dist/review/reviewer/settings.js +2 -1
  40. package/dist/review/reviewer/verdict.d.ts +71 -9
  41. package/dist/review/reviewer/verdict.js +102 -10
  42. package/dist/review/reviewer.d.ts +5 -2
  43. package/dist/review/reviewer.js +56 -41
  44. package/dist/review/reviewer.test.js +170 -17
  45. package/dist/review/typed/constant-inputs.d.ts +35 -0
  46. package/dist/review/typed/constant-inputs.js +168 -0
  47. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  48. package/dist/review/typed/dead-null-guards.js +147 -0
  49. package/dist/review/typed/redundancy.js +6 -0
  50. package/dist/review/typed/redundancy.test.js +136 -0
  51. package/dist/review/unused-exports.js +3 -2
  52. package/dist/review-learning/acted-on.d.ts +23 -3
  53. package/dist/review-learning/acted-on.js +25 -13
  54. package/dist/review-learning/human-edits.d.ts +7 -0
  55. package/dist/review-learning/human-edits.js +99 -0
  56. package/dist/review-learning/human-edits.test.d.ts +1 -0
  57. package/dist/review-learning/human-edits.test.js +47 -0
  58. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  59. package/dist/review-learning/learn-from-reviews.js +128 -12
  60. package/dist/review-learning/lessons.d.ts +62 -9
  61. package/dist/review-learning/lessons.js +132 -21
  62. package/dist/review-learning/outcomes.d.ts +26 -0
  63. package/dist/review-learning/outcomes.js +73 -0
  64. package/dist/review-learning/review-learning.test.js +158 -22
  65. package/dist/review-learning/review-points.d.ts +4 -0
  66. package/dist/review-learning/review-points.js +14 -0
  67. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  68. package/dist/review-learning/rules-from-reviews.js +86 -0
  69. package/dist/review-learning/team-lessons.d.ts +17 -1
  70. package/dist/review-learning/team-lessons.js +29 -5
  71. package/dist/templates/universal-config.js +3 -0
  72. package/dist/types/index.d.ts +63 -0
  73. package/dist/types/index.js +11 -0
  74. package/dist/utils/profile.d.ts +5 -1
  75. package/dist/utils/profile.js +7 -1
  76. package/dist/utils/profile.test.js +10 -0
  77. package/package.json +6 -6
@@ -0,0 +1,147 @@
1
+ /** Filters SQL never satisfies with NULL: the column of each, as the query names it. */
2
+ const NON_NULL_FILTERS = new Set(['gt', 'gte', 'lt', 'lte', 'eq', 'in', 'like', 'ilike']);
3
+ export function deadNullGuards(typed, changedSources, h) {
4
+ const { ts, checker } = typed;
5
+ const nonNullColumns = (chain) => {
6
+ const columns = new Set();
7
+ for (const c of chain) {
8
+ const col = h.strArg(c.call, 0);
9
+ if (!col)
10
+ continue;
11
+ if (c.name === 'not' && h.strArg(c.call, 1) === 'is' && h.isNullLiteral(c.call.arguments[2]))
12
+ columns.add(col);
13
+ else if (NON_NULL_FILTERS.has(c.name) && !h.isNullLiteral(c.call.arguments[1]))
14
+ columns.add(col);
15
+ }
16
+ return columns;
17
+ };
18
+ // Every query in the program, by the row type it returns: which columns each guarantees non-null.
19
+ const byRow = new Map();
20
+ /**
21
+ * The named row types a query returns: the type itself, or each named member of an intersection
22
+ * (`Row & Record<string, unknown>`). A generic's type parameter and a library type are never a row.
23
+ */
24
+ const rowSymbols = (t) => {
25
+ if (!t || t.flags & ts.TypeFlags.TypeParameter)
26
+ return [];
27
+ if (t.isIntersection())
28
+ return t.types.flatMap(rowSymbols);
29
+ const sym = t.aliasSymbol ?? t.symbol;
30
+ const decl = sym?.declarations?.[0];
31
+ return sym && decl && !decl.getSourceFile().isDeclarationFile ? [sym] : [];
32
+ };
33
+ for (const sf of typed.appSources) {
34
+ if (!/\.(from|returns)\b|read[A-Z]\w*</.test(sf.text))
35
+ continue;
36
+ h.walk(sf, node => {
37
+ if (!ts.isCallExpression(node) || !h.outermost(node))
38
+ return;
39
+ const chain = h.chainOf(node);
40
+ if (chain.length === 0 || !startsQuery(chain))
41
+ return;
42
+ const columns = nonNullColumns(chain);
43
+ const touched = chain.some(c => h.touched(c.call.expression.name));
44
+ for (const sym of rowSymbols(h.rowTypeOf(chain, node))) {
45
+ const list = byRow.get(sym) ?? [];
46
+ list.push({ columns, touched, file: sf.fileName, node: chain[0].call });
47
+ byRow.set(sym, list);
48
+ }
49
+ });
50
+ }
51
+ // A query starts at `.from(…)`/`.select(…)` (or a bare `from(…)`); `query.or(after)` only continues one already counted.
52
+ function startsQuery(chain) {
53
+ if (chain.some(c => /^(from|select|table|selectFrom)$/.test(c.name)))
54
+ return true;
55
+ const root = chain[0].call.expression.expression;
56
+ return ts.isCallExpression(root) && ts.isIdentifier(root.expression) && /^(from|select|table)$/.test(root.expression.text);
57
+ }
58
+ for (const [sym, queries] of byRow) {
59
+ if (!sym.declarations?.length)
60
+ continue;
61
+ const type = checker.getDeclaredTypeOfSymbol(sym);
62
+ for (const prop of checker.getPropertiesOfType(type)) {
63
+ const decl = prop.declarations?.[0];
64
+ if (!decl || !ts.isPropertySignature(decl) || !decl.type)
65
+ continue;
66
+ const nullable = h.isNullable(checker.getTypeFromTypeNode(decl.type)) || !!decl.questionToken;
67
+ if (!nullable || !queries.every(q => q.columns.has(prop.name)))
68
+ continue;
69
+ if (builtWithout(typed, sym, prop.name, h))
70
+ continue;
71
+ const queryFiles = new Set(queries.filter(q => q.touched).map(q => q.file));
72
+ for (const guard of guardsOn(typed, prop, h)) {
73
+ if (!h.touched(guard.node) && !queryFiles.has(guard.node.getSourceFile().fileName))
74
+ continue;
75
+ h.report('dead-null-guard', 'Null guard on a column the query makes non-null', guard.node, `\`${guard.text}\` guards \`${prop.name}\`, but ${queries.length === 1 ? 'the only query' : `all ${queries.length} queries`} returning \`${sym.name}\` (${h.at(queries[0].node)}) filter \`${prop.name}\` non-null, so this branch never runs${guard.emptyString ? ' (it still catches an empty string; compare with \'\' if that case is real)' : ''}.`, `Delete the guard, and narrow \`${sym.name}.${prop.name}\` to its non-null type so the next reader sees the guarantee.`);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ /** A null guard on the property: `x.p ?? d`, `x.p?.y`, `x.p == null` / `!== null` / `=== undefined`, `!x.p`, `if (x.p)`, `x.p ? a : b`. */
81
+ function guardsOn(typed, prop, h) {
82
+ const { ts, checker } = typed;
83
+ const found = [];
84
+ const decl = prop.declarations?.[0];
85
+ const isProp = (access) => {
86
+ const s = checker.getSymbolAtLocation(access.name);
87
+ return !!s && (s === prop || s.declarations?.[0] === decl);
88
+ };
89
+ const isNullish = (n) => n.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(n) && n.text === 'undefined');
90
+ for (const sf of typed.appSources) {
91
+ if (!sf.text.includes(prop.name))
92
+ continue;
93
+ h.walk(sf, node => {
94
+ if (!ts.isPropertyAccessExpression(node) || node.name.text !== prop.name || !isProp(node))
95
+ return;
96
+ const p = node.parent;
97
+ let guard;
98
+ if (ts.isBinaryExpression(p) && p.left === node && p.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
99
+ guard = p;
100
+ else if (ts.isPropertyAccessExpression(p) && p.expression === node && p.questionDotToken)
101
+ guard = p;
102
+ else if (ts.isBinaryExpression(p) && [ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(p.operatorToken.kind) && (isNullish(p.left === node ? p.right : p.left)))
103
+ guard = p;
104
+ else if (ts.isPrefixUnaryExpression(p) && p.operator === ts.SyntaxKind.ExclamationToken)
105
+ guard = p;
106
+ else if ((ts.isIfStatement(p) && p.expression === node) || (ts.isConditionalExpression(p) && p.condition === node))
107
+ guard = node;
108
+ // A number or boolean column is falsy for 0 or false too: a truthiness test there carries meaning beyond null.
109
+ // A string one is falsy only for '' besides null; that is reported, with the '' case named.
110
+ let emptyString = false;
111
+ if (guard && (ts.isPrefixUnaryExpression(p) || guard === node)) {
112
+ const t = checker.getNonNullableType(checker.getTypeAtLocation(node));
113
+ if (t.flags & (ts.TypeFlags.NumberLike | ts.TypeFlags.BooleanLike | ts.TypeFlags.BigIntLike))
114
+ guard = undefined;
115
+ else if (t.flags & ts.TypeFlags.StringLike)
116
+ emptyString = true;
117
+ }
118
+ if (guard)
119
+ found.push({ node: guard, text: guard.getText().slice(0, 60), emptyString });
120
+ });
121
+ }
122
+ return found;
123
+ }
124
+ /** An object literal typed as the row that leaves the property out or sets it null: then a null row exists, and the guard is not dead. */
125
+ function builtWithout(typed, sym, name, h) {
126
+ const { ts, checker } = typed;
127
+ let built = false;
128
+ for (const sf of typed.appSources) {
129
+ if (built || !sf.text.includes(sym.name))
130
+ continue;
131
+ h.walk(sf, node => {
132
+ if (built || !ts.isObjectLiteralExpression(node))
133
+ return;
134
+ const ctx = checker.getContextualType(node);
135
+ // The row type itself, or a type built from it (`Row & Extra`, `Row | Other`).
136
+ const parts = ctx ? [ctx, ...(ctx.isUnionOrIntersection() ? ctx.types : [])] : [];
137
+ if (!parts.some(t => (t.aliasSymbol ?? t.symbol) === sym))
138
+ return;
139
+ const member = node.properties.find(p => p.name && ts.isIdentifier(p.name) && p.name.text === name);
140
+ if (node.properties.some(p => ts.isSpreadAssignment(p)))
141
+ return;
142
+ if (!member || (ts.isPropertyAssignment(member) && member.initializer.kind === ts.SyntaxKind.NullKeyword))
143
+ built = true;
144
+ });
145
+ }
146
+ return built;
147
+ }
@@ -9,6 +9,8 @@
9
9
  * write-only-property a property the hosts set and nothing reads. When the value only
10
10
  * leaves through serialisation to a callee the program does not declare
11
11
  * (a wire payload), that is a hint for the reviewer, not a block.
12
+ * Plus dead-null-guard (dead-null-guards.ts): a null guard on a column every query returning the row filters;
13
+ * constant-member and constant-argument (constant-inputs.ts): an input production never varies.
12
14
  * Plus a note, not yet proven on merged pull requests so it never blocks:
13
15
  * nullable-not-null-column the row type of `.from('t')` says `| null` for a column the
14
16
  * migrations make NOT NULL (`schema_migrations`, schema-nullability.ts).
@@ -21,6 +23,8 @@ import { emitsDeclarations } from '../package-layout.js';
21
23
  import { isTestFile } from '../test-files.js';
22
24
  import { loadProgram } from './program.js';
23
25
  import { loadSchemaNullability, tableKey } from './schema-nullability.js';
26
+ import { deadNullGuards } from './dead-null-guards.js';
27
+ import { constantInputs } from './constant-inputs.js';
24
28
  export const TYPED_CHECKS = 'typed-checks-unavailable';
25
29
  /** The typed checks on a change: nothing when it touches no TypeScript or the project has no tsconfig. */
26
30
  export function typedChecks(cwd, changedLines, config) {
@@ -147,6 +151,7 @@ function redundancyFailures(typed, changedLines, config) {
147
151
  }
148
152
  });
149
153
  }
154
+ deadNullGuards(typed, changedSources, { chainOf, outermost, rowTypeOf, strArg, isNullLiteral, isNullable, touched, at, report, walk });
150
155
  const declaredTypes = [];
151
156
  for (const sf of changedSources) {
152
157
  walk(sf, node => {
@@ -214,6 +219,7 @@ function redundancyFailures(typed, changedLines, config) {
214
219
  const stem = rel(t.node.getSourceFile()).replace(/\.[cm]?ts$/, '');
215
220
  return typed.textFiles.find(s => (word(t.name).test(s.text) || s.text.includes(stem) || s.text.includes(stem.replace(/^src\/lib\//, '$lib/'))) && new RegExp(`\\.${prop}\\b|\\{[^}]*\\b${prop}\\b[^}]*\\}`).test(s.text));
216
221
  };
222
+ constantInputs(typed, changedSources, declaredTypes, { hostsOf, touched, spanTouched, at, rel, report, walk });
217
223
  // A value of the type that leaves the program's sight: placed in a string, or passed to a callee the program does not declare.
218
224
  const escapeMemo = new Map();
219
225
  const escapes = (t) => {
@@ -119,6 +119,142 @@ export async function scan(since: string): Promise<Row[]> {
119
119
  expect(broken.typedError).toMatch(/\.generated\/tsconfig\.json.*install the dependencies/s);
120
120
  }, 60_000);
121
121
  });
122
+ describe('a null guard on a column the query makes non-null', () => {
123
+ const SESSION = `import { from } from './db';
124
+ export interface Session { id: string; ended_at: string | null; label: string | null }
125
+ export async function endedSessions(): Promise<Session[]> {
126
+ return from('sessions').not('ended_at', 'is', null).returns<Session[]>();
127
+ }
128
+ `;
129
+ const CONSUMER = `import { endedSessions } from './sessions';
130
+ export async function summary(): Promise<string[]> {
131
+ const rows = await endedSessions();
132
+ return rows.map(row => {
133
+ if (row.ended_at == null) return 'open';
134
+ const ended = row.ended_at ?? 'never';
135
+ if (!row.label) return ended;
136
+ return row.label;
137
+ });
138
+ }
139
+ `;
140
+ const guards = async () => (await review()).advisory.filter(f => f.id === 'dead-null-guard').map(f => [f.files?.[0], f.line]);
141
+ it('names each guard the filter made dead, and leaves a guard on a column no query filters alone', async () => {
142
+ write('src/sessions.ts', SESSION);
143
+ write('src/summary.ts', CONSUMER);
144
+ git('add', '-A');
145
+ git('commit', '-qm', 'sessions');
146
+ expect(await guards()).toEqual([['src/summary.ts', 5], ['src/summary.ts', 6]]);
147
+ });
148
+ it("names a truthiness test on a filtered string column with the empty-string case, and leaves a number's alone", async () => {
149
+ write('src/sessions.ts', `import { from } from './db';
150
+ export interface Visit { id: string; page: string | null; seconds: number | null }
151
+ export async function visits(): Promise<Visit[]> {
152
+ return from('visits').not('page', 'is', null).not('seconds', 'is', null).returns<Visit[]>();
153
+ }
154
+ export async function pages(): Promise<string[]> {
155
+ return (await visits()).filter(v => !!v.page && !v.seconds).map(v => v.page ?? '');
156
+ }
157
+ `);
158
+ git('add', '-A');
159
+ git('commit', '-qm', 'visits');
160
+ const result = (await review()).advisory.filter(f => f.id === 'dead-null-guard');
161
+ expect(result.map(f => f.line)).toEqual([7, 7]); // \`!v.page\` and \`v.page ?? ''\`, not \`!v.seconds\`
162
+ expect(result.some(f => /empty string/.test(f.details ?? ''))).toBe(true);
163
+ });
164
+ it('follows a query typed through a generic pager and an intersection, and ignores the pager\'s own type parameter', async () => {
165
+ write('src/pager.ts', `import { from, type Query } from './db';
166
+ export async function readAll<Row>(page: () => Query<unknown>): Promise<Row[]> {
167
+ await page();
168
+ return from('audit').returns<Row[]>(); // the helper's own query, typed by its parameter: not a row type
169
+ }
170
+ `);
171
+ write('src/sessions.ts', `import { from } from './db';
172
+ import { readAll } from './pager';
173
+ export interface Session { id: string; ended_at: string | null }
174
+ export async function endedSessions(): Promise<Session[]> {
175
+ return readAll<Session & Record<string, unknown>>(() => from('sessions').not('ended_at', 'is', null));
176
+ }
177
+ export async function summary(): Promise<string[]> {
178
+ return (await endedSessions()).map(row => row.ended_at ?? 'never');
179
+ }
180
+ `);
181
+ git('add', '-A');
182
+ git('commit', '-qm', 'pager');
183
+ expect(await guards()).toEqual([['src/sessions.ts', 8]]);
184
+ });
185
+ it('says nothing when another query returns the same rows unfiltered', async () => {
186
+ write('src/sessions.ts', `${SESSION}export async function allSessions(): Promise<Session[]> {
187
+ return from('sessions').returns<Session[]>();
188
+ }
189
+ `);
190
+ write('src/summary.ts', CONSUMER);
191
+ git('add', '-A');
192
+ git('commit', '-qm', 'sessions');
193
+ expect(await guards()).toEqual([]);
194
+ });
195
+ it('says nothing when code builds such a row with the column null', async () => {
196
+ write('src/sessions.ts', `${SESSION}export const draft: Session = { id: 'new', ended_at: null, label: null };
197
+ `);
198
+ write('src/summary.ts', CONSUMER);
199
+ git('add', '-A');
200
+ git('commit', '-qm', 'sessions');
201
+ expect(await guards()).toEqual([]);
202
+ });
203
+ });
204
+ describe('an input production never varies', () => {
205
+ const found = async (id) => (await review()).advisory.filter(f => f.id === id).map(f => [f.files?.[0], f.line]);
206
+ const commit = () => { git('add', '-A'); git('commit', '-qm', 'change'); };
207
+ it('names a member every production object sets to the same value while code branches on it; tests do not count', async () => {
208
+ write('src/jobs.ts', `export interface Job { id: string; retries: number }
209
+ export function next(job: Job): string { return job.retries > 0 ? 'retry' : 'run'; }
210
+ export const nightly: Job = { id: 'nightly', retries: 0 };
211
+ export const hourly: Job = { id: 'hourly', retries: 0 };
212
+ `);
213
+ write('src/jobs.test.ts', `import { next } from './jobs';
214
+ export const flaky = next({ id: 'flaky', retries: 3 });
215
+ `);
216
+ commit();
217
+ expect(await found('constant-member')).toEqual([['src/jobs.ts', 1]]);
218
+ });
219
+ it('says nothing when production sets the member differently', async () => {
220
+ write('src/jobs.ts', `export interface Job { id: string; retries: number }
221
+ export function next(job: Job): string { return job.retries > 0 ? 'retry' : 'run'; }
222
+ export const nightly: Job = { id: 'nightly', retries: 0 };
223
+ export const hourly: Job = { id: 'hourly', retries: 2 };
224
+ `);
225
+ commit();
226
+ expect(await found('constant-member')).toEqual([]);
227
+ });
228
+ it('names a parameter every production call passes the same literal while the body branches on it', async () => {
229
+ write('src/format.ts', `export function label(text: string, upper: boolean): string { return upper ? text.toUpperCase() : text; }
230
+ export const a = label('one', false);
231
+ export const b = label('two', false);
232
+ export function wrap(text: string, client: { send(t: string): string }): string { return client.send(text); }
233
+ `);
234
+ write('src/send.ts', `import { wrap } from './format';
235
+ const client = { send: (t: string) => t };
236
+ export const x = wrap('a', client);
237
+ export const y = wrap('b', client); // the same variable name is not the same literal
238
+ `);
239
+ write('src/format.test.ts', `import { label } from './format';
240
+ export const loud = label('three', true);
241
+ `);
242
+ commit();
243
+ expect(await found('constant-argument')).toEqual([['src/format.ts', 1]]);
244
+ });
245
+ it('says nothing when production calls vary, or the function is passed around as a value', async () => {
246
+ write('src/format.ts', `export function label(text: string, upper: boolean): string { return upper ? text.toUpperCase() : text; }
247
+ export const a = label('one', false);
248
+ export const b = label('two', true);
249
+ export function shout(text: string, loud: boolean): string { return loud ? text + '!' : text; }
250
+ export const c = shout('x', false);
251
+ export const d = shout('y', false);
252
+ export const handlers = [shout];
253
+ `);
254
+ commit();
255
+ expect(await found('constant-argument')).toEqual([]);
256
+ });
257
+ });
122
258
  describe('a nullable row type for a NOT NULL column, from migrations in another repository', () => {
123
259
  const SUPABASE = `export class Query<T> {
124
260
  select(_columns: string): Query<T> { return this; }
@@ -113,7 +113,7 @@ export function unusedExportFailures(cwd, diff, config) {
113
113
  const points = pointsAtCache(cwd);
114
114
  return candidates
115
115
  .filter(exp => ![...(users.get(exp.name) ?? [])].some(user => !isOwnFile(user, exp) && !isTestFile(user) && points(user, exp.file)))
116
- .map(unused);
116
+ .map(exp => unused(exp, !settings.block));
117
117
  }
118
118
  /**
119
119
  * In a project that emits declarations, an exported type named elsewhere in its own file is part
@@ -178,9 +178,10 @@ function filesNaming(cwd, names, excluded) {
178
178
  }
179
179
  return found;
180
180
  }
181
- function unused(exp) {
181
+ function unused(exp, advisory) {
182
182
  return {
183
183
  id: 'unused-export',
184
+ ...(advisory ? { advisory } : {}),
184
185
  title: `Unused export \`${exp.name}\``,
185
186
  details: `\`${exp.name}\` is exported but no other file uses it (a test is not a consumer). An export nothing imports is dead code that readers and agents treat as part of the module's contract.`,
186
187
  severity: 'medium',
@@ -9,17 +9,37 @@ export interface ReviewComment {
9
9
  commit: string;
10
10
  body: string;
11
11
  author: string;
12
+ source?: 'person' | 'bot';
13
+ prAuthor?: string;
14
+ /** Whether the lines it points at changed before the merge. */
15
+ actedOn?: boolean;
16
+ }
17
+ /** A review's body: where a reviewer makes the points that are not about one line. */
18
+ export interface ReviewBody {
19
+ id: string;
20
+ prNumber: number;
21
+ /** The commit the review was written on. */
22
+ commit: string;
23
+ body: string;
24
+ author: string;
25
+ source?: 'person' | 'bot';
26
+ prAuthor?: string;
12
27
  }
13
28
  export interface MergedPr {
14
29
  number: number;
15
30
  mergeSha: string;
16
31
  mergedAt: string;
17
32
  comments: ReviewComment[];
33
+ reviews: ReviewBody[];
18
34
  }
19
35
  /** git in one repository; tests substitute their own. */
20
36
  export type Git = (args: string[]) => string;
21
37
  export declare function gitIn(cwd: string): Git;
22
- /** Acted-on comments of a merged PR; comments whose commit or file cannot be found are skipped. */
23
- export declare function actedOn(git: Git, pr: MergedPr): ReviewComment[];
38
+ /** Every comment whose commit and file can be found, each marked whether its lines changed before the merge. */
39
+ export declare function withActedOn(git: Git, pr: MergedPr): ReviewComment[];
40
+ /** Files the PR changed after a review was written: acted on when there are any. Empty when the commit is unavailable. */
41
+ export declare function changedSince(git: Git, from: string, to: string): string[];
42
+ /** A changed range on the old side, with how many lines it removed and added (a pure insertion removes none). */
43
+ export type Hunk = [start: number, end: number, removed: number, added: number];
24
44
  /** Old-side line ranges that changed between two commits in one file. */
25
- export declare function changedHunks(git: Git, from: string, to: string, file: string): Array<[number, number]>;
45
+ export declare function changedHunks(git: Git, from: string, to: string, file: string): Hunk[];
@@ -2,23 +2,34 @@
2
2
  * Which review comments a developer acted on: the lines a comment points at
3
3
  * changed between the commit it was written on and the merged result.
4
4
  *
5
- * Acted-on comments are review work the team valued, whoever wrote them (a
6
- * person, a review bot, Rigour). A comment nobody acted on is not evidence of
7
- * anything and never becomes a lesson.
5
+ * Acted-on comments are review work the team valued. The learner keeps the
6
+ * people's by default (a review bot's acted-on comments are mostly one-off fixes,
7
+ * and on a busy repository they outnumber the people's many times over). A
8
+ * comment nobody acted on is not evidence of anything and never becomes a lesson.
8
9
  */
9
10
  import { execFileSync } from 'child_process';
10
11
  export function gitIn(cwd) {
11
12
  return (args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
12
13
  }
13
- /** Acted-on comments of a merged PR; comments whose commit or file cannot be found are skipped. */
14
- export function actedOn(git, pr) {
14
+ /** Every comment whose commit and file can be found, each marked whether its lines changed before the merge. */
15
+ export function withActedOn(git, pr) {
15
16
  ensureCommits(git, pr);
16
- return pr.comments.filter(comment => {
17
+ return pr.comments.flatMap(comment => {
17
18
  if (!hasFile(git, comment.commit, comment.path) || !hasFile(git, pr.mergeSha, comment.path))
18
- return false;
19
- return changedHunks(git, comment.commit, pr.mergeSha, comment.path).some(([s, e]) => s <= comment.end && comment.start <= e);
19
+ return [];
20
+ const acted = changedHunks(git, comment.commit, pr.mergeSha, comment.path).some(([s, e]) => s <= comment.end && comment.start <= e);
21
+ return [{ ...comment, actedOn: acted }];
20
22
  });
21
23
  }
24
+ /** Files the PR changed after a review was written: acted on when there are any. Empty when the commit is unavailable. */
25
+ export function changedSince(git, from, to) {
26
+ try {
27
+ return git(['diff', '--name-only', from, to]).split('\n').filter(Boolean);
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
22
33
  /** Old-side line ranges that changed between two commits in one file. */
23
34
  export function changedHunks(git, from, to, file) {
24
35
  let diff = '';
@@ -29,17 +40,18 @@ export function changedHunks(git, from, to, file) {
29
40
  return [];
30
41
  }
31
42
  const hunks = [];
32
- for (const match of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/gm)) {
43
+ for (const match of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? \+\d+(?:,(\d+))? @@/gm)) {
33
44
  const start = Number(match[1]);
34
- const count = match[2] === undefined ? 1 : Number(match[2]);
35
- // A pure insertion (count 0) sits after `start`; it touches that line.
36
- hunks.push([start, start + Math.max(count, 1) - 1]);
45
+ const removed = match[2] === undefined ? 1 : Number(match[2]);
46
+ const added = match[3] === undefined ? 1 : Number(match[3]);
47
+ // A pure insertion (removed 0) sits after `start`; it touches that line.
48
+ hunks.push([start, start + Math.max(removed, 1) - 1, removed, added]);
37
49
  }
38
50
  return hunks;
39
51
  }
40
52
  /** Commits a comment was written on may have been force-pushed away; GitHub still serves the PR ref. */
41
53
  function ensureCommits(git, pr) {
42
- const missing = [...new Set(pr.comments.map(c => c.commit))].filter(sha => !hasCommit(git, sha));
54
+ const missing = [...new Set([...pr.comments.map(c => c.commit), ...pr.reviews.map(r => r.commit)])].filter(sha => !hasCommit(git, sha));
43
55
  if (missing.length === 0)
44
56
  return;
45
57
  try {
@@ -0,0 +1,7 @@
1
+ /** Each file as the agent just left it (repository-relative paths). */
2
+ export declare function recordAgentWrites(cwd: string, files: string[]): void;
3
+ /**
4
+ * The files a person changed since the agent last wrote them, stored as candidate lessons with correction
5
+ * evidence (the rule writer turns each into the rule behind it, or finds none). Returns how many were taken.
6
+ */
7
+ export declare function captureHumanEdits(cwd: string, by?: string): number;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * A person editing what an agent wrote is the strongest lesson there is: they corrected the work
3
+ * toward what the team wants. The after-edit hook records each file as the agent left it
4
+ * (recordAgentWrites); at the stop and the push, a recorded file that now reads differently was
5
+ * changed by someone else (captureHumanEdits), and that change becomes a candidate lesson with
6
+ * correction evidence. A change that came from git (a checkout, a pull) or only moved whitespace
7
+ * is not a correction. Each edit is taken once. Everything stays in the repository's .rigour/.
8
+ */
9
+ import crypto from 'crypto';
10
+ import { execFileSync } from 'child_process';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { compactDiff } from '../review/stories.js';
14
+ import { mergeLessons, readLessons, writeLessons } from './lessons.js';
15
+ const DIR = path.join('.rigour', 'agent-writes');
16
+ const MAX_FILE_BYTES = 200_000;
17
+ /** Lines of the change kept as the lesson's words. */
18
+ const MAX_DIFF_LINES = 12;
19
+ const recordPath = (cwd, file) => path.join(cwd, DIR, `${crypto.createHash('sha256').update(file).digest('hex').slice(0, 16)}.json`);
20
+ const squash = (text) => text.replace(/\s+/g, '');
21
+ function headOf(cwd) {
22
+ try {
23
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function atHead(cwd, file) {
30
+ try {
31
+ return execFileSync('git', ['show', `HEAD:${file}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 4 * MAX_FILE_BYTES });
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /** Each file as the agent just left it (repository-relative paths). */
38
+ export function recordAgentWrites(cwd, files) {
39
+ const head = headOf(cwd);
40
+ for (const file of files) {
41
+ try {
42
+ const content = fs.readFileSync(path.join(cwd, file), 'utf8');
43
+ if (content.length > MAX_FILE_BYTES)
44
+ continue;
45
+ fs.mkdirSync(path.join(cwd, DIR), { recursive: true });
46
+ fs.writeFileSync(recordPath(cwd, file), JSON.stringify({ file, content, ...(head ? { head } : {}), at: new Date().toISOString() }));
47
+ }
48
+ catch {
49
+ // a file the agent deleted or cannot be read: nothing to compare later
50
+ }
51
+ }
52
+ }
53
+ /**
54
+ * The files a person changed since the agent last wrote them, stored as candidate lessons with correction
55
+ * evidence (the rule writer turns each into the rule behind it, or finds none). Returns how many were taken.
56
+ */
57
+ export function captureHumanEdits(cwd, by = 'a person') {
58
+ let entries = [];
59
+ try {
60
+ entries = fs.readdirSync(path.join(cwd, DIR));
61
+ }
62
+ catch {
63
+ return 0;
64
+ }
65
+ const at = new Date().toISOString();
66
+ const lessons = [];
67
+ const head = headOf(cwd);
68
+ for (const entry of entries) {
69
+ const record = path.join(cwd, DIR, entry);
70
+ let write;
71
+ let now;
72
+ try {
73
+ write = JSON.parse(fs.readFileSync(record, 'utf8'));
74
+ now = fs.readFileSync(path.join(cwd, write.file), 'utf8');
75
+ }
76
+ catch {
77
+ fs.rmSync(record, { force: true });
78
+ continue;
79
+ }
80
+ if (now === write.content)
81
+ continue;
82
+ fs.rmSync(record, { force: true }); // taken once, whatever it was
83
+ if (squash(now) === squash(write.content))
84
+ continue; // whitespace only
85
+ if (head && head !== write.head && atHead(cwd, write.file) === now)
86
+ continue; // came from git: a checkout, a pull, a merge
87
+ const diff = compactDiff(write.content, now).slice(0, MAX_DIFF_LINES).join('\n');
88
+ const text = `In ${write.file}, a person changed what the agent wrote:\n${diff}`;
89
+ lessons.push({
90
+ id: crypto.createHash('sha256').update(`${write.file}\u0000${diff}`).digest('hex').slice(0, 12),
91
+ text, file: write.file, symbols: [], state: 'candidate',
92
+ evidence: [{ kind: 'correction', pr: 0, comment: `edit-${crypto.createHash('sha256').update(`${write.file}${write.at}`).digest('hex').slice(0, 12)}`, author: by, text, detail: `edited after the agent's write of ${write.at}`, at }],
93
+ createdAt: at, updatedAt: at,
94
+ });
95
+ }
96
+ if (lessons.length)
97
+ writeLessons(cwd, mergeLessons(readLessons(cwd), lessons).lessons);
98
+ return lessons.length;
99
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
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 { captureHumanEdits, recordAgentWrites } from './human-edits.js';
7
+ import { readLessons } from './lessons.js';
8
+ let repo;
9
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
10
+ const write = (body) => fs.writeFileSync(path.join(repo, 'src/job.ts'), body);
11
+ beforeEach(() => {
12
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'human-edits-'));
13
+ git('init', '-q', '-b', 'main');
14
+ git('config', 'user.email', 't@example.com');
15
+ git('config', 'user.name', 't');
16
+ git('config', 'commit.gpgsign', 'false');
17
+ fs.mkdirSync(path.join(repo, 'src'));
18
+ write('export function job(rows) {\n return rows.map(r => r.id);\n}\n');
19
+ git('add', '-A');
20
+ git('commit', '-qm', 'base');
21
+ });
22
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
23
+ describe('a person editing what the agent wrote', () => {
24
+ it('becomes a lesson with correction evidence, taken once', () => {
25
+ write('export function job(rows) {\n return rows.slice(0, 10000).map(r => r.id);\n}\n'); // the agent's write
26
+ recordAgentWrites(repo, ['src/job.ts']);
27
+ write('export function job(rows, after) {\n return rows.filter(r => r.id > after).slice(0, 500).map(r => r.id);\n}\n'); // the person's fix
28
+ expect(captureHumanEdits(repo, 'lead@acme')).toBe(1);
29
+ const [lesson] = readLessons(repo);
30
+ expect(lesson).toMatchObject({ file: 'src/job.ts', state: 'verified', promotedBy: 'correction' });
31
+ expect(lesson.text).toContain('a person changed what the agent wrote');
32
+ expect(lesson.text).toContain('+ return rows.filter(r => r.id > after).slice(0, 500).map(r => r.id);');
33
+ expect(lesson.evidence[0]).toMatchObject({ kind: 'correction', author: 'lead@acme' });
34
+ expect(captureHumanEdits(repo, 'lead@acme')).toBe(0); // taken once
35
+ });
36
+ it('is not a correction when only whitespace moved, or the change came from git', () => {
37
+ recordAgentWrites(repo, ['src/job.ts']);
38
+ write('export function job(rows) {\n return rows.map(r => r.id);\n}\n'); // a formatter
39
+ expect(captureHumanEdits(repo)).toBe(0);
40
+ recordAgentWrites(repo, ['src/job.ts']);
41
+ git('checkout', '-q', '-b', 'other');
42
+ write('export const job = (rows) => rows.map(r => r.id);\n');
43
+ git('commit', '-qam', 'someone else\'s branch');
44
+ expect(captureHumanEdits(repo)).toBe(0); // a checkout brought it, not a person correcting the agent
45
+ expect(readLessons(repo)).toEqual([]);
46
+ });
47
+ });