@rigour-labs/core 6.12.2-rc.3 → 6.12.2-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ /** Names this test file can call that assert. `read` reads a module once per run (cached by the caller). */
2
+ export declare function assertionHelpers(cwd: string, file: string, content: string, read: (abs: string) => Promise<string | undefined>): Promise<Set<string>>;
3
+ /** A call to one of the names on this line; `member` also counts `self.name(` (a Python method). */
4
+ export declare function callsHelper(line: string, helpers: Set<string>, member?: boolean): boolean;
5
+ /** Python: names this test file can call that assert (see the file comment). */
6
+ export declare function pythonAssertionHelpers(cwd: string, file: string, content: string, read: (abs: string) => Promise<string | undefined>): Promise<Set<string>>;
@@ -0,0 +1,155 @@
1
+ import path from 'path';
2
+ const HELPER_NAME = /^(?:expect|assert|should)[A-Z0-9_$]/;
3
+ const ASSERTS = /\bexpect\s*[(.]|\bassert\s*[.(]/;
4
+ const IMPORT = /import\s+(?:type\s+)?([\w$]+\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]([^'"]+)['"]/g;
5
+ const EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.js'];
6
+ /** Names this test file can call that assert. `read` reads a module once per run (cached by the caller). */
7
+ export async function assertionHelpers(cwd, file, content, read) {
8
+ const helpers = new Set(definedAsserting(content));
9
+ for (const m of content.matchAll(IMPORT)) {
10
+ const names = importedNames(m[1], m[2]);
11
+ for (const [local, exported] of names)
12
+ if (HELPER_NAME.test(local) || HELPER_NAME.test(exported))
13
+ helpers.add(local);
14
+ if (!m[3].startsWith('.'))
15
+ continue;
16
+ const source = await moduleSource(path.join(cwd, path.dirname(file), m[3]), read);
17
+ if (!source)
18
+ continue;
19
+ const asserting = new Set(definedAsserting(source));
20
+ for (const [local, exported] of names)
21
+ if (asserting.has(exported))
22
+ helpers.add(local);
23
+ }
24
+ return helpers;
25
+ }
26
+ /** A call to one of the names on this line; `member` also counts `self.name(` (a Python method). */
27
+ export function callsHelper(line, helpers, member = false) {
28
+ const before = member ? '(?:^|[^\\w$.]|\\bself\\.)' : '(?:^|[^\\w$.])';
29
+ for (const name of helpers)
30
+ if (new RegExp(`${before}${name.replace(/\$/g, '\\$')}\\s*[(<]`).test(line))
31
+ return true;
32
+ return false;
33
+ }
34
+ /** `[local, exported]` pairs of an import's default and named bindings. */
35
+ function importedNames(defaultName, named) {
36
+ const pairs = [];
37
+ const d = defaultName?.replace(/[\s,]/g, '');
38
+ if (d)
39
+ pairs.push([d, 'default']);
40
+ for (const part of (named ?? '').split(',')) {
41
+ const [exported, local] = part.replace(/^\s*type\s+/, '').trim().split(/\s+as\s+/);
42
+ if (exported)
43
+ pairs.push([(local ?? exported).trim(), exported.trim()]);
44
+ }
45
+ return pairs;
46
+ }
47
+ async function moduleSource(base, read) {
48
+ // An ESM import names the compiled file (`./helpers.js`); the source is `./helpers.ts`.
49
+ const stems = [...new Set([base, base.replace(/\.[cm]?js$/, '')])];
50
+ for (const stem of stems)
51
+ for (const ext of EXTENSIONS) {
52
+ const text = await read(stem + ext);
53
+ if (text !== undefined)
54
+ return text;
55
+ }
56
+ return undefined;
57
+ }
58
+ /** Functions defined in this source whose body calls expect or assert. */
59
+ function definedAsserting(source) {
60
+ const names = [];
61
+ const DEF = /function\s*\*?\s*([\w$]+)\s*[<(]|(?:const|let|var)\s+([\w$]+)\s*(?::[^=]+)?=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/g;
62
+ for (const m of source.matchAll(DEF)) {
63
+ const from = m.index + m[0].length;
64
+ const body = m[1] || !m[0].endsWith('=>') ? blockAt(source, afterParams(source, m.index)) : arrowBodyAt(source, from);
65
+ if (ASSERTS.test(body))
66
+ names.push(m[1] ?? m[2]);
67
+ }
68
+ return names;
69
+ }
70
+ /** Just past a function's parameter list (which may hold `{ … }` types or defaults), from its `function` keyword. */
71
+ function afterParams(source, from) {
72
+ const open = source.indexOf('(', from);
73
+ if (open < 0)
74
+ return from;
75
+ let depth = 0;
76
+ for (let i = open; i < source.length; i++) {
77
+ if (source[i] === '(')
78
+ depth++;
79
+ else if (source[i] === ')' && --depth === 0)
80
+ return i + 1;
81
+ }
82
+ return source.length;
83
+ }
84
+ /** The first braced block from `from`, braces balanced. */
85
+ function blockAt(source, from) {
86
+ const open = source.indexOf('{', from);
87
+ if (open < 0)
88
+ return '';
89
+ let depth = 0;
90
+ for (let i = open; i < source.length; i++) {
91
+ if (source[i] === '{')
92
+ depth++;
93
+ else if (source[i] === '}' && --depth === 0)
94
+ return source.slice(open, i + 1);
95
+ }
96
+ return source.slice(open);
97
+ }
98
+ /** An arrow function's body: a block, else its expression up to the end of the statement. */
99
+ function arrowBodyAt(source, from) {
100
+ const rest = source.slice(from);
101
+ if (rest.trimStart().startsWith('{'))
102
+ return blockAt(source, from);
103
+ const end = rest.search(/;|\n\s*\n/);
104
+ return end < 0 ? rest : rest.slice(0, end);
105
+ }
106
+ const PY_ASSERTS = /\bassert\b|\bself\.assert\w*\s*\(|\braise\s+AssertionError\b|\bpytest\.(?:raises|fail)\b|\.assert_\w+\s*\(/;
107
+ const PY_FROM = /^\s*from\s+(\.*[\w.]*)\s+import\s+\(?([^)\n]*)/gm;
108
+ /** Python: names this test file can call that assert (see the file comment). */
109
+ export async function pythonAssertionHelpers(cwd, file, content, read) {
110
+ const helpers = new Set(pythonDefinedAsserting(content));
111
+ for (const m of content.matchAll(PY_FROM)) {
112
+ for (const part of m[2].split(',')) {
113
+ const [exported, local] = part.trim().split(/\s+as\s+/);
114
+ if (exported && HELPER_NAME_PY.test(exported))
115
+ helpers.add((local ?? exported).trim());
116
+ }
117
+ const source = await pythonModule(cwd, file, m[1], read);
118
+ for (const name of source ? pythonDefinedAsserting(source) : [])
119
+ helpers.add(name);
120
+ }
121
+ return helpers;
122
+ }
123
+ const HELPER_NAME_PY = /^(?:expect|assert|should)_?\w/;
124
+ async function pythonModule(cwd, file, module, read) {
125
+ const dots = module.match(/^\.*/)[0].length;
126
+ const parts = module.slice(dots).split('.').filter(Boolean);
127
+ let base = cwd;
128
+ if (dots) {
129
+ base = path.join(cwd, path.dirname(file));
130
+ for (let i = 1; i < dots; i++)
131
+ base = path.dirname(base);
132
+ }
133
+ const stem = path.join(base, ...parts);
134
+ return await read(`${stem}.py`) ?? await read(path.join(stem, '__init__.py'));
135
+ }
136
+ /** Python functions and methods in this source whose body asserts. */
137
+ function pythonDefinedAsserting(source) {
138
+ const names = [];
139
+ const lines = source.split('\n');
140
+ lines.forEach((line, i) => {
141
+ const def = line.match(/^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/);
142
+ if (!def || def[2].startsWith('test_'))
143
+ return;
144
+ for (let j = i + 1; j < lines.length; j++) {
145
+ const next = lines[j];
146
+ if (next.trim() && next.search(/\S/) <= def[1].length && !/^\s*[)\]]/.test(next))
147
+ break;
148
+ if (PY_ASSERTS.test(next)) {
149
+ names.push(def[2]);
150
+ break;
151
+ }
152
+ }
153
+ });
154
+ return names;
155
+ }
@@ -25,6 +25,7 @@ import { FileScanner } from '../utils/scanner.js';
25
25
  import { Logger } from '../utils/logger.js';
26
26
  import { languageAdapters } from './language-adapters/index.js';
27
27
  import { checkGoTestQuality, checkJavaKotlinTestQuality } from './test-quality-lang.js';
28
+ import { assertionHelpers, callsHelper, pythonAssertionHelpers } from './test-quality-helpers.js';
28
29
  import { JS_TEST_START_PATTERN, JS_ASSERTION_PATTERNS, JS_MOCK_PATTERNS, JS_TAUTOLOGICAL_PATTERNS, JS_VAR_TAUTOLOGY_PATTERN, PYTHON_TEST_FUNC_PATTERN, PYTHON_ASSERTION_PATTERNS, PYTHON_MOCK_PATTERNS, PYTHON_TAUTOLOGICAL_PATTERNS, PYTHON_FIXTURE_PATTERN, PYTHON_CONFTEST_NAME, } from './test-quality-matchers.js';
29
30
  import fs from 'fs-extra';
30
31
  import path from 'path';
@@ -66,6 +67,13 @@ export class TestQualityGate extends Gate {
66
67
  '**/target/**', '**/.gradle/**', '**/out/**'],
67
68
  });
68
69
  Logger.info(`Test Quality: Scanning ${files.length} test files`);
70
+ // Helper modules several test files import are read once per run.
71
+ const modules = new Map();
72
+ const read = (abs) => {
73
+ if (!modules.has(abs))
74
+ modules.set(abs, Promise.resolve().then(() => fs.readFile(abs, 'utf-8')).catch(() => undefined));
75
+ return modules.get(abs);
76
+ };
69
77
  const CONCURRENCY = 16;
70
78
  for (let i = 0; i < files.length; i += CONCURRENCY) {
71
79
  const batch = files.slice(i, i + CONCURRENCY);
@@ -79,10 +87,10 @@ export class TestQualityGate extends Gate {
79
87
  const localIssues = [];
80
88
  switch (adapter.id) {
81
89
  case 'js':
82
- this.checkJSTestQuality(content, file, localIssues);
90
+ this.checkJSTestQuality(content, file, localIssues, await assertionHelpers(context.cwd, file, content, read));
83
91
  break;
84
92
  case 'python':
85
- this.checkPythonTestQuality(content, file, localIssues);
93
+ this.checkPythonTestQuality(content, file, localIssues, await pythonAssertionHelpers(context.cwd, file, content, read));
86
94
  break;
87
95
  case 'go':
88
96
  checkGoTestQuality(content, file, localIssues, {
@@ -121,7 +129,7 @@ export class TestQualityGate extends Gate {
121
129
  }
122
130
  return failures;
123
131
  }
124
- checkJSTestQuality(content, file, issues) {
132
+ checkJSTestQuality(content, file, issues, helpers = new Set()) {
125
133
  const lines = content.split('\n');
126
134
  // Track test blocks for analysis
127
135
  let inTestBlock = false;
@@ -170,7 +178,7 @@ export class TestQualityGate extends Gate {
170
178
  braceDepth--;
171
179
  }
172
180
  // Check for assertions
173
- if (JS_ASSERTION_PATTERNS.some(p => p.test(line))) {
181
+ if (JS_ASSERTION_PATTERNS.some(p => p.test(line)) || callsHelper(line, helpers)) {
174
182
  hasAssertion = true;
175
183
  }
176
184
  // Check for mocks
@@ -244,7 +252,7 @@ export class TestQualityGate extends Gate {
244
252
  });
245
253
  }
246
254
  }
247
- checkPythonTestQuality(content, file, issues) {
255
+ checkPythonTestQuality(content, file, issues, helpers = new Set()) {
248
256
  const lines = content.split('\n');
249
257
  const basename = path.basename(file);
250
258
  // Skip conftest.py entirely — these contain fixtures, not tests
@@ -309,7 +317,7 @@ export class TestQualityGate extends Gate {
309
317
  }
310
318
  testContent += line + '\n';
311
319
  // Check for assertions
312
- if (PYTHON_ASSERTION_PATTERNS.some(p => p.test(trimmed))) {
320
+ if (PYTHON_ASSERTION_PATTERNS.some(p => p.test(trimmed)) || callsHelper(trimmed, helpers, true)) {
313
321
  hasAssertion = true;
314
322
  }
315
323
  // Check for mocks
@@ -284,24 +284,51 @@ function redundancyFailures(typed, changedLines, config) {
284
284
  }
285
285
  return [];
286
286
  };
287
+ // The parts of `target` that receive T's values inside `value`, found by walking both the same way: arrays, union
288
+ // members of the value, and same-named properties (`R[]` receiving `{ …, p: T }[]` gives the type of `R.p`).
289
+ const receiving = (t, value, target, depth = 0) => {
290
+ const to = checker.getNonNullableType(target);
291
+ if (value.symbol === t.sym || value.aliasSymbol === t.sym)
292
+ return mentionsType(t, to) ? [] : [to];
293
+ if (depth > MENTION_DEPTH)
294
+ return [];
295
+ if (value.isUnion())
296
+ return value.types.flatMap(v => receiving(t, v, target, depth + 1));
297
+ if (checker.isArrayType(value))
298
+ return checker.isArrayType(to) ? receiving(t, checker.getTypeArguments(value)[0], checker.getTypeArguments(to)[0], depth + 1) : [];
299
+ if (!(value.flags & ts.TypeFlags.Object))
300
+ return [];
301
+ return checker.getPropertiesOfType(value).flatMap(p => {
302
+ const into = checker.getPropertyOfType(to, p.name);
303
+ return into ? receiving(t, checker.getTypeOfSymbol(p), checker.getTypeOfSymbol(into), depth + 1) : [];
304
+ });
305
+ };
306
+ // Where T's values go: the declared types they travel as (`into`), and the types of the places that receive them
307
+ // (`receivers`: a same-shape inline type, a parameter's type), whose members are T's members under another type.
287
308
  const flowMemo = new Map();
288
- const flowsInto = (t) => {
309
+ const flowsOf = (t) => {
289
310
  const cached = flowMemo.get(t.sym);
290
311
  if (cached)
291
312
  return cached;
292
313
  const into = new Set();
314
+ const receivers = new Set();
293
315
  for (const sf of typed.appSources) {
294
316
  walk(sf, node => {
295
317
  if (!ts.isExpression(node) || !isFlowSite(node) || !mentionsType(t, checker.getTypeAtLocation(node)))
296
318
  return;
319
+ const target = checker.getContextualType(node);
320
+ if (target && !(target.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) && !mentionsType(t, target))
321
+ for (const r of receiving(t, checker.getTypeAtLocation(node), target))
322
+ receivers.add(r);
297
323
  for (const d of namedAt(node, t))
298
324
  into.add(d);
299
325
  });
300
326
  }
301
- const found = [...into];
327
+ const found = { into: [...into], receivers: [...receivers] };
302
328
  flowMemo.set(t.sym, found);
303
329
  return found;
304
330
  };
331
+ const flowsInto = (t) => flowsOf(t).into;
305
332
  // T leaves the program where its own values do, or where a type it travels as does.
306
333
  const leaves = (t, seen = new Set([t.sym])) => {
307
334
  const direct = escapes(t);
@@ -371,8 +398,27 @@ function redundancyFailures(typed, changedLines, config) {
371
398
  if (!sym)
372
399
  return 'no such property';
373
400
  const declaration = sym.declarations?.[0];
374
- const isOurs = (s) => !!s && (s === sym || !!s.declarations?.some(d => d === declaration));
375
- const ofType = (expr) => mentionsType(t, checker.getTypeAtLocation(expr));
401
+ const ownRead = (s) => !!s && (s === sym || !!s.declarations?.some(d => d === declaration));
402
+ const ownType = (expr) => mentionsType(t, checker.getTypeAtLocation(expr));
403
+ let found = scanReads(name, ownRead, ownType);
404
+ if (found)
405
+ return found;
406
+ // A value of T received as another type (`p?: { x: string }` holding a T) is read through that type's member of the same name.
407
+ const { receivers } = flowsOf(t);
408
+ const members = new Set(receivers.map(r => checker.getPropertyOfType(r, name)).filter((m) => !!m));
409
+ if (members.size) {
410
+ const memberDecls = new Set([...members].flatMap(m => m.declarations ?? []));
411
+ const viaRead = (s) => !!s && (members.has(s) || !!s.declarations?.some(d => memberDecls.has(d)));
412
+ const viaType = (expr) => receivers.includes(checker.getNonNullableType(checker.getTypeAtLocation(expr)));
413
+ found = scanReads(name, viaRead, viaType);
414
+ if (found)
415
+ return found;
416
+ }
417
+ const text = textReads(t, name);
418
+ return text ? `${text.file} (by name)` : undefined;
419
+ };
420
+ // A read of `name` in a non-test file: an access whose symbol `isOurs` accepts, or a destructuring or `['name']` of a value `ofType` accepts.
421
+ const scanReads = (name, isOurs, ofType) => {
376
422
  for (const sf of typed.appSources) {
377
423
  if (!sf.text.includes(name) && !sf.text.includes('...'))
378
424
  continue;
@@ -390,8 +436,7 @@ function redundancyFailures(typed, changedLines, config) {
390
436
  if (read)
391
437
  return at(read);
392
438
  }
393
- const text = textReads(t, name);
394
- return text ? `${text.file} (by name)` : undefined;
439
+ return undefined;
395
440
  };
396
441
  for (const t of declaredTypes) {
397
442
  const hosts = hostsOf(t);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.12.2-rc.3",
3
+ "version": "6.12.2-rc.5",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -75,11 +75,11 @@
75
75
  "@anthropic-ai/sdk": "^0.132.1",
76
76
  "pg": "^8.16.3",
77
77
  "openai": "^5.23.2",
78
- "@rigour-labs/brain-darwin-arm64": "6.12.2-rc.3",
79
- "@rigour-labs/brain-linux-arm64": "6.12.2-rc.3",
80
- "@rigour-labs/brain-darwin-x64": "6.12.2-rc.3",
81
- "@rigour-labs/brain-win-x64": "6.12.2-rc.3",
82
- "@rigour-labs/brain-linux-x64": "6.12.2-rc.3"
78
+ "@rigour-labs/brain-darwin-x64": "6.12.2-rc.5",
79
+ "@rigour-labs/brain-linux-arm64": "6.12.2-rc.5",
80
+ "@rigour-labs/brain-win-x64": "6.12.2-rc.5",
81
+ "@rigour-labs/brain-linux-x64": "6.12.2-rc.5",
82
+ "@rigour-labs/brain-darwin-arm64": "6.12.2-rc.5"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/fs-extra": "^11.0.4",