@rigour-labs/core 6.7.1 → 6.7.4

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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/hooks/index.d.ts +3 -3
  3. package/dist/hooks/index.js +2 -2
  4. package/dist/hooks/session-state.d.ts +3 -0
  5. package/dist/hooks/session-state.js +10 -0
  6. package/dist/hooks/stop-review.d.ts +8 -15
  7. package/dist/hooks/stop-review.js +36 -2
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.js +4 -2
  10. package/dist/review/backtest-init.js +13 -11
  11. package/dist/review/backtest-init.test.js +26 -2
  12. package/dist/review/backtest.js +35 -3
  13. package/dist/review/backtest.test.js +44 -2
  14. package/dist/review/dead-code.test.js +13 -0
  15. package/dist/review/orphan-files.js +3 -2
  16. package/dist/review/quiet.d.ts +1 -1
  17. package/dist/review/quiet.js +3 -1
  18. package/dist/review/review-task.d.ts +2 -6
  19. package/dist/review/review-task.js +2 -2
  20. package/dist/review/review.js +19 -4
  21. package/dist/review/review.test.js +11 -0
  22. package/dist/review/reviewer/adapters.d.ts +21 -0
  23. package/dist/review/reviewer/adapters.js +70 -15
  24. package/dist/review/reviewer/adapters.test.js +22 -0
  25. package/dist/review/reviewer/background.test.js +1 -1
  26. package/dist/review/reviewer/context.d.ts +3 -0
  27. package/dist/review/reviewer/context.js +14 -5
  28. package/dist/review/reviewer/context.test.js +26 -1
  29. package/dist/review/reviewer/exec.d.ts +9 -0
  30. package/dist/review/reviewer/exec.js +28 -1
  31. package/dist/review/reviewer/exec.test.js +38 -2
  32. package/dist/review/reviewer/inputs.d.ts +6 -0
  33. package/dist/review/reviewer/inputs.js +26 -0
  34. package/dist/review/reviewer/judge-env.d.ts +3 -0
  35. package/dist/review/reviewer/judge-env.js +10 -0
  36. package/dist/review/reviewer/prompt.js +68 -12
  37. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  38. package/dist/review/reviewer/rule-writer.js +30 -0
  39. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  40. package/dist/review/reviewer/rule-writer.test.js +52 -0
  41. package/dist/review/reviewer/settings.d.ts +4 -0
  42. package/dist/review/reviewer/settings.js +2 -1
  43. package/dist/review/reviewer/verdict.d.ts +71 -9
  44. package/dist/review/reviewer/verdict.js +102 -10
  45. package/dist/review/reviewer.d.ts +5 -2
  46. package/dist/review/reviewer.js +56 -41
  47. package/dist/review/reviewer.test.js +170 -17
  48. package/dist/review/typed/constant-inputs.d.ts +35 -0
  49. package/dist/review/typed/constant-inputs.js +168 -0
  50. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  51. package/dist/review/typed/dead-null-guards.js +147 -0
  52. package/dist/review/typed/redundancy.js +6 -0
  53. package/dist/review/typed/redundancy.test.js +136 -0
  54. package/dist/review/unused-exports.js +3 -2
  55. package/dist/review-learning/acted-on.d.ts +23 -3
  56. package/dist/review-learning/acted-on.js +25 -13
  57. package/dist/review-learning/human-edits.d.ts +7 -0
  58. package/dist/review-learning/human-edits.js +99 -0
  59. package/dist/review-learning/human-edits.test.d.ts +1 -0
  60. package/dist/review-learning/human-edits.test.js +47 -0
  61. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  62. package/dist/review-learning/learn-from-reviews.js +128 -12
  63. package/dist/review-learning/lessons.d.ts +62 -9
  64. package/dist/review-learning/lessons.js +122 -20
  65. package/dist/review-learning/outcomes.d.ts +26 -0
  66. package/dist/review-learning/outcomes.js +73 -0
  67. package/dist/review-learning/review-learning.test.js +149 -22
  68. package/dist/review-learning/review-points.d.ts +4 -0
  69. package/dist/review-learning/review-points.js +14 -0
  70. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  71. package/dist/review-learning/rules-from-reviews.js +86 -0
  72. package/dist/review-learning/team-lessons.d.ts +17 -1
  73. package/dist/review-learning/team-lessons.js +29 -5
  74. package/dist/services/temporal-drift.js +1 -1
  75. package/dist/storage/db.d.ts +4 -6
  76. package/dist/storage/db.js +63 -90
  77. package/dist/storage/db.test.d.ts +1 -0
  78. package/dist/storage/db.test.js +46 -0
  79. package/dist/templates/universal-config.js +3 -0
  80. package/dist/types/index.d.ts +63 -0
  81. package/dist/types/index.js +11 -0
  82. package/dist/utils/profile.d.ts +5 -1
  83. package/dist/utils/profile.js +7 -1
  84. package/dist/utils/profile.test.js +10 -0
  85. package/package.json +8 -9
@@ -0,0 +1,168 @@
1
+ import { isTestFile } from '../test-files.js';
2
+ export function constantInputs(typed, changedSources, declared, h) {
3
+ constantMembers(typed, declared, h);
4
+ constantArguments(typed, changedSources, h);
5
+ }
6
+ /** A literal's value as text (`0`, `'a'`, `true`, `null`), or undefined for anything computed. */
7
+ function literal(ts, node) {
8
+ if (!node)
9
+ return undefined;
10
+ if (ts.isNumericLiteral(node) || ts.isStringLiteralLike(node))
11
+ return `${ts.isNumericLiteral(node) ? '' : "'"}${node.text}${ts.isNumericLiteral(node) ? '' : "'"}`;
12
+ if (node.kind === ts.SyntaxKind.TrueKeyword)
13
+ return 'true';
14
+ if (node.kind === ts.SyntaxKind.FalseKeyword)
15
+ return 'false';
16
+ if (node.kind === ts.SyntaxKind.NullKeyword)
17
+ return 'null';
18
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand))
19
+ return `-${node.operand.text}`;
20
+ return undefined;
21
+ }
22
+ /** The access is tested: an `if`/ternary/`while` condition, a comparison, `!`, `&&`/`||`, or a `switch`. */
23
+ function inCondition(ts, node) {
24
+ for (let n = node, p = node.parent; p; n = p, p = p.parent) {
25
+ if ((ts.isIfStatement(p) || ts.isWhileStatement(p) || ts.isConditionalExpression(p)) && p.expression === n)
26
+ return true;
27
+ if (ts.isConditionalExpression(p) && p.condition === n)
28
+ return true;
29
+ if (ts.isSwitchStatement(p) && p.expression === n)
30
+ return true;
31
+ if (ts.isBinaryExpression(p) && [ts.SyntaxKind.GreaterThanToken, ts.SyntaxKind.LessThanToken, ts.SyntaxKind.GreaterThanEqualsToken, ts.SyntaxKind.LessThanEqualsToken, ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(p.operatorToken.kind)) {
32
+ if (ts.isIfStatement(p.parent) || ts.isConditionalExpression(p.parent) || ts.isBinaryExpression(p.parent) || ts.isPrefixUnaryExpression(p.parent) || ts.isReturnStatement(p.parent))
33
+ return true;
34
+ continue;
35
+ }
36
+ if (ts.isPrefixUnaryExpression(p) && p.operator === ts.SyntaxKind.ExclamationToken)
37
+ continue;
38
+ if (ts.isParenthesizedExpression(p))
39
+ continue;
40
+ return false;
41
+ }
42
+ return false;
43
+ }
44
+ function constantMembers(typed, declared, h) {
45
+ const { ts, checker } = typed;
46
+ for (const t of declared) {
47
+ const hosts = h.hostsOf(t).filter(host => !isTestFile(h.rel(host.getSourceFile())));
48
+ if (hosts.length < 2 || hosts.some(host => host.properties.some(p => ts.isSpreadAssignment(p))))
49
+ continue;
50
+ for (const m of t.members) {
51
+ const name = m.name.getText();
52
+ const values = hosts.map(host => {
53
+ const p = host.properties.find(q => q.name && ts.isIdentifier(q.name) && q.name.text === name);
54
+ return p && ts.isPropertyAssignment(p) ? literal(ts, p.initializer) : undefined;
55
+ });
56
+ if (values.some(v => v === undefined) || new Set(values).size !== 1)
57
+ continue;
58
+ const anchor = h.touched(m) ? m : hosts.every(h.spanTouched) ? hosts.find(h.spanTouched) : undefined;
59
+ if (!anchor)
60
+ continue;
61
+ const sym = checker.getPropertyOfType(t.type, name);
62
+ const tested = sym && findReads(typed, sym, h).find(read => inCondition(ts, read));
63
+ if (!tested)
64
+ continue;
65
+ h.report('constant-member', 'A member every production object sets to the same value', anchor, `\`${t.name}.${name}\` is ${values[0]} in all ${hosts.length} production objects of the type (e.g. ${h.at(hosts[0])}), yet ${h.at(tested)} branches on it: the other branches never run outside tests.`, `Delete the branches and the member, or pass the real value where the objects are built.`);
66
+ }
67
+ }
68
+ }
69
+ /** Every non-test read of the property symbol. */
70
+ function findReads(typed, sym, h) {
71
+ const { ts, checker } = typed;
72
+ const decl = sym.declarations?.[0];
73
+ const reads = [];
74
+ for (const sf of typed.appSources) {
75
+ if (isTestFile(h.rel(sf)) || !sf.text.includes(sym.name))
76
+ continue;
77
+ h.walk(sf, node => {
78
+ if (!ts.isPropertyAccessExpression(node) || node.name.text !== sym.name)
79
+ return;
80
+ const s = checker.getSymbolAtLocation(node.name);
81
+ if (s && (s === sym || s.declarations?.[0] === decl))
82
+ reads.push(node);
83
+ });
84
+ }
85
+ return reads;
86
+ }
87
+ function constantArguments(typed, changedSources, h) {
88
+ const { ts, checker } = typed;
89
+ // Functions the change declared, by declaration node.
90
+ const functions = [];
91
+ for (const sf of changedSources) {
92
+ h.walk(sf, node => {
93
+ if (ts.isFunctionDeclaration(node) && node.name && node.body && node.parameters.length)
94
+ functions.push(node);
95
+ else if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) && node.parameters.length)
96
+ functions.push(node);
97
+ });
98
+ }
99
+ if (functions.length === 0)
100
+ return;
101
+ const wanted = new Set(functions);
102
+ const calls = new Map();
103
+ // Tests included: a test passing another value is the evidence a production-only constant is a test seam.
104
+ for (const sf of typed.program.getSourceFiles().filter(f => !f.isDeclarationFile && !f.fileName.includes('/node_modules/'))) {
105
+ const test = isTestFile(h.rel(sf));
106
+ h.walk(sf, node => {
107
+ if (!ts.isCallExpression(node))
108
+ return;
109
+ const decl = checker.getResolvedSignature(node)?.declaration;
110
+ if (!decl || !wanted.has(decl))
111
+ return;
112
+ const entry = calls.get(decl) ?? { prod: [], test: [] };
113
+ (test ? entry.test : entry.prod).push(node);
114
+ calls.set(decl, entry);
115
+ });
116
+ }
117
+ // A function handed around as a value (a callback, an export others call dynamically) has callers the checker cannot list.
118
+ const passedAsValue = (fn) => {
119
+ const name = ts.isFunctionDeclaration(fn) ? fn.name : ts.isVariableDeclaration(fn.parent) ? fn.parent.name : undefined;
120
+ if (!name || !ts.isIdentifier(name))
121
+ return true;
122
+ const sym = checker.getSymbolAtLocation(name);
123
+ let escaped = false;
124
+ for (const sf of typed.appSources) {
125
+ if (escaped || !sf.text.includes(name.text))
126
+ continue;
127
+ h.walk(sf, node => {
128
+ if (escaped || !ts.isIdentifier(node) || node.text !== name.text || node === name)
129
+ return;
130
+ if (checker.getSymbolAtLocation(node) !== sym)
131
+ return;
132
+ const p = node.parent;
133
+ const called = ts.isCallExpression(p) && p.expression === node;
134
+ const imported = ts.isImportSpecifier(p) || ts.isExportSpecifier(p) || ts.isImportClause(p);
135
+ if (!called && !imported)
136
+ escaped = true;
137
+ });
138
+ }
139
+ return escaped;
140
+ };
141
+ for (const fn of functions) {
142
+ const entry = calls.get(fn);
143
+ if (!entry || entry.prod.length < 2 || passedAsValue(fn) || !fn.body)
144
+ continue;
145
+ fn.parameters.forEach((param, i) => {
146
+ if (param.dotDotDotToken || !ts.isIdentifier(param.name))
147
+ return;
148
+ // Literals only: two calls passing a variable of the same name do not pass the same value.
149
+ const texts = entry.prod.map(c => (c.arguments[i] ? literal(ts, c.arguments[i]) : 'undefined'));
150
+ if (texts.some(t => t === undefined) || new Set(texts).size !== 1)
151
+ return;
152
+ // Proof of dead code: the body branches on the parameter, and production only ever takes one side.
153
+ const paramSym = checker.getSymbolAtLocation(param.name);
154
+ let tested;
155
+ h.walk(fn.body, node => {
156
+ if (!tested && ts.isIdentifier(node) && node.text === param.name.text && checker.getSymbolAtLocation(node) === paramSym && inCondition(ts, node))
157
+ tested = node;
158
+ });
159
+ if (!tested)
160
+ return;
161
+ const anchor = h.touched(param) ? param : entry.prod.every(c => h.spanTouched(c)) ? entry.prod[0] : undefined;
162
+ if (!anchor)
163
+ return;
164
+ const fnName = ts.isFunctionDeclaration(fn) ? fn.name.text : fn.parent.name.text;
165
+ h.report('constant-argument', 'A parameter every production call passes the same value', anchor, `\`${fnName}\`'s \`${param.name.text}\` is ${texts[0]} in all ${entry.prod.length} production calls (e.g. ${h.at(entry.prod[0])}), yet ${h.at(tested)} branches on it: the other branch never runs outside tests.`, `Inline the value and delete the branch, or pass the real value where it is called.`);
166
+ });
167
+ }
168
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * dead-null-guard: code that guards a column against null after the query already made it non-null.
3
+ *
4
+ * A query that filters `col` (`.not(col, 'is', null)`, or a range or equality on it, which SQL never
5
+ * matches on NULL) returns rows whose `col` is never null. A guard on `row.col` in code that reads
6
+ * those rows (`?? fallback`, `?.`, `== null`, `!row.col`) can then never fire: dead code that
7
+ * reviewers keep finding round after round, and a row type that keeps telling the next reader
8
+ * otherwise. nullable-filtered-column names the type; this names each guard.
9
+ *
10
+ * Reported only when it is certain: the row type is the one the filtered query returns (`.returns<Row[]>()`
11
+ * or `read…<Row>()`), EVERY query in the program that returns that row type filters the same column,
12
+ * and no object literal builds a row of that type with the column missing or null. A guard is
13
+ * reported on a line the change touched, or anywhere in a file whose filtered query the change touched.
14
+ */
15
+ import type * as TS from 'typescript';
16
+ import type { TypedProgram } from './program.js';
17
+ type Chain = Array<{
18
+ call: TS.CallExpression;
19
+ name: string;
20
+ }>;
21
+ export interface GuardHelpers {
22
+ chainOf: (call: TS.CallExpression) => Chain;
23
+ outermost: (call: TS.CallExpression) => boolean;
24
+ rowTypeOf: (chain: Chain, outer: TS.Node) => TS.Type | undefined;
25
+ strArg: (call: TS.CallExpression, i: number) => string | undefined;
26
+ isNullLiteral: (n: TS.Node | undefined) => boolean;
27
+ isNullable: (t: TS.Type) => boolean;
28
+ touched: (node: TS.Node) => boolean;
29
+ at: (node: TS.Node) => string;
30
+ report: (id: string, title: string, node: TS.Node, details: string, hint: string) => void;
31
+ walk: (node: TS.Node, fn: (n: TS.Node) => void) => void;
32
+ }
33
+ export declare function deadNullGuards(typed: TypedProgram, changedSources: TS.SourceFile[], h: GuardHelpers): void;
34
+ export {};
@@ -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 {