@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,122 @@
1
+ /**
2
+ * Small, checker-aware AST helpers shared by the semantic rules.
3
+ */
4
+ import path from 'path';
5
+ import ts from 'typescript';
6
+ export function isFunctionLike(node) {
7
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)
8
+ || ts.isArrowFunction(node) || ts.isMethodDeclaration(node);
9
+ }
10
+ /** Drop parentheses, type assertions, non-null and `await` around an expression. */
11
+ export function unwrap(expr) {
12
+ let current = expr;
13
+ for (;;) {
14
+ if (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current)
15
+ || ts.isTypeAssertionExpression(current) || ts.isSatisfiesExpression(current) || ts.isAwaitExpression(current)) {
16
+ current = current.expression;
17
+ }
18
+ else {
19
+ return current;
20
+ }
21
+ }
22
+ }
23
+ /**
24
+ * Symbol for a node, following import aliases to the declaration. For the
25
+ * name in a shorthand property (`{ headers }`) this is the variable it reads,
26
+ * not the property it creates.
27
+ */
28
+ export function symbolOf(checker, node) {
29
+ const symbol = ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node
30
+ ? checker.getShorthandAssignmentValueSymbol(node.parent)
31
+ : checker.getSymbolAtLocation(node);
32
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias)
33
+ return checker.getAliasedSymbol(symbol);
34
+ return symbol;
35
+ }
36
+ /** The function a call invokes, when it resolves to one declared in source. */
37
+ export function calledFunction(checker, call) {
38
+ const symbol = symbolOf(checker, call.expression);
39
+ for (const decl of symbol?.declarations ?? []) {
40
+ if (isFunctionLike(decl) && decl.body)
41
+ return decl;
42
+ if ((ts.isVariableDeclaration(decl) || ts.isPropertyAssignment(decl)) && decl.initializer) {
43
+ const init = unwrap(decl.initializer);
44
+ if (isFunctionLike(init) && init.body)
45
+ return init;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ /** `fetch` in `fetch(...)`, `deps.fetch(...)`, `this.fetch(...)`. */
51
+ export function calleeName(call) {
52
+ const callee = unwrap(call.expression);
53
+ if (ts.isIdentifier(callee))
54
+ return callee.text;
55
+ if (ts.isPropertyAccessExpression(callee))
56
+ return callee.name.text;
57
+ return undefined;
58
+ }
59
+ export function enclosingFunction(node) {
60
+ for (let current = node.parent; current; current = current.parent) {
61
+ if (isFunctionLike(current))
62
+ return current;
63
+ }
64
+ return undefined;
65
+ }
66
+ /** Visit every descendant of `root` that belongs to `root` itself, not to a nested function. */
67
+ export function forEachOwnNode(root, visit) {
68
+ const walk = (node) => {
69
+ visit(node);
70
+ if (node !== root && isFunctionLike(node))
71
+ return;
72
+ ts.forEachChild(node, walk);
73
+ };
74
+ ts.forEachChild(root, walk);
75
+ }
76
+ export function forEachNode(root, visit) {
77
+ const walk = (node) => {
78
+ visit(node);
79
+ ts.forEachChild(node, walk);
80
+ };
81
+ ts.forEachChild(root, walk);
82
+ }
83
+ /** Text of a property name (`a`, `'a'`, `"x-token"`), or undefined for computed names. */
84
+ export function propertyNameText(name) {
85
+ if (!name)
86
+ return undefined;
87
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))
88
+ return name.text;
89
+ if (ts.isNoSubstitutionTemplateLiteral(name))
90
+ return name.text;
91
+ return undefined;
92
+ }
93
+ export function lineOf(node) {
94
+ const sf = node.getSourceFile();
95
+ return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
96
+ }
97
+ /** "http.ts:51" style location for evidence, relative to cwd. */
98
+ export function locationOf(cwd, node) {
99
+ return `${relativeFile(cwd, node.getSourceFile())}:${lineOf(node)}`;
100
+ }
101
+ export function relativeFile(cwd, sf) {
102
+ return path.relative(cwd, sf.fileName).replace(/\\/g, '/');
103
+ }
104
+ /** Short, single-line source text for messages. */
105
+ export function snippet(node, max = 60) {
106
+ const text = node.getText(node.getSourceFile()).replace(/\s+/g, ' ');
107
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
108
+ }
109
+ /** True when any identifier inside `expr` resolves to `target`. */
110
+ export function mentionsSymbol(checker, expr, target) {
111
+ let found = false;
112
+ const walk = (node) => {
113
+ if (found)
114
+ return;
115
+ if (ts.isIdentifier(node) && symbolOf(checker, node) === target)
116
+ found = true;
117
+ else
118
+ ts.forEachChild(node, walk);
119
+ };
120
+ walk(expr);
121
+ return found;
122
+ }
@@ -0,0 +1,16 @@
1
+ export interface BenchmarkCase {
2
+ name: string;
3
+ rule: string | null;
4
+ file: string | null;
5
+ line: number | null;
6
+ note: string;
7
+ }
8
+ export interface CaseResult {
9
+ case: BenchmarkCase;
10
+ /** Expected finding reported on `before/` (true for negatives). */
11
+ caught: boolean;
12
+ /** Findings on `after/` plus unexpected findings on `before/`. */
13
+ falsePositives: string[];
14
+ }
15
+ export declare function loadCases(root: string): BenchmarkCase[];
16
+ export declare function runCase(root: string, bench: BenchmarkCase): CaseResult;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Semantic benchmark: synthetic before/after cases per bug class.
3
+ *
4
+ * `before/` must produce the expected finding (recall); `after/` and cases
5
+ * with `rule: null` must produce none (false positives).
6
+ */
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { analyzeFiles } from './engine.js';
10
+ export function loadCases(root) {
11
+ return fs.readdirSync(root, { withFileTypes: true })
12
+ .filter(entry => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'case.json')))
13
+ .map(entry => ({ name: entry.name, ...JSON.parse(fs.readFileSync(path.join(root, entry.name, 'case.json'), 'utf8')) }))
14
+ .sort((a, b) => a.name.localeCompare(b.name));
15
+ }
16
+ export function runCase(root, bench) {
17
+ const before = findingsIn(path.join(root, bench.name, 'before'));
18
+ const after = findingsIn(path.join(root, bench.name, 'after'));
19
+ const isExpected = (f) => bench.rule !== null && f === `${bench.rule} ${bench.file}:${bench.line}`;
20
+ return {
21
+ case: bench,
22
+ caught: bench.rule === null || before.some(isExpected),
23
+ falsePositives: [...before.filter(f => !isExpected(f)).map(f => `before: ${f}`), ...after.map(f => `after: ${f}`)],
24
+ };
25
+ }
26
+ function findingsIn(dir) {
27
+ if (!fs.existsSync(dir))
28
+ return [];
29
+ const files = fs.readdirSync(dir).filter(f => /\.[cm]?[jt]sx?$/.test(f));
30
+ return analyzeFiles(dir, files).map(f => `${f.rule} ${f.file}:${f.line}`);
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import path from 'path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { loadCases, runCase } from './benchmark.js';
4
+ const ROOT = path.resolve(__dirname, '../../../../benchmarks/semantic');
5
+ describe('semantic benchmark', () => {
6
+ const cases = loadCases(ROOT);
7
+ it('has cases for every built-in rule, including negatives', () => {
8
+ const rules = new Set(cases.map(c => c.rule));
9
+ expect(rules).toEqual(new Set(['in-memory-aggregation', 'credential-redirect', 'degraded-response-cached', null]));
10
+ });
11
+ for (const bench of cases) {
12
+ it(`${bench.name}: ${bench.note}`, () => {
13
+ const result = runCase(ROOT, bench);
14
+ expect(result.falsePositives).toEqual([]);
15
+ expect(result.caught).toBe(true);
16
+ });
17
+ }
18
+ });
@@ -0,0 +1,8 @@
1
+ import type { SemanticFinding, SemanticRule } from './types.js';
2
+ export declare const BUILT_IN_RULES: SemanticRule[];
3
+ export interface EngineOptions {
4
+ rules?: SemanticRule[];
5
+ batchSize?: number;
6
+ }
7
+ /** Analyze `files` (cwd-relative or absolute). Declaration files are skipped. */
8
+ export declare function analyzeFiles(cwd: string, files: string[], opts?: EngineOptions): SemanticFinding[];
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Runs semantic rules over a set of JS/TS files.
3
+ */
4
+ import path from 'path';
5
+ import { loadProjectConfig, programBatches } from './program.js';
6
+ import { Summaries } from './summaries.js';
7
+ import { inMemoryAggregation } from './rules/in-memory-aggregation.js';
8
+ import { credentialRedirect } from './rules/credential-redirect.js';
9
+ import { degradedResponseCached } from './rules/degraded-response-cached.js';
10
+ export const BUILT_IN_RULES = [inMemoryAggregation, credentialRedirect, degradedResponseCached];
11
+ /** Analyze `files` (cwd-relative or absolute). Declaration files are skipped. */
12
+ export function analyzeFiles(cwd, files, opts = {}) {
13
+ const rules = opts.rules ?? BUILT_IN_RULES;
14
+ const roots = files
15
+ .filter(f => /\.(?:[cm]?[jt]s|[jt]sx)$/i.test(f) && !/\.d\.[cm]?ts$/i.test(f))
16
+ .map(f => path.resolve(cwd, f));
17
+ if (roots.length === 0 || rules.length === 0)
18
+ return [];
19
+ const { options } = loadProjectConfig(cwd);
20
+ const rootSet = new Set(roots.map(r => path.normalize(r)));
21
+ const findings = [];
22
+ for (const program of programBatches(roots, options, opts.batchSize)) {
23
+ const checker = program.getTypeChecker();
24
+ const summaries = new Summaries(checker);
25
+ for (const sourceFile of program.getSourceFiles()) {
26
+ if (!rootSet.has(path.normalize(sourceFile.fileName)))
27
+ continue;
28
+ for (const rule of rules) {
29
+ findings.push(...rule.check({ cwd, checker, sourceFile, summaries }));
30
+ }
31
+ rootSet.delete(path.normalize(sourceFile.fileName));
32
+ }
33
+ }
34
+ return dedupe(findings);
35
+ }
36
+ function dedupe(findings) {
37
+ const seen = new Set();
38
+ return findings.filter(f => {
39
+ const key = `${f.rule}:${f.file}:${f.line}`;
40
+ if (seen.has(key))
41
+ return false;
42
+ seen.add(key);
43
+ return true;
44
+ });
45
+ }
@@ -0,0 +1,31 @@
1
+ import { type ReadVerdict, type YesNoModel } from './intent-check.js';
2
+ export interface IntentCase {
3
+ name: string;
4
+ note: string;
5
+ labels: Record<string, 'optional' | 'required'>;
6
+ expectFinding: boolean;
7
+ /** False when the finder must skip the site (no model call). */
8
+ expectSite?: boolean;
9
+ }
10
+ export interface IntentCaseResult {
11
+ case: IntentCase;
12
+ siteFound: boolean;
13
+ verdicts: ReadVerdict[];
14
+ findings: number;
15
+ }
16
+ export interface IntentScore {
17
+ /** Individual answers matching the label (question 1 expects yes for optional, question 2 no). */
18
+ rawCorrect: number;
19
+ rawTotal: number;
20
+ /** Reads whose flipped answers agreed, and how many of those were right. */
21
+ consistent: number;
22
+ consistentCorrect: number;
23
+ reads: number;
24
+ truePositives: number;
25
+ falseNegatives: number;
26
+ falsePositives: number;
27
+ finderErrors: string[];
28
+ }
29
+ export declare function loadIntentCases(root: string): IntentCase[];
30
+ export declare function runIntentCase(root: string, bench: IntentCase, model: YesNoModel): Promise<IntentCaseResult>;
31
+ export declare function scoreIntent(results: IntentCaseResult[]): IntentScore;
@@ -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>;