@rigour-labs/core 6.6.4 → 6.6.6

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 (70) hide show
  1. package/dist/gates/frontend-secret-exposure.d.ts +1 -1
  2. package/dist/gates/frontend-secret-exposure.js +9 -3
  3. package/dist/gates/frontend-secret-exposure.test.js +4 -1
  4. package/dist/hooks/index.d.ts +1 -1
  5. package/dist/hooks/index.js +1 -1
  6. package/dist/hooks/stop-review.d.ts +6 -9
  7. package/dist/hooks/stop-review.js +5 -10
  8. package/dist/index.d.ts +5 -2
  9. package/dist/index.js +5 -2
  10. package/dist/review/backtest-init.d.ts +8 -0
  11. package/dist/review/backtest-init.js +87 -0
  12. package/dist/review/backtest-init.test.d.ts +1 -0
  13. package/dist/review/backtest-init.test.js +74 -0
  14. package/dist/review/backtest.d.ts +315 -0
  15. package/dist/review/backtest.js +197 -0
  16. package/dist/review/backtest.test.d.ts +1 -0
  17. package/dist/review/backtest.test.js +129 -0
  18. package/dist/review/code-patterns.test.js +58 -0
  19. package/dist/review/dead-code.test.js +8 -0
  20. package/dist/review/duplicate-functions.js +7 -2
  21. package/dist/review/lint-overlay.d.ts +21 -0
  22. package/dist/review/lint-overlay.js +89 -0
  23. package/dist/review/loop-copies.d.ts +2 -0
  24. package/dist/review/loop-copies.js +116 -0
  25. package/dist/review/migration-order.js +5 -1
  26. package/dist/review/orphan-files.js +5 -1
  27. package/dist/review/partial-fixes.d.ts +2 -0
  28. package/dist/review/partial-fixes.js +138 -0
  29. package/dist/review/partial-wiring.d.ts +2 -0
  30. package/dist/review/partial-wiring.js +201 -0
  31. package/dist/review/quiet.d.ts +6 -0
  32. package/dist/review/quiet.js +12 -2
  33. package/dist/review/quiet.test.js +11 -0
  34. package/dist/review/review.d.ts +6 -0
  35. package/dist/review/review.js +12 -2
  36. package/dist/review/reviewer/adapters.d.ts +35 -0
  37. package/dist/review/reviewer/adapters.js +156 -0
  38. package/dist/review/reviewer/background.d.ts +34 -0
  39. package/dist/review/reviewer/background.js +128 -0
  40. package/dist/review/reviewer/background.test.d.ts +1 -0
  41. package/dist/review/reviewer/background.test.js +104 -0
  42. package/dist/review/reviewer/exec.d.ts +17 -0
  43. package/dist/review/reviewer/exec.js +23 -0
  44. package/dist/review/reviewer/inputs.d.ts +37 -0
  45. package/dist/review/reviewer/inputs.js +103 -0
  46. package/dist/review/reviewer/merge-impact.d.ts +2 -0
  47. package/dist/review/reviewer/merge-impact.js +64 -0
  48. package/dist/review/reviewer/prompt.d.ts +21 -0
  49. package/dist/review/reviewer/prompt.js +123 -0
  50. package/dist/review/reviewer/store.d.ts +26 -0
  51. package/dist/review/reviewer/store.js +57 -0
  52. package/dist/review/reviewer/verdict.d.ts +127 -0
  53. package/dist/review/reviewer/verdict.js +156 -0
  54. package/dist/review/reviewer.d.ts +41 -49
  55. package/dist/review/reviewer.js +210 -194
  56. package/dist/review/reviewer.test.js +199 -48
  57. package/dist/review/toolchain.d.ts +5 -2
  58. package/dist/review/toolchain.js +81 -6
  59. package/dist/review/toolchain.test.js +39 -1
  60. package/dist/review/typed/program.d.ts +27 -0
  61. package/dist/review/typed/program.js +90 -0
  62. package/dist/review/typed/redundancy.d.ts +10 -0
  63. package/dist/review/typed/redundancy.js +339 -0
  64. package/dist/review/typed/redundancy.test.d.ts +1 -0
  65. package/dist/review/typed/redundancy.test.js +121 -0
  66. package/dist/review/unused-exports.js +8 -4
  67. package/dist/templates/universal-config.js +11 -1
  68. package/dist/types/index.d.ts +128 -8
  69. package/dist/types/index.js +26 -2
  70. package/package.json +6 -6
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The project's TypeScript program, built once per review from its own tsconfig.json and its own
3
+ * `typescript` (the compiler version and options then match what the project type-checks with;
4
+ * Rigour's bundled compiler is the fallback). What the type checker knows (which literal is typed
5
+ * as T, which property is read where) is what the typed checks reason from. Files outside the
6
+ * program (.svelte, a package under its own tsconfig) are kept as text and scanned by name only,
7
+ * which can suppress a finding and never adds one.
8
+ */
9
+ import { spawnSync } from 'child_process';
10
+ import fs from 'fs';
11
+ import { createRequire } from 'module';
12
+ import path from 'path';
13
+ import ownTs from 'typescript';
14
+ import { installedBin } from '../../utils/installed-bin.js';
15
+ export const TEST_FILE = /\.(test|spec|e2e)\.[cm]?[jt]sx?$|(^|\/)(tests?|e2e|__tests__|__mocks__)\//;
16
+ const GIT_TIMEOUT_MS = 10_000;
17
+ /** The program, `skip` when this is not a TypeScript project, or `error` when it is one that cannot be loaded. */
18
+ export function loadProgram(root) {
19
+ const tsconfig = path.join(root, 'tsconfig.json');
20
+ if (!fs.existsSync(tsconfig))
21
+ return { skip: 'no tsconfig.json' };
22
+ const ts = projectTypescript(root);
23
+ let parsed = parseConfig(ts, tsconfig);
24
+ if ('error' in parsed && /\.svelte-kit\//.test(parsed.error) && syncSvelteKit(root))
25
+ parsed = parseConfig(ts, tsconfig);
26
+ if ('error' in parsed)
27
+ return { error: `tsconfig.json cannot be loaded: ${parsed.error}` };
28
+ const program = ts.createProgram({ rootNames: parsed.config.fileNames, options: { ...parsed.config.options, noEmit: true } });
29
+ const rel = (sf) => path.relative(root, sf.fileName).split(path.sep).join('/');
30
+ const sources = program.getSourceFiles().filter(sf => !sf.isDeclarationFile && !sf.fileName.includes('/node_modules/'));
31
+ const inProgram = new Set(sources.map(rel));
32
+ return {
33
+ program: {
34
+ ts, program, checker: program.getTypeChecker(), root, rel,
35
+ appSources: sources.filter(sf => !TEST_FILE.test(rel(sf))),
36
+ textFiles: textFiles(root, inProgram),
37
+ inProgram,
38
+ },
39
+ };
40
+ }
41
+ function projectTypescript(root) {
42
+ try {
43
+ return createRequire(path.join(root, 'package.json'))('typescript');
44
+ }
45
+ catch {
46
+ return ownTs;
47
+ }
48
+ }
49
+ function parseConfig(ts, tsconfig) {
50
+ try {
51
+ const config = ts.getParsedCommandLineOfConfigFile(tsconfig, {}, {
52
+ ...ts.sys,
53
+ onUnRecoverableConfigFileDiagnostic: diagnostic => {
54
+ throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
55
+ },
56
+ });
57
+ if (!config)
58
+ return { error: 'unreadable' };
59
+ // A missing `extends` target or an unknown option is a diagnostic, not an exception; tsc would fail on it too.
60
+ const errors = config.errors.filter(d => d.category === ts.DiagnosticCategory.Error).map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'));
61
+ return errors.length ? { error: errors.join('; ') } : { config };
62
+ }
63
+ catch (error) {
64
+ return { error: String(error.message ?? error) };
65
+ }
66
+ }
67
+ /** A SvelteKit tsconfig extends a generated one; `svelte-kit sync` writes it (never downloaded: the project's own binary or nothing). */
68
+ function syncSvelteKit(root) {
69
+ const bin = installedBin(root, root, 'svelte-kit');
70
+ if (!bin)
71
+ return false;
72
+ return spawnSync(bin, ['sync'], { cwd: root, stdio: 'ignore', timeout: 120_000 }).status === 0;
73
+ }
74
+ function textFiles(root, inProgram) {
75
+ const listed = spawnSync('git', ['ls-files', '--', '*.svelte', '*.ts', '*.js'], { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
76
+ if (listed.status !== 0)
77
+ return [];
78
+ const files = [];
79
+ for (const file of listed.stdout.split('\n')) {
80
+ if (!file || inProgram.has(file) || file.endsWith('.d.ts') || TEST_FILE.test(file))
81
+ continue;
82
+ try {
83
+ files.push({ file, text: fs.readFileSync(path.join(root, file), 'utf8') });
84
+ }
85
+ catch {
86
+ // deleted in the working tree
87
+ }
88
+ }
89
+ return files;
90
+ }
@@ -0,0 +1,10 @@
1
+ import type { Config, Failure } from '../../types/index.js';
2
+ export interface Redundancy {
3
+ failures: Failure[];
4
+ hints: string[];
5
+ /** A TypeScript project whose program cannot be built: the checks could not run, which is never a pass (review.ts turns it into a gate error). */
6
+ error?: string;
7
+ }
8
+ export declare const TYPED_CHECKS = "typed-checks-unavailable";
9
+ /** The typed checks on a change: nothing when it touches no TypeScript or the project has no tsconfig. */
10
+ export declare function typedChecks(cwd: string, changedLines: Record<string, Set<number>>, config: Config): Redundancy;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * What a change made redundant, found with the TypeScript checker instead of a model's attention.
3
+ * Four blocking classes, the dead code a person kept finding after every other gate was green:
4
+ * duplicate-null-filter `.not(col, 'is', null)` in a query chain that already ranges or
5
+ * equals on `col` (SQL excludes NULL there);
6
+ * nullable-filtered-column the query filters `col` non-null, the row type still says `| null`,
7
+ * so every guard on it is dead;
8
+ * optional-always-supplied an optional property every host object supplies;
9
+ * write-only-property a property the hosts set and nothing reads. When the value only
10
+ * leaves through serialisation to a callee the program does not declare
11
+ * (a wire payload), that is a hint for the reviewer, not a block.
12
+ * Plus a hint: a function that scans a collection, called once per item of another (nested-scan).
13
+ * Scoped to lines the change touched and members it declared, or whose every host it wrote; a
14
+ * pre-existing member with one touched writer is the team's backlog, not this change's.
15
+ */
16
+ import micromatch from 'micromatch';
17
+ import { emitsDeclarations } from '../package-layout.js';
18
+ import { loadProgram, TEST_FILE } from './program.js';
19
+ export const TYPED_CHECKS = 'typed-checks-unavailable';
20
+ /** The typed checks on a change: nothing when it touches no TypeScript or the project has no tsconfig. */
21
+ export function typedChecks(cwd, changedLines, config) {
22
+ if (!config.gates.redundancy?.enabled || !Object.keys(changedLines).some(file => /\.[cm]?ts$/.test(file) && !TEST_FILE.test(file)))
23
+ return { failures: [], hints: [] };
24
+ const loaded = loadProgram(cwd);
25
+ if ('skip' in loaded)
26
+ return { failures: [], hints: [] };
27
+ if ('error' in loaded)
28
+ return { failures: [], hints: [], error: `${loaded.error} (install the dependencies, and run the framework's sync if it has one)` };
29
+ return redundancyFailures(loaded.program, changedLines, config);
30
+ }
31
+ const RANGE = new Set(['gt', 'gte', 'lt', 'lte', 'eq', 'in', 'like', 'ilike']);
32
+ const MENTION_DEPTH = 3;
33
+ function redundancyFailures(typed, changedLines, config) {
34
+ const settings = config.gates.redundancy;
35
+ if (!settings?.enabled)
36
+ return { failures: [], hints: [] };
37
+ const { ts, checker, rel } = typed;
38
+ const changed = Object.keys(changedLines).filter(file => /\.[cm]?ts$/.test(file) && !file.endsWith('.d.ts') && !TEST_FILE.test(file));
39
+ const failures = [];
40
+ const hints = [];
41
+ const line = (node) => node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line + 1;
42
+ const at = (node) => `${rel(node.getSourceFile())}:${line(node)}`;
43
+ const touched = (node) => !!changedLines[rel(node.getSourceFile())]?.has(line(node));
44
+ const spanTouched = (node) => {
45
+ const set = changedLines[rel(node.getSourceFile())];
46
+ if (!set)
47
+ return false;
48
+ const sf = node.getSourceFile();
49
+ for (let l = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1; l <= sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1; l++)
50
+ if (set.has(l))
51
+ return true;
52
+ return false;
53
+ };
54
+ const report = (id, title, node, details, hint) => failures.push({ id, title, details, hint, severity: 'medium', provenance: 'traditional', files: [rel(node.getSourceFile())], line: line(node) });
55
+ const walk = (node, fn) => {
56
+ fn(node);
57
+ ts.forEachChild(node, child => walk(child, fn));
58
+ };
59
+ const strArg = (call, i) => call.arguments[i] && ts.isStringLiteralLike(call.arguments[i]) ? call.arguments[i].text : undefined;
60
+ const isNullLiteral = (n) => !!n && n.kind === ts.SyntaxKind.NullKeyword;
61
+ const changedSources = changed.map(file => typed.program.getSourceFile(`${typed.root}/${file}`)).filter((sf) => !!sf);
62
+ // A method chain `a.b(...).c(...)` as its calls, outermost last.
63
+ const chainOf = (call) => {
64
+ const calls = [];
65
+ let n = call;
66
+ while (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
67
+ calls.unshift({ call: n, name: n.expression.name.text });
68
+ n = n.expression.expression;
69
+ }
70
+ return calls;
71
+ };
72
+ const outermost = (call) => !(ts.isPropertyAccessExpression(call.parent) && ts.isCallExpression(call.parent.parent) && call.parent.parent.expression === call.parent);
73
+ const elementType = (t) => checker.isArrayType(t) ? checker.getTypeArguments(t)[0] : (t.getNumberIndexType() ?? t);
74
+ /** The row type a chain returns: `.returns<Row[]>()`, else the type argument of an enclosing `read…<Row>()` call. */
75
+ const rowTypeOf = (chain, outer) => {
76
+ const ret = chain.find(c => c.name === 'returns' && c.call.typeArguments?.length);
77
+ if (ret)
78
+ return elementType(checker.getTypeFromTypeNode(ret.call.typeArguments[0]));
79
+ for (let n = outer.parent; n; n = n.parent) {
80
+ if (ts.isCallExpression(n) && n.typeArguments?.length && ts.isIdentifier(n.expression) && /^read[A-Z]\w*$/.test(n.expression.text))
81
+ return checker.getTypeFromTypeNode(n.typeArguments[0]);
82
+ }
83
+ return undefined;
84
+ };
85
+ const isNullable = (t) => t.isUnion() && t.types.some(m => !!(m.flags & ts.TypeFlags.Null));
86
+ // duplicate-null-filter and nullable-filtered-column
87
+ for (const sf of changedSources) {
88
+ walk(sf, node => {
89
+ if (!ts.isCallExpression(node) || !outermost(node))
90
+ return;
91
+ const chain = chainOf(node);
92
+ const nots = chain.filter(c => c.name === 'not' && strArg(c.call, 1) === 'is' && isNullLiteral(c.call.arguments[2]) && strArg(c.call, 0));
93
+ if (nots.length === 0 || !spanTouched(node))
94
+ return;
95
+ for (const n of nots) {
96
+ const col = strArg(n.call, 0);
97
+ const range = chain.find(c => RANGE.has(c.name) && strArg(c.call, 0) === col);
98
+ if (range) {
99
+ report('duplicate-null-filter', 'Redundant null filter', n.call, `\`.not('${col}', 'is', null)\` is redundant next to \`.${range.name}('${col}', …)\` at ${at(range.call)}: SQL already excludes NULL there.`, 'Delete the null filter.');
100
+ }
101
+ const row = rowTypeOf(chain, node);
102
+ const prop = row && checker.getPropertyOfType(row, col);
103
+ const decl = prop?.declarations?.[0];
104
+ if (decl && ts.isPropertySignature(decl) && decl.type && isNullable(checker.getTypeFromTypeNode(decl.type))) {
105
+ // Anchored on the query the change wrote; the declaration may be in a file it did not touch.
106
+ report('nullable-filtered-column', 'Nullable type for a column the query filters non-null', n.call, `\`${col}\` is declared nullable at ${at(decl)} but this query filters it non-null, so every guard on it is dead.`, 'Narrow the row type and delete the guards on it.');
107
+ }
108
+ }
109
+ });
110
+ }
111
+ const declaredTypes = [];
112
+ for (const sf of changedSources) {
113
+ walk(sf, node => {
114
+ if (!(ts.isInterfaceDeclaration(node) || (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type))))
115
+ return;
116
+ const sym = checker.getSymbolAtLocation(node.name);
117
+ if (!sym)
118
+ return;
119
+ const members = (ts.isInterfaceDeclaration(node) ? node.members : node.type.members).filter((m) => ts.isPropertySignature(m) && ts.isIdentifier(m.name));
120
+ declaredTypes.push({ node, name: node.name.text, sym, type: checker.getDeclaredTypeOfSymbol(sym), members });
121
+ });
122
+ }
123
+ // Does a type carry T: itself, an array of it, a union or intersection member, or a property a few levels down.
124
+ const mentionMemo = new Map();
125
+ const mentionsType = (t, ty, depth = 0) => {
126
+ if (!ty || depth > MENTION_DEPTH)
127
+ return false;
128
+ if (ty.symbol === t.sym || ty.aliasSymbol === t.sym)
129
+ return true;
130
+ if (ty.aliasTypeArguments?.some(a => mentionsType(t, a, depth + 1)))
131
+ return true;
132
+ const key = `${t.sym.escapedName}|${ty.id}`;
133
+ if (mentionMemo.has(key))
134
+ return mentionMemo.get(key);
135
+ mentionMemo.set(key, false);
136
+ let hit = false;
137
+ if (checker.isArrayType(ty))
138
+ hit = mentionsType(t, checker.getTypeArguments(ty)[0], depth + 1);
139
+ else if (ty.isUnionOrIntersection())
140
+ hit = ty.types.some(m => mentionsType(t, m, depth + 1));
141
+ else if (ty.flags & ts.TypeFlags.Object && !(ty.symbol && ty.symbol.flags & ts.SymbolFlags.Function))
142
+ hit = checker.getPropertiesOfType(ty).some(p => mentionsType(t, checker.getTypeOfSymbol(p), depth + 1));
143
+ mentionMemo.set(key, hit);
144
+ return hit;
145
+ };
146
+ // Object literals the checker types as T, in non-test files that mention T by name.
147
+ const hostsOf = (t) => {
148
+ const hosts = [];
149
+ const mentions = (ty) => ty.symbol === t.sym || ty.aliasSymbol === t.sym || (ty.isUnionOrIntersection() && ty.types.some(mentions));
150
+ for (const sf of typed.appSources) {
151
+ if (!sf.text.includes(t.name))
152
+ continue;
153
+ walk(sf, node => {
154
+ if (!ts.isObjectLiteralExpression(node))
155
+ return;
156
+ const ctx = checker.getContextualType(node);
157
+ if (ctx && mentions(ctx))
158
+ hosts.push(node);
159
+ });
160
+ }
161
+ return hosts;
162
+ };
163
+ const literalHas = (lit, name) => lit.properties.some(p => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) || ts.isMethodDeclaration(p)) && !!p.name && ts.isIdentifier(p.name) && p.name.text === name);
164
+ const literalSpreads = (lit) => lit.properties.some(p => ts.isSpreadAssignment(p));
165
+ const word = (s) => new RegExp(`\\b${s.replace(/[$]/g, '\\$')}\\b`);
166
+ // An untyped file is a host when it names the type and visibly supplies a required member (a consumer only reads them).
167
+ const supplyRe = (key) => new RegExp(`(?<![.\\w])${key}\\s*:|(?<![.\\w])${key}\\s*\\([^()]*\\)\\s*\\{|[{,]\\s*${key}\\s*[,}]`);
168
+ const textHosts = (t) => {
169
+ const required = t.members.filter(m => !m.questionToken).map(m => m.name.getText());
170
+ const keys = required.length ? required : t.members.map(m => m.name.getText());
171
+ return typed.textFiles.filter(s => word(t.name).test(s.text) && keys.some(k => supplyRe(k).test(s.text)));
172
+ };
173
+ // A read in an untyped file counts only where that file names the type or imports its module.
174
+ const textReads = (t, prop) => {
175
+ const stem = rel(t.node.getSourceFile()).replace(/\.[cm]?ts$/, '');
176
+ 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));
177
+ };
178
+ // 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.
179
+ const escapeMemo = new Map();
180
+ const escapes = (t) => {
181
+ if (escapeMemo.has(t.sym))
182
+ return escapeMemo.get(t.sym);
183
+ let found;
184
+ for (const sf of typed.appSources) {
185
+ if (found)
186
+ break;
187
+ walk(sf, node => {
188
+ if (found || !ts.isExpression(node) || !node.parent)
189
+ return;
190
+ const p = node.parent;
191
+ const isArg = ts.isCallExpression(p) && p.arguments.includes(node);
192
+ const stringified = ts.isTemplateSpan(p) || (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.PlusToken);
193
+ if (!isArg && !stringified)
194
+ return;
195
+ const ty = (ts.isObjectLiteralExpression(node) && checker.getContextualType(node)) || checker.getTypeAtLocation(node);
196
+ if (!mentionsType(t, ty))
197
+ return;
198
+ if (stringified) {
199
+ found = `string building at ${at(node)}`;
200
+ return;
201
+ }
202
+ const call = p;
203
+ const sig = checker.getResolvedSignature(call);
204
+ const decl = sig?.declaration;
205
+ // Storing into a collection (events.push(e), map.set(k, v)) keeps the value typed and visible.
206
+ if (decl?.getSourceFile().isDeclarationFile && ts.isPropertyAccessExpression(call.expression)) {
207
+ const holder = checker.getTypeAtLocation(call.expression.expression);
208
+ const holderName = String(holder.symbol?.escapedName ?? holder.aliasSymbol?.escapedName ?? '');
209
+ if (checker.isArrayType(holder) || /^(Map|Set|WeakMap|WeakSet|Promise|ReadonlyMap|ReadonlySet)$/.test(holderName))
210
+ return;
211
+ }
212
+ const param = sig?.parameters[call.arguments.indexOf(node)];
213
+ const paramType = param && checker.getTypeOfSymbolAtLocation(param, call);
214
+ if (!decl || !typed.inProgram.has(rel(decl.getSourceFile())) || !mentionsType(t, paramType))
215
+ found = `${call.expression.getText().slice(0, 40)}() at ${at(node)}`;
216
+ });
217
+ }
218
+ escapeMemo.set(t.sym, found);
219
+ return found;
220
+ };
221
+ // A property is read when any non-test file accesses or destructures it; a spread only passes it on.
222
+ const isWriteTarget = (access) => ts.isBinaryExpression(access.parent) && access.parent.left === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
223
+ const isRead = (t, name) => {
224
+ const sym = checker.getPropertyOfType(t.type, name);
225
+ if (!sym)
226
+ return 'no such property';
227
+ const declaration = sym.declarations?.[0];
228
+ const isOurs = (s) => !!s && (s === sym || !!s.declarations?.some(d => d === declaration));
229
+ const ofType = (expr) => mentionsType(t, checker.getTypeAtLocation(expr));
230
+ for (const sf of typed.appSources) {
231
+ if (!sf.text.includes(name) && !sf.text.includes('...'))
232
+ continue;
233
+ let read;
234
+ walk(sf, node => {
235
+ if (read)
236
+ return;
237
+ if (ts.isPropertyAccessExpression(node) && node.name.text === name && !isWriteTarget(node) && isOurs(checker.getSymbolAtLocation(node.name)))
238
+ read = node;
239
+ else if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent) && ((node.propertyName && ts.isIdentifier(node.propertyName) && node.propertyName.text === name) || (!node.propertyName && ts.isIdentifier(node.name) && node.name.text === name)) && ofType(node.parent))
240
+ read = node;
241
+ else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression) && node.argumentExpression.text === name && ofType(node.expression))
242
+ read = node;
243
+ });
244
+ if (read)
245
+ return at(read);
246
+ }
247
+ const text = textReads(t, name);
248
+ return text ? `${text.file} (by name)` : undefined;
249
+ };
250
+ for (const t of declaredTypes) {
251
+ const hosts = hostsOf(t);
252
+ if (hosts.length === 0 && !t.members.some(m => m.questionToken))
253
+ continue;
254
+ // A declared wire contract (a payload another service reads) is read by that service: its members are never write-only here.
255
+ const wire = micromatch.isMatch(rel(t.node.getSourceFile()), settings.wire_contracts);
256
+ // In a library (declarations emitted), an exported type is read by consumers outside this program: what looks
257
+ // write-only or always-supplied here is a hint for the reviewer, not a block.
258
+ const published = !!t.node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) && emitsDeclarations(typed.root, rel(t.node.getSourceFile()));
259
+ const publish = (id, node, text) => hints.push(`${id} ${at(node)}: ${text} (an exported type of a library: consumers outside this program may read it)`);
260
+ for (const m of t.members) {
261
+ const name = m.name.getText();
262
+ const supplied = hosts.filter(h => literalHas(h, name));
263
+ const spreads = hosts.some(literalSpreads);
264
+ // The change owns the finding when it declared the member or wrote every host; it is anchored on
265
+ // a line the change touched (the member, else a host it wrote) and names the other place.
266
+ const anchor = touched(m) ? m : hosts.length && hosts.every(spanTouched) ? hosts.find(spanTouched) : undefined;
267
+ if (!anchor)
268
+ continue;
269
+ const member = `\`${t.name}.${name}\`${anchor === m ? '' : ` (${at(m)})`}`;
270
+ if (m.questionToken && !spreads) {
271
+ const outside = textHosts(t);
272
+ const all = hosts.length + outside.length;
273
+ if (all > 0 && supplied.length === hosts.length && outside.every(s => supplyRe(name).test(s.text))) {
274
+ const text = `${member} is optional but every host supplies it (${all} host(s), e.g. ${hosts[0] ? at(hosts[0]) : outside[0].file}).`;
275
+ if (published)
276
+ publish('optional-always-supplied', m, text);
277
+ else
278
+ report('optional-always-supplied', 'Optional member every host supplies', anchor, text, 'Make it required and drop the `?.` and the fallbacks on it.');
279
+ }
280
+ }
281
+ if (supplied.length && !wire && !isRead(t, name)) {
282
+ const exit = escapes(t);
283
+ if (exit)
284
+ hints.push(`write-only-property ${at(m)}: ${t.name}.${name} is set by ${supplied.length} host(s) and read by no code; the value leaves only through ${exit}. Confirm an external reader needs it, else delete it`);
285
+ else if (published)
286
+ publish('write-only-property', m, `${member} is set by ${supplied.length} host(s) and read nowhere in this program`);
287
+ else {
288
+ report('write-only-property', 'Property written and never read', anchor, `${member} is set by ${supplied.length} host(s) (e.g. ${at(supplied[0])}) and read nowhere outside tests.`, 'Delete it, or name the reader. If another service reads this type, list its file under gates.redundancy.wire_contracts.');
289
+ }
290
+ }
291
+ }
292
+ }
293
+ // Hint: a function with a collection parameter that scans it, called inside a loop or an array callback over another collection.
294
+ for (const sf of changedSources) {
295
+ const scanners = new Map();
296
+ walk(sf, node => {
297
+ if (!(ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) || !node.body)
298
+ return;
299
+ const name = ts.isFunctionDeclaration(node) ? node.name?.text : ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) ? node.parent.name.text : undefined;
300
+ if (!name)
301
+ return;
302
+ const collections = node.parameters.filter(p => ts.isIdentifier(p.name) && p.type && (ts.isArrayTypeNode(p.type) || (ts.isTypeOperatorNode(p.type) && ts.isArrayTypeNode(p.type.type)) || (ts.isTypeReferenceNode(p.type) && /^(Array|ReadonlyArray|Map|Set|Iterable)$/.test(p.type.typeName.getText())))).map(p => p.name.text);
303
+ if (collections.length === 0)
304
+ return;
305
+ let scans;
306
+ walk(node.body, n => {
307
+ if (scans)
308
+ return;
309
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && /^(find|filter|some|every|findIndex|includes|indexOf)$/.test(n.expression.name.text) && ts.isIdentifier(n.expression.expression) && collections.includes(n.expression.expression.text))
310
+ scans = n.expression.expression.text;
311
+ if (ts.isForOfStatement(n) && ts.isIdentifier(n.expression) && collections.includes(n.expression.text))
312
+ scans = n.expression.text;
313
+ });
314
+ if (scans)
315
+ scanners.set(name, scans);
316
+ });
317
+ if (scanners.size === 0)
318
+ continue;
319
+ const inLoop = (n) => {
320
+ for (let p = n.parent; p; p = p.parent) {
321
+ if (ts.isForOfStatement(p) || ts.isForStatement(p) || ts.isForInStatement(p) || ts.isWhileStatement(p))
322
+ return 'a loop';
323
+ if ((ts.isArrowFunction(p) || ts.isFunctionExpression(p)) && ts.isCallExpression(p.parent) && ts.isPropertyAccessExpression(p.parent.expression) && /^(map|flatMap|filter|forEach|some|every|find|reduce)$/.test(p.parent.expression.name.text))
324
+ return `.${p.parent.expression.name.text}() over ${p.parent.expression.expression.getText().slice(0, 40)}`;
325
+ if (ts.isFunctionDeclaration(p))
326
+ return undefined;
327
+ }
328
+ return undefined;
329
+ };
330
+ walk(sf, node => {
331
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || !scanners.has(node.expression.text))
332
+ return;
333
+ const loop = inLoop(node);
334
+ if (loop)
335
+ hints.push(`nested-scan ${at(node)}: ${node.expression.text}() scans its \`${scanners.get(node.expression.text)}\` argument and is called inside ${loop}; confirm the sizes or index the inner collection once`);
336
+ });
337
+ }
338
+ return { failures, hints };
339
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import { createRequire } from 'module';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { ConfigSchema } from '../../types/index.js';
8
+ import { reviewChange } from '../review.js';
9
+ let repo;
10
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
11
+ const write = (rel, body) => {
12
+ fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
13
+ fs.writeFileSync(path.join(repo, rel), body);
14
+ };
15
+ const config = (redundancy = {}) => ConfigSchema.parse({ version: 1, gates: { redundancy, unused_exports: { enabled: false }, orphan_files: { enabled: false } } });
16
+ const typescriptDir = path.dirname(createRequire(import.meta.url).resolve('typescript/package.json'));
17
+ const DB = `export interface Row { id: string; set_id: string | null; updated_at: string }
18
+ export class Query<T> {
19
+ not(_column: string, _operator: string, _value: unknown): Query<T> { return this; }
20
+ gte(_column: string, _value: unknown): Query<T> { return this; }
21
+ returns<R>(): Promise<R> { return Promise.resolve([] as unknown as R); }
22
+ }
23
+ export function from(_table: string): Query<unknown> { return new Query(); }
24
+ `;
25
+ beforeEach(() => {
26
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'typed-'));
27
+ git('init', '-q', '-b', 'main');
28
+ git('config', 'user.email', 't@example.com');
29
+ git('config', 'user.name', 't');
30
+ git('config', 'commit.gpgsign', 'false');
31
+ write('package.json', '{"name":"fixture","devDependencies":{"typescript":"*"}}\n');
32
+ write('tsconfig.json', '{"compilerOptions":{"strict":true,"module":"esnext","target":"es2022","moduleResolution":"bundler","skipLibCheck":true},"include":["src"]}\n');
33
+ write('.gitignore', 'node_modules\n');
34
+ fs.mkdirSync(path.join(repo, 'node_modules'));
35
+ fs.symlinkSync(typescriptDir, path.join(repo, 'node_modules/typescript'), 'junction'); // the project's own compiler, never downloaded
36
+ write('src/db.ts', DB);
37
+ write('src/main.ts', "export const main = 1;\n");
38
+ git('add', '-A');
39
+ git('commit', '-qm', 'base');
40
+ git('checkout', '-qb', 'feature');
41
+ });
42
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
43
+ const review = (cfg = config()) => reviewChange({ cwd: repo, config: cfg, source: { mode: 'base', base: 'main' }, typed: true });
44
+ describe('what a change made redundant', () => {
45
+ it('reports a null filter beside a range on the same column, and the nullable row type the query filters non-null', async () => {
46
+ write('src/scan.ts', `import { from, type Row } from './db';
47
+ export async function scan(since: string): Promise<Row[]> {
48
+ return from('t').not('set_id', 'is', null).gte('set_id', since).returns<Row[]>();
49
+ }
50
+ `);
51
+ git('add', '-A');
52
+ git('commit', '-qm', 'scan');
53
+ const result = await review();
54
+ const found = result.findings.map(f => [f.id, f.files?.[0], f.line]);
55
+ expect(found).toContainEqual(['duplicate-null-filter', 'src/scan.ts', 3]);
56
+ expect(found).toContainEqual(['nullable-filtered-column', 'src/scan.ts', 3]); // anchored on the query the change wrote
57
+ expect(result.findings.find(f => f.id === 'nullable-filtered-column')?.details).toContain('declared nullable at src/db.ts:1');
58
+ expect(result.findings.find(f => f.id === 'duplicate-null-filter')?.details).toContain("`.gte('set_id', …)` at src/scan.ts:3");
59
+ }, 60_000);
60
+ it('reports an optional member every host supplies and a property written but never read; a spread host or a read elsewhere clears them', async () => {
61
+ write('src/types.ts', 'export interface Candidate { userId?: string; title: string; extra: string }\n');
62
+ write('src/a.ts', "import type { Candidate } from './types';\nexport const a: Candidate = { userId: 'u1', title: 'one', extra: 'x' };\n");
63
+ write('src/b.ts', "import type { Candidate } from './types';\nexport const b: Candidate = { userId: 'u2', title: 'two', extra: 'y' };\n");
64
+ write('src/use.ts', "import { a } from './a';\nimport { b } from './b';\nexport const titles = [a.title, b.title];\n");
65
+ git('add', '-A');
66
+ git('commit', '-qm', 'candidates');
67
+ const result = await review();
68
+ const found = result.findings.map(f => [f.id, f.files?.[0]]);
69
+ expect(found).toContainEqual(['optional-always-supplied', 'src/types.ts']);
70
+ expect(found).toContainEqual(['write-only-property', 'src/types.ts']);
71
+ const writeOnly = result.findings.filter(f => f.id === 'write-only-property').map(f => f.details.match(/`([^`]+)`/)[1]);
72
+ expect(writeOnly).toEqual(['Candidate.userId', 'Candidate.extra']); // userId is supplied everywhere and read nowhere: both findings hold
73
+ expect(found.map(([id]) => id)).not.toContain('typed-checks-unavailable');
74
+ // Declared as a wire contract: another service reads it, so nothing on it is write-only.
75
+ const wired = await review(config({ wire_contracts: ['src/types.ts'] }));
76
+ expect(wired.findings.map(f => f.id)).not.toContain('write-only-property');
77
+ // A value that leaves only through serialisation is a hint for the reviewer, not a block.
78
+ write('src/send.ts', "import { a } from './a';\nexport const body = JSON.stringify(a);\n");
79
+ git('add', '-A');
80
+ git('commit', '-qm', 'send');
81
+ const sent = await review();
82
+ expect(sent.findings.map(f => f.id)).not.toContain('write-only-property');
83
+ expect(sent.hints).toEqual([expect.stringContaining('Candidate.userId is set by 2 host(s)'), expect.stringContaining('write-only-property src/types.ts:1: Candidate.extra')]);
84
+ expect(sent.hints[1]).toContain('leaves only through JSON.stringify() at src/send.ts:2');
85
+ }, 60_000);
86
+ it('in a library, an exported type has consumers the program cannot see: its members are hints, a local type still blocks', async () => {
87
+ write('tsconfig.json', '{"compilerOptions":{"strict":true,"declaration":true,"module":"esnext","target":"es2022","moduleResolution":"bundler","skipLibCheck":true},"include":["src"]}\n');
88
+ write('src/types.ts', 'export interface Result { items?: string[]; cached: boolean }\ninterface Local { note: string }\nexport const local: Local = { note: "n" };\n');
89
+ write('src/a.ts', "import type { Result } from './types';\nexport const a: Result = { items: [], cached: true };\n");
90
+ write('src/b.ts', "import type { Result } from './types';\nexport const b: Result = { items: [], cached: false };\n");
91
+ git('add', '-A');
92
+ git('commit', '-qm', 'library');
93
+ const result = await review();
94
+ expect(result.findings.map(f => [f.id, f.details.match(/`([^`]+)`/)[1]])).toEqual([['write-only-property', 'Local.note']]);
95
+ expect(result.hints).toEqual([
96
+ expect.stringMatching(/^optional-always-supplied src\/types\.ts:1: `Result\.items` is optional .* consumers outside this program may read it\)$/),
97
+ expect.stringMatching(/^write-only-property src\/types\.ts:1: `Result\.items` is set by 2 host\(s\)/),
98
+ expect.stringMatching(/^write-only-property src\/types\.ts:1: `Result\.cached` is set by 2 host\(s\)/),
99
+ ]);
100
+ }, 60_000);
101
+ it('hints at a function that scans a collection, called once per item of another', async () => {
102
+ write('src/later.ts', 'export function hasLater(starts: string[], at: string): boolean {\n return starts.some(s => s > at);\n}\nexport function plan(rows: string[], starts: string[]): boolean[] {\n return rows.map(r => hasLater(starts, r));\n}\n');
103
+ git('add', '-A');
104
+ git('commit', '-qm', 'later');
105
+ const result = await review();
106
+ expect(result.hints).toEqual([expect.stringMatching(/^nested-scan src\/later\.ts:5: hasLater\(\) scans its `starts` argument and is called inside \.map\(\) over rows/)]);
107
+ expect(result.findings).toEqual([]);
108
+ }, 60_000);
109
+ it('says nothing for a project with no tsconfig, and blocks a TypeScript project whose program cannot be built', async () => {
110
+ write('src/x.ts', 'export const x = 1;\n');
111
+ git('add', '-A');
112
+ git('commit', '-qm', 'x');
113
+ fs.unlinkSync(path.join(repo, 'tsconfig.json'));
114
+ expect((await review()).findings).toEqual([]);
115
+ write('tsconfig.json', '{"extends":"./.generated/tsconfig.json"}\n');
116
+ const broken = await review();
117
+ expect(broken.status).toBe('ERROR'); // a check that could not run is a crashed gate, never a pass
118
+ expect(broken.gateErrors).toEqual(['typed-checks-unavailable']);
119
+ expect(broken.typedError).toMatch(/\.generated\/tsconfig\.json.*install the dependencies/s);
120
+ }, 60_000);
121
+ });
@@ -5,8 +5,10 @@
5
5
  * dead exports pile up in agent-written code. Only exports on added lines are checked. Another
6
6
  * file uses an export when it names the symbol as a whole word AND points at the module: an import,
7
7
  * re-export, dynamic import or mock whose specifier ends in the module's name (its folder's, for an
8
- * index file). A same-named word elsewhere, such as an unrelated route parameter, is not a use. What a framework calls by convention
9
- * (SvelteKit and Next.js route modules, hooks, serverless functions) is never reported.
8
+ * index file). A same-named word elsewhere, such as an unrelated route parameter, is not a use, and
9
+ * neither is a test: an export only its tests import is dead in production, and exporting a thing
10
+ * for its test is the habit that keeps it so. What a framework calls by convention (SvelteKit and
11
+ * Next.js route modules, hooks, serverless functions) is never reported.
10
12
  */
11
13
  import { spawnSync } from 'child_process';
12
14
  import fs from 'fs';
@@ -15,6 +17,8 @@ import { emitsDeclarations, isPackageEntry } from './package-layout.js';
15
17
  const GIT_TIMEOUT_MS = 10_000;
16
18
  const CODE = /\.(ts|tsx|js|jsx|mjs|svelte)$/;
17
19
  const SKIPPED = /\.(test|spec)\.|\.d\.ts$|(^|\/)(dist|build|out|coverage|\.next|[\w-]+-dist)\//;
20
+ /** A test is not a consumer: production code must use the export. */
21
+ const TEST = /\.(test|spec|e2e)\.[cm]?[jt]sx?$|(^|\/)(tests?|e2e|__tests__|__mocks__)\//;
18
22
  /** What a route or hook module exports for its framework, not for an importer. */
19
23
  const FRAMEWORK_EXPORTS = new Set([
20
24
  'load', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'fallback', 'actions',
@@ -109,7 +113,7 @@ export function unusedExportFailures(cwd, diff, config) {
109
113
  return []; // git could not answer: say nothing rather than guess
110
114
  const points = pointsAtCache(cwd);
111
115
  return candidates
112
- .filter(exp => ![...(users.get(exp.name) ?? [])].some(user => !isOwnFile(user, exp) && points(user, exp.file)))
116
+ .filter(exp => ![...(users.get(exp.name) ?? [])].some(user => !isOwnFile(user, exp) && !TEST.test(user) && points(user, exp.file)))
113
117
  .map(unused);
114
118
  }
115
119
  /**
@@ -179,7 +183,7 @@ function unused(exp) {
179
183
  return {
180
184
  id: 'unused-export',
181
185
  title: 'Unused export',
182
- details: `\`${exp.name}\` is exported but no other file uses it. An export nothing imports is dead code that readers and agents treat as part of the module's contract.`,
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.`,
183
187
  severity: 'medium',
184
188
  provenance: 'traditional',
185
189
  files: [exp.file],
@@ -255,6 +255,13 @@ export const UNIVERSAL_CONFIG = {
255
255
  optional_params: {
256
256
  enabled: true,
257
257
  },
258
+ change_sweep: {
259
+ enabled: true,
260
+ },
261
+ redundancy: {
262
+ enabled: true,
263
+ wire_contracts: [],
264
+ },
258
265
  duplicate_functions: {
259
266
  enabled: true,
260
267
  },
@@ -314,7 +321,10 @@ export const UNIVERSAL_CONFIG = {
314
321
  show_preexisting: false,
315
322
  reviewer: {
316
323
  enabled: false,
317
- command: 'claude',
324
+ on_push: 'background',
325
+ reviewers: ['claude'],
326
+ mode: 'single',
327
+ models: {},
318
328
  timeout_ms: 900000,
319
329
  },
320
330
  },