@rigour-labs/core 6.7.2 → 6.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/index.d.ts +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/session-state.d.ts +3 -0
- package/dist/hooks/session-state.js +10 -0
- package/dist/hooks/stop-review.d.ts +8 -15
- package/dist/hooks/stop-review.js +36 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/review/backtest-init.js +13 -11
- package/dist/review/backtest-init.test.js +26 -2
- package/dist/review/backtest.js +35 -3
- package/dist/review/backtest.test.js +44 -2
- package/dist/review/dead-code.test.js +13 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/quiet.d.ts +1 -1
- package/dist/review/quiet.js +3 -1
- package/dist/review/review-task.d.ts +2 -6
- package/dist/review/review-task.js +2 -2
- package/dist/review/reviewer/adapters.d.ts +21 -0
- package/dist/review/reviewer/adapters.js +70 -15
- package/dist/review/reviewer/adapters.test.js +22 -0
- package/dist/review/reviewer/background.test.js +1 -1
- package/dist/review/reviewer/context.d.ts +3 -0
- package/dist/review/reviewer/context.js +14 -5
- package/dist/review/reviewer/context.test.js +26 -1
- package/dist/review/reviewer/exec.d.ts +9 -0
- package/dist/review/reviewer/exec.js +28 -1
- package/dist/review/reviewer/exec.test.js +38 -2
- package/dist/review/reviewer/inputs.d.ts +6 -0
- package/dist/review/reviewer/inputs.js +26 -0
- package/dist/review/reviewer/judge-env.d.ts +3 -0
- package/dist/review/reviewer/judge-env.js +10 -0
- package/dist/review/reviewer/prompt.js +68 -12
- package/dist/review/reviewer/rule-writer.d.ts +10 -0
- package/dist/review/reviewer/rule-writer.js +30 -0
- package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
- package/dist/review/reviewer/rule-writer.test.js +52 -0
- package/dist/review/reviewer/settings.d.ts +4 -0
- package/dist/review/reviewer/settings.js +2 -1
- package/dist/review/reviewer/verdict.d.ts +71 -9
- package/dist/review/reviewer/verdict.js +102 -10
- package/dist/review/reviewer.d.ts +5 -2
- package/dist/review/reviewer.js +56 -41
- package/dist/review/reviewer.test.js +170 -17
- package/dist/review/typed/constant-inputs.d.ts +35 -0
- package/dist/review/typed/constant-inputs.js +168 -0
- package/dist/review/typed/dead-null-guards.d.ts +34 -0
- package/dist/review/typed/dead-null-guards.js +147 -0
- package/dist/review/typed/redundancy.js +6 -0
- package/dist/review/typed/redundancy.test.js +136 -0
- package/dist/review/unused-exports.js +3 -2
- package/dist/review-learning/acted-on.d.ts +23 -3
- package/dist/review-learning/acted-on.js +25 -13
- package/dist/review-learning/human-edits.d.ts +7 -0
- package/dist/review-learning/human-edits.js +99 -0
- package/dist/review-learning/human-edits.test.d.ts +1 -0
- package/dist/review-learning/human-edits.test.js +47 -0
- package/dist/review-learning/learn-from-reviews.d.ts +37 -4
- package/dist/review-learning/learn-from-reviews.js +128 -12
- package/dist/review-learning/lessons.d.ts +62 -9
- package/dist/review-learning/lessons.js +132 -21
- package/dist/review-learning/outcomes.d.ts +26 -0
- package/dist/review-learning/outcomes.js +73 -0
- package/dist/review-learning/review-learning.test.js +158 -22
- package/dist/review-learning/review-points.d.ts +4 -0
- package/dist/review-learning/review-points.js +14 -0
- package/dist/review-learning/rules-from-reviews.d.ts +14 -0
- package/dist/review-learning/rules-from-reviews.js +86 -0
- package/dist/review-learning/team-lessons.d.ts +17 -1
- package/dist/review-learning/team-lessons.js +29 -5
- package/dist/templates/universal-config.js +3 -0
- package/dist/types/index.d.ts +63 -0
- package/dist/types/index.js +11 -0
- package/dist/utils/profile.d.ts +5 -1
- package/dist/utils/profile.js +7 -1
- package/dist/utils/profile.test.js +10 -0
- package/package.json +6 -6
|
@@ -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
|
-
/**
|
|
23
|
-
export declare function
|
|
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):
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
14
|
-
export function
|
|
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.
|
|
17
|
+
return pr.comments.flatMap(comment => {
|
|
17
18
|
if (!hasFile(git, comment.commit, comment.path) || !hasFile(git, pr.mergeSha, comment.path))
|
|
18
|
-
return
|
|
19
|
-
|
|
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+(
|
|
43
|
+
for (const match of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? \+\d+(?:,(\d+))? @@/gm)) {
|
|
33
44
|
const start = Number(match[1]);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
});
|