@rigour-labs/core 6.6.5 → 6.7.0
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/README.md +16 -92
- package/dist/environment.test.js +0 -4
- package/dist/gates/agent-team.d.ts +0 -2
- package/dist/gates/agent-team.js +0 -8
- package/dist/gates/checkpoint.d.ts +0 -1
- package/dist/gates/checkpoint.js +0 -1
- package/dist/gates/context.d.ts +0 -4
- package/dist/gates/context.js +7 -17
- package/dist/gates/deep-analysis.d.ts +0 -1
- package/dist/gates/deep-runner.js +0 -1
- package/dist/gates/frontend-secret-exposure.d.ts +1 -1
- package/dist/gates/frontend-secret-exposure.js +9 -3
- package/dist/gates/frontend-secret-exposure.test.js +6 -3
- package/dist/gates/hallucinated-imports/index.d.ts +0 -2
- package/dist/gates/hallucinated-imports/index.js +2 -2
- package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
- package/dist/gates/hallucinated-imports.test.js +10 -0
- package/dist/gates/runner.js +3 -4
- package/dist/gates/test-quality-matchers.d.ts +0 -4
- package/dist/gates/test-quality-matchers.js +0 -7
- package/dist/gates/test-quality.d.ts +0 -2
- package/dist/gates/test-quality.js +1 -10
- package/dist/hooks/checker.js +1 -1
- package/dist/hooks/index.d.ts +2 -3
- package/dist/hooks/index.js +1 -2
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/hooks/types.d.ts +0 -14
- package/dist/hooks/types.js +1 -15
- package/dist/index.d.ts +11 -3
- package/dist/index.js +11 -3
- package/dist/inference/cloud-provider.d.ts +2 -0
- package/dist/inference/cloud-provider.js +16 -6
- package/dist/inference/cloud-provider.test.js +21 -0
- package/dist/pattern-index/embeddings.d.ts +0 -5
- package/dist/pattern-index/embeddings.js +11 -4
- package/dist/pattern-index/semantic-runtime.d.ts +10 -0
- package/dist/pattern-index/semantic-runtime.js +46 -0
- package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
- package/dist/pattern-index/semantic-runtime.test.js +31 -0
- package/dist/review/backtest-init.d.ts +8 -0
- package/dist/review/backtest-init.js +87 -0
- package/dist/review/backtest-init.test.d.ts +1 -0
- package/dist/review/backtest-init.test.js +74 -0
- package/dist/review/backtest-judges.d.ts +38 -0
- package/dist/review/backtest-judges.js +63 -0
- package/dist/review/backtest-judges.test.d.ts +1 -0
- package/dist/review/backtest-judges.test.js +32 -0
- package/dist/review/backtest.d.ts +320 -0
- package/dist/review/backtest.js +207 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/baseline.js +2 -1
- package/dist/review/dead-code.test.js +15 -0
- package/dist/review/fix-scope.d.ts +21 -0
- package/dist/review/fix-scope.js +94 -0
- package/dist/review/fix-scope.test.d.ts +1 -0
- package/dist/review/fix-scope.test.js +104 -0
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/partial-wiring.js +5 -3
- package/dist/review/query-patterns.js +3 -2
- package/dist/review/quiet.d.ts +6 -0
- package/dist/review/quiet.js +12 -2
- package/dist/review/quiet.test.js +11 -0
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +10 -2
- package/dist/review/reviewer/adapters.d.ts +51 -0
- package/dist/review/reviewer/adapters.js +181 -0
- package/dist/review/reviewer/adapters.test.d.ts +1 -0
- package/dist/review/reviewer/adapters.test.js +23 -0
- package/dist/review/reviewer/background.d.ts +41 -0
- package/dist/review/reviewer/background.js +153 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +118 -0
- package/dist/review/reviewer/consensus.d.ts +36 -0
- package/dist/review/reviewer/consensus.js +144 -0
- package/dist/review/reviewer/context.d.ts +75 -0
- package/dist/review/reviewer/context.js +140 -0
- package/dist/review/reviewer/context.test.d.ts +1 -0
- package/dist/review/reviewer/context.test.js +18 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +27 -0
- package/dist/review/reviewer/exec.test.d.ts +1 -0
- package/dist/review/reviewer/exec.test.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +39 -0
- package/dist/review/reviewer/inputs.js +107 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/panel.d.ts +67 -0
- package/dist/review/reviewer/panel.js +101 -0
- package/dist/review/reviewer/panel.test.d.ts +1 -0
- package/dist/review/reviewer/panel.test.js +110 -0
- package/dist/review/reviewer/prompt.d.ts +32 -0
- package/dist/review/reviewer/prompt.js +145 -0
- package/dist/review/reviewer/settings.d.ts +58 -0
- package/dist/review/reviewer/settings.js +152 -0
- package/dist/review/reviewer/settings.test.d.ts +1 -0
- package/dist/review/reviewer/settings.test.js +57 -0
- package/dist/review/reviewer/store.d.ts +49 -0
- package/dist/review/reviewer/store.js +99 -0
- package/dist/review/reviewer/usage.d.ts +2 -0
- package/dist/review/reviewer/usage.js +28 -0
- package/dist/review/reviewer/usage.test.d.ts +1 -0
- package/dist/review/reviewer/usage.test.js +14 -0
- package/dist/review/reviewer/verdict.d.ts +141 -0
- package/dist/review/reviewer/verdict.js +166 -0
- package/dist/review/reviewer.d.ts +84 -49
- package/dist/review/reviewer.js +364 -195
- package/dist/review/reviewer.test.js +316 -50
- package/dist/review/test-files.d.ts +5 -1
- package/dist/review/test-files.js +6 -2
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +89 -7
- package/dist/review/toolchain.test.js +45 -4
- package/dist/review/typed/program.d.ts +26 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +378 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +179 -0
- package/dist/review/typed/schema-nullability.d.ts +6 -0
- package/dist/review/typed/schema-nullability.js +218 -0
- package/dist/review/typed/schema-nullability.test.d.ts +1 -0
- package/dist/review/typed/schema-nullability.test.js +63 -0
- package/dist/review/unused-exports.js +10 -7
- package/dist/semantic/learn/types.d.ts +2 -2
- package/dist/services/adaptive-thresholds.d.ts +2 -52
- package/dist/services/adaptive-thresholds.js +2 -147
- package/dist/services/adaptive-thresholds.test.js +1 -81
- package/dist/settings.d.ts +14 -6
- package/dist/settings.js +0 -12
- package/dist/storage/team-diagnostics.js +1 -1
- package/dist/storage/team-schema.d.ts +1 -1
- package/dist/storage/team-schema.js +2 -0
- package/dist/storage/team-store.js +26 -3
- package/dist/storage/team-sync-scope.test.js +35 -2
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +11 -1
- package/dist/templates/paradigms.js +0 -8
- package/dist/templates/presets.d.ts +0 -1
- package/dist/templates/presets.js +0 -23
- package/dist/templates/universal-config.js +16 -46
- package/dist/types/index.d.ts +202 -574
- package/dist/types/index.js +64 -67
- package/dist/types/index.test.d.ts +1 -0
- package/dist/types/index.test.js +33 -0
- package/dist/utils/command-line.d.ts +8 -0
- package/dist/utils/command-line.js +8 -0
- package/dist/utils/command-line.test.d.ts +1 -0
- package/dist/utils/command-line.test.js +8 -0
- package/dist/utils/profile.js +1 -1
- package/package.json +6 -7
|
@@ -0,0 +1,378 @@
|
|
|
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 note, not yet proven on merged pull requests so it never blocks:
|
|
13
|
+
* nullable-not-null-column the row type of `.from('t')` says `| null` for a column the
|
|
14
|
+
* migrations make NOT NULL (`schema_migrations`, schema-nullability.ts).
|
|
15
|
+
* Plus a hint: a function that scans a collection, called once per item of another (nested-scan).
|
|
16
|
+
* Scoped to lines the change touched and members it declared, or whose every host it wrote; a
|
|
17
|
+
* pre-existing member with one touched writer is the team's backlog, not this change's.
|
|
18
|
+
*/
|
|
19
|
+
import micromatch from 'micromatch';
|
|
20
|
+
import { emitsDeclarations } from '../package-layout.js';
|
|
21
|
+
import { isTestFile } from '../test-files.js';
|
|
22
|
+
import { loadProgram } from './program.js';
|
|
23
|
+
import { loadSchemaNullability, tableKey } from './schema-nullability.js';
|
|
24
|
+
export const TYPED_CHECKS = 'typed-checks-unavailable';
|
|
25
|
+
/** The typed checks on a change: nothing when it touches no TypeScript or the project has no tsconfig. */
|
|
26
|
+
export function typedChecks(cwd, changedLines, config) {
|
|
27
|
+
if (!config.gates.redundancy?.enabled || !Object.keys(changedLines).some(file => /\.[cm]?ts$/.test(file) && !isTestFile(file)))
|
|
28
|
+
return { failures: [], hints: [] };
|
|
29
|
+
const loaded = loadProgram(cwd);
|
|
30
|
+
if ('skip' in loaded)
|
|
31
|
+
return { failures: [], hints: [] };
|
|
32
|
+
if ('error' in loaded)
|
|
33
|
+
return { failures: [], hints: [], error: `${loaded.error} (install the dependencies, and run the framework's sync if it has one)` };
|
|
34
|
+
return redundancyFailures(loaded.program, changedLines, config);
|
|
35
|
+
}
|
|
36
|
+
const RANGE = new Set(['gt', 'gte', 'lt', 'lte', 'eq', 'in', 'like', 'ilike']);
|
|
37
|
+
const MENTION_DEPTH = 3;
|
|
38
|
+
function redundancyFailures(typed, changedLines, config) {
|
|
39
|
+
const settings = config.gates.redundancy;
|
|
40
|
+
if (!settings?.enabled)
|
|
41
|
+
return { failures: [], hints: [] };
|
|
42
|
+
const { ts, checker, rel } = typed;
|
|
43
|
+
const changed = Object.keys(changedLines).filter(file => /\.[cm]?ts$/.test(file) && !file.endsWith('.d.ts') && !isTestFile(file));
|
|
44
|
+
const failures = [];
|
|
45
|
+
const hints = [];
|
|
46
|
+
const line = (node) => node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
|
47
|
+
const at = (node) => `${rel(node.getSourceFile())}:${line(node)}`;
|
|
48
|
+
const touched = (node) => !!changedLines[rel(node.getSourceFile())]?.has(line(node));
|
|
49
|
+
const spanTouched = (node) => {
|
|
50
|
+
const set = changedLines[rel(node.getSourceFile())];
|
|
51
|
+
if (!set)
|
|
52
|
+
return false;
|
|
53
|
+
const sf = node.getSourceFile();
|
|
54
|
+
for (let l = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1; l <= sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1; l++)
|
|
55
|
+
if (set.has(l))
|
|
56
|
+
return true;
|
|
57
|
+
return false;
|
|
58
|
+
};
|
|
59
|
+
const report = (id, title, node, details, hint) => failures.push({ id, title, details, hint, severity: 'medium', provenance: 'traditional', files: [rel(node.getSourceFile())], line: line(node) });
|
|
60
|
+
const walk = (node, fn) => {
|
|
61
|
+
fn(node);
|
|
62
|
+
ts.forEachChild(node, child => walk(child, fn));
|
|
63
|
+
};
|
|
64
|
+
const strArg = (call, i) => call.arguments[i] && ts.isStringLiteralLike(call.arguments[i]) ? call.arguments[i].text : undefined;
|
|
65
|
+
const isNullLiteral = (n) => !!n && n.kind === ts.SyntaxKind.NullKeyword;
|
|
66
|
+
const changedSources = changed.map(file => typed.program.getSourceFile(`${typed.root}/${file}`)).filter((sf) => !!sf);
|
|
67
|
+
// A method chain `a.b(...).c(...)` as its calls, outermost last.
|
|
68
|
+
const chainOf = (call) => {
|
|
69
|
+
const calls = [];
|
|
70
|
+
let n = call;
|
|
71
|
+
while (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
|
|
72
|
+
calls.unshift({ call: n, name: n.expression.name.text });
|
|
73
|
+
n = n.expression.expression;
|
|
74
|
+
}
|
|
75
|
+
return calls;
|
|
76
|
+
};
|
|
77
|
+
const outermost = (call) => !(ts.isPropertyAccessExpression(call.parent) && ts.isCallExpression(call.parent.parent) && call.parent.parent.expression === call.parent);
|
|
78
|
+
const elementType = (t) => checker.isArrayType(t) ? checker.getTypeArguments(t)[0] : (t.getNumberIndexType() ?? t);
|
|
79
|
+
/** The row type a chain returns: `.returns<Row[]>()`, else the type argument of an enclosing `read…<Row>()` call. */
|
|
80
|
+
const rowTypeOf = (chain, outer) => {
|
|
81
|
+
const ret = chain.find(c => c.name === 'returns' && c.call.typeArguments?.length);
|
|
82
|
+
if (ret)
|
|
83
|
+
return elementType(checker.getTypeFromTypeNode(ret.call.typeArguments[0]));
|
|
84
|
+
for (let n = outer.parent; n; n = n.parent) {
|
|
85
|
+
if (ts.isCallExpression(n) && n.typeArguments?.length && ts.isIdentifier(n.expression) && /^read[A-Z]\w*$/.test(n.expression.text))
|
|
86
|
+
return checker.getTypeFromTypeNode(n.typeArguments[0]);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
};
|
|
90
|
+
const isNullable = (t) => t.isUnion() && t.types.some(m => !!(m.flags & ts.TypeFlags.Null));
|
|
91
|
+
// nullable-not-null-column: read once, and only when a changed file queries a table.
|
|
92
|
+
let schema;
|
|
93
|
+
const reportedDecls = new Set();
|
|
94
|
+
const notNullColumns = (chain, outer, filtered) => {
|
|
95
|
+
const from = chain.find(c => c.name === 'from' && strArg(c.call, 0));
|
|
96
|
+
if (!from)
|
|
97
|
+
return;
|
|
98
|
+
const schemaCall = chain.find(c => c.name === 'schema' && strArg(c.call, 0));
|
|
99
|
+
const table = `${schemaCall ? `${strArg(schemaCall.call, 0)}.` : ''}${strArg(from.call, 0)}`;
|
|
100
|
+
schema ??= loadSchemaNullability(typed.root, settings.schema_migrations);
|
|
101
|
+
const columns = schema.get(tableKey(table));
|
|
102
|
+
const row = columns && rowTypeOf(chain, outer);
|
|
103
|
+
if (!columns || !row)
|
|
104
|
+
return;
|
|
105
|
+
const select = chain.find(c => c.name === 'select');
|
|
106
|
+
const selected = select ? strArg(select.call, 0) ?? '' : '';
|
|
107
|
+
// The query's own line the change touched: a call's start is the start of the whole chain, so its method name.
|
|
108
|
+
const queryLine = chain.map(c => c.call.expression.name).find(touched);
|
|
109
|
+
for (const prop of checker.getPropertiesOfType(row)) {
|
|
110
|
+
const decl = prop.declarations?.[0];
|
|
111
|
+
const name = prop.name;
|
|
112
|
+
if (!decl || !ts.isPropertySignature(decl) || !decl.type || reportedDecls.has(decl) || filtered.has(name))
|
|
113
|
+
continue;
|
|
114
|
+
// An alias (`name:other_column`) maps another column onto this property.
|
|
115
|
+
if (columns.get(name) !== true || new RegExp(`(^|[\\s,(])${name.replace(/[$]/g, '\\$')}\\s*:`).test(selected) || !isNullable(checker.getTypeFromTypeNode(decl.type)))
|
|
116
|
+
continue;
|
|
117
|
+
const anchor = touched(decl) ? decl : queryLine;
|
|
118
|
+
if (!anchor)
|
|
119
|
+
continue;
|
|
120
|
+
reportedDecls.add(decl);
|
|
121
|
+
report('nullable-not-null-column', 'Nullable type for a NOT NULL column', anchor, `\`${name}\` is declared nullable at ${at(decl)} but \`${table}.${name}\` is NOT NULL in the migrations, so every guard on it is dead.`, 'Narrow the row type and delete the guards on it. If the migrations Rigour read are not the ones this code runs against, point gates.redundancy.schema_migrations at them.');
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
// duplicate-null-filter, nullable-filtered-column and nullable-not-null-column
|
|
125
|
+
for (const sf of changedSources) {
|
|
126
|
+
walk(sf, node => {
|
|
127
|
+
if (!ts.isCallExpression(node) || !outermost(node))
|
|
128
|
+
return;
|
|
129
|
+
const chain = chainOf(node);
|
|
130
|
+
const nots = chain.filter(c => c.name === 'not' && strArg(c.call, 1) === 'is' && isNullLiteral(c.call.arguments[2]) && strArg(c.call, 0));
|
|
131
|
+
notNullColumns(chain, node, new Set(nots.map(n => strArg(n.call, 0))));
|
|
132
|
+
if (nots.length === 0 || !spanTouched(node))
|
|
133
|
+
return;
|
|
134
|
+
for (const n of nots) {
|
|
135
|
+
const col = strArg(n.call, 0);
|
|
136
|
+
const range = chain.find(c => RANGE.has(c.name) && strArg(c.call, 0) === col);
|
|
137
|
+
if (range) {
|
|
138
|
+
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.');
|
|
139
|
+
}
|
|
140
|
+
const row = rowTypeOf(chain, node);
|
|
141
|
+
const prop = row && checker.getPropertyOfType(row, col);
|
|
142
|
+
const decl = prop?.declarations?.[0];
|
|
143
|
+
if (decl && ts.isPropertySignature(decl) && decl.type && isNullable(checker.getTypeFromTypeNode(decl.type))) {
|
|
144
|
+
// Anchored on the query the change wrote; the declaration may be in a file it did not touch.
|
|
145
|
+
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.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const declaredTypes = [];
|
|
151
|
+
for (const sf of changedSources) {
|
|
152
|
+
walk(sf, node => {
|
|
153
|
+
if (!(ts.isInterfaceDeclaration(node) || (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type))))
|
|
154
|
+
return;
|
|
155
|
+
const sym = checker.getSymbolAtLocation(node.name);
|
|
156
|
+
if (!sym)
|
|
157
|
+
return;
|
|
158
|
+
const members = (ts.isInterfaceDeclaration(node) ? node.members : node.type.members).filter((m) => ts.isPropertySignature(m) && ts.isIdentifier(m.name));
|
|
159
|
+
declaredTypes.push({ node, name: node.name.text, sym, type: checker.getDeclaredTypeOfSymbol(sym), members });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Does a type carry T: itself, an array of it, a union or intersection member, or a property a few levels down.
|
|
163
|
+
const mentionMemo = new Map();
|
|
164
|
+
const mentionsType = (t, ty, depth = 0) => {
|
|
165
|
+
if (!ty || depth > MENTION_DEPTH)
|
|
166
|
+
return false;
|
|
167
|
+
if (ty.symbol === t.sym || ty.aliasSymbol === t.sym)
|
|
168
|
+
return true;
|
|
169
|
+
if (ty.aliasTypeArguments?.some(a => mentionsType(t, a, depth + 1)))
|
|
170
|
+
return true;
|
|
171
|
+
const key = `${t.sym.escapedName}|${ty.id}`;
|
|
172
|
+
if (mentionMemo.has(key))
|
|
173
|
+
return mentionMemo.get(key);
|
|
174
|
+
mentionMemo.set(key, false);
|
|
175
|
+
let hit = false;
|
|
176
|
+
if (checker.isArrayType(ty))
|
|
177
|
+
hit = mentionsType(t, checker.getTypeArguments(ty)[0], depth + 1);
|
|
178
|
+
else if (ty.isUnionOrIntersection())
|
|
179
|
+
hit = ty.types.some(m => mentionsType(t, m, depth + 1));
|
|
180
|
+
else if (ty.flags & ts.TypeFlags.Object && !(ty.symbol && ty.symbol.flags & ts.SymbolFlags.Function))
|
|
181
|
+
hit = checker.getPropertiesOfType(ty).some(p => mentionsType(t, checker.getTypeOfSymbol(p), depth + 1));
|
|
182
|
+
mentionMemo.set(key, hit);
|
|
183
|
+
return hit;
|
|
184
|
+
};
|
|
185
|
+
// Object literals the checker types as T, in non-test files that mention T by name.
|
|
186
|
+
const hostsOf = (t) => {
|
|
187
|
+
const hosts = [];
|
|
188
|
+
const mentions = (ty) => ty.symbol === t.sym || ty.aliasSymbol === t.sym || (ty.isUnionOrIntersection() && ty.types.some(mentions));
|
|
189
|
+
for (const sf of typed.appSources) {
|
|
190
|
+
if (!sf.text.includes(t.name))
|
|
191
|
+
continue;
|
|
192
|
+
walk(sf, node => {
|
|
193
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
194
|
+
return;
|
|
195
|
+
const ctx = checker.getContextualType(node);
|
|
196
|
+
if (ctx && mentions(ctx))
|
|
197
|
+
hosts.push(node);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return hosts;
|
|
201
|
+
};
|
|
202
|
+
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);
|
|
203
|
+
const literalSpreads = (lit) => lit.properties.some(p => ts.isSpreadAssignment(p));
|
|
204
|
+
const word = (s) => new RegExp(`\\b${s.replace(/[$]/g, '\\$')}\\b`);
|
|
205
|
+
// An untyped file is a host when it names the type and visibly supplies a required member (a consumer only reads them).
|
|
206
|
+
const supplyRe = (key) => new RegExp(`(?<![.\\w])${key}\\s*:|(?<![.\\w])${key}\\s*\\([^()]*\\)\\s*\\{|[{,]\\s*${key}\\s*[,}]`);
|
|
207
|
+
const textHosts = (t) => {
|
|
208
|
+
const required = t.members.filter(m => !m.questionToken).map(m => m.name.getText());
|
|
209
|
+
const keys = required.length ? required : t.members.map(m => m.name.getText());
|
|
210
|
+
return typed.textFiles.filter(s => word(t.name).test(s.text) && keys.some(k => supplyRe(k).test(s.text)));
|
|
211
|
+
};
|
|
212
|
+
// A read in an untyped file counts only where that file names the type or imports its module.
|
|
213
|
+
const textReads = (t, prop) => {
|
|
214
|
+
const stem = rel(t.node.getSourceFile()).replace(/\.[cm]?ts$/, '');
|
|
215
|
+
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
|
+
};
|
|
217
|
+
// 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
|
+
const escapeMemo = new Map();
|
|
219
|
+
const escapes = (t) => {
|
|
220
|
+
if (escapeMemo.has(t.sym))
|
|
221
|
+
return escapeMemo.get(t.sym);
|
|
222
|
+
let found;
|
|
223
|
+
for (const sf of typed.appSources) {
|
|
224
|
+
if (found)
|
|
225
|
+
break;
|
|
226
|
+
walk(sf, node => {
|
|
227
|
+
if (found || !ts.isExpression(node) || !node.parent)
|
|
228
|
+
return;
|
|
229
|
+
const p = node.parent;
|
|
230
|
+
const isArg = ts.isCallExpression(p) && p.arguments.includes(node);
|
|
231
|
+
const stringified = ts.isTemplateSpan(p) || (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.PlusToken);
|
|
232
|
+
if (!isArg && !stringified)
|
|
233
|
+
return;
|
|
234
|
+
const ty = (ts.isObjectLiteralExpression(node) && checker.getContextualType(node)) || checker.getTypeAtLocation(node);
|
|
235
|
+
if (!mentionsType(t, ty))
|
|
236
|
+
return;
|
|
237
|
+
if (stringified) {
|
|
238
|
+
found = `string building at ${at(node)}`;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const call = p;
|
|
242
|
+
const sig = checker.getResolvedSignature(call);
|
|
243
|
+
const decl = sig?.declaration;
|
|
244
|
+
// Storing into a collection (events.push(e), map.set(k, v)) keeps the value typed and visible.
|
|
245
|
+
if (decl?.getSourceFile().isDeclarationFile && ts.isPropertyAccessExpression(call.expression)) {
|
|
246
|
+
const holder = checker.getTypeAtLocation(call.expression.expression);
|
|
247
|
+
const holderName = String(holder.symbol?.escapedName ?? holder.aliasSymbol?.escapedName ?? '');
|
|
248
|
+
if (checker.isArrayType(holder) || /^(Map|Set|WeakMap|WeakSet|Promise|ReadonlyMap|ReadonlySet)$/.test(holderName))
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const param = sig?.parameters[call.arguments.indexOf(node)];
|
|
252
|
+
const paramType = param && checker.getTypeOfSymbolAtLocation(param, call);
|
|
253
|
+
if (!decl || !typed.inProgram.has(rel(decl.getSourceFile())) || !mentionsType(t, paramType))
|
|
254
|
+
found = `${call.expression.getText().slice(0, 40)}() at ${at(node)}`;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
escapeMemo.set(t.sym, found);
|
|
258
|
+
return found;
|
|
259
|
+
};
|
|
260
|
+
// A property is read when any non-test file accesses or destructures it; a spread only passes it on.
|
|
261
|
+
const isWriteTarget = (access) => ts.isBinaryExpression(access.parent) && access.parent.left === access && access.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
|
|
262
|
+
const isRead = (t, name) => {
|
|
263
|
+
const sym = checker.getPropertyOfType(t.type, name);
|
|
264
|
+
if (!sym)
|
|
265
|
+
return 'no such property';
|
|
266
|
+
const declaration = sym.declarations?.[0];
|
|
267
|
+
const isOurs = (s) => !!s && (s === sym || !!s.declarations?.some(d => d === declaration));
|
|
268
|
+
const ofType = (expr) => mentionsType(t, checker.getTypeAtLocation(expr));
|
|
269
|
+
for (const sf of typed.appSources) {
|
|
270
|
+
if (!sf.text.includes(name) && !sf.text.includes('...'))
|
|
271
|
+
continue;
|
|
272
|
+
let read;
|
|
273
|
+
walk(sf, node => {
|
|
274
|
+
if (read)
|
|
275
|
+
return;
|
|
276
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === name && !isWriteTarget(node) && isOurs(checker.getSymbolAtLocation(node.name)))
|
|
277
|
+
read = node;
|
|
278
|
+
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))
|
|
279
|
+
read = node;
|
|
280
|
+
else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression) && node.argumentExpression.text === name && ofType(node.expression))
|
|
281
|
+
read = node;
|
|
282
|
+
});
|
|
283
|
+
if (read)
|
|
284
|
+
return at(read);
|
|
285
|
+
}
|
|
286
|
+
const text = textReads(t, name);
|
|
287
|
+
return text ? `${text.file} (by name)` : undefined;
|
|
288
|
+
};
|
|
289
|
+
for (const t of declaredTypes) {
|
|
290
|
+
const hosts = hostsOf(t);
|
|
291
|
+
if (hosts.length === 0 && !t.members.some(m => m.questionToken))
|
|
292
|
+
continue;
|
|
293
|
+
// A declared wire contract (a payload another service reads) is read by that service: its members are never write-only here.
|
|
294
|
+
const wire = micromatch.isMatch(rel(t.node.getSourceFile()), settings.wire_contracts);
|
|
295
|
+
// In a library (declarations emitted), an exported type is read by consumers outside this program: what looks
|
|
296
|
+
// write-only or always-supplied here is a hint for the reviewer, not a block.
|
|
297
|
+
const published = !!t.node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) && emitsDeclarations(typed.root, rel(t.node.getSourceFile()));
|
|
298
|
+
const publish = (id, node, text) => hints.push(`${id} ${at(node)}: ${text} (an exported type of a library: consumers outside this program may read it)`);
|
|
299
|
+
for (const m of t.members) {
|
|
300
|
+
const name = m.name.getText();
|
|
301
|
+
const supplied = hosts.filter(h => literalHas(h, name));
|
|
302
|
+
const spreads = hosts.some(literalSpreads);
|
|
303
|
+
// The change owns the finding when it declared the member or wrote every host; it is anchored on
|
|
304
|
+
// a line the change touched (the member, else a host it wrote) and names the other place.
|
|
305
|
+
const anchor = touched(m) ? m : hosts.length && hosts.every(spanTouched) ? hosts.find(spanTouched) : undefined;
|
|
306
|
+
if (!anchor)
|
|
307
|
+
continue;
|
|
308
|
+
const member = `\`${t.name}.${name}\`${anchor === m ? '' : ` (${at(m)})`}`;
|
|
309
|
+
if (m.questionToken && !spreads) {
|
|
310
|
+
const outside = textHosts(t);
|
|
311
|
+
const all = hosts.length + outside.length;
|
|
312
|
+
if (all > 0 && supplied.length === hosts.length && outside.every(s => supplyRe(name).test(s.text))) {
|
|
313
|
+
const text = `${member} is optional but every host supplies it (${all} host(s), e.g. ${hosts[0] ? at(hosts[0]) : outside[0].file}).`;
|
|
314
|
+
if (published)
|
|
315
|
+
publish('optional-always-supplied', m, text);
|
|
316
|
+
else
|
|
317
|
+
report('optional-always-supplied', 'Optional member every host supplies', anchor, text, 'Make it required and drop the `?.` and the fallbacks on it.');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (supplied.length && !wire && !isRead(t, name)) {
|
|
321
|
+
const exit = escapes(t);
|
|
322
|
+
if (exit)
|
|
323
|
+
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`);
|
|
324
|
+
else if (published)
|
|
325
|
+
publish('write-only-property', m, `${member} is set by ${supplied.length} host(s) and read nowhere in this program`);
|
|
326
|
+
else {
|
|
327
|
+
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.');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Hint: a function with a collection parameter that scans it, called inside a loop or an array callback over another collection.
|
|
333
|
+
for (const sf of changedSources) {
|
|
334
|
+
const scanners = new Map();
|
|
335
|
+
walk(sf, node => {
|
|
336
|
+
if (!(ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) || !node.body)
|
|
337
|
+
return;
|
|
338
|
+
const name = ts.isFunctionDeclaration(node) ? node.name?.text : ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) ? node.parent.name.text : undefined;
|
|
339
|
+
if (!name)
|
|
340
|
+
return;
|
|
341
|
+
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);
|
|
342
|
+
if (collections.length === 0)
|
|
343
|
+
return;
|
|
344
|
+
let scans;
|
|
345
|
+
walk(node.body, n => {
|
|
346
|
+
if (scans)
|
|
347
|
+
return;
|
|
348
|
+
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))
|
|
349
|
+
scans = n.expression.expression.text;
|
|
350
|
+
if (ts.isForOfStatement(n) && ts.isIdentifier(n.expression) && collections.includes(n.expression.text))
|
|
351
|
+
scans = n.expression.text;
|
|
352
|
+
});
|
|
353
|
+
if (scans)
|
|
354
|
+
scanners.set(name, scans);
|
|
355
|
+
});
|
|
356
|
+
if (scanners.size === 0)
|
|
357
|
+
continue;
|
|
358
|
+
const inLoop = (n) => {
|
|
359
|
+
for (let p = n.parent; p; p = p.parent) {
|
|
360
|
+
if (ts.isForOfStatement(p) || ts.isForStatement(p) || ts.isForInStatement(p) || ts.isWhileStatement(p))
|
|
361
|
+
return 'a loop';
|
|
362
|
+
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))
|
|
363
|
+
return `.${p.parent.expression.name.text}() over ${p.parent.expression.expression.getText().slice(0, 40)}`;
|
|
364
|
+
if (ts.isFunctionDeclaration(p))
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
return undefined;
|
|
368
|
+
};
|
|
369
|
+
walk(sf, node => {
|
|
370
|
+
if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || !scanners.has(node.expression.text))
|
|
371
|
+
return;
|
|
372
|
+
const loop = inLoop(node);
|
|
373
|
+
if (loop)
|
|
374
|
+
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`);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return { failures, hints };
|
|
378
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
});
|
|
122
|
+
describe('a nullable row type for a NOT NULL column, from migrations in another repository', () => {
|
|
123
|
+
const SUPABASE = `export class Query<T> {
|
|
124
|
+
select(_columns: string): Query<T> { return this; }
|
|
125
|
+
returns<R>(): Promise<R> { return Promise.resolve([] as unknown as R); }
|
|
126
|
+
}
|
|
127
|
+
export const db = {
|
|
128
|
+
schema(_name: string) { return db; },
|
|
129
|
+
from(_table: string): Query<unknown> { return new Query(); },
|
|
130
|
+
};
|
|
131
|
+
`;
|
|
132
|
+
let migrations;
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
migrations = fs.mkdtempSync(path.join(os.tmpdir(), 'other-repo-migrations-'));
|
|
135
|
+
fs.writeFileSync(path.join(migrations, '20240101_sets.sql'), 'create table study_set (id uuid primary key, owner_id uuid not null, title text, archived_at timestamptz not null);');
|
|
136
|
+
write('src/sb.ts', SUPABASE);
|
|
137
|
+
git('add', '-A');
|
|
138
|
+
git('commit', '-qm', 'client');
|
|
139
|
+
});
|
|
140
|
+
afterEach(() => { fs.rmSync(migrations, { recursive: true, force: true }); });
|
|
141
|
+
it('notes the nullable member the change declared, never blocking, and skips nullable columns and aliases', async () => {
|
|
142
|
+
write('src/sets.ts', `import { db } from './sb';
|
|
143
|
+
export interface SetRow { id: string; owner_id: string | null; title: string | null; done: string | null }
|
|
144
|
+
export async function sets(): Promise<SetRow[]> {
|
|
145
|
+
return db.from('study_set').select('id, owner_id, title, done:archived_at').returns<SetRow[]>();
|
|
146
|
+
}
|
|
147
|
+
`);
|
|
148
|
+
git('add', '-A');
|
|
149
|
+
git('commit', '-qm', 'sets');
|
|
150
|
+
const result = await review(config({ schema_migrations: [migrations] }));
|
|
151
|
+
expect(result.findings.filter(f => f.id === 'nullable-not-null-column')).toEqual([]);
|
|
152
|
+
const notes = result.advisory.filter(f => f.id === 'nullable-not-null-column');
|
|
153
|
+
expect(notes.map(f => [f.files?.[0], f.line])).toEqual([['src/sets.ts', 2]]);
|
|
154
|
+
expect(notes[0].details).toContain('`owner_id` is declared nullable at src/sets.ts:2 but `study_set.owner_id` is NOT NULL');
|
|
155
|
+
});
|
|
156
|
+
it('anchors on the new query when the row type is older, and reads the schema the query names', async () => {
|
|
157
|
+
fs.writeFileSync(path.join(migrations, '20240202_app.sql'), 'create table app.study_set (id uuid, owner_id uuid);');
|
|
158
|
+
git('checkout', '-q', 'main'); // the row type is older than the branch
|
|
159
|
+
write('src/rows.ts', 'export interface SetRow { id: string; owner_id: string | null }\n');
|
|
160
|
+
git('add', '-A');
|
|
161
|
+
git('commit', '-qm', 'rows');
|
|
162
|
+
git('checkout', '-q', 'feature');
|
|
163
|
+
git('merge', '-q', '--no-edit', 'main');
|
|
164
|
+
write('src/sets.ts', `import { db } from './sb';
|
|
165
|
+
import type { SetRow } from './rows';
|
|
166
|
+
export async function publicSets(): Promise<SetRow[]> {
|
|
167
|
+
return db
|
|
168
|
+
.from('study_set').select('*').returns<SetRow[]>();
|
|
169
|
+
}
|
|
170
|
+
export async function appSets(): Promise<SetRow[]> {
|
|
171
|
+
return db.schema('app').from('study_set').select('*').returns<SetRow[]>();
|
|
172
|
+
}
|
|
173
|
+
`);
|
|
174
|
+
git('add', '-A');
|
|
175
|
+
git('commit', '-qm', 'sets');
|
|
176
|
+
const result = await review(config({ schema_migrations: [migrations] }));
|
|
177
|
+
expect(result.advisory.filter(f => f.id === 'nullable-not-null-column').map(f => [f.files?.[0], f.line])).toEqual([['src/sets.ts', 5]]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** `schema.table` → column → NOT NULL. A table mapped to `null` is one whose columns cannot be known. */
|
|
2
|
+
export type SchemaNullability = Map<string, Map<string, boolean> | null>;
|
|
3
|
+
/** The migrations in `dirs` (relative to the repository, absolute, or `~/`), replayed in file-name order. Missing folders are skipped. */
|
|
4
|
+
export declare function loadSchemaNullability(root: string, dirs: string[]): SchemaNullability;
|
|
5
|
+
/** `schema.table` with Postgres's folding: unquoted names lower-cased, the schema `public` by default. */
|
|
6
|
+
export declare function tableKey(name: string): string;
|