@rigour-labs/core 6.2.5 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +2 -0
  2. package/dist/discovery-signals.d.ts +27 -0
  3. package/dist/discovery-signals.js +96 -0
  4. package/dist/discovery.d.ts +1 -2
  5. package/dist/discovery.js +36 -49
  6. package/dist/discovery.test.js +84 -71
  7. package/dist/gates/deep-analysis.d.ts +2 -0
  8. package/dist/gates/deep-analysis.js +5 -1
  9. package/dist/gates/deep-intent.d.ts +3 -0
  10. package/dist/gates/deep-intent.js +57 -0
  11. package/dist/gates/deep-intent.test.d.ts +1 -0
  12. package/dist/gates/deep-intent.test.js +51 -0
  13. package/dist/gates/deep-runner.js +1 -0
  14. package/dist/gates/deprecated-apis-ast.d.ts +8 -0
  15. package/dist/gates/deprecated-apis-ast.js +25 -0
  16. package/dist/gates/deprecated-apis-rules-node.js +2 -8
  17. package/dist/gates/deprecated-apis.js +5 -1
  18. package/dist/gates/deprecated-apis.test.js +30 -0
  19. package/dist/gates/frontend-secret-exposure.js +5 -1
  20. package/dist/gates/frontend-secret-exposure.test.js +20 -0
  21. package/dist/gates/hallucinated-imports/framework-modules.d.ts +14 -0
  22. package/dist/gates/hallucinated-imports/framework-modules.js +75 -0
  23. package/dist/gates/hallucinated-imports/index.js +14 -6
  24. package/dist/gates/hallucinated-imports/js-resolver.d.ts +16 -12
  25. package/dist/gates/hallucinated-imports/js-resolver.js +65 -153
  26. package/dist/gates/hallucinated-imports/ts-path-target.js +16 -0
  27. package/dist/gates/hallucinated-imports/tsconfig-paths.d.ts +16 -0
  28. package/dist/gates/hallucinated-imports/tsconfig-paths.js +120 -0
  29. package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +1 -0
  30. package/dist/gates/hallucinated-imports-sveltekit.test.js +108 -0
  31. package/dist/gates/runner.js +5 -0
  32. package/dist/gates/semantic-bugs.d.ts +23 -0
  33. package/dist/gates/semantic-bugs.js +48 -0
  34. package/dist/gates/semantic-bugs.test.d.ts +1 -0
  35. package/dist/gates/semantic-bugs.test.js +70 -0
  36. package/dist/gates/side-effect-analysis/index.js +11 -1
  37. package/dist/gates/side-effect-analysis.test.d.ts +1 -0
  38. package/dist/gates/side-effect-analysis.test.js +162 -0
  39. package/dist/gates/side-effect-helpers/pattern-detection.js +35 -7
  40. package/dist/gates/side-effect-helpers/resource-tracking.js +17 -10
  41. package/dist/gates/side-effect-helpers/scope-analysis.d.ts +6 -0
  42. package/dist/gates/side-effect-helpers/scope-analysis.js +20 -0
  43. package/dist/index.d.ts +5 -1
  44. package/dist/index.js +4 -1
  45. package/dist/inference/sidecar-provider.js +12 -1
  46. package/dist/inference/types.d.ts +2 -0
  47. package/dist/semantic/ast.d.ts +29 -0
  48. package/dist/semantic/ast.js +122 -0
  49. package/dist/semantic/benchmark.d.ts +16 -0
  50. package/dist/semantic/benchmark.js +31 -0
  51. package/dist/semantic/benchmark.test.d.ts +1 -0
  52. package/dist/semantic/benchmark.test.js +18 -0
  53. package/dist/semantic/engine.d.ts +8 -0
  54. package/dist/semantic/engine.js +45 -0
  55. package/dist/semantic/intent/benchmark.d.ts +31 -0
  56. package/dist/semantic/intent/benchmark.js +74 -0
  57. package/dist/semantic/intent/intent-check.d.ts +43 -0
  58. package/dist/semantic/intent/intent-check.js +72 -0
  59. package/dist/semantic/intent/intent.test.d.ts +1 -0
  60. package/dist/semantic/intent/intent.test.js +46 -0
  61. package/dist/semantic/intent/parallel-reads.d.ts +23 -0
  62. package/dist/semantic/intent/parallel-reads.js +59 -0
  63. package/dist/semantic/learn/benchmark.d.ts +20 -0
  64. package/dist/semantic/learn/benchmark.js +71 -0
  65. package/dist/semantic/learn/compile.d.ts +4 -0
  66. package/dist/semantic/learn/compile.js +56 -0
  67. package/dist/semantic/learn/conditions.d.ts +22 -0
  68. package/dist/semantic/learn/conditions.js +93 -0
  69. package/dist/semantic/learn/extract.d.ts +30 -0
  70. package/dist/semantic/learn/extract.js +122 -0
  71. package/dist/semantic/learn/git-trees.d.ts +8 -0
  72. package/dist/semantic/learn/git-trees.js +32 -0
  73. package/dist/semantic/learn/identity.d.ts +18 -0
  74. package/dist/semantic/learn/identity.js +97 -0
  75. package/dist/semantic/learn/learn.d.ts +25 -0
  76. package/dist/semantic/learn/learn.js +109 -0
  77. package/dist/semantic/learn/learn.test.d.ts +1 -0
  78. package/dist/semantic/learn/learn.test.js +67 -0
  79. package/dist/semantic/learn/store.d.ts +4 -0
  80. package/dist/semantic/learn/store.js +39 -0
  81. package/dist/semantic/learn/types.d.ts +468 -0
  82. package/dist/semantic/learn/types.js +55 -0
  83. package/dist/semantic/origins.d.ts +42 -0
  84. package/dist/semantic/origins.js +149 -0
  85. package/dist/semantic/origins.test.d.ts +1 -0
  86. package/dist/semantic/origins.test.js +77 -0
  87. package/dist/semantic/program.d.ts +13 -0
  88. package/dist/semantic/program.js +62 -0
  89. package/dist/semantic/rules/credential-redirect.d.ts +2 -0
  90. package/dist/semantic/rules/credential-redirect.js +209 -0
  91. package/dist/semantic/rules/degraded-response-cached.d.ts +2 -0
  92. package/dist/semantic/rules/degraded-response-cached.js +271 -0
  93. package/dist/semantic/rules/in-memory-aggregation.d.ts +2 -0
  94. package/dist/semantic/rules/in-memory-aggregation.js +179 -0
  95. package/dist/semantic/summaries.d.ts +18 -0
  96. package/dist/semantic/summaries.js +33 -0
  97. package/dist/semantic/types.d.ts +32 -0
  98. package/dist/semantic/types.js +1 -0
  99. package/dist/storage/index.d.ts +1 -0
  100. package/dist/storage/index.js +1 -0
  101. package/dist/storage/team-diagnostics.d.ts +22 -0
  102. package/dist/storage/team-diagnostics.js +58 -0
  103. package/dist/storage/team-diagnostics.test.d.ts +1 -0
  104. package/dist/storage/team-diagnostics.test.js +51 -0
  105. package/dist/storage/team-schema.d.ts +6 -0
  106. package/dist/storage/team-schema.js +82 -0
  107. package/dist/storage/team-store.js +25 -72
  108. package/dist/templates/universal-config.js +4 -0
  109. package/dist/types/index.d.ts +56 -0
  110. package/dist/types/index.js +7 -0
  111. package/package.json +6 -6
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Intent benchmark: labelled `Promise.all` sites for optional-read-no-fallback.
3
+ *
4
+ * Each case is one file (`code.ts`) whose reads are labelled optional or
5
+ * required, and whether a finding is expected. Scoring separates the raw
6
+ * answers from the verdicts that survive the flipped-question filter, so
7
+ * the filter's effect (accuracy gained, answers discarded) is measured.
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { programBatches, loadProjectConfig } from '../program.js';
12
+ import { classifyReads, findingsFor } from './intent-check.js';
13
+ import { findParallelReads } from './parallel-reads.js';
14
+ export function loadIntentCases(root) {
15
+ return fs.readdirSync(root, { withFileTypes: true })
16
+ .filter(entry => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'case.json')))
17
+ .map(entry => ({ name: entry.name, ...readCase(path.join(root, entry.name, 'case.json')) }))
18
+ .sort((a, b) => a.name.localeCompare(b.name));
19
+ }
20
+ function readCase(file) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ }
24
+ catch (error) {
25
+ throw new Error(`Invalid intent benchmark case ${file}: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ }
28
+ export async function runIntentCase(root, bench, model) {
29
+ const dir = path.join(root, bench.name);
30
+ const file = path.join(dir, 'code.ts');
31
+ const [program] = [...programBatches([file], loadProjectConfig(dir).options)];
32
+ const [site] = findParallelReads(dir, program.getTypeChecker(), program.getSourceFile(file));
33
+ if (!site)
34
+ return { case: bench, siteFound: false, verdicts: [], findings: 0 };
35
+ const verdicts = await classifyReads(model, site);
36
+ return { case: bench, siteFound: true, verdicts, findings: findingsFor(site, verdicts).length };
37
+ }
38
+ export function scoreIntent(results) {
39
+ const score = {
40
+ rawCorrect: 0, rawTotal: 0, consistent: 0, consistentCorrect: 0, reads: 0,
41
+ truePositives: 0, falseNegatives: 0, falsePositives: 0, finderErrors: [],
42
+ };
43
+ for (const result of results) {
44
+ if (result.siteFound !== (result.case.expectSite ?? true))
45
+ score.finderErrors.push(result.case.name);
46
+ for (const verdict of result.verdicts)
47
+ scoreRead(score, verdict, result.case.labels[verdict.read.label]);
48
+ scoreSite(score, result);
49
+ }
50
+ return score;
51
+ }
52
+ function scoreRead(score, { verdict, answers }, label) {
53
+ if (!label)
54
+ return;
55
+ const optional = label === 'optional';
56
+ score.reads += 1;
57
+ score.rawTotal += 2;
58
+ score.rawCorrect += Number(answers[0] === (optional ? 'yes' : 'no')) + Number(answers[1] === (optional ? 'no' : 'yes'));
59
+ if (verdict === 'unknown')
60
+ return;
61
+ score.consistent += 1;
62
+ score.consistentCorrect += Number(verdict === label);
63
+ }
64
+ function scoreSite(score, result) {
65
+ if (result.case.expectFinding) {
66
+ if (result.findings > 0)
67
+ score.truePositives += 1;
68
+ else
69
+ score.falseNegatives += 1;
70
+ }
71
+ else if (result.findings > 0) {
72
+ score.falsePositives += 1;
73
+ }
74
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Asks the local model the one thing code cannot say: is a read optional?
3
+ *
4
+ * Flipped-question protocol: each read gets two questions of opposite
5
+ * polarity ("should it still return if X fails?" / "is X required?"). An
6
+ * answer counts only when both agree (yes/no = optional, no/yes = required);
7
+ * anything else is unknown, and unknown never becomes a finding. A finding
8
+ * needs one read judged optional and another judged required at the same
9
+ * site: then the optional read's failure takes down the required one.
10
+ */
11
+ import type { InferenceProvider } from '../../inference/types.js';
12
+ import type { SemanticFinding } from '../types.js';
13
+ import type { ParallelRead, ParallelReadSite } from './parallel-reads.js';
14
+ export type Answer = 'yes' | 'no' | null;
15
+ export type Verdict = 'optional' | 'required' | 'unknown';
16
+ export interface YesNoModel {
17
+ ask(prompt: string): Promise<Answer>;
18
+ }
19
+ export interface ReadVerdict {
20
+ read: ParallelRead;
21
+ verdict: Verdict;
22
+ answers: [Answer, Answer];
23
+ }
24
+ export declare const RULE_ID = "optional-read-no-fallback";
25
+ export declare const YES_NO_SCHEMA: {
26
+ type: string;
27
+ properties: {
28
+ answer: {
29
+ type: string;
30
+ enum: string[];
31
+ };
32
+ };
33
+ required: string[];
34
+ additionalProperties: boolean;
35
+ };
36
+ /** A local provider as a yes/no oracle: schema-constrained, temperature 0. */
37
+ export declare function yesNoModel(provider: InferenceProvider): YesNoModel;
38
+ export declare function verdictOf([ifFails, required]: [Answer, Answer]): Verdict;
39
+ export declare function questionsFor(site: ParallelReadSite, read: ParallelRead): [string, string];
40
+ export declare function promptFor(site: ParallelReadSite, question: string): string;
41
+ export declare function classifyReads(model: YesNoModel, site: ParallelReadSite): Promise<ReadVerdict[]>;
42
+ /** Findings for a site: every read judged optional, when another read is judged required. */
43
+ export declare function findingsFor(site: ParallelReadSite, verdicts: ReadVerdict[]): SemanticFinding[];
@@ -0,0 +1,72 @@
1
+ import { lineOf } from '../ast.js';
2
+ export const RULE_ID = 'optional-read-no-fallback';
3
+ const MAX_SOURCE_LINES = 80;
4
+ export const YES_NO_SCHEMA = {
5
+ type: 'object',
6
+ properties: { answer: { type: 'string', enum: ['yes', 'no'] } },
7
+ required: ['answer'],
8
+ additionalProperties: false,
9
+ };
10
+ /** A local provider as a yes/no oracle: schema-constrained, temperature 0. */
11
+ export function yesNoModel(provider) {
12
+ return {
13
+ async ask(prompt) {
14
+ const text = await provider.analyze(prompt, { jsonSchema: YES_NO_SCHEMA, temperature: 0, maxTokens: 16 });
15
+ try {
16
+ const answer = JSON.parse(text)?.answer;
17
+ return answer === 'yes' || answer === 'no' ? answer : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ },
23
+ };
24
+ }
25
+ export function verdictOf([ifFails, required]) {
26
+ if (ifFails === 'yes' && required === 'no')
27
+ return 'optional';
28
+ if (ifFails === 'no' && required === 'yes')
29
+ return 'required';
30
+ return 'unknown';
31
+ }
32
+ export function questionsFor(site, read) {
33
+ return [
34
+ `If \`${read.label}\` throws, should \`${site.fnName}\` still return a result built from the other calls?`,
35
+ `Is the result of \`${read.label}\` required for \`${site.fnName}\` to return anything useful?`,
36
+ ];
37
+ }
38
+ export function promptFor(site, question) {
39
+ const source = site.fn.getText(site.fn.getSourceFile()).split('\n').slice(0, MAX_SOURCE_LINES).join('\n');
40
+ return [
41
+ 'You are reviewing a TypeScript function.',
42
+ '```ts', source, '```',
43
+ `The calls ${site.reads.map(r => `\`${r.label}\``).join(', ')} run together in \`await Promise.all([...])\`. If any of them throws, the whole Promise.all rejects.`,
44
+ `Question: ${question}`,
45
+ 'Answer with JSON: {"answer": "yes"} or {"answer": "no"}.',
46
+ ].join('\n');
47
+ }
48
+ export async function classifyReads(model, site) {
49
+ const verdicts = [];
50
+ for (const read of site.reads) {
51
+ const [ifFails, required] = questionsFor(site, read);
52
+ const answers = [await model.ask(promptFor(site, ifFails)), await model.ask(promptFor(site, required))];
53
+ verdicts.push({ read, verdict: verdictOf(answers), answers });
54
+ }
55
+ return verdicts;
56
+ }
57
+ /** Findings for a site: every read judged optional, when another read is judged required. */
58
+ export function findingsFor(site, verdicts) {
59
+ const required = verdicts.filter(v => v.verdict === 'required').map(v => `\`${v.read.label}\``);
60
+ if (required.length === 0)
61
+ return [];
62
+ return verdicts.filter(v => v.verdict === 'optional').map(({ read }) => ({
63
+ rule: RULE_ID,
64
+ severity: 'medium',
65
+ provenance: 'deep-analysis',
66
+ file: site.file,
67
+ line: lineOf(read.call),
68
+ message: `\`${read.label}\` looks optional to \`${site.fnName}\`, but it runs in Promise.all with ${required.join(', ')}, so its failure also discards ${required.length > 1 ? 'them' : 'that result'}.`,
69
+ hint: 'Give the optional read its own catch that returns a fallback (and tell the caller the result is partial), or confirm it is required.',
70
+ evidence: [`${site.file}:${site.line} Promise.all`, 'Intent from the local model: two flipped questions answered consistently.'],
71
+ }));
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import path from 'path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { loadIntentCases, runIntentCase, scoreIntent } from './benchmark.js';
4
+ import { verdictOf } from './intent-check.js';
5
+ const ROOT = path.resolve(__dirname, '../../../../../benchmarks/intent');
6
+ /** Answers from the labels: an oracle, optionally contradicting itself on one read. */
7
+ function labelledModel(labels, contradict) {
8
+ return {
9
+ async ask(prompt) {
10
+ const label = Object.keys(labels).find(name => prompt.includes(`Question: If \`${name}\``) || prompt.includes(`result of \`${name}\``));
11
+ if (!label)
12
+ return null;
13
+ const optional = labels[label] === 'optional';
14
+ const firstQuestion = prompt.includes('Question: If');
15
+ if (label === contradict)
16
+ return 'yes';
17
+ return firstQuestion === optional ? 'yes' : 'no';
18
+ },
19
+ };
20
+ }
21
+ describe('verdictOf', () => {
22
+ it('counts only consistent flipped answers', () => {
23
+ expect(verdictOf(['yes', 'no'])).toBe('optional');
24
+ expect(verdictOf(['no', 'yes'])).toBe('required');
25
+ expect(verdictOf(['yes', 'yes'])).toBe('unknown');
26
+ expect(verdictOf(['no', 'no'])).toBe('unknown');
27
+ expect(verdictOf([null, 'no'])).toBe('unknown');
28
+ });
29
+ });
30
+ describe('intent benchmark with a labelled oracle', () => {
31
+ const cases = loadIntentCases(ROOT);
32
+ it('finds every labelled site, skips the fixed one, and scores the oracle perfectly', async () => {
33
+ const results = await Promise.all(cases.map(bench => runIntentCase(ROOT, bench, labelledModel(bench.labels))));
34
+ const score = scoreIntent(results);
35
+ expect(score.finderErrors).toEqual([]);
36
+ expect(score.falsePositives).toBe(0);
37
+ expect(score.falseNegatives).toBe(0);
38
+ expect(score.rawCorrect).toBe(score.rawTotal);
39
+ });
40
+ it('drops a read whose answers contradict each other instead of guessing', async () => {
41
+ const bench = cases.find(c => c.name === 'dashboard');
42
+ const result = await runIntentCase(ROOT, bench, labelledModel(bench.labels, 'getRecommendedCourses'));
43
+ expect(result.verdicts.find(v => v.read.label === 'getRecommendedCourses')?.verdict).toBe('unknown');
44
+ expect(result.findings).toBe(0);
45
+ });
46
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `await Promise.all([...])` sites where one read failing loses them all.
3
+ *
4
+ * This part is deterministic: two or more calls run together with no
5
+ * handling of their own, and nothing in the function catches the rejection.
6
+ * Whether that is a bug depends on intent (is one of the reads optional?),
7
+ * which the code cannot say; see intent-check.ts.
8
+ */
9
+ import ts from 'typescript';
10
+ import { type FunctionLike } from '../ast.js';
11
+ export interface ParallelRead {
12
+ call: ts.CallExpression;
13
+ /** Callee text, e.g. `fetchStepEngagement`. */
14
+ label: string;
15
+ }
16
+ export interface ParallelReadSite {
17
+ file: string;
18
+ line: number;
19
+ fnName: string;
20
+ fn: FunctionLike;
21
+ reads: ParallelRead[];
22
+ }
23
+ export declare function findParallelReads(cwd: string, checker: ts.TypeChecker, sourceFile: ts.SourceFile): ParallelReadSite[];
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `await Promise.all([...])` sites where one read failing loses them all.
3
+ *
4
+ * This part is deterministic: two or more calls run together with no
5
+ * handling of their own, and nothing in the function catches the rejection.
6
+ * Whether that is a bug depends on intent (is one of the reads optional?),
7
+ * which the code cannot say; see intent-check.ts.
8
+ */
9
+ import ts from 'typescript';
10
+ import { calledFunction, enclosingFunction, forEachNode, lineOf, relativeFile, unwrap } from '../ast.js';
11
+ import { promiseAllItems } from '../origins.js';
12
+ import { functionKeyOf } from '../learn/identity.js';
13
+ export function findParallelReads(cwd, checker, sourceFile) {
14
+ const sites = [];
15
+ forEachNode(sourceFile, (node) => {
16
+ if (!ts.isCallExpression(node) || !isPromiseAll(node))
17
+ return;
18
+ const fn = enclosingFunction(node);
19
+ if (!fn || isInsideTry(node, fn))
20
+ return;
21
+ const reads = (promiseAllItems(node) ?? [])
22
+ .map(item => unwrap(item))
23
+ .filter((item) => ts.isCallExpression(item) && !handlesOwnFailure(checker, item))
24
+ .map(call => ({ call, label: call.expression.getText(sourceFile).replace(/\s+/g, '') }));
25
+ if (reads.length < 2)
26
+ return;
27
+ sites.push({ file: relativeFile(cwd, sourceFile), line: lineOf(node), fnName: functionKeyOf(node), fn, reads });
28
+ });
29
+ return sites;
30
+ }
31
+ function isPromiseAll(call) {
32
+ const callee = call.expression;
33
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === 'all'
34
+ && ts.isIdentifier(callee.expression) && callee.expression.text === 'Promise';
35
+ }
36
+ /** Inside a `try` (with a catch) of the same function: the rejection is handled there. */
37
+ function isInsideTry(node, fn) {
38
+ for (let child = node, parent = node.parent; parent && parent !== fn; child = parent, parent = parent.parent) {
39
+ if (ts.isTryStatement(parent) && parent.tryBlock === child && parent.catchClause)
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ /** `x().catch(...)`, `x().then(ok, fail)`, or a callee whose body catches and returns. */
45
+ function handlesOwnFailure(checker, call) {
46
+ const callee = call.expression;
47
+ if (ts.isPropertyAccessExpression(callee)) {
48
+ if (callee.name.text === 'catch' || callee.name.text === 'finally')
49
+ return true;
50
+ if (callee.name.text === 'then' && call.arguments.length >= 2)
51
+ return true;
52
+ }
53
+ const target = calledFunction(checker, call);
54
+ const body = target?.body;
55
+ return !!body && ts.isBlock(body) && body.statements.some(s => ts.isTryStatement(s) && !!s.catchClause && catchReturns(s.catchClause));
56
+ }
57
+ function catchReturns(clause) {
58
+ return clause.block.statements.some(s => ts.isReturnStatement(s));
59
+ }
@@ -0,0 +1,20 @@
1
+ import { type LearnReport } from './learn.js';
2
+ export interface LearnCase {
3
+ name: string;
4
+ note: string;
5
+ expect: {
6
+ template: string;
7
+ property: string;
8
+ guard?: string;
9
+ scoped: boolean;
10
+ } | null;
11
+ variants?: string[];
12
+ unsupported?: string[];
13
+ }
14
+ export interface LearnCaseResult {
15
+ case: LearnCase;
16
+ report: LearnReport;
17
+ problems: string[];
18
+ }
19
+ export declare function loadLearnCases(root: string): LearnCase[];
20
+ export declare function runLearnCase(root: string, bench: LearnCase): Promise<LearnCaseResult>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Learn benchmark: fixes whose rule `rigour learn` must reproduce.
3
+ *
4
+ * Each case has `before/` and `after/` trees and a `case.json` naming the rule
5
+ * expected (or `null` when no rule may be learned). The learned rule is then
6
+ * run over `variants/`: it must report exactly the listed locations, which
7
+ * measures whether it generalises beyond the code it came from.
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { analyzeFiles } from '../engine.js';
12
+ import { compileLearnedRule } from './compile.js';
13
+ import { learnFromFix } from './learn.js';
14
+ export function loadLearnCases(root) {
15
+ return fs.readdirSync(root, { withFileTypes: true })
16
+ .filter(entry => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'case.json')))
17
+ .map(entry => ({ name: entry.name, ...readCase(path.join(root, entry.name, 'case.json')) }))
18
+ .sort((a, b) => a.name.localeCompare(b.name));
19
+ }
20
+ function readCase(file) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ }
24
+ catch (error) {
25
+ throw new Error(`Invalid learn benchmark case ${file}: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ }
28
+ export async function runLearnCase(root, bench) {
29
+ const dir = path.join(root, bench.name);
30
+ const beforeDir = path.join(dir, 'before');
31
+ const afterDir = path.join(dir, 'after');
32
+ const report = await learnFromFix({ beforeDir, afterDir, repoDir: afterDir, files: changedFiles(beforeDir, afterDir) });
33
+ const problems = [...expectationProblems(bench, report)];
34
+ const [rule] = report.learned;
35
+ if (rule && bench.variants)
36
+ problems.push(...variantProblems(path.join(dir, 'variants'), rule, bench.variants));
37
+ return { case: bench, report, problems };
38
+ }
39
+ function changedFiles(beforeDir, afterDir) {
40
+ return fs.readdirSync(beforeDir).filter(name => {
41
+ const after = path.join(afterDir, name);
42
+ return fs.existsSync(after) && fs.readFileSync(path.join(beforeDir, name), 'utf8') !== fs.readFileSync(after, 'utf8');
43
+ });
44
+ }
45
+ function expectationProblems(bench, report) {
46
+ const problems = [];
47
+ for (const fn of bench.unsupported ?? []) {
48
+ if (!report.unsupported.some(u => u.fn === fn))
49
+ problems.push(`expected ${fn} to be reported unsupported`);
50
+ }
51
+ if (bench.expect === null) {
52
+ if (report.learned.length > 0)
53
+ problems.push(`learned ${report.learned.map(r => r.id).join(', ')} but no rule was expected`);
54
+ return problems;
55
+ }
56
+ const want = bench.expect;
57
+ const matches = report.learned.filter(rule => rule.pattern.template === want.template
58
+ && rule.pattern.property === want.property
59
+ && (rule.pattern.template !== 'require-guard' || rule.pattern.guard === want.guard)
60
+ && Boolean(rule.pattern.scope) === want.scoped);
61
+ if (matches.length !== 1 || report.learned.length !== 1) {
62
+ problems.push(`expected one ${want.template} rule for ${want.property}; learned ${JSON.stringify(report.learned.map(r => r.pattern))}, rejected ${report.rejected.map(r => r.reason).join('; ')}`);
63
+ }
64
+ return problems;
65
+ }
66
+ function variantProblems(variantsDir, rule, expected) {
67
+ const files = fs.readdirSync(variantsDir).filter(f => /\.[cm]?[jt]sx?$/.test(f));
68
+ const found = analyzeFiles(variantsDir, files, { rules: [compileLearnedRule(rule)] }).map(f => `${f.file}:${f.line}`).sort();
69
+ const want = [...expected].sort();
70
+ return JSON.stringify(found) === JSON.stringify(want) ? [] : [`variants: expected ${want.join(', ') || 'none'}, got ${found.join(', ') || 'none'}`];
71
+ }
@@ -0,0 +1,4 @@
1
+ import type { SemanticRule } from '../types.js';
2
+ import type { LearnedRule } from './types.js';
3
+ export declare const LEARNED_PREFIX = "learned/";
4
+ export declare function compileLearnedRule(rule: LearnedRule): SemanticRule;
@@ -0,0 +1,56 @@
1
+ import { forEachNode, lineOf, relativeFile, snippet } from '../ast.js';
2
+ import { guardsOf, mentionsName, namesIn, propertiesNamed, setsProperty } from './conditions.js';
3
+ import { calleeKey, functionKeyOf, invocationArgs, isInvocation } from './identity.js';
4
+ export const LEARNED_PREFIX = 'learned/';
5
+ export function compileLearnedRule(rule) {
6
+ const id = `${LEARNED_PREFIX}${rule.id}`;
7
+ return {
8
+ id,
9
+ check(ctx) {
10
+ const findings = [];
11
+ const file = relativeFile(ctx.cwd, ctx.sourceFile);
12
+ if (rule.pattern.scope && rule.pattern.scope.file !== file)
13
+ return findings;
14
+ forEachNode(ctx.sourceFile, (node) => {
15
+ if (!isInvocation(node) || !matchesCall(ctx, rule.pattern, node))
16
+ return;
17
+ const at = violation(ctx, rule.pattern, node);
18
+ if (!at)
19
+ return;
20
+ findings.push({
21
+ rule: id,
22
+ severity: rule.severity,
23
+ provenance: 'traditional',
24
+ file,
25
+ line: lineOf(at),
26
+ message: rule.message,
27
+ hint: `Learned from ${describeSource(rule)}. Apply the same fix here, or delete .rigour/rules/${rule.id}.json if the rule no longer holds.`,
28
+ evidence: [`${file}:${lineOf(node)} ${snippet(node)}`],
29
+ });
30
+ });
31
+ return findings;
32
+ },
33
+ };
34
+ }
35
+ function matchesCall(ctx, pattern, call) {
36
+ if (pattern.scope && functionKeyOf(call) !== pattern.scope.fn)
37
+ return false;
38
+ return calleeKey(ctx.checker, ctx.cwd, call, pattern.callee.level) === pattern.callee.key;
39
+ }
40
+ /** The node that breaks the rule in `call`, or undefined when the call complies. */
41
+ function violation(ctx, pattern, call) {
42
+ const args = invocationArgs(call);
43
+ if (pattern.template === 'require-option') {
44
+ return setsProperty(ctx.checker, args[pattern.argIndex], pattern.property) ? undefined : call;
45
+ }
46
+ for (const prop of args.flatMap(arg => propertiesNamed(arg, pattern.property))) {
47
+ const applies = !pattern.appliesWhenArgsMention || args.some(arg => mentionsName(arg, pattern.guard, prop));
48
+ if (applies && !namesIn(guardsOf(prop, call)).includes(pattern.guard))
49
+ return prop;
50
+ }
51
+ return undefined;
52
+ }
53
+ function describeSource(rule) {
54
+ const where = `${rule.source.file} (${rule.source.function})`;
55
+ return rule.source.commit ? `fix ${rule.source.commit.slice(0, 8)} in ${where}` : `a fix in ${where}`;
56
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * What guards a value, and whether an options object sets a property.
3
+ *
4
+ * A guard is a condition the value's selection depends on inside the call:
5
+ * `c ? v : w`, `c && v`, or a spread chosen by `...(c ? { k: v } : {})`.
6
+ */
7
+ import ts from 'typescript';
8
+ /** Conditions between `node` and `stop` (exclusive) that decide whether `node` is used, plus its own `?:` test. */
9
+ export declare function guardsOf(node: ts.Node, stop: ts.Node): ts.Expression[];
10
+ /** Identifier and property names read by `exprs`; property names (`x.stepsAvailable`) listed first. */
11
+ export declare function namesIn(exprs: ts.Node[]): string[];
12
+ /** Property assignments named `key` (case-insensitive) anywhere inside `root`. */
13
+ export declare function propertiesNamed(root: ts.Node, key: string): ts.PropertyAssignment[];
14
+ /** Whether `root` reads `name`, outside the subtree `except`. */
15
+ export declare function mentionsName(root: ts.Node, name: string, except?: ts.Node): boolean;
16
+ /**
17
+ * Whether `arg` provably sets `property`: an object literal with the property
18
+ * (directly or through a resolved spread), or a variable whose every origin is one.
19
+ */
20
+ export declare function setsProperty(checker: ts.TypeChecker, arg: ts.Expression | undefined, property: string): boolean;
21
+ /** Property names an object literal argument sets directly. */
22
+ export declare function literalPropertyNames(arg: ts.Expression | undefined): string[];
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What guards a value, and whether an options object sets a property.
3
+ *
4
+ * A guard is a condition the value's selection depends on inside the call:
5
+ * `c ? v : w`, `c && v`, or a spread chosen by `...(c ? { k: v } : {})`.
6
+ */
7
+ import ts from 'typescript';
8
+ import { originsOf, propertyValues } from '../origins.js';
9
+ import { propertyNameText, unwrap } from '../ast.js';
10
+ const SHORT_CIRCUIT = new Set([ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken]);
11
+ /** Conditions between `node` and `stop` (exclusive) that decide whether `node` is used, plus its own `?:` test. */
12
+ export function guardsOf(node, stop) {
13
+ const guards = [];
14
+ const value = ts.isPropertyAssignment(node) ? unwrap(node.initializer) : undefined;
15
+ if (value && ts.isConditionalExpression(value))
16
+ guards.push(value.condition);
17
+ for (let child = node, parent = node.parent; parent && parent !== stop; child = parent, parent = parent.parent) {
18
+ const guard = selectingCondition(parent, child);
19
+ if (guard)
20
+ guards.push(guard);
21
+ }
22
+ return guards;
23
+ }
24
+ /** The condition in `parent` that decides whether `child` is evaluated, if any. */
25
+ function selectingCondition(parent, child) {
26
+ if (ts.isConditionalExpression(parent) && child !== parent.condition)
27
+ return parent.condition;
28
+ if (ts.isBinaryExpression(parent) && parent.right === child && SHORT_CIRCUIT.has(parent.operatorToken.kind))
29
+ return parent.left;
30
+ return undefined;
31
+ }
32
+ /** Identifier and property names read by `exprs`; property names (`x.stepsAvailable`) listed first. */
33
+ export function namesIn(exprs) {
34
+ const properties = [];
35
+ const identifiers = [];
36
+ const walk = (node) => {
37
+ if (ts.isPropertyAccessExpression(node))
38
+ properties.push(node.name.text);
39
+ else if (ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node))
40
+ identifiers.push(node.text);
41
+ ts.forEachChild(node, walk);
42
+ };
43
+ exprs.forEach(walk);
44
+ return [...new Set([...properties, ...identifiers])];
45
+ }
46
+ /** Property assignments named `key` (case-insensitive) anywhere inside `root`. */
47
+ export function propertiesNamed(root, key) {
48
+ const found = [];
49
+ const wanted = key.toLowerCase();
50
+ const walk = (node) => {
51
+ if (ts.isPropertyAssignment(node) && propertyNameText(node.name)?.toLowerCase() === wanted)
52
+ found.push(node);
53
+ ts.forEachChild(node, walk);
54
+ };
55
+ walk(root);
56
+ return found;
57
+ }
58
+ /** Whether `root` reads `name`, outside the subtree `except`. */
59
+ export function mentionsName(root, name, except) {
60
+ let found = false;
61
+ const walk = (node) => {
62
+ if (found || node === except)
63
+ return;
64
+ if (ts.isIdentifier(node) && node.text === name)
65
+ found = true;
66
+ else
67
+ ts.forEachChild(node, walk);
68
+ };
69
+ walk(root);
70
+ return found;
71
+ }
72
+ /**
73
+ * Whether `arg` provably sets `property`: an object literal with the property
74
+ * (directly or through a resolved spread), or a variable whose every origin is one.
75
+ */
76
+ export function setsProperty(checker, arg, property) {
77
+ if (!arg)
78
+ return false;
79
+ const expr = unwrap(arg);
80
+ if (ts.isObjectLiteralExpression(expr))
81
+ return propertyValues(checker, expr, property).values.length > 0;
82
+ const origins = originsOf(checker, expr);
83
+ return origins.length > 0 && origins.every(o => o.kind === 'object' && propertyValues(checker, o.node, property).values.length > 0);
84
+ }
85
+ /** Property names an object literal argument sets directly. */
86
+ export function literalPropertyNames(arg) {
87
+ const expr = arg && unwrap(arg);
88
+ if (!expr || !ts.isObjectLiteralExpression(expr))
89
+ return [];
90
+ return expr.properties
91
+ .map(p => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) ? propertyNameText(p.name) : undefined)
92
+ .filter((n) => n !== undefined);
93
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Finds the edit a fix made and proposes rules that would have caught it.
3
+ *
4
+ * Functions are aligned by name path, calls inside them by callee text and
5
+ * order. Two edit shapes are generalised (Getafix-style templates):
6
+ * - require-option: an argument gains a property (`init` -> `{ ...init, redirect: 'manual' }`);
7
+ * - require-guard: a property's value gains a condition (`max-age` only when `stepsAvailable`).
8
+ * Each edit yields candidates from most general (callee name) to most
9
+ * specific (this call in this function); validation picks the first that holds.
10
+ */
11
+ import ts from 'typescript';
12
+ import type { LearnedPattern } from './types.js';
13
+ export interface TreeFile {
14
+ cwd: string;
15
+ checker: ts.TypeChecker;
16
+ sourceFile: ts.SourceFile;
17
+ }
18
+ export interface ProposedEdit {
19
+ file: string;
20
+ fn: string;
21
+ /** e.g. "deps.fetch(...) gained `redirect`". */
22
+ description: string;
23
+ candidates: LearnedPattern[];
24
+ }
25
+ export interface Extraction {
26
+ edits: ProposedEdit[];
27
+ /** Changed functions whose edit matches no template. */
28
+ unsupported: string[];
29
+ }
30
+ export declare function extractEdits(file: string, before: TreeFile, after: TreeFile): Extraction;