@rigour-labs/core 6.2.5 → 6.3.1
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 +2 -0
- package/dist/discovery-signals.d.ts +27 -0
- package/dist/discovery-signals.js +96 -0
- package/dist/discovery.d.ts +1 -2
- package/dist/discovery.js +36 -49
- package/dist/discovery.test.js +84 -71
- package/dist/gates/deep-analysis.d.ts +2 -0
- package/dist/gates/deep-analysis.js +5 -1
- package/dist/gates/deep-intent.d.ts +3 -0
- package/dist/gates/deep-intent.js +57 -0
- package/dist/gates/deep-intent.test.d.ts +1 -0
- package/dist/gates/deep-intent.test.js +51 -0
- package/dist/gates/deep-runner.js +1 -0
- package/dist/gates/deprecated-apis-ast.d.ts +8 -0
- package/dist/gates/deprecated-apis-ast.js +25 -0
- package/dist/gates/deprecated-apis-rules-node.js +2 -8
- package/dist/gates/deprecated-apis.js +5 -1
- package/dist/gates/deprecated-apis.test.js +30 -0
- package/dist/gates/frontend-secret-exposure.js +5 -1
- package/dist/gates/frontend-secret-exposure.test.js +20 -0
- package/dist/gates/hallucinated-imports/framework-modules.d.ts +14 -0
- package/dist/gates/hallucinated-imports/framework-modules.js +75 -0
- package/dist/gates/hallucinated-imports/index.js +14 -6
- package/dist/gates/hallucinated-imports/js-resolver.d.ts +16 -12
- package/dist/gates/hallucinated-imports/js-resolver.js +65 -153
- package/dist/gates/hallucinated-imports/ts-path-target.js +16 -0
- package/dist/gates/hallucinated-imports/tsconfig-paths.d.ts +16 -0
- package/dist/gates/hallucinated-imports/tsconfig-paths.js +120 -0
- package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports-sveltekit.test.js +108 -0
- package/dist/gates/runner.js +5 -0
- package/dist/gates/semantic-bugs.d.ts +23 -0
- package/dist/gates/semantic-bugs.js +48 -0
- package/dist/gates/semantic-bugs.test.d.ts +1 -0
- package/dist/gates/semantic-bugs.test.js +70 -0
- package/dist/gates/side-effect-analysis/index.js +11 -1
- package/dist/gates/side-effect-analysis.test.d.ts +1 -0
- package/dist/gates/side-effect-analysis.test.js +162 -0
- package/dist/gates/side-effect-helpers/pattern-detection.js +35 -7
- package/dist/gates/side-effect-helpers/resource-tracking.js +17 -10
- package/dist/gates/side-effect-helpers/scope-analysis.d.ts +6 -0
- package/dist/gates/side-effect-helpers/scope-analysis.js +20 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/inference/model-download.test.js +54 -2
- package/dist/inference/model-manager.d.ts +6 -0
- package/dist/inference/model-manager.js +58 -18
- package/dist/inference/sidecar-provider.js +12 -1
- package/dist/inference/types.d.ts +8 -2
- package/dist/inference/types.js +7 -3
- package/dist/semantic/ast.d.ts +29 -0
- package/dist/semantic/ast.js +122 -0
- package/dist/semantic/benchmark.d.ts +16 -0
- package/dist/semantic/benchmark.js +31 -0
- package/dist/semantic/benchmark.test.d.ts +1 -0
- package/dist/semantic/benchmark.test.js +18 -0
- package/dist/semantic/engine.d.ts +8 -0
- package/dist/semantic/engine.js +45 -0
- package/dist/semantic/intent/benchmark.d.ts +31 -0
- package/dist/semantic/intent/benchmark.js +74 -0
- package/dist/semantic/intent/intent-check.d.ts +43 -0
- package/dist/semantic/intent/intent-check.js +72 -0
- package/dist/semantic/intent/intent.test.d.ts +1 -0
- package/dist/semantic/intent/intent.test.js +46 -0
- package/dist/semantic/intent/parallel-reads.d.ts +23 -0
- package/dist/semantic/intent/parallel-reads.js +59 -0
- package/dist/semantic/learn/benchmark.d.ts +20 -0
- package/dist/semantic/learn/benchmark.js +71 -0
- package/dist/semantic/learn/compile.d.ts +4 -0
- package/dist/semantic/learn/compile.js +56 -0
- package/dist/semantic/learn/conditions.d.ts +22 -0
- package/dist/semantic/learn/conditions.js +93 -0
- package/dist/semantic/learn/extract.d.ts +30 -0
- package/dist/semantic/learn/extract.js +122 -0
- package/dist/semantic/learn/git-trees.d.ts +8 -0
- package/dist/semantic/learn/git-trees.js +32 -0
- package/dist/semantic/learn/identity.d.ts +18 -0
- package/dist/semantic/learn/identity.js +97 -0
- package/dist/semantic/learn/learn.d.ts +25 -0
- package/dist/semantic/learn/learn.js +109 -0
- package/dist/semantic/learn/learn.test.d.ts +1 -0
- package/dist/semantic/learn/learn.test.js +67 -0
- package/dist/semantic/learn/store.d.ts +4 -0
- package/dist/semantic/learn/store.js +39 -0
- package/dist/semantic/learn/types.d.ts +468 -0
- package/dist/semantic/learn/types.js +55 -0
- package/dist/semantic/origins.d.ts +42 -0
- package/dist/semantic/origins.js +149 -0
- package/dist/semantic/origins.test.d.ts +1 -0
- package/dist/semantic/origins.test.js +77 -0
- package/dist/semantic/program.d.ts +13 -0
- package/dist/semantic/program.js +62 -0
- package/dist/semantic/rules/credential-redirect.d.ts +2 -0
- package/dist/semantic/rules/credential-redirect.js +209 -0
- package/dist/semantic/rules/degraded-response-cached.d.ts +2 -0
- package/dist/semantic/rules/degraded-response-cached.js +271 -0
- package/dist/semantic/rules/in-memory-aggregation.d.ts +2 -0
- package/dist/semantic/rules/in-memory-aggregation.js +179 -0
- package/dist/semantic/summaries.d.ts +18 -0
- package/dist/semantic/summaries.js +33 -0
- package/dist/semantic/types.d.ts +32 -0
- package/dist/semantic/types.js +1 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/team-diagnostics.d.ts +22 -0
- package/dist/storage/team-diagnostics.js +58 -0
- package/dist/storage/team-diagnostics.test.d.ts +1 -0
- package/dist/storage/team-diagnostics.test.js +51 -0
- package/dist/storage/team-schema.d.ts +6 -0
- package/dist/storage/team-schema.js +82 -0
- package/dist/storage/team-store.js +25 -72
- package/dist/templates/universal-config.js +4 -0
- package/dist/types/index.d.ts +56 -0
- package/dist/types/index.js +7 -0
- package/package.json +6 -6
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* degraded-response-cached: an HTTP response that can carry a failure
|
|
3
|
+
* fallback is sent with a positive max-age.
|
|
4
|
+
*
|
|
5
|
+
* A read that fails is often caught and replaced with null or a default so
|
|
6
|
+
* the rest of the answer still renders. If that degraded answer is cached,
|
|
7
|
+
* clients keep serving it after the failure recovers. The rule proves both
|
|
8
|
+
* halves: the body derives from a function that returns a fallback from a
|
|
9
|
+
* `catch`, and the cache header is set without any condition on the body.
|
|
10
|
+
*/
|
|
11
|
+
import ts from 'typescript';
|
|
12
|
+
import { calledFunction, enclosingFunction, forEachNode, forEachOwnNode, lineOf, locationOf, propertyNameText, relativeFile, snippet, symbolOf, unwrap } from '../ast.js';
|
|
13
|
+
import { originsOf, propertyValues } from '../origins.js';
|
|
14
|
+
const POSITIVE_CACHE = /(?:^|[\s,])(?:s-)?max-age\s*=\s*[1-9]/i;
|
|
15
|
+
const RESPONSE_CLASSES = new Set(['Response', 'NextResponse']);
|
|
16
|
+
export const degradedResponseCached = {
|
|
17
|
+
id: 'degraded-response-cached',
|
|
18
|
+
check(ctx) {
|
|
19
|
+
const findings = [];
|
|
20
|
+
forEachNode(ctx.sourceFile, (node) => {
|
|
21
|
+
if (!ts.isCallExpression(node) && !ts.isNewExpression(node))
|
|
22
|
+
return;
|
|
23
|
+
const finding = responseParts(node) ? checkDirect(ctx, node) : ts.isCallExpression(node) ? checkCall(ctx, node) : null;
|
|
24
|
+
if (finding)
|
|
25
|
+
findings.push(finding);
|
|
26
|
+
});
|
|
27
|
+
return findings;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/** `Response.json(body, init)`, `NextResponse.json(...)`, `new Response(body, init)`. */
|
|
31
|
+
function responseParts(node) {
|
|
32
|
+
const args = node.arguments ?? ts.factory.createNodeArray();
|
|
33
|
+
if (args.length < 2)
|
|
34
|
+
return null;
|
|
35
|
+
const callee = unwrap(node.expression);
|
|
36
|
+
const isJson = ts.isCallExpression(node) && ts.isPropertyAccessExpression(callee) && callee.name.text === 'json'
|
|
37
|
+
&& ts.isIdentifier(callee.expression) && RESPONSE_CLASSES.has(callee.expression.text);
|
|
38
|
+
const isNew = ts.isNewExpression(node) && ts.isIdentifier(callee) && RESPONSE_CLASSES.has(callee.text);
|
|
39
|
+
return isJson || isNew ? { body: args[0], init: args[1] } : null;
|
|
40
|
+
}
|
|
41
|
+
/** A response built in place from a fallback-derived body, cached without a condition on it. */
|
|
42
|
+
function checkDirect(ctx, node) {
|
|
43
|
+
const { body, init } = responseParts(node);
|
|
44
|
+
const fallback = fallbackIn(ctx, body);
|
|
45
|
+
if (!fallback)
|
|
46
|
+
return null;
|
|
47
|
+
const bodySymbols = identifierSymbols(ctx.checker, body);
|
|
48
|
+
const entry = cacheEntries(ctx.checker, init).find(e => POSITIVE_CACHE.test(e.value.text)
|
|
49
|
+
&& !e.guards.some(g => [...identifierSymbols(ctx.checker, g)].some(s => bodySymbols.has(s))));
|
|
50
|
+
return entry ? finding(ctx, node, entry, fallback) : null;
|
|
51
|
+
}
|
|
52
|
+
/** A call to a function that caches one of its parameters, passed a fallback-derived value. */
|
|
53
|
+
function checkCall(ctx, call) {
|
|
54
|
+
const fn = calledFunction(ctx.checker, call);
|
|
55
|
+
if (!fn)
|
|
56
|
+
return null;
|
|
57
|
+
for (const [index, entry] of cachedParams(ctx.summaries, fn)) {
|
|
58
|
+
const arg = call.arguments[index];
|
|
59
|
+
const fallback = arg ? fallbackIn(ctx, arg) : null;
|
|
60
|
+
if (fallback)
|
|
61
|
+
return finding(ctx, call, entry, fallback);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function cachedParams(summaries, fn) {
|
|
66
|
+
return summaries.memo('degraded-response-cached:cached-params', fn, new Map(), () => {
|
|
67
|
+
const params = new Map();
|
|
68
|
+
const checker = summaries.checker;
|
|
69
|
+
forEachOwnNode(fn, (node) => {
|
|
70
|
+
if (!ts.isCallExpression(node) && !ts.isNewExpression(node))
|
|
71
|
+
return;
|
|
72
|
+
const parts = responseParts(node);
|
|
73
|
+
if (!parts)
|
|
74
|
+
return;
|
|
75
|
+
for (const index of bodyParams(checker, parts.body, fn)) {
|
|
76
|
+
const param = checker.getSymbolAtLocation(fn.parameters[index].name);
|
|
77
|
+
const entry = cacheEntries(checker, parts.init).find(e => POSITIVE_CACHE.test(e.value.text)
|
|
78
|
+
&& !(param && e.guards.some(g => identifierSymbols(checker, g).has(param))));
|
|
79
|
+
if (entry)
|
|
80
|
+
params.set(index, entry);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return params;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Parameters of `fn` that become the response body, directly or spread into it. */
|
|
87
|
+
function bodyParams(checker, body, fn) {
|
|
88
|
+
const indices = [];
|
|
89
|
+
const collect = (origin) => {
|
|
90
|
+
if (origin.kind === 'param' && origin.fn === fn)
|
|
91
|
+
indices.push(origin.index);
|
|
92
|
+
};
|
|
93
|
+
for (const origin of originsOf(checker, body)) {
|
|
94
|
+
collect(origin);
|
|
95
|
+
if (origin.kind !== 'object')
|
|
96
|
+
continue;
|
|
97
|
+
for (const spread of propertyValues(checker, origin.node, '').spreads)
|
|
98
|
+
originsOf(checker, spread).forEach(collect);
|
|
99
|
+
}
|
|
100
|
+
return indices;
|
|
101
|
+
}
|
|
102
|
+
/** Every `cache-control` value in an init's headers, with the conditions guarding it. */
|
|
103
|
+
function cacheEntries(checker, init) {
|
|
104
|
+
const entries = [];
|
|
105
|
+
for (const origin of originsOf(checker, init)) {
|
|
106
|
+
if (origin.kind !== 'object')
|
|
107
|
+
continue;
|
|
108
|
+
for (const headers of propertyValues(checker, origin.node, 'headers').values) {
|
|
109
|
+
collectHeaderEntries(checker, headers, [], entries, 0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return entries;
|
|
113
|
+
}
|
|
114
|
+
function collectHeaderEntries(checker, expr, guards, into, depth) {
|
|
115
|
+
if (depth > 6)
|
|
116
|
+
return;
|
|
117
|
+
const node = unwrap(expr);
|
|
118
|
+
if (ts.isConditionalExpression(node)) {
|
|
119
|
+
collectHeaderEntries(checker, node.whenTrue, [...guards, node.condition], into, depth + 1);
|
|
120
|
+
collectHeaderEntries(checker, node.whenFalse, [...guards, node.condition], into, depth + 1);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (ts.isIdentifier(node)) {
|
|
124
|
+
for (const origin of originsOf(checker, node)) {
|
|
125
|
+
if (origin.kind === 'object')
|
|
126
|
+
collectHeaderEntries(checker, origin.node, guards, into, depth + 1);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
131
|
+
return;
|
|
132
|
+
for (const prop of node.properties) {
|
|
133
|
+
if (ts.isSpreadAssignment(prop))
|
|
134
|
+
collectHeaderEntries(checker, prop.expression, guards, into, depth + 1);
|
|
135
|
+
else if (ts.isPropertyAssignment(prop) && propertyNameText(prop.name)?.toLowerCase() === 'cache-control') {
|
|
136
|
+
collectValues(prop.initializer, guards, into);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function collectValues(expr, guards, into) {
|
|
141
|
+
const node = unwrap(expr);
|
|
142
|
+
if (ts.isStringLiteralLike(node))
|
|
143
|
+
into.push({ value: node, guards });
|
|
144
|
+
else if (ts.isConditionalExpression(node)) {
|
|
145
|
+
collectValues(node.whenTrue, [...guards, node.condition], into);
|
|
146
|
+
collectValues(node.whenFalse, [...guards, node.condition], into);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The catch-fallback a value can carry, found through calls, spreads and property values. */
|
|
150
|
+
function fallbackIn(ctx, expr) {
|
|
151
|
+
return fallbackOf(ctx.summaries, expr, 0);
|
|
152
|
+
}
|
|
153
|
+
function fallbackOf(summaries, expr, depth) {
|
|
154
|
+
if (depth > 4)
|
|
155
|
+
return null;
|
|
156
|
+
for (const origin of originsOf(summaries.checker, expr)) {
|
|
157
|
+
if (origin.kind === 'call') {
|
|
158
|
+
const fn = calledFunction(summaries.checker, origin.node);
|
|
159
|
+
// A call with no source body (JSON.stringify, String) passes its arguments' values through.
|
|
160
|
+
const found = fn ? returnsFallback(summaries, fn) : fallbackInArguments(summaries, origin.node, depth);
|
|
161
|
+
if (found)
|
|
162
|
+
return found;
|
|
163
|
+
}
|
|
164
|
+
if (origin.kind === 'object') {
|
|
165
|
+
for (const part of objectParts(origin.node)) {
|
|
166
|
+
for (const id of identifiersIn(part)) {
|
|
167
|
+
const found = fallbackOf(summaries, id, depth + 1);
|
|
168
|
+
if (found)
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function fallbackInArguments(summaries, call, depth) {
|
|
177
|
+
for (const arg of call.arguments) {
|
|
178
|
+
const found = fallbackOf(summaries, arg, depth + 1);
|
|
179
|
+
if (found)
|
|
180
|
+
return found;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
/** Whether `fn` can return a value produced by a failure fallback, and where. */
|
|
185
|
+
function returnsFallback(summaries, fn) {
|
|
186
|
+
return summaries.memo('degraded-response-cached:fallback', fn, null, () => {
|
|
187
|
+
let found = null;
|
|
188
|
+
forEachOwnNode(fn, (node) => {
|
|
189
|
+
if (found || !ts.isReturnStatement(node))
|
|
190
|
+
return;
|
|
191
|
+
if (isInsideCatch(node, fn) && isFallbackValue(node.expression))
|
|
192
|
+
found = { fn, node };
|
|
193
|
+
else if (node.expression)
|
|
194
|
+
found = fallbackOf(summaries, node.expression, 1);
|
|
195
|
+
});
|
|
196
|
+
if (!found && !ts.isBlock(fn.body) && fn.body)
|
|
197
|
+
found = fallbackOf(summaries, fn.body, 1);
|
|
198
|
+
return found;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function isInsideCatch(node, fn) {
|
|
202
|
+
for (let current = node.parent; current && current !== fn; current = current.parent) {
|
|
203
|
+
if (ts.isCatchClause(current))
|
|
204
|
+
return enclosingFunction(current) === fn;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/** `return;`, `return null`, `return undefined`, `return []`, `return {}`, `return false`. */
|
|
209
|
+
function isFallbackValue(expr) {
|
|
210
|
+
if (!expr)
|
|
211
|
+
return true;
|
|
212
|
+
const node = unwrap(expr);
|
|
213
|
+
return node.kind === ts.SyntaxKind.NullKeyword || node.kind === ts.SyntaxKind.FalseKeyword
|
|
214
|
+
|| (ts.isIdentifier(node) && node.text === 'undefined')
|
|
215
|
+
|| (ts.isArrayLiteralExpression(node) && node.elements.length === 0)
|
|
216
|
+
|| (ts.isObjectLiteralExpression(node) && node.properties.length === 0);
|
|
217
|
+
}
|
|
218
|
+
/** Property values and spreads of an object literal. */
|
|
219
|
+
function objectParts(obj) {
|
|
220
|
+
const parts = [];
|
|
221
|
+
for (const prop of obj.properties) {
|
|
222
|
+
if (ts.isPropertyAssignment(prop))
|
|
223
|
+
parts.push(prop.initializer);
|
|
224
|
+
else if (ts.isShorthandPropertyAssignment(prop))
|
|
225
|
+
parts.push(prop.name);
|
|
226
|
+
else if (ts.isSpreadAssignment(prop))
|
|
227
|
+
parts.push(prop.expression);
|
|
228
|
+
}
|
|
229
|
+
return parts;
|
|
230
|
+
}
|
|
231
|
+
function identifiersIn(expr) {
|
|
232
|
+
const ids = [];
|
|
233
|
+
const walk = (node) => {
|
|
234
|
+
if (ts.isIdentifier(node))
|
|
235
|
+
ids.push(node);
|
|
236
|
+
else if (!ts.isPropertyAccessExpression(node))
|
|
237
|
+
ts.forEachChild(node, walk);
|
|
238
|
+
else
|
|
239
|
+
walk(node.expression);
|
|
240
|
+
};
|
|
241
|
+
walk(expr);
|
|
242
|
+
return ids;
|
|
243
|
+
}
|
|
244
|
+
function identifierSymbols(checker, expr) {
|
|
245
|
+
const symbols = new Set();
|
|
246
|
+
const walk = (node) => {
|
|
247
|
+
if (ts.isIdentifier(node)) {
|
|
248
|
+
const symbol = symbolOf(checker, node);
|
|
249
|
+
if (symbol)
|
|
250
|
+
symbols.add(symbol);
|
|
251
|
+
}
|
|
252
|
+
ts.forEachChild(node, walk);
|
|
253
|
+
};
|
|
254
|
+
walk(expr);
|
|
255
|
+
return symbols;
|
|
256
|
+
}
|
|
257
|
+
function finding(ctx, node, entry, fallback) {
|
|
258
|
+
const fnName = fallback.fn.name && ts.isIdentifier(fallback.fn.name) ? fallback.fn.name.text : 'a function';
|
|
259
|
+
return {
|
|
260
|
+
rule: 'degraded-response-cached',
|
|
261
|
+
severity: 'medium',
|
|
262
|
+
provenance: 'traditional',
|
|
263
|
+
file: relativeFile(ctx.cwd, ctx.sourceFile),
|
|
264
|
+
line: lineOf(node),
|
|
265
|
+
message: `This response is cached (\`cache-control: ${entry.value.text}\`, ${locationOf(ctx.cwd, entry.value)}) `
|
|
266
|
+
+ `but its body can carry the fallback \`${fnName}\` returns when a read fails (${locationOf(ctx.cwd, fallback.node)}). `
|
|
267
|
+
+ `A degraded answer then stays cached after the failure recovers.`,
|
|
268
|
+
hint: 'Send `no-store` when any part of the answer is a fallback (for example `cache-control: body.complete ? "private, max-age=300" : "no-store"`).',
|
|
269
|
+
evidence: [`${locationOf(ctx.cwd, entry.value)} ${snippet(entry.value)}`, `${locationOf(ctx.cwd, fallback.node)} ${snippet(fallback.node)}`],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* in-memory-aggregation: every page of a paged read is collected into an
|
|
3
|
+
* array that is then only counted or capped.
|
|
4
|
+
*
|
|
5
|
+
* Work and memory grow with the table. A size cap that throws does not bound
|
|
6
|
+
* the cost; it turns growth into an outage once the data passes the cap.
|
|
7
|
+
* Counting belongs in the data store (count / group by) or a paged fold.
|
|
8
|
+
*/
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
import { calleeName, forEachNode, forEachOwnNode, isFunctionLike, lineOf, relativeFile, snippet, symbolOf, unwrap } from '../ast.js';
|
|
11
|
+
const PAGED_CALL = /(?:fetch|read|get|list|load|query|select)All|all(?:Pages|Rows|Records)|paginat|eachPage|forEachPage/i;
|
|
12
|
+
const PAGE_CURSOR = /^(?:cursor|offset|page|pageToken|nextToken|after|skip|next|startAfter)$/i;
|
|
13
|
+
const AGGREGATE_METHODS = new Set(['length', 'reduce', 'some', 'every', 'includes', 'find', 'findIndex', 'indexOf']);
|
|
14
|
+
export const inMemoryAggregation = {
|
|
15
|
+
id: 'in-memory-aggregation',
|
|
16
|
+
check(ctx) {
|
|
17
|
+
const findings = [];
|
|
18
|
+
forEachNode(ctx.sourceFile, (node) => {
|
|
19
|
+
if (isFunctionLike(node) && node.body)
|
|
20
|
+
findings.push(...checkFunction(ctx, node));
|
|
21
|
+
});
|
|
22
|
+
return findings;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
function checkFunction(ctx, fn) {
|
|
26
|
+
const findings = [];
|
|
27
|
+
forEachOwnNode(fn, (node) => {
|
|
28
|
+
if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer)
|
|
29
|
+
return;
|
|
30
|
+
const init = unwrap(node.initializer);
|
|
31
|
+
if (!ts.isArrayLiteralExpression(init) || init.elements.length > 0)
|
|
32
|
+
return;
|
|
33
|
+
const symbol = ctx.checker.getSymbolAtLocation(node.name);
|
|
34
|
+
if (!symbol)
|
|
35
|
+
return;
|
|
36
|
+
const finding = checkAccumulator(ctx, fn, node.name, symbol);
|
|
37
|
+
if (finding)
|
|
38
|
+
findings.push(finding);
|
|
39
|
+
});
|
|
40
|
+
return findings;
|
|
41
|
+
}
|
|
42
|
+
function checkAccumulator(ctx, fn, name, symbol) {
|
|
43
|
+
const refs = references(ctx.checker, fn, symbol, name);
|
|
44
|
+
const fills = refs.filter(isPushTarget);
|
|
45
|
+
const pagedFill = fills.find(ref => isPagedFill(ref, fn));
|
|
46
|
+
if (!pagedFill)
|
|
47
|
+
return null;
|
|
48
|
+
const others = refs.filter(ref => !isPushTarget(ref));
|
|
49
|
+
const capGuard = others.find(isThrowingSizeCap);
|
|
50
|
+
if (!capGuard && !(others.length > 0 && others.every(isAggregateUse)))
|
|
51
|
+
return null;
|
|
52
|
+
const pushCall = pagedFill.parent.parent;
|
|
53
|
+
const reason = capGuard
|
|
54
|
+
? `then capped with \`${snippet(capGuard.parent.parent, 50)}\``
|
|
55
|
+
: 'and then only counted';
|
|
56
|
+
return {
|
|
57
|
+
rule: 'in-memory-aggregation',
|
|
58
|
+
severity: 'high',
|
|
59
|
+
provenance: 'traditional',
|
|
60
|
+
file: relativeFile(ctx.cwd, ctx.sourceFile),
|
|
61
|
+
line: lineOf(pushCall),
|
|
62
|
+
message: `\`${name.text}\` collects every page of a paged read in memory (\`${snippet(pushCall, 70)}\`) ${reason}. `
|
|
63
|
+
+ `Time and memory grow with the table, and a cap that throws fails every request once the data passes it.`,
|
|
64
|
+
hint: 'Aggregate where the data lives (count / group by in SQL or an RPC), or fold each page into running totals instead of keeping all rows.',
|
|
65
|
+
evidence: [`${relativeFile(ctx.cwd, ctx.sourceFile)}:${lineOf(pushCall)} ${snippet(pushCall, 70)}`],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function references(checker, fn, symbol, declName) {
|
|
69
|
+
const refs = [];
|
|
70
|
+
forEachOwnNode(fn, (node) => {
|
|
71
|
+
if (ts.isIdentifier(node) && node !== declName && symbolOf(checker, node) === symbol)
|
|
72
|
+
refs.push(node);
|
|
73
|
+
});
|
|
74
|
+
return refs;
|
|
75
|
+
}
|
|
76
|
+
/** `ref.push(...)` */
|
|
77
|
+
function isPushTarget(ref) {
|
|
78
|
+
const access = ref.parent;
|
|
79
|
+
return ts.isPropertyAccessExpression(access) && access.expression === ref && access.name.text === 'push'
|
|
80
|
+
&& ts.isCallExpression(access.parent) && access.parent.expression === access;
|
|
81
|
+
}
|
|
82
|
+
function isPagedFill(ref, fn) {
|
|
83
|
+
const call = ref.parent.parent;
|
|
84
|
+
if (call.arguments.some(arg => containsAwaitedCall(arg, name => PAGED_CALL.test(name))))
|
|
85
|
+
return true;
|
|
86
|
+
const loop = enclosingLoop(call, fn);
|
|
87
|
+
return !!loop && containsCursorDrivenAwait(loop);
|
|
88
|
+
}
|
|
89
|
+
function containsAwaitedCall(node, matches) {
|
|
90
|
+
let found = false;
|
|
91
|
+
const walk = (n) => {
|
|
92
|
+
if (found)
|
|
93
|
+
return;
|
|
94
|
+
if (ts.isAwaitExpression(n)) {
|
|
95
|
+
const awaited = unwrap(n.expression);
|
|
96
|
+
if (ts.isCallExpression(awaited)) {
|
|
97
|
+
const name = calleeName(awaited);
|
|
98
|
+
if (name && matches(name))
|
|
99
|
+
found = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!found)
|
|
103
|
+
ts.forEachChild(n, walk);
|
|
104
|
+
};
|
|
105
|
+
walk(node);
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
function enclosingLoop(node, fn) {
|
|
109
|
+
for (let current = node.parent; current && current !== fn; current = current.parent) {
|
|
110
|
+
if (ts.isForStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current))
|
|
111
|
+
return current;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/** An awaited call inside the loop whose arguments mention a page cursor. */
|
|
116
|
+
function containsCursorDrivenAwait(loop) {
|
|
117
|
+
let found = false;
|
|
118
|
+
const walk = (n) => {
|
|
119
|
+
if (found)
|
|
120
|
+
return;
|
|
121
|
+
if (ts.isAwaitExpression(n) && ts.isCallExpression(unwrap(n.expression))) {
|
|
122
|
+
const call = unwrap(n.expression);
|
|
123
|
+
found = call.arguments.some(arg => mentionsCursor(arg));
|
|
124
|
+
}
|
|
125
|
+
if (!found)
|
|
126
|
+
ts.forEachChild(n, walk);
|
|
127
|
+
};
|
|
128
|
+
walk(loop.statement);
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
function mentionsCursor(node) {
|
|
132
|
+
let found = false;
|
|
133
|
+
const walk = (n) => {
|
|
134
|
+
if (found)
|
|
135
|
+
return;
|
|
136
|
+
if (ts.isIdentifier(n) && PAGE_CURSOR.test(n.text))
|
|
137
|
+
found = true;
|
|
138
|
+
else
|
|
139
|
+
ts.forEachChild(n, walk);
|
|
140
|
+
};
|
|
141
|
+
walk(node);
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
/** `if (ref.length > cap) throw ...` */
|
|
145
|
+
function isThrowingSizeCap(ref) {
|
|
146
|
+
const access = ref.parent;
|
|
147
|
+
if (!ts.isPropertyAccessExpression(access) || access.name.text !== 'length')
|
|
148
|
+
return false;
|
|
149
|
+
const comparison = access.parent;
|
|
150
|
+
if (!ts.isBinaryExpression(comparison))
|
|
151
|
+
return false;
|
|
152
|
+
const op = comparison.operatorToken.kind;
|
|
153
|
+
if (op !== ts.SyntaxKind.GreaterThanToken && op !== ts.SyntaxKind.GreaterThanEqualsToken)
|
|
154
|
+
return false;
|
|
155
|
+
const statement = comparison.parent;
|
|
156
|
+
return ts.isIfStatement(statement) && statement.expression === comparison && throwsDirectly(statement.thenStatement);
|
|
157
|
+
}
|
|
158
|
+
function throwsDirectly(statement) {
|
|
159
|
+
if (ts.isThrowStatement(statement))
|
|
160
|
+
return true;
|
|
161
|
+
return ts.isBlock(statement) && statement.statements.some(s => ts.isThrowStatement(s));
|
|
162
|
+
}
|
|
163
|
+
/** `ref.length`, `ref.reduce(...)`, `ref.filter(...).length`, `new Set(ref).size`. */
|
|
164
|
+
function isAggregateUse(ref) {
|
|
165
|
+
const access = ref.parent;
|
|
166
|
+
if (ts.isNewExpression(access) && ts.isIdentifier(access.expression) && access.expression.text === 'Set') {
|
|
167
|
+
const size = access.parent;
|
|
168
|
+
return ts.isPropertyAccessExpression(size) && size.name.text === 'size';
|
|
169
|
+
}
|
|
170
|
+
if (!ts.isPropertyAccessExpression(access) || access.expression !== ref)
|
|
171
|
+
return false;
|
|
172
|
+
if (AGGREGATE_METHODS.has(access.name.text))
|
|
173
|
+
return true;
|
|
174
|
+
if (access.name.text === 'filter' && ts.isCallExpression(access.parent)) {
|
|
175
|
+
const after = access.parent.parent;
|
|
176
|
+
return ts.isPropertyAccessExpression(after) && after.name.text === 'length';
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-function facts, computed once and reused by every caller.
|
|
3
|
+
*
|
|
4
|
+
* Rules decide what a fact is (for example "parameter 2 reaches a fetch that
|
|
5
|
+
* may follow redirects"); this store only memoizes them and breaks recursion:
|
|
6
|
+
* a function whose fact is being computed further up the stack answers with
|
|
7
|
+
* the rule's empty value, which never produces a finding.
|
|
8
|
+
*/
|
|
9
|
+
import type ts from 'typescript';
|
|
10
|
+
import type { FunctionLike } from './ast.js';
|
|
11
|
+
export declare class Summaries {
|
|
12
|
+
readonly checker: ts.TypeChecker;
|
|
13
|
+
private readonly values;
|
|
14
|
+
private readonly active;
|
|
15
|
+
constructor(checker: ts.TypeChecker);
|
|
16
|
+
memo<T>(kind: string, fn: FunctionLike, empty: T, compute: () => T): T;
|
|
17
|
+
private table;
|
|
18
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class Summaries {
|
|
2
|
+
checker;
|
|
3
|
+
values = new Map();
|
|
4
|
+
active = new Map();
|
|
5
|
+
constructor(checker) {
|
|
6
|
+
this.checker = checker;
|
|
7
|
+
}
|
|
8
|
+
memo(kind, fn, empty, compute) {
|
|
9
|
+
const byFn = this.table(this.values, kind, () => new WeakMap());
|
|
10
|
+
if (byFn.has(fn))
|
|
11
|
+
return byFn.get(fn);
|
|
12
|
+
const running = this.table(this.active, kind, () => new Set());
|
|
13
|
+
if (running.has(fn))
|
|
14
|
+
return empty;
|
|
15
|
+
running.add(fn);
|
|
16
|
+
try {
|
|
17
|
+
const value = compute();
|
|
18
|
+
byFn.set(fn, value);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
running.delete(fn);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
table(map, kind, create) {
|
|
26
|
+
let entry = map.get(kind);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
entry = create();
|
|
29
|
+
map.set(kind, entry);
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic engine types.
|
|
3
|
+
*
|
|
4
|
+
* A semantic rule reports a defect only when it can show how it happens:
|
|
5
|
+
* the line where the risky value enters and the line where it does harm.
|
|
6
|
+
* Anything the engine cannot resolve produces no finding.
|
|
7
|
+
*/
|
|
8
|
+
import type ts from 'typescript';
|
|
9
|
+
import type { Provenance, Severity } from '../types/index.js';
|
|
10
|
+
import type { Summaries } from './summaries.js';
|
|
11
|
+
export interface SemanticFinding {
|
|
12
|
+
rule: string;
|
|
13
|
+
severity: Severity;
|
|
14
|
+
provenance: Provenance;
|
|
15
|
+
/** cwd-relative posix path of the reported line. */
|
|
16
|
+
file: string;
|
|
17
|
+
line: number;
|
|
18
|
+
message: string;
|
|
19
|
+
hint: string;
|
|
20
|
+
/** Supporting locations, e.g. "http.ts:51 deps.fetch(url, init)". */
|
|
21
|
+
evidence: string[];
|
|
22
|
+
}
|
|
23
|
+
export interface RuleContext {
|
|
24
|
+
cwd: string;
|
|
25
|
+
checker: ts.TypeChecker;
|
|
26
|
+
sourceFile: ts.SourceFile;
|
|
27
|
+
summaries: Summaries;
|
|
28
|
+
}
|
|
29
|
+
export interface SemanticRule {
|
|
30
|
+
id: string;
|
|
31
|
+
check(ctx: RuleContext): SemanticFinding[];
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -16,4 +16,5 @@ export type { ApplicableLessons, InteractionEvidence, LessonRecord, LessonState,
|
|
|
16
16
|
export { queueLocalLessonsForTeam } from './team-import.js';
|
|
17
17
|
export type { LocalLessonImportOptions, LocalLessonImportResult } from './team-import.js';
|
|
18
18
|
export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, } from './team-store.js';
|
|
19
|
+
export { explainTeamConnectionError } from './team-diagnostics.js';
|
|
19
20
|
export type { TeamConfiguration, TeamSemanticConfiguration, TeamModeStatus, TeamDoctorResult, SemanticKnowledgeCandidate, SemanticKnowledgeResult, } from './team-store.js';
|
package/dist/storage/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export { recordContextEvent, recordModelUsage, setContextCacheRecord, getContext
|
|
|
11
11
|
export { recordInteractionEvidence, countInteractionEvidence, recordInteractionLesson, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId } from './lessons.js';
|
|
12
12
|
export { queueLocalLessonsForTeam } from './team-import.js';
|
|
13
13
|
export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, } from './team-store.js';
|
|
14
|
+
export { explainTeamConnectionError } from './team-diagnostics.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns team-database failures into messages that say what to do.
|
|
3
|
+
*
|
|
4
|
+
* `team doctor` used to throw "Database role is not provisioned for this
|
|
5
|
+
* team" whenever the membership query returned no row, including when the
|
|
6
|
+
* role was provisioned but row-level security hid it, and let connection
|
|
7
|
+
* errors escape as stack traces.
|
|
8
|
+
*/
|
|
9
|
+
export interface QueryablePool {
|
|
10
|
+
query(sql: string, params?: unknown[]): Promise<{
|
|
11
|
+
rows: any[];
|
|
12
|
+
rowCount: number | null;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
/** A connection or query error, explained with the fix. */
|
|
16
|
+
export declare function explainTeamConnectionError(error: unknown): string;
|
|
17
|
+
/**
|
|
18
|
+
* Why the membership query returned no row: RLS without a policy for this
|
|
19
|
+
* login, or no membership row for it. Visibility is read from the catalog,
|
|
20
|
+
* which every login can see.
|
|
21
|
+
*/
|
|
22
|
+
export declare function diagnoseMissingMembership(pool: QueryablePool): Promise<string>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns team-database failures into messages that say what to do.
|
|
3
|
+
*
|
|
4
|
+
* `team doctor` used to throw "Database role is not provisioned for this
|
|
5
|
+
* team" whenever the membership query returned no row, including when the
|
|
6
|
+
* role was provisioned but row-level security hid it, and let connection
|
|
7
|
+
* errors escape as stack traces.
|
|
8
|
+
*/
|
|
9
|
+
const UNTRUSTED_CERTIFICATE = new Set([
|
|
10
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
11
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
12
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
13
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
14
|
+
]);
|
|
15
|
+
/** A connection or query error, explained with the fix. */
|
|
16
|
+
export function explainTeamConnectionError(error) {
|
|
17
|
+
const err = error;
|
|
18
|
+
const code = err?.code ?? '';
|
|
19
|
+
const detail = err?.message ?? String(error);
|
|
20
|
+
if (UNTRUSTED_CERTIFICATE.has(code)) {
|
|
21
|
+
return `TLS: the database certificate chain is not trusted by Node (${code}). Managed hosts such as Supabase sign `
|
|
22
|
+
+ `with their own root CA: download it from the provider, set NODE_EXTRA_CA_CERTS=<path to the CA file>, and keep sslmode=verify-full.`;
|
|
23
|
+
}
|
|
24
|
+
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EHOSTUNREACH') {
|
|
25
|
+
return `Cannot reach the team database (${code}): check the host, port and network access. ${detail}`;
|
|
26
|
+
}
|
|
27
|
+
if (code === '28P01' || code === '28000') {
|
|
28
|
+
return `The database rejected the login (${code}): check the user and password in the team database URL.`;
|
|
29
|
+
}
|
|
30
|
+
if (code === '3F000' || code === '42P01') {
|
|
31
|
+
return `The team schema is missing (${code}): run \`rigour team init-schema\` with the administrator URL.`;
|
|
32
|
+
}
|
|
33
|
+
return detail;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Why the membership query returned no row: RLS without a policy for this
|
|
37
|
+
* login, or no membership row for it. Visibility is read from the catalog,
|
|
38
|
+
* which every login can see.
|
|
39
|
+
*/
|
|
40
|
+
export async function diagnoseMissingMembership(pool) {
|
|
41
|
+
let hidden = [];
|
|
42
|
+
try {
|
|
43
|
+
const result = await pool.query(`SELECT c.relname AS table_name
|
|
44
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
45
|
+
WHERE n.nspname = 'rigour' AND c.relname IN ('memberships', 'meta') AND c.relrowsecurity
|
|
46
|
+
AND NOT EXISTS (SELECT 1 FROM pg_policies p WHERE p.schemaname = 'rigour' AND p.tablename = c.relname)`);
|
|
47
|
+
hidden = result.rows.map(row => String(row.table_name));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Catalog not readable: fall back to the provisioning message.
|
|
51
|
+
}
|
|
52
|
+
if (hidden.length > 0) {
|
|
53
|
+
return `Row-level security is enabled on ${hidden.map(t => `rigour.${t}`).join(' and ')} with no policy, so this login `
|
|
54
|
+
+ `cannot see its membership. Run \`rigour team init-schema\` with the administrator URL to add the read policies.`;
|
|
55
|
+
}
|
|
56
|
+
return 'This database role has no membership for the configured organization, team and actor. '
|
|
57
|
+
+ 'Ask the administrator to add it (see docs/ENTERPRISE.md).';
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|