@rigour-labs/core 6.3.1 → 6.4.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.
Files changed (130) hide show
  1. package/README.md +1 -1
  2. package/dist/deep/changed-functions.d.ts +15 -0
  3. package/dist/deep/changed-functions.js +34 -0
  4. package/dist/deep/code-pass.d.ts +10 -0
  5. package/dist/deep/code-pass.js +37 -2
  6. package/dist/deep/code-pass.test.d.ts +1 -0
  7. package/dist/deep/code-pass.test.js +69 -0
  8. package/dist/deep/code-review-prompt.d.ts +1 -1
  9. package/dist/deep/code-review-prompt.js +3 -2
  10. package/dist/deep/diff-tests/calls.d.ts +8 -0
  11. package/dist/deep/diff-tests/calls.js +85 -0
  12. package/dist/deep/diff-tests/calls.test.d.ts +1 -0
  13. package/dist/deep/diff-tests/calls.test.js +33 -0
  14. package/dist/deep/diff-tests/run.d.ts +21 -0
  15. package/dist/deep/diff-tests/run.js +178 -0
  16. package/dist/deep/diff-tests/run.test.d.ts +1 -0
  17. package/dist/deep/diff-tests/run.test.js +58 -0
  18. package/dist/deep/reference-pack.d.ts +19 -0
  19. package/dist/deep/reference-pack.js +101 -0
  20. package/dist/deep/reference-pack.test.d.ts +1 -0
  21. package/dist/deep/reference-pack.test.js +46 -0
  22. package/dist/deep/review-context-export.d.ts +11 -0
  23. package/dist/deep/review-context-export.js +31 -0
  24. package/dist/deep/review-context-export.test.d.ts +1 -0
  25. package/dist/deep/review-context-export.test.js +32 -0
  26. package/dist/deep/self-check.d.ts +21 -0
  27. package/dist/deep/self-check.js +49 -0
  28. package/dist/firewall/trusted-control.d.ts +1 -2
  29. package/dist/gates/deep-analysis.d.ts +2 -0
  30. package/dist/gates/deep-analysis.js +12 -0
  31. package/dist/gates/deep-runner.js +2 -1
  32. package/dist/gates/hallucinated-imports/js-resolver.d.ts +7 -0
  33. package/dist/gates/hallucinated-imports/js-resolver.js +17 -1
  34. package/dist/gates/hallucinated-imports-sveltekit.test.js +24 -0
  35. package/dist/gates/semantic-bugs.js +4 -3
  36. package/dist/gates/semantic-bugs.test.js +11 -5
  37. package/dist/hooks/checker.d.ts +1 -2
  38. package/dist/hooks/index.d.ts +4 -0
  39. package/dist/hooks/index.js +2 -0
  40. package/dist/hooks/stop-review.d.ts +25 -0
  41. package/dist/hooks/stop-review.js +31 -0
  42. package/dist/index.d.ts +26 -2
  43. package/dist/index.js +10 -6
  44. package/dist/inference/index.d.ts +1 -1
  45. package/dist/inference/index.js +3 -5
  46. package/dist/inference/local-model.test.d.ts +1 -0
  47. package/dist/inference/local-model.test.js +23 -0
  48. package/dist/inference/sidecar-provider.d.ts +10 -1
  49. package/dist/inference/sidecar-provider.js +28 -6
  50. package/dist/inference/types.d.ts +8 -1
  51. package/dist/inference/types.js +14 -0
  52. package/dist/inference/types.test.d.ts +1 -0
  53. package/dist/inference/types.test.js +19 -0
  54. package/dist/review/agent-fixes.d.ts +18 -0
  55. package/dist/review/agent-fixes.js +118 -0
  56. package/dist/review/agent-fixes.test.d.ts +1 -0
  57. package/dist/review/agent-fixes.test.js +32 -0
  58. package/dist/review/changed-lines.d.ts +19 -0
  59. package/dist/review/changed-lines.js +22 -0
  60. package/dist/review/diff-test-findings.d.ts +3 -0
  61. package/dist/review/diff-test-findings.js +41 -0
  62. package/dist/review/effectiveness.d.ts +34 -0
  63. package/dist/review/effectiveness.js +114 -0
  64. package/dist/review/effectiveness.test.d.ts +1 -0
  65. package/dist/review/effectiveness.test.js +42 -0
  66. package/dist/review/git-diff.d.ts +9 -0
  67. package/dist/review/git-diff.js +42 -0
  68. package/dist/review/review.d.ts +38 -0
  69. package/dist/review/review.js +52 -0
  70. package/dist/review/review.test.d.ts +1 -0
  71. package/dist/review/review.test.js +87 -0
  72. package/dist/semantic/benchmark.js +2 -2
  73. package/dist/semantic/benchmark.test.js +5 -1
  74. package/dist/semantic/engine.d.ts +3 -0
  75. package/dist/semantic/engine.js +8 -2
  76. package/dist/semantic/failure-handling.d.ts +12 -0
  77. package/dist/semantic/failure-handling.js +41 -0
  78. package/dist/semantic/intent/parallel-reads.js +2 -30
  79. package/dist/semantic/learn/learn.d.ts +2 -0
  80. package/dist/semantic/learn/learn.js +18 -0
  81. package/dist/semantic/project-facts.d.ts +25 -0
  82. package/dist/semantic/project-facts.js +104 -0
  83. package/dist/semantic/project-facts.test.d.ts +1 -0
  84. package/dist/semantic/project-facts.test.js +45 -0
  85. package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.d.ts +2 -0
  86. package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.js +71 -0
  87. package/dist/semantic/rules/ecosystem/deps-internal-symbol.d.ts +2 -0
  88. package/dist/semantic/rules/ecosystem/deps-internal-symbol.js +36 -0
  89. package/dist/semantic/rules/ecosystem/dom-self-query-in-component.d.ts +2 -0
  90. package/dist/semantic/rules/ecosystem/dom-self-query-in-component.js +60 -0
  91. package/dist/semantic/rules/ecosystem/env-bare-browser-global.d.ts +2 -0
  92. package/dist/semantic/rules/ecosystem/env-bare-browser-global.js +57 -0
  93. package/dist/semantic/rules/ecosystem/exports-condition-parity.d.ts +2 -0
  94. package/dist/semantic/rules/ecosystem/exports-condition-parity.js +57 -0
  95. package/dist/semantic/rules/ecosystem/exports-forgotten-type.d.ts +2 -0
  96. package/dist/semantic/rules/ecosystem/exports-forgotten-type.js +128 -0
  97. package/dist/semantic/rules/ecosystem/imports-barrel-cycle.d.ts +2 -0
  98. package/dist/semantic/rules/ecosystem/imports-barrel-cycle.js +78 -0
  99. package/dist/semantic/rules/ecosystem/index.d.ts +15 -0
  100. package/dist/semantic/rules/ecosystem/index.js +22 -0
  101. package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.d.ts +2 -0
  102. package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.js +87 -0
  103. package/dist/semantic/rules/ecosystem/react-inline-html-object.d.ts +2 -0
  104. package/dist/semantic/rules/ecosystem/react-inline-html-object.js +30 -0
  105. package/dist/semantic/rules/ecosystem/shared.d.ts +18 -0
  106. package/dist/semantic/rules/ecosystem/shared.js +43 -0
  107. package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.d.ts +2 -0
  108. package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.js +36 -0
  109. package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.d.ts +2 -0
  110. package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.js +66 -0
  111. package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.d.ts +2 -0
  112. package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.js +31 -0
  113. package/dist/semantic/rules/ecosystem/vue-static-computed.d.ts +2 -0
  114. package/dist/semantic/rules/ecosystem/vue-static-computed.js +51 -0
  115. package/dist/semantic/sites/call-sites.d.ts +25 -0
  116. package/dist/semantic/sites/call-sites.js +69 -0
  117. package/dist/semantic/sites/call-sites.test.d.ts +1 -0
  118. package/dist/semantic/sites/call-sites.test.js +46 -0
  119. package/dist/semantic/sites/export.d.ts +2 -0
  120. package/dist/semantic/sites/export.js +22 -0
  121. package/dist/semantic/types.d.ts +4 -0
  122. package/dist/services/engineering-knowledge-graph.d.ts +1 -2
  123. package/dist/templates/universal-config.js +2 -1
  124. package/dist/types/index.d.ts +31 -8
  125. package/dist/types/index.js +4 -2
  126. package/dist/utils/diff-removed.test.d.ts +1 -0
  127. package/dist/utils/diff-removed.test.js +29 -0
  128. package/dist/utils/diff.d.ts +12 -0
  129. package/dist/utils/diff.js +26 -0
  130. package/package.json +6 -6
package/README.md CHANGED
@@ -26,7 +26,7 @@ The core library powering [Rigour](https://rigour.run) — 27+ quality gates, fi
26
26
  - **Dependency bloat** — unused deps, heavy alternatives (moment→dayjs), duplicate purpose packages.
27
27
  - **Context-window artifacts**, inconsistent error handling, promise safety, deprecated APIs.
28
28
 
29
- **Semantic Bugs (type-aware, opt-in):** a TypeScript program with the checker and cross-file value tracing proves a defect before reporting it: credentials forwarded on redirect, paged reads aggregated in memory, cached responses that can carry a failure fallback. Rules learned from fixes (`rigour learn`) run alongside them from `.rigour/rules/`.
29
+ **Semantic Bugs (type-aware, on by default):** a TypeScript program with the checker and cross-file value tracing proves a defect before reporting it: credentials forwarded on redirect, paged reads aggregated in memory, cached responses that can carry a failure fallback. Rules learned from fixes (`rigour learn`) run alongside them from `.rigour/rules/`.
30
30
 
31
31
  **Agent Governance:** Multi-agent scope isolation, EWMA-based checkpoint supervision, context drift, retry loop breaker, memory & skills governance with DLP scanning.
32
32
 
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Functions a change touched: the outermost function around each changed line.
3
+ * Shared by the reference pack (callees and callers of the change) and
4
+ * differential tests (the exported functions to exercise).
5
+ */
6
+ import ts from 'typescript';
7
+ import { type FunctionLike } from '../semantic/ast.js';
8
+ /**
9
+ * Lines come from `sourceFile` directly, so this works on an unbound program
10
+ * too (parent pointers exist only once a type checker has been created).
11
+ */
12
+ export declare function changedFunctions(sourceFile: ts.SourceFile, focusLines: number[]): FunctionLike[];
13
+ /** `export function f` or `export const f = () => …`. */
14
+ export declare function isExported(fn: FunctionLike): boolean;
15
+ export declare function functionName(fn: FunctionLike): string | undefined;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Functions a change touched: the outermost function around each changed line.
3
+ * Shared by the reference pack (callees and callers of the change) and
4
+ * differential tests (the exported functions to exercise).
5
+ */
6
+ import ts from 'typescript';
7
+ import { forEachNode, isFunctionLike } from '../semantic/ast.js';
8
+ /**
9
+ * Lines come from `sourceFile` directly, so this works on an unbound program
10
+ * too (parent pointers exist only once a type checker has been created).
11
+ */
12
+ export function changedFunctions(sourceFile, focusLines) {
13
+ const found = [];
14
+ forEachNode(sourceFile, (node) => {
15
+ if (!isFunctionLike(node) || !node.body)
16
+ return;
17
+ const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
18
+ const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
19
+ if (focusLines.some(l => l >= start && l <= end) && !found.some(outer => outer.pos <= node.pos && node.end <= outer.end))
20
+ found.push(node);
21
+ });
22
+ return found;
23
+ }
24
+ /** `export function f` or `export const f = () => …`. */
25
+ export function isExported(fn) {
26
+ const holder = ts.isFunctionDeclaration(fn) ? fn : fn.parent?.parent?.parent;
27
+ return !!holder && ts.canHaveModifiers(holder) && !!ts.getModifiers(holder)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
28
+ }
29
+ export function functionName(fn) {
30
+ if (ts.isFunctionDeclaration(fn))
31
+ return fn.name?.text;
32
+ const decl = fn.parent;
33
+ return decl && ts.isVariableDeclaration(decl) && ts.isIdentifier(decl.name) ? decl.name.text : undefined;
34
+ }
@@ -1,6 +1,7 @@
1
1
  import type { InferenceProvider } from '../inference/types.js';
2
2
  import type { FileFacts } from './fact-extractor.js';
3
3
  import { type CodeContext } from './code-context.js';
4
+ import type { RemovedBlock } from '../utils/diff.js';
4
5
  import type { PassConfig, PassResult } from './facts-pass.js';
5
6
  export interface CodePassConfig extends PassConfig {
6
7
  cwd: string;
@@ -8,6 +9,15 @@ export interface CodePassConfig extends PassConfig {
8
9
  maxSourceChars: number;
9
10
  /** Changed lines per file (cwd-relative path → line numbers). */
10
11
  focusLines?: Record<string, number[]>;
12
+ /**
13
+ * Stronger tiers only: send reference material with each file, review it
14
+ * twice with that material in opposite orders, and self-check the union.
15
+ */
16
+ reference?: {
17
+ maxChars: number;
18
+ removed?: Record<string, RemovedBlock[]>;
19
+ prBody?: string;
20
+ };
11
21
  }
12
22
  export interface CodePassResult extends PassResult {
13
23
  contexts: CodeContext[];
@@ -7,6 +7,8 @@ import fs from 'fs-extra';
7
7
  import { buildCodeContext } from './code-context.js';
8
8
  import { buildCodeReviewPrompt } from './code-review-prompt.js';
9
9
  import { parseFindings } from './parse-findings.js';
10
+ import { buildReferencePack } from './reference-pack.js';
11
+ import { selfCheck } from './self-check.js';
10
12
  import { Logger } from '../utils/logger.js';
11
13
  export async function runCodePass(provider, facts, config) {
12
14
  const result = { findings: [], chunksTotal: facts.length, chunksFailed: 0, contexts: [] };
@@ -25,10 +27,12 @@ export async function runCodePass(provider, facts, config) {
25
27
  recordFailure(result, `${file.path}: ${error.message}`, config);
26
28
  continue;
27
29
  }
30
+ const pack = config.reference ? packFor(file.path, config) : undefined;
31
+ if (pack)
32
+ context = { ...context, source: `${context.source}\n${pack.source}` };
28
33
  result.contexts.push(context);
29
34
  try {
30
- const response = await provider.analyze(buildCodeReviewPrompt(context), config.inference);
31
- result.findings.push(...parseFindings(response));
35
+ result.findings.push(...(pack ? await reviewTwiceAndCheck(provider, context, pack, config) : await reviewOnce(provider, context, '', config)));
32
36
  }
33
37
  catch (error) {
34
38
  recordFailure(result, `${file.path}: ${error.message}`, config);
@@ -36,6 +40,37 @@ export async function runCodePass(provider, facts, config) {
36
40
  }
37
41
  return result;
38
42
  }
43
+ async function reviewOnce(provider, context, reference, config) {
44
+ return parseFindings(await provider.analyze(buildCodeReviewPrompt(context, reference), config.inference));
45
+ }
46
+ async function reviewTwiceAndCheck(provider, context, pack, config) {
47
+ const first = await reviewOnce(provider, context, pack.text, config);
48
+ const second = pack.sections.length > 1 ? await reviewOnce(provider, context, [...pack.sections].reverse().join('\n\n'), config) : [];
49
+ const union = dedupe([...first, ...second]);
50
+ if (union.length === 0)
51
+ return union;
52
+ const checked = await selfCheck(provider, context, pack.text, union, config.inference);
53
+ if (checked.withdrawn > 0)
54
+ config.onProgress?.(` Self-check withdrew ${checked.withdrawn} of ${union.length} finding(s) in ${context.file}.`);
55
+ return checked.kept;
56
+ }
57
+ function packFor(file, config) {
58
+ const reference = config.reference;
59
+ return buildReferencePack({
60
+ cwd: config.cwd, file, focusLines: config.focusLines?.[file],
61
+ removed: reference.removed?.[file], prBody: reference.prBody, maxChars: reference.maxChars,
62
+ });
63
+ }
64
+ function dedupe(findings) {
65
+ const seen = new Set();
66
+ return findings.filter(f => {
67
+ const key = `${f.file}:${f.line}:${f.category}`;
68
+ if (seen.has(key))
69
+ return false;
70
+ seen.add(key);
71
+ return true;
72
+ });
73
+ }
39
74
  function recordFailure(result, message, config) {
40
75
  result.chunksFailed++;
41
76
  result.firstError ??= message;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { runCodePass } from './code-pass.js';
6
+ import { parseVerdict } from './self-check.js';
7
+ let dir;
8
+ afterEach(() => { if (dir)
9
+ fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; });
10
+ const SOURCE = 'export function total(items: number[]) {\n let sum = 0;\n for (let i = 0; i <= items.length; i++) sum += items[i];\n return sum;\n}\n';
11
+ const facts = { path: 'total.ts', language: 'typescript', classes: [], functions: [] };
12
+ function finding(line, category, description) {
13
+ return { category, severity: 'high', file: 'total.ts', line, description, suggestion: 's', confidence: 0.9 };
14
+ }
15
+ /** Review passes answer from `reviews` in turn; self-checks withdraw findings whose line is in `withdraw`. */
16
+ function fakeProvider(reviews, withdraw) {
17
+ const prompts = [];
18
+ const provider = {
19
+ name: 'fake', isAvailable: async () => true, setup: async () => { }, dispose: () => { },
20
+ analyze: async (prompt) => {
21
+ prompts.push(prompt);
22
+ if (prompt.startsWith('You reported a defect')) {
23
+ const line = Number(prompt.match(/REPORTED at total\.ts:(\d+)/)?.[1]);
24
+ return JSON.stringify({ real: !withdraw.includes(line) });
25
+ }
26
+ return JSON.stringify({ findings: reviews.shift() ?? [] });
27
+ },
28
+ };
29
+ return { provider, prompts };
30
+ }
31
+ function run(provider, reference) {
32
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-pass-'));
33
+ fs.writeFileSync(path.join(dir, 'total.ts'), SOURCE);
34
+ return runCodePass(provider, [facts], { cwd: dir, inference: {}, maxSourceChars: 10_000, focusLines: { 'total.ts': [3] }, reference });
35
+ }
36
+ describe('runCodePass', () => {
37
+ it('reviews once without reference material (the small tiers)', async () => {
38
+ const { provider, prompts } = fakeProvider([[finding(3, 'correctness', 'off by one on `items`')]], []);
39
+ const result = await run(provider);
40
+ expect(prompts).toHaveLength(1);
41
+ expect(result.findings.map(f => f.line)).toEqual([3]);
42
+ });
43
+ it('does not repeat an identical pass when there is only one reference section', async () => {
44
+ const { provider, prompts } = fakeProvider([[]], []);
45
+ await run(provider, { maxChars: 4000, prBody: 'Sum the basket.' });
46
+ expect(prompts).toHaveLength(1);
47
+ });
48
+ it('with reference material, reviews twice in opposite orders, merges, and keeps what the self-check confirms', async () => {
49
+ const { provider, prompts } = fakeProvider([
50
+ [finding(3, 'correctness', 'reads past the end of `items`')],
51
+ [finding(3, 'correctness', 'same bug seen again'), finding(2, 'consistency', 'style-ish claim about `sum`')],
52
+ ], [2]);
53
+ const result = await run(provider, {
54
+ maxChars: 4000, prBody: 'Sum the basket.',
55
+ removed: { 'total.ts': [{ line: 3, text: [' for (let i = 0; i < items.length; i++) sum += items[i];'] }] },
56
+ });
57
+ const reviews = prompts.filter(p => !p.startsWith('You reported a defect'));
58
+ expect(reviews).toHaveLength(2);
59
+ expect(reviews[0].indexOf('PR DESCRIPTION')).toBeLessThan(reviews[0].indexOf('REMOVED by this change'));
60
+ expect(reviews[1].indexOf('REMOVED by this change')).toBeLessThan(reviews[1].indexOf('PR DESCRIPTION'));
61
+ expect(prompts.filter(p => p.startsWith('You reported a defect'))).toHaveLength(2);
62
+ expect(result.findings.map(f => [f.line, f.category])).toEqual([[3, 'correctness']]);
63
+ });
64
+ it('reads a self-check reply only when it is a verdict', () => {
65
+ expect(parseVerdict('{"real": false, "reason": "handled"}')).toBe(false);
66
+ expect(parseVerdict('Sure! {"real": true}')).toBe(true);
67
+ expect(parseVerdict('I am not sure')).toBeUndefined();
68
+ });
69
+ });
@@ -14,4 +14,4 @@ export declare const REVIEW_CATEGORIES: ReadonlyArray<{
14
14
  key: string;
15
15
  focus: string;
16
16
  }>;
17
- export declare function buildCodeReviewPrompt(context: CodeContext): string;
17
+ export declare function buildCodeReviewPrompt(context: CodeContext, reference?: string): string;
@@ -18,13 +18,14 @@ const REVIEW_RULES = `RULES:
18
18
  4. If you find nothing concrete, return {"findings": []}. An empty list is a good answer.
19
19
  5. confidence: 0.9+ only when the defect is certain from the code shown; 0.5-0.7 when it depends on how callers use it.
20
20
  6. Respond ONLY with JSON: {"findings":[{"category","severity","file","line","description","suggestion","confidence"}]}.`;
21
- export function buildCodeReviewPrompt(context) {
21
+ export function buildCodeReviewPrompt(context, reference = '') {
22
22
  const categories = REVIEW_CATEGORIES.map(c => `- ${c.key}: ${c.focus}`).join('\n');
23
23
  return [
24
24
  'You are a senior engineer reviewing a change before it ships. Read the source and report real defects.',
25
25
  `CATEGORIES:\n${categories}`,
26
26
  REVIEW_RULES,
27
27
  `SOURCE (line numbers on the left):\n${context.text}`,
28
+ reference ? `REFERENCE (context for the change, not under review; report defects only at lines of ${context.file}):\n${reference}` : '',
28
29
  `Review ${context.file}. Return findings as JSON.`,
29
- ].join('\n\n');
30
+ ].filter(Boolean).join('\n\n');
30
31
  }
@@ -0,0 +1,8 @@
1
+ export type Runner = 'vitest' | 'jest';
2
+ export declare function safeCalls(name: string, candidates: string[]): string[];
3
+ /**
4
+ * A test file that runs each call and records its outcome (a stable rendering of
5
+ * the value, or the error's type and message) to the file named by
6
+ * RIGOUR_DIFF_OUT. It asserts nothing: the verdict is base versus head.
7
+ */
8
+ export declare function testFileSource(runner: Runner, name: string, importPath: string, calls: string[]): string;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The calls a differential test makes, and the test file that records them.
3
+ *
4
+ * The model proposes calls; it never writes test code. A call is kept only if
5
+ * it is `name(...)` with plain literal arguments (strings, numbers, booleans,
6
+ * null, undefined, and arrays or objects of those), so what runs is the
7
+ * function under test on data, nothing else: no file, network or process
8
+ * access can come from model output. Rigour writes the file that runs them.
9
+ */
10
+ import ts from 'typescript';
11
+ const MAX_CALLS = 8;
12
+ export function safeCalls(name, candidates) {
13
+ const kept = new Set();
14
+ for (const candidate of candidates) {
15
+ const text = candidate.trim().replace(/;$/, '');
16
+ if (kept.size >= MAX_CALLS || text.length > 300)
17
+ continue;
18
+ const statement = ts.createSourceFile('call.ts', text, ts.ScriptTarget.Latest, true).statements;
19
+ if (statement.length !== 1 || !ts.isExpressionStatement(statement[0]))
20
+ continue;
21
+ const call = statement[0].expression;
22
+ if (ts.isCallExpression(call) && ts.isIdentifier(call.expression) && call.expression.text === name
23
+ && !call.typeArguments && call.arguments.every(isLiteralValue))
24
+ kept.add(call.getText());
25
+ }
26
+ return [...kept];
27
+ }
28
+ function isLiteralValue(node) {
29
+ if (ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
30
+ return true;
31
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword)
32
+ return true;
33
+ if (ts.isIdentifier(node))
34
+ return node.text === 'undefined' || node.text === 'NaN' || node.text === 'Infinity';
35
+ if (ts.isPrefixUnaryExpression(node))
36
+ return node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand);
37
+ if (ts.isArrayLiteralExpression(node))
38
+ return node.elements.every(isLiteralValue);
39
+ if (ts.isObjectLiteralExpression(node)) {
40
+ return node.properties.every(p => ts.isPropertyAssignment(p) && !ts.isComputedPropertyName(p.name) && isLiteralValue(p.initializer));
41
+ }
42
+ return false;
43
+ }
44
+ /**
45
+ * A test file that runs each call and records its outcome (a stable rendering of
46
+ * the value, or the error's type and message) to the file named by
47
+ * RIGOUR_DIFF_OUT. It asserts nothing: the verdict is base versus head.
48
+ */
49
+ export function testFileSource(runner, name, importPath, calls) {
50
+ return [
51
+ runner === 'vitest' ? "import { test } from 'vitest';" : '',
52
+ "import { writeFileSync } from 'fs';",
53
+ `import { ${name} } from ${JSON.stringify(importPath)};`,
54
+ '',
55
+ 'function render(value: unknown): string {',
56
+ " const seen = new WeakSet<object>();",
57
+ ' return JSON.stringify(value, (_key, v) => {',
58
+ " if (typeof v === 'bigint') return `${v}n`;",
59
+ " if (typeof v === 'function') return '[function]';",
60
+ " if (v instanceof Map) return { map: [...v.entries()] };",
61
+ " if (v instanceof Set) return { set: [...v.values()] };",
62
+ " if (v && typeof v === 'object') { if (seen.has(v)) return '[circular]'; seen.add(v); }",
63
+ " return v === undefined ? '[undefined]' : v;",
64
+ ' }) ?? "[undefined]";',
65
+ '}',
66
+ '',
67
+ `const calls: Array<[string, () => unknown]> = [`,
68
+ ...calls.map(call => ` [${JSON.stringify(call)}, () => ${call}],`),
69
+ '];',
70
+ '',
71
+ "test('rigour differential calls', async () => {",
72
+ ' const out: Record<string, string> = {};',
73
+ ' for (const [label, run] of calls) {',
74
+ ' try {',
75
+ ' out[label] = `returns ${render(await run())}`;',
76
+ ' } catch (error) {',
77
+ " const e = error as { constructor?: { name?: string }; message?: string };",
78
+ " out[label] = `throws ${e?.constructor?.name ?? 'Error'}: ${e?.message ?? String(error)}`;",
79
+ ' }',
80
+ ' }',
81
+ ' writeFileSync(process.env.RIGOUR_DIFF_OUT as string, JSON.stringify(out));',
82
+ '});',
83
+ '',
84
+ ].filter((line, i) => i > 0 || line).join('\n');
85
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { safeCalls, testFileSource } from './calls.js';
3
+ describe('safeCalls', () => {
4
+ it('keeps calls of the function with literal arguments only', () => {
5
+ expect(safeCalls('encodePath', [
6
+ "encodePath('/a b')",
7
+ "encodePath('/x?y', { keepQuery: true, depth: -1 }, [1, null, undefined]);",
8
+ 'encodePath(`/%`)',
9
+ ])).toEqual(["encodePath('/a b')", "encodePath('/x?y', { keepQuery: true, depth: -1 }, [1, null, undefined])", 'encodePath(`/%`)']);
10
+ });
11
+ it('drops anything that could do more than call the function on data', () => {
12
+ expect(safeCalls('encodePath', [
13
+ "require('fs').rmSync('/')",
14
+ "encodePath(process.env.HOME)",
15
+ "encodePath((() => { fetch('x') })())",
16
+ 'encodePath(`${secret}`)',
17
+ "other('/a')",
18
+ "encodePath('/a'); encodePath('/b')",
19
+ "encodePath({ [key]: 1 })",
20
+ ])).toEqual([]);
21
+ });
22
+ });
23
+ describe('testFileSource', () => {
24
+ it('imports the function, runs each call and records return values or errors without asserting', () => {
25
+ const source = testFileSource('vitest', 'encodePath', '../src/path', ["encodePath('/a b')"]);
26
+ expect(source).toContain("import { test } from 'vitest';");
27
+ expect(source).toContain('import { encodePath } from "../src/path";');
28
+ expect(source).toContain(`["encodePath('/a b')", () => encodePath('/a b')],`);
29
+ expect(source).toContain('writeFileSync(process.env.RIGOUR_DIFF_OUT as string, JSON.stringify(out));');
30
+ expect(source).not.toContain('expect(');
31
+ expect(testFileSource('jest', 'f', './f', ['f(1)'])).not.toContain("from 'vitest'");
32
+ });
33
+ });
@@ -0,0 +1,21 @@
1
+ import type { InferenceOptions, InferenceProvider } from '../../inference/types.js';
2
+ export interface DiffTestInput {
3
+ cwd: string;
4
+ /** The ref the change is compared against (a merge base, or HEAD for working changes). */
5
+ baseRef: string;
6
+ focusLines: Record<string, number[]>;
7
+ provider: InferenceProvider;
8
+ inference: InferenceOptions;
9
+ prBody?: string;
10
+ timeoutMs?: number;
11
+ }
12
+ export interface BehaviourChange {
13
+ file: string;
14
+ /** A changed line inside the function, so the finding lands on the change. */
15
+ line: number;
16
+ name: string;
17
+ call: string;
18
+ before: string;
19
+ after: string;
20
+ }
21
+ export declare function runDiffTests(input: DiffTestInput): Promise<BehaviourChange[]>;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Differential tests: call each changed exported function on the same inputs
3
+ * before and after the change, and report where its behaviour changed.
4
+ *
5
+ * Many bugs reviewers miss only show up when the code runs: an encoder that
6
+ * stopped escaping spaces, a status that no longer reaches `notFound`. The
7
+ * model proposes edge-case calls (validated to be literal-only), Rigour writes
8
+ * a recording test, runs it in a worktree of the base and in the head tree,
9
+ * and compares. A change the PR description says is intended is dropped.
10
+ *
11
+ * Opt-in (`rigour review --diff-tests`), for the max and cloud tiers, and only
12
+ * where the package already runs vitest or jest.
13
+ */
14
+ import { execa } from 'execa';
15
+ import fs from 'fs';
16
+ import os from 'os';
17
+ import path from 'path';
18
+ import { evaluateTypedCommand } from '../../firewall/typed-command.js';
19
+ import { loadProjectConfig, programBatches } from '../../semantic/program.js';
20
+ import { ProjectFacts } from '../../semantic/project-facts.js';
21
+ import { changedFunctions, functionName, isExported } from '../changed-functions.js';
22
+ import { safeCalls, testFileSource } from './calls.js';
23
+ const MAX_TARGETS = 5;
24
+ /** A junction on Windows needs no admin rights; elsewhere a plain directory symlink. */
25
+ const DIR_LINK = process.platform === 'win32' ? 'junction' : 'dir';
26
+ const TS_FILE = /\.(?:[cm]?ts|tsx)$/i;
27
+ const CALLS_SCHEMA = { type: 'object', properties: { calls: { type: 'array', items: { type: 'string' } } }, required: ['calls'] };
28
+ const INTENT_SCHEMA = { type: 'object', properties: { intended: { type: 'boolean' } }, required: ['intended'] };
29
+ export async function runDiffTests(input) {
30
+ const targets = findTargets(input.cwd, input.focusLines);
31
+ if (targets.length === 0)
32
+ return [];
33
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-diff-base-'));
34
+ try {
35
+ await execa('git', ['worktree', 'add', '-q', '--detach', base, input.baseRef], { cwd: input.cwd });
36
+ linkDependencies(input.cwd, base);
37
+ const changes = [];
38
+ for (const target of targets) {
39
+ if (!fs.existsSync(path.join(base, target.file)))
40
+ continue; // a new file has no "before"
41
+ const calls = await proposeCalls(input, target);
42
+ if (calls.length === 0)
43
+ continue;
44
+ const before = await record(base, input.cwd, target, calls, input.timeoutMs);
45
+ const after = await record(input.cwd, input.cwd, target, calls, input.timeoutMs);
46
+ for (const call of calls) {
47
+ if (before[call] !== undefined && after[call] !== undefined && before[call] !== after[call]) {
48
+ changes.push({ file: target.file, line: target.line, name: target.name, call, before: before[call], after: after[call] });
49
+ }
50
+ }
51
+ }
52
+ return input.prBody ? await dropIntended(input, changes) : changes;
53
+ }
54
+ finally {
55
+ await execa('git', ['worktree', 'remove', '--force', base], { cwd: input.cwd, reject: false });
56
+ fs.rmSync(base, { recursive: true, force: true });
57
+ }
58
+ }
59
+ /** Changed exported functions in TS files of packages that run vitest or jest. */
60
+ function findTargets(cwd, focusLines) {
61
+ const facts = new ProjectFacts(cwd);
62
+ const targets = [];
63
+ const files = Object.keys(focusLines).filter(f => TS_FILE.test(f) && !/\.(?:test|spec)\./.test(f) && focusLines[f].length > 0);
64
+ const options = loadProjectConfig(cwd).options;
65
+ for (const file of files) {
66
+ const runner = facts.declares(file, 'vitest') ? 'vitest' : facts.declares(file, 'jest') ? 'jest' : undefined;
67
+ const packageDir = facts.packageDir(file);
68
+ if (!runner || !packageDir)
69
+ continue;
70
+ const absolute = path.resolve(cwd, file);
71
+ const [program] = programBatches([absolute], options, 1);
72
+ const sourceFile = program?.getSourceFile(absolute);
73
+ if (!program || !sourceFile)
74
+ continue;
75
+ program.getTypeChecker(); // binds the program: export checks walk parent pointers
76
+ for (const fn of changedFunctions(sourceFile, focusLines[file]).filter(isExported)) {
77
+ const name = functionName(fn);
78
+ const start = sourceFile.getLineAndCharacterOfPosition(fn.getStart(sourceFile)).line + 1;
79
+ const end = sourceFile.getLineAndCharacterOfPosition(fn.getEnd()).line + 1;
80
+ const line = focusLines[file].find(l => l >= start && l <= end);
81
+ if (name && line)
82
+ targets.push({ file, line, name, source: fn.getText(sourceFile), runner, packageDir });
83
+ if (targets.length >= MAX_TARGETS)
84
+ return targets;
85
+ }
86
+ }
87
+ return targets;
88
+ }
89
+ async function proposeCalls(input, target) {
90
+ const prompt = [
91
+ `Propose up to 8 calls of \`${target.name}\` that probe its edge cases: empty, boundary, unusual characters, missing optional fields.`,
92
+ 'Use literal arguments only (strings, numbers, booleans, null, undefined, arrays and objects of those). No variables, no other functions.',
93
+ `FUNCTION (${target.file}):\n${target.source}`,
94
+ input.prBody ? `THE CHANGE INTENDS:\n${input.prBody.slice(0, 1000)}` : '',
95
+ `Respond ONLY with JSON: {"calls": ["${target.name}(...)", ...]}`,
96
+ ].filter(Boolean).join('\n\n');
97
+ try {
98
+ const reply = await input.provider.analyze(prompt, { ...input.inference, jsonSchema: CALLS_SCHEMA });
99
+ const parsed = JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1));
100
+ return safeCalls(target.name, Array.isArray(parsed.calls) ? parsed.calls.map(String) : []);
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ }
106
+ /**
107
+ * Run the recording test in `root` (the base worktree or the head tree, `head`
108
+ * being where the target was found) and return each call's outcome; empty when
109
+ * the run fails.
110
+ */
111
+ async function record(root, head, target, calls, timeoutMs = 120_000) {
112
+ const testFile = path.join(root, path.dirname(target.file), `__rigour_diff_${target.name}.test.ts`);
113
+ const outFile = path.join(os.tmpdir(), `rigour-diff-${process.pid}-${Date.now()}.json`);
114
+ const importPath = `./${path.basename(target.file).replace(/\.(?:[cm]?ts|tsx)$/, '')}`;
115
+ const packageDir = path.join(root, path.relative(head, target.packageDir));
116
+ fs.writeFileSync(testFile, testFileSource(target.runner, target.name, importPath, calls));
117
+ try {
118
+ const runner = installedRunner(packageDir, root, target.runner);
119
+ const command = runner && `${runner} ${target.runner === 'vitest' ? 'run ' : ''}${path.relative(packageDir, testFile)}`;
120
+ if (!command || evaluateTypedCommand(command).decision !== 'allow')
121
+ return {};
122
+ const [bin, ...args] = command.split(' ');
123
+ await execa(bin, args, { cwd: packageDir, env: { ...process.env, RIGOUR_DIFF_OUT: outFile, CI: '1' }, timeout: timeoutMs, reject: false });
124
+ return fs.existsSync(outFile) ? JSON.parse(fs.readFileSync(outFile, 'utf8')) : {};
125
+ }
126
+ finally {
127
+ fs.rmSync(testFile, { force: true });
128
+ fs.rmSync(outFile, { force: true });
129
+ }
130
+ }
131
+ /** The package's own installed runner, never a download: node_modules/.bin up to the repository root. */
132
+ function installedRunner(packageDir, root, runner) {
133
+ for (let dir = packageDir; dir.startsWith(root); dir = path.dirname(dir)) {
134
+ const bin = path.join(dir, 'node_modules', '.bin', runner);
135
+ if (fs.existsSync(bin))
136
+ return bin;
137
+ if (dir === root)
138
+ break;
139
+ }
140
+ return undefined;
141
+ }
142
+ /** The base worktree has no installed packages: link the head's node_modules into it. */
143
+ function linkDependencies(head, base) {
144
+ const walk = (dir, depth) => {
145
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
146
+ const from = path.join(dir, entry.name);
147
+ const to = path.join(base, path.relative(head, from));
148
+ if (entry.name === 'node_modules') {
149
+ // A linked node_modules (pnpm, a workspace) counts; its target is what the base needs.
150
+ if (fs.existsSync(path.dirname(to)) && !fs.existsSync(to))
151
+ fs.symlinkSync(fs.realpathSync(from), to, DIR_LINK);
152
+ }
153
+ else if (entry.isDirectory() && entry.name !== '.git' && depth < 3) {
154
+ walk(from, depth + 1); // never through symlinks: no loops
155
+ }
156
+ }
157
+ };
158
+ walk(head, 0);
159
+ }
160
+ async function dropIntended(input, changes) {
161
+ const kept = [];
162
+ for (const change of changes) {
163
+ const prompt = [
164
+ `THE CHANGE INTENDS:\n${input.prBody.slice(0, 1500)}`,
165
+ `BEHAVIOUR CHANGED: ${change.call} ${change.before} before the change, and ${change.after} after it.`,
166
+ 'Is this behaviour change what the description asks for? Respond ONLY with JSON: {"intended": true|false}.',
167
+ ].join('\n\n');
168
+ try {
169
+ const reply = await input.provider.analyze(prompt, { ...input.inference, jsonSchema: INTENT_SCHEMA });
170
+ if (JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1)).intended !== true)
171
+ kept.push(change);
172
+ }
173
+ catch {
174
+ kept.push(change);
175
+ }
176
+ }
177
+ return kept;
178
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import { runDiffTests } from './run.js';
7
+ // The workspace root's node_modules, where the vitest binary is installed.
8
+ const WORKSPACE_MODULES = path.resolve(__dirname, '../../../../../node_modules');
9
+ let repo;
10
+ afterEach(() => { if (repo)
11
+ fs.rmSync(repo, { recursive: true, force: true }); repo = undefined; });
12
+ const BEFORE = "export function encodePath(p: string): string {\n return p.replace(/ /g, '%20').replace(/#/g, '%23');\n}\n";
13
+ const AFTER = "export function encodePath(p: string): string {\n return p.replace(/#/g, '%23');\n}\n";
14
+ function repoWithChange() {
15
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'diff-tests-'));
16
+ const git = (...args) => execFileSync('git', args, { cwd: repo });
17
+ git('init', '-q');
18
+ git('config', 'user.email', 't@example.com');
19
+ git('config', 'user.name', 't');
20
+ git('config', 'commit.gpgsign', 'false');
21
+ fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ name: 'paths', type: 'module', devDependencies: { vitest: '*' } }));
22
+ fs.writeFileSync(path.join(repo, '.gitignore'), 'node_modules\n');
23
+ fs.mkdirSync(path.join(repo, 'src'));
24
+ fs.writeFileSync(path.join(repo, 'src/encode.ts'), BEFORE);
25
+ git('add', '-A');
26
+ git('commit', '-qm', 'init');
27
+ fs.symlinkSync(WORKSPACE_MODULES, path.join(repo, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir');
28
+ fs.writeFileSync(path.join(repo, 'src/encode.ts'), AFTER);
29
+ return repo;
30
+ }
31
+ function provider(calls, intended = false) {
32
+ return {
33
+ name: 'fake', isAvailable: async () => true, setup: async () => { }, dispose: () => { },
34
+ analyze: async (prompt) => prompt.includes('"intended"') ? JSON.stringify({ intended }) : JSON.stringify({ calls }),
35
+ };
36
+ }
37
+ describe('runDiffTests', () => {
38
+ it('reports a call whose outcome changed between the base and the change', async () => {
39
+ const cwd = repoWithChange();
40
+ const changes = await runDiffTests({
41
+ cwd, baseRef: 'HEAD', focusLines: { 'src/encode.ts': [2] }, inference: {},
42
+ provider: provider(["encodePath('/a b')", "encodePath('/x#y')", "process.exit(1)"]),
43
+ });
44
+ expect(changes).toEqual([{
45
+ file: 'src/encode.ts', line: 2, name: 'encodePath', call: "encodePath('/a b')",
46
+ before: 'returns "/a%20b"', after: 'returns "/a b"',
47
+ }]);
48
+ expect(fs.readdirSync(path.join(cwd, 'src'))).toEqual(['encode.ts']);
49
+ }, 60_000);
50
+ it('drops a change the PR description says is intended', async () => {
51
+ const cwd = repoWithChange();
52
+ const changes = await runDiffTests({
53
+ cwd, baseRef: 'HEAD', focusLines: { 'src/encode.ts': [2] }, inference: {}, prBody: 'Stop encoding spaces.',
54
+ provider: provider(["encodePath('/a b')"], true),
55
+ });
56
+ expect(changes).toEqual([]);
57
+ }, 60_000);
58
+ });