@rigour-labs/core 6.2.5 → 6.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) 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/model-download.test.js +54 -2
  46. package/dist/inference/model-manager.d.ts +6 -0
  47. package/dist/inference/model-manager.js +58 -18
  48. package/dist/inference/sidecar-provider.js +12 -1
  49. package/dist/inference/types.d.ts +8 -2
  50. package/dist/inference/types.js +7 -3
  51. package/dist/semantic/ast.d.ts +29 -0
  52. package/dist/semantic/ast.js +122 -0
  53. package/dist/semantic/benchmark.d.ts +16 -0
  54. package/dist/semantic/benchmark.js +31 -0
  55. package/dist/semantic/benchmark.test.d.ts +1 -0
  56. package/dist/semantic/benchmark.test.js +18 -0
  57. package/dist/semantic/engine.d.ts +8 -0
  58. package/dist/semantic/engine.js +45 -0
  59. package/dist/semantic/intent/benchmark.d.ts +31 -0
  60. package/dist/semantic/intent/benchmark.js +74 -0
  61. package/dist/semantic/intent/intent-check.d.ts +43 -0
  62. package/dist/semantic/intent/intent-check.js +72 -0
  63. package/dist/semantic/intent/intent.test.d.ts +1 -0
  64. package/dist/semantic/intent/intent.test.js +46 -0
  65. package/dist/semantic/intent/parallel-reads.d.ts +23 -0
  66. package/dist/semantic/intent/parallel-reads.js +59 -0
  67. package/dist/semantic/learn/benchmark.d.ts +20 -0
  68. package/dist/semantic/learn/benchmark.js +71 -0
  69. package/dist/semantic/learn/compile.d.ts +4 -0
  70. package/dist/semantic/learn/compile.js +56 -0
  71. package/dist/semantic/learn/conditions.d.ts +22 -0
  72. package/dist/semantic/learn/conditions.js +93 -0
  73. package/dist/semantic/learn/extract.d.ts +30 -0
  74. package/dist/semantic/learn/extract.js +122 -0
  75. package/dist/semantic/learn/git-trees.d.ts +8 -0
  76. package/dist/semantic/learn/git-trees.js +32 -0
  77. package/dist/semantic/learn/identity.d.ts +18 -0
  78. package/dist/semantic/learn/identity.js +97 -0
  79. package/dist/semantic/learn/learn.d.ts +25 -0
  80. package/dist/semantic/learn/learn.js +109 -0
  81. package/dist/semantic/learn/learn.test.d.ts +1 -0
  82. package/dist/semantic/learn/learn.test.js +67 -0
  83. package/dist/semantic/learn/store.d.ts +4 -0
  84. package/dist/semantic/learn/store.js +39 -0
  85. package/dist/semantic/learn/types.d.ts +468 -0
  86. package/dist/semantic/learn/types.js +55 -0
  87. package/dist/semantic/origins.d.ts +42 -0
  88. package/dist/semantic/origins.js +149 -0
  89. package/dist/semantic/origins.test.d.ts +1 -0
  90. package/dist/semantic/origins.test.js +77 -0
  91. package/dist/semantic/program.d.ts +13 -0
  92. package/dist/semantic/program.js +62 -0
  93. package/dist/semantic/rules/credential-redirect.d.ts +2 -0
  94. package/dist/semantic/rules/credential-redirect.js +209 -0
  95. package/dist/semantic/rules/degraded-response-cached.d.ts +2 -0
  96. package/dist/semantic/rules/degraded-response-cached.js +271 -0
  97. package/dist/semantic/rules/in-memory-aggregation.d.ts +2 -0
  98. package/dist/semantic/rules/in-memory-aggregation.js +179 -0
  99. package/dist/semantic/summaries.d.ts +18 -0
  100. package/dist/semantic/summaries.js +33 -0
  101. package/dist/semantic/types.d.ts +32 -0
  102. package/dist/semantic/types.js +1 -0
  103. package/dist/storage/index.d.ts +1 -0
  104. package/dist/storage/index.js +1 -0
  105. package/dist/storage/team-diagnostics.d.ts +22 -0
  106. package/dist/storage/team-diagnostics.js +58 -0
  107. package/dist/storage/team-diagnostics.test.d.ts +1 -0
  108. package/dist/storage/team-diagnostics.test.js +51 -0
  109. package/dist/storage/team-schema.d.ts +6 -0
  110. package/dist/storage/team-schema.js +82 -0
  111. package/dist/storage/team-store.js +25 -72
  112. package/dist/templates/universal-config.js +4 -0
  113. package/dist/types/index.d.ts +56 -0
  114. package/dist/types/index.js +7 -0
  115. package/package.json +6 -6
@@ -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;
@@ -0,0 +1,122 @@
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 { forEachNode, isFunctionLike } from '../ast.js';
13
+ import { guardsOf, literalPropertyNames, namesIn, propertiesNamed } from './conditions.js';
14
+ import { calleeDeclarationOf, calleeNameOf, calleeTextOf, functionKeyOf, functionName, invocationArgs, isInvocation, } from './identity.js';
15
+ export function extractEdits(file, before, after) {
16
+ const beforeFns = functionTexts(before.sourceFile);
17
+ const afterFns = functionTexts(after.sourceFile);
18
+ const changed = [...new Set([...beforeFns.keys(), ...afterFns.keys()])]
19
+ .filter(fn => beforeFns.get(fn) !== afterFns.get(fn));
20
+ const beforeCalls = callsByFunction(before.sourceFile);
21
+ const afterCalls = callsByFunction(after.sourceFile);
22
+ const edits = [];
23
+ const withEdit = new Set();
24
+ for (const [slot, afterCall] of afterCalls) {
25
+ const beforeCall = beforeCalls.get(slot);
26
+ if (!beforeCall || beforeCall.getText() === afterCall.getText())
27
+ continue;
28
+ const fn = functionKeyOf(afterCall);
29
+ const found = [...optionEdits(file, fn, before, beforeCall, afterCall), ...guardEdits(file, fn, before, beforeCall, afterCall)];
30
+ if (found.length > 0)
31
+ withEdit.add(fn);
32
+ edits.push(...found);
33
+ }
34
+ // A function whose only change is inside a nested function that yielded an edit is covered by it.
35
+ const covered = (fn) => [...withEdit].some(key => key === fn || key.startsWith(`${fn}>`));
36
+ return { edits, unsupported: changed.filter(fn => !covered(fn)) };
37
+ }
38
+ /** Text of each named function, keyed by name path. */
39
+ function functionTexts(sf) {
40
+ const texts = new Map();
41
+ forEachNode(sf, (node) => {
42
+ if (isFunctionLike(node) && functionName(node))
43
+ texts.set(functionKeyOf(node), node.getText(sf));
44
+ });
45
+ return texts;
46
+ }
47
+ /** Calls keyed by `<function>|<callee text>|<ordinal>`, so a call pairs with its counterpart. */
48
+ function callsByFunction(sf) {
49
+ const calls = new Map();
50
+ const counts = new Map();
51
+ forEachNode(sf, (node) => {
52
+ if (!isInvocation(node))
53
+ return;
54
+ const base = `${functionKeyOf(node)}|${calleeTextOf(node)}`;
55
+ const ordinal = counts.get(base) ?? 0;
56
+ counts.set(base, ordinal + 1);
57
+ calls.set(`${base}|${ordinal}`, node);
58
+ });
59
+ return calls;
60
+ }
61
+ function optionEdits(file, fn, before, beforeCall, afterCall) {
62
+ const edits = [];
63
+ const afterArgs = invocationArgs(afterCall);
64
+ afterArgs.forEach((arg, argIndex) => {
65
+ const had = new Set(literalPropertyNames(invocationArgs(beforeCall)[argIndex]).map(n => n.toLowerCase()));
66
+ for (const property of literalPropertyNames(arg)) {
67
+ if (had.has(property.toLowerCase()))
68
+ continue;
69
+ const value = literalValue(arg, property);
70
+ const candidates = calleeLevels(before, beforeCall).map(({ callee, scoped }) => ({
71
+ template: 'require-option', callee, argIndex, property, ...(value ? { value } : {}), ...(scoped ? { scope: { file, fn } } : {}),
72
+ }));
73
+ edits.push({ file, fn, description: `${calleeTextOf(afterCall)}(...) gained \`${property}\``, candidates });
74
+ }
75
+ });
76
+ return edits;
77
+ }
78
+ function guardEdits(file, fn, before, beforeCall, afterCall) {
79
+ const edits = [];
80
+ const seen = new Map();
81
+ for (const afterProp of invocationArgs(afterCall).flatMap(arg => allProperties(arg))) {
82
+ const key = afterProp.name.getText().replace(/^['"`]|['"`]$/g, '');
83
+ const ordinal = seen.get(key.toLowerCase()) ?? 0;
84
+ seen.set(key.toLowerCase(), ordinal + 1);
85
+ const beforeProp = invocationArgs(beforeCall).flatMap(arg => propertiesNamed(arg, key))[ordinal];
86
+ if (!beforeProp)
87
+ continue;
88
+ const had = new Set(namesIn(guardsOf(beforeProp, beforeCall)));
89
+ const guard = namesIn(guardsOf(afterProp, afterCall)).find(n => !had.has(n));
90
+ if (!guard)
91
+ continue;
92
+ const candidates = calleeLevels(before, beforeCall).map(({ callee, scoped }) => ({
93
+ template: 'require-guard', callee, property: key, guard, appliesWhenArgsMention: !scoped, ...(scoped ? { scope: { file, fn } } : {}),
94
+ }));
95
+ edits.push({ file, fn, description: `\`${key}\` in ${calleeTextOf(afterCall)}(...) gained a condition on \`${guard}\``, candidates });
96
+ }
97
+ return edits;
98
+ }
99
+ /** Callee matchers from most to least general, ending with this exact call in this function. */
100
+ function calleeLevels(tree, call) {
101
+ const levels = [];
102
+ const name = calleeNameOf(call);
103
+ if (name)
104
+ levels.push({ callee: { level: 'name', key: name }, scoped: false });
105
+ const declaration = calleeDeclarationOf(tree.checker, tree.cwd, call);
106
+ if (declaration)
107
+ levels.push({ callee: { level: 'declaration', key: declaration }, scoped: false });
108
+ const text = calleeTextOf(call);
109
+ levels.push({ callee: { level: 'text', key: text }, scoped: false });
110
+ levels.push({ callee: { level: 'text', key: text }, scoped: true });
111
+ return levels;
112
+ }
113
+ function allProperties(root) {
114
+ const found = [];
115
+ forEachNode(root, (node) => { if (ts.isPropertyAssignment(node))
116
+ found.push(node); });
117
+ return found;
118
+ }
119
+ function literalValue(arg, property) {
120
+ const prop = propertiesNamed(arg, property)[0];
121
+ return prop && ts.isStringLiteralLike(prop.initializer) ? prop.initializer.text : undefined;
122
+ }
@@ -0,0 +1,8 @@
1
+ export interface FixTrees {
2
+ commit: string;
3
+ beforeDir: string;
4
+ afterDir: string;
5
+ files: string[];
6
+ dispose(): void;
7
+ }
8
+ export declare function extractFixTrees(repoDir: string, rev: string): FixTrees;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The trees before and after a fix commit, extracted with `git archive` into
3
+ * a temporary folder. Read-only against the repository: no checkout, no
4
+ * worktree, no change to its index or refs.
5
+ */
6
+ import { execFileSync } from 'child_process';
7
+ import fs from 'fs';
8
+ import os from 'os';
9
+ import path from 'path';
10
+ export function extractFixTrees(repoDir, rev) {
11
+ const git = (...args) => execFileSync('git', ['-C', repoDir, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
12
+ const commit = git('rev-parse', '--verify', `${rev}^{commit}`);
13
+ const parent = git('rev-parse', '--verify', `${commit}^`);
14
+ const files = git('diff', '--name-only', '--diff-filter=M', parent, commit).split('\n').filter(Boolean);
15
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-learn-'));
16
+ const unpack = (sha, name) => {
17
+ const dir = path.join(root, name);
18
+ const archive = path.join(root, `${name}.tar`);
19
+ fs.mkdirSync(dir);
20
+ git('archive', '--format=tar', '-o', archive, sha);
21
+ execFileSync('tar', ['-xf', archive, '-C', dir]);
22
+ fs.rmSync(archive);
23
+ return dir;
24
+ };
25
+ return {
26
+ commit,
27
+ beforeDir: unpack(parent, 'before'),
28
+ afterDir: unpack(commit, 'after'),
29
+ files,
30
+ dispose: () => fs.rmSync(root, { recursive: true, force: true }),
31
+ };
32
+ }
@@ -0,0 +1,18 @@
1
+ import ts from 'typescript';
2
+ import type { CalleeMatcher } from './types.js';
3
+ export type Invocation = ts.CallExpression | ts.NewExpression;
4
+ export declare function isInvocation(node: ts.Node): node is Invocation;
5
+ export declare function invocationArgs(call: Invocation): readonly ts.Expression[];
6
+ /** Last name of the callee: `fetch` for `deps.fetch(...)`, `Response` for `new Response(...)`. */
7
+ export declare function calleeNameOf(call: Invocation): string | undefined;
8
+ export declare function calleeTextOf(call: Invocation): string;
9
+ /**
10
+ * `<file>#<qualified name>` of the declaration the callee resolves to: a
11
+ * cwd-relative path inside the project, `lib:<file>` for a library.
12
+ */
13
+ export declare function calleeDeclarationOf(checker: ts.TypeChecker, cwd: string, call: Invocation): string | undefined;
14
+ export declare function calleeKey(checker: ts.TypeChecker, cwd: string, call: Invocation, level: CalleeMatcher['level']): string | undefined;
15
+ /** A function-like node with a name: declared, a method, or assigned to a variable or property. */
16
+ export declare function functionName(fn: ts.Node): string | undefined;
17
+ /** `outer>inner` path of the named functions around `node`, or `<module>` at the top level. */
18
+ export declare function functionKeyOf(node: ts.Node): string;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Stable identities for calls and functions, shared by learning and scanning.
3
+ *
4
+ * The same identity must come out of the code before a fix, the fixed code
5
+ * and the rest of the repository, so declaration keys are cwd-relative and
6
+ * function keys are name paths (`Class.method`, `outer>inner`), not positions.
7
+ */
8
+ import path from 'path';
9
+ import ts from 'typescript';
10
+ import { isFunctionLike, propertyNameText, symbolOf, unwrap } from '../ast.js';
11
+ export function isInvocation(node) {
12
+ return ts.isCallExpression(node) || ts.isNewExpression(node);
13
+ }
14
+ export function invocationArgs(call) {
15
+ return call.arguments ?? [];
16
+ }
17
+ /** Last name of the callee: `fetch` for `deps.fetch(...)`, `Response` for `new Response(...)`. */
18
+ export function calleeNameOf(call) {
19
+ const callee = unwrap(call.expression);
20
+ if (ts.isIdentifier(callee))
21
+ return callee.text;
22
+ if (ts.isPropertyAccessExpression(callee))
23
+ return callee.name.text;
24
+ return undefined;
25
+ }
26
+ export function calleeTextOf(call) {
27
+ return call.expression.getText(call.getSourceFile()).replace(/\s+/g, '');
28
+ }
29
+ /**
30
+ * `<file>#<qualified name>` of the declaration the callee resolves to: a
31
+ * cwd-relative path inside the project, `lib:<file>` for a library.
32
+ */
33
+ export function calleeDeclarationOf(checker, cwd, call) {
34
+ const decl = symbolOf(checker, call.expression)?.declarations?.[0];
35
+ if (!decl)
36
+ return undefined;
37
+ const file = decl.getSourceFile().fileName;
38
+ const relative = path.relative(cwd, file).replace(/\\/g, '/');
39
+ const where = relative.startsWith('..') || relative.includes('node_modules/') ? `lib:${path.basename(file)}` : relative;
40
+ return `${where}#${qualifiedName(decl)}`;
41
+ }
42
+ export function calleeKey(checker, cwd, call, level) {
43
+ if (level === 'name')
44
+ return calleeNameOf(call);
45
+ if (level === 'text')
46
+ return calleeTextOf(call);
47
+ return calleeDeclarationOf(checker, cwd, call);
48
+ }
49
+ /** Name path of a named declaration: `HttpDeps.fetch`, `Client.send`, `load`. */
50
+ function qualifiedName(decl) {
51
+ const names = [];
52
+ for (let node = decl; node && !ts.isSourceFile(node); node = node.parent) {
53
+ const name = declarationName(node);
54
+ if (name)
55
+ names.unshift(name);
56
+ }
57
+ return names.join('.') || '<anonymous>';
58
+ }
59
+ const NAMED_DECLARATIONS = new Set([
60
+ ts.SyntaxKind.VariableDeclaration, ts.SyntaxKind.FunctionDeclaration, ts.SyntaxKind.ClassDeclaration,
61
+ ts.SyntaxKind.InterfaceDeclaration, ts.SyntaxKind.TypeAliasDeclaration, ts.SyntaxKind.MethodDeclaration,
62
+ ts.SyntaxKind.MethodSignature, ts.SyntaxKind.PropertySignature, ts.SyntaxKind.PropertyDeclaration,
63
+ ts.SyntaxKind.ModuleDeclaration, ts.SyntaxKind.PropertyAssignment,
64
+ ]);
65
+ function declarationName(node) {
66
+ if (!NAMED_DECLARATIONS.has(node.kind))
67
+ return undefined;
68
+ const name = node.name;
69
+ if (!name || ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name))
70
+ return undefined;
71
+ return propertyNameText(name);
72
+ }
73
+ /** A function-like node with a name: declared, a method, or assigned to a variable or property. */
74
+ export function functionName(fn) {
75
+ if (!isFunctionLike(fn))
76
+ return undefined;
77
+ if ((ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && fn.name)
78
+ return propertyNameText(fn.name);
79
+ const parent = fn.parent;
80
+ if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name))
81
+ return parent.name.text;
82
+ if (ts.isPropertyAssignment(parent))
83
+ return propertyNameText(parent.name);
84
+ return undefined;
85
+ }
86
+ /** `outer>inner` path of the named functions around `node`, or `<module>` at the top level. */
87
+ export function functionKeyOf(node) {
88
+ const names = [];
89
+ for (let current = node; current && !ts.isSourceFile(current); current = current.parent) {
90
+ const name = functionName(current);
91
+ if (!name)
92
+ continue;
93
+ const owner = ts.isMethodDeclaration(current) && ts.isClassLike(current.parent) ? current.parent.name?.text : undefined;
94
+ names.unshift(owner ? `${owner}.${name}` : name);
95
+ }
96
+ return names.join('>') || '<module>';
97
+ }
@@ -0,0 +1,25 @@
1
+ import { type ProposedEdit } from './extract.js';
2
+ import type { LearnedRule } from './types.js';
3
+ export interface LearnInput {
4
+ beforeDir: string;
5
+ afterDir: string;
6
+ /** Repository the rules will guard; hits here are listed for review. */
7
+ repoDir: string;
8
+ /** Changed files, relative to both trees. */
9
+ files: string[];
10
+ commit?: string;
11
+ maxHits?: number;
12
+ }
13
+ export interface Rejection {
14
+ edit: ProposedEdit;
15
+ reason: string;
16
+ }
17
+ export interface LearnReport {
18
+ learned: LearnedRule[];
19
+ rejected: Rejection[];
20
+ unsupported: Array<{
21
+ file: string;
22
+ fn: string;
23
+ }>;
24
+ }
25
+ export declare function learnFromFix(input: LearnInput): Promise<LearnReport>;