diff --git a/docs/AST_PARSER_FALLBACK.md b/docs/AST_PARSER_FALLBACK.md new file mode 100644 index 00000000..c60485c8 --- /dev/null +++ b/docs/AST_PARSER_FALLBACK.md @@ -0,0 +1,115 @@ +# AST Parser Fallback Strategy + +A single parser failing should not cost a whole file. New syntax, a half-edited file, a parser bug +or a timeout can all make the preferred parser reject input that an analyzer could still use in +part. The fallback chain tries parser strategies in order for each file, returns the best tree it +can get, and records exactly what happened, so degraded results are visible and never silent. + +| Layer | Location | +| --- | --- | +| Fallback chain and lexical outline (pure, engine) | `libs/engine/core/ast-parser-fallback.ts` | +| Fixtures | `tests/fixtures/ast-parser-fallback/` | +| Tests | `libs/engine/core/ast-parser-fallback.spec.ts` | + +## Public interface + +```ts +import { + parseWithFallback, + createLexicalOutlineStrategy, + TransientParserError, +} from '@engine/core/ast-parser-fallback'; + +const result = await parseWithFallback( + [{ path: 'contracts/Vault.sol', content: '...' }], + [strictSolidityParser, recoveringSolidityParser, createLexicalOutlineStrategy()], + { timeoutMs: 5000, requireComplete: false }, +); + +for (const file of result.files) { + if (file.status === 'degraded') { + // file.ast is usable but file.complete may be false; file.attempts says why. + } +} +``` + +A strategy is `{ name, languages, maxBytes?, parse(source, { path, language }) }`. `parse` may be +sync or async and returns `{ ast, complete?, diagnostics? }`. `complete: false` marks a partial +tree (a recovering parser, an outline). Register strategies most faithful first. + +## Selection rules + +1. The first strategy returning a complete tree wins. The file is `parsed` if that was the first + strategy tried, `degraded` if an earlier one failed (`fallbackUsed: true`). +2. A partial tree is remembered and the chain continues in case a later strategy completes. +3. If none completes, the earliest partial tree is returned as `degraded` (`complete: false`). + With `requireComplete: true` the file is `failed` instead, for gates that need a full tree. +4. A file whose language no strategy supports is `unsupported`; a file larger than `maxFileBytes` + is `skipped`; if every strategy fails the file is `failed`. None of these produce an AST, so an + analyzer can never mistake them for an empty file. + +`strategyOrder` overrides the order per language by strategy name. `disabledStrategies` removes +strategies without changing the registered list. + +## Built-in lexical outline + +`createLexicalOutlineStrategy()` never fails on text input. For Solidity, Rust and Vyper it blanks +comments and strings, matches braces (or indentation for Vyper) and returns an `OutlineAst` of +declarations with line ranges: top-level contracts, functions, structs, enums, events, errors, +traits, modules and constants, plus functions and events inside contracts, interfaces, libraries, +`impl`, `trait` and `mod` blocks. It always reports `complete: false` with a `lexical_outline` +warning, plus `unterminated_block` / `unmatched_close_brace` errors for unbalanced files. +Its default `maxBytes` (20 MiB) is higher than typical parsers', so large files that strict +parsers skip still get an outline. + +## Configuration + +| Option | Default | Meaning | +| --- | --- | --- | +| `timeoutMs` | `5000` | Budget per attempt. | +| `maxAttemptsPerStrategy` | `2` | Attempts for *transient* errors only. | +| `maxFileBytes` | 5 MiB | Larger files are `skipped`. | +| `maxDiagnosticsPerFile` | `50` | Diagnostics beyond this are dropped. | +| `requireComplete` | `false` | Fail instead of returning a partial tree. | +| `strategyOrder` | none | `{ [language]: [strategy names] }`. | +| `disabledStrategies` | `[]` | Strategy names to skip. | +| `languageOf` | by extension | Custom path to language mapping. A source can also set `language`. | +| `rootDir` | none | Absolute root used to relativize absolute input paths. | + +## Failure semantics + +| Situation | Behavior | +| --- | --- | +| Strategy throws | Attempt `error`; next strategy. Not retried: parsers are deterministic. | +| `TransientParserError` (or `error.transient === true`) | Attempt `transient-error`; retried up to `maxAttemptsPerStrategy`. | +| Strategy exceeds `timeoutMs` | Attempt `timeout`; next strategy. A synchronous parser cannot be interrupted, so its result is discarded. | +| Strategy returns no `ast` | Attempt `invalid-output`; next strategy. | +| File larger than a strategy's `maxBytes` | Attempt `skipped-size`; next strategy. | +| Invalid path, duplicate path or duplicate strategy name | File or strategy dropped, reported in `issues`. | +| Invalid option or unknown name in `strategyOrder` | Default used or name ignored, `INVALID_CONFIG` / `UNKNOWN_STRATEGY`. | + +`parseWithFallback` never throws for bad input. Error text is truncated to one line (200 chars) and +diagnostics are reduced to code, line, column and message, so file content is not copied into +results. Diagnostics map directly onto `ParserRecoveryDiagnostics` (`apps/api/src/scanner`). + +## Determinism, retries and tenancy + +- Files are processed in path order, one at a time. The result depends only on sources, strategies + and options, never on input order. Run-dependent data lives in `telemetry`. A timeout is the one + machine-dependent outcome; it is visible per file in `attempts` and counted in `summary.timedOut`. +- Retrying a whole job is safe; there is no module state or I/O. Cache by commit, options and the + versions of the strategies in the chain. +- Strategies are supplied per call, so each tenant or job can have its own chain, and the chain + only sees the sources passed in. + +## Using the result + +`status`, `complete` and `strategy` tell an analyzer how much to trust the tree. Rules that need +full syntax should skip `complete: false` files and say so; rules that work from declarations can +use an outline. A common policy is to treat findings from degraded files with lower confidence, +for example with a `ruleOverrides` entry in rule-level confidence calibration. + +## Backward compatibility + +Additive. Existing analyzers and parsers are untouched; a parser joins the chain by being wrapped +as a strategy. Without a chain, behavior is unchanged. diff --git a/docs/CROSS_FILE_REFERENCE_RESOLUTION.md b/docs/CROSS_FILE_REFERENCE_RESOLUTION.md new file mode 100644 index 00000000..3eb9b977 --- /dev/null +++ b/docs/CROSS_FILE_REFERENCE_RESOLUTION.md @@ -0,0 +1,114 @@ +# Cross-File Reference Resolution + +Rules that look at one file at a time cannot tell where an imported symbol comes from. Cross-file +resolution builds the import graph for a repository snapshot, indexes top-level definitions, and +resolves each use of an imported symbol to the file and line that defines it. + +| Layer | Location | +| --- | --- | +| Resolver (pure, engine) | `libs/engine/core/cross-file-resolver.ts` | +| Fixtures | `tests/fixtures/cross-file-resolution/` | +| Tests | `libs/engine/core/cross-file-resolver.spec.ts` | + +## Public interface + +```ts +import { + resolveCrossFileReferences, + getAffectedFiles, + getReferencesTo, + parseRemappings, +} from '@engine/core/cross-file-resolver'; + +const resolution = resolveCrossFileReferences( + [{ path: 'contracts/Vault.sol', content: '...' }, /* ... */], + { remappings: parseRemappings(remappingsTxt) }, +); +``` + +- `resolveCrossFileReferences(sources, options)` returns a `CrossFileResolution` + (`schemaVersion`, `files`, `skippedFiles`, `symbols`, `imports`, `references`, `dependencies`, + `cycles`, `issues`, `counts`). +- `getAffectedFiles(resolution, changedFiles)` returns the changed files plus all transitive + importers. Use it to widen `AnalyzerConfig.changedFiles` when `changedFilesOnly` is set, so a + change to a base contract re-analyzes its dependents. +- `getReferencesTo(resolution, file, name)` lists every use that resolves to a definition. +- `parseRemappings(text)` parses a `remappings.txt` body. + +## Configuration + +| Option | Default | Meaning | +| --- | --- | --- | +| `remappings` | `{}` | Solidity `prefix -> target` remappings. The longest matching prefix wins. | +| `rootDir` | none | Absolute root used to relativize absolute input paths. | +| `maxFiles` | `5000` | Files beyond this count (after sorting by path) are skipped with `TOO_MANY_FILES`. | +| `maxFileBytes` | `1048576` | Larger files are skipped with `FILE_TOO_LARGE`. | + +There are no other switches. Languages are chosen by extension. + +## Supported languages + +| Language | Extensions | Imports understood | Symbols indexed (top level) | +| --- | --- | --- | --- | +| Solidity | `.sol` | `import "x"`, `import "x" as N`, `import * as N from "x"`, `import {A, B as C} from "x"`, remappings | contract, interface, library, struct, enum, event, error, function, user-defined type, constant | +| Rust | `.rs` | `mod x;`, `use crate::/self::/super::` paths, `{}` groups, `as`, `*`, `pub use` / `pub mod` re-exports | fn, struct, enum, trait, type, const, static, union | +| Vyper | `.vy`, `.vyi` | `import a.b as c`, `from a import B`, `from . import m`, relative dots | def, struct, event, interface, flag, enum, constants | + +Solidity plain imports (`import "x";`) and symbol imports are transitively visible to importers, as +in the Solidity compiler. Rust and Vyper imports are not re-exported unless marked `pub use`. + +## Output semantics + +- **Imports** have a `status`: + - `resolved`: the target is in the snapshot (`resolvedPath` is set). + - `external`: the specifier points outside the snapshot (`@openzeppelin/...` with no vendored + copy, `std::...`, `ethereum.ercs`). Not an error. + - `unresolved`: a relative import, `mod`, or in-repo `use` whose target is missing. Raises + `UNRESOLVED_IMPORT`. +- **References** have a `status`: + - `resolved` with a single `target` definition. + - `ambiguous` with all `candidates`, when two plain imports provide the same name + (`AMBIGUOUS_SYMBOL`). + - `external` when the symbol comes from an external import. + - `unresolved` when the import resolved but defines no such symbol (`UNRESOLVED_SYMBOL`). +- Comments and string contents are never counted as references. A definition in the same file + shadows an import of the same name. `a.b` member accesses are not treated as imports of `b`. +- Namespace uses (`Errors.ZeroAmount`, `math::sub`) are reported with the member as `name` and the + namespace as `via`. +- `cycles` lists strongly connected import groups; each also raises one `IMPORT_CYCLE` issue. + Resolution terminates on cycles. + +## Failure semantics + +| Situation | Behavior | +| --- | --- | +| Unsupported extension | File listed in `skippedFiles`, issue `UNSUPPORTED_LANGUAGE`. | +| Path is empty, absolute outside `rootDir`, or escapes the root (`..`) | File dropped, `INVALID_PATH`. Relative imports that climb above the root are `unresolved`. | +| Duplicate path | One copy kept (smallest content, so input order does not matter), `DUPLICATE_PATH`. | +| File too large / too many files | Skipped, `FILE_TOO_LARGE` / `TOO_MANY_FILES`. | +| Exception while processing one file | `PARSE_ERROR` for that file; every other file is still resolved. | +| Empty input | Empty resolution with no issues. | + +The resolver never throws for bad repository input and never returns a partial result silently: +every skipped or degraded item has an entry in `issues`. + +## Determinism, retries and tenancy + +- The function is pure: no I/O, clock, randomness or module state. For the same sources and + options it returns byte-identical output, whatever order the sources arrive in. Every list is + sorted (by path, line, column, name). +- A worker can retry a failed job freely; there is nothing to clean up. Persist or cache results + keyed by commit SHA, `options` and rule-pack version. +- It only sees the sources passed in, so tenants are isolated as long as one call is made per + tenant repository. Paths in the output are repository-relative. + +## Backward compatibility + +The module is additive. No existing interface changed, analyzers that ignore it behave exactly as +before, and `schemaVersion` is bumped when the output shape changes. + +## Limits + +Resolution is lexical, not a full parse: names inside function bodies are matched by identifier, so +a local variable that shares a name with an imported symbol is counted as a reference. Contract +members, inline Rust modules and Solidity `using ... global` are not indexed. diff --git a/docs/RULE_CONFIDENCE_CALIBRATION.md b/docs/RULE_CONFIDENCE_CALIBRATION.md new file mode 100644 index 00000000..44041e69 --- /dev/null +++ b/docs/RULE_CONFIDENCE_CALIBRATION.md @@ -0,0 +1,97 @@ +# Rule-Level Confidence Calibration + +Some rules are right almost every time, others mostly produce noise. Calibration turns reviewed +outcomes (true / false positive verdicts) into a per-rule track record and uses it to attach a +confidence value and band to each finding, so reports and gates can rank or filter by how much a +finding is likely to be real. + +| Layer | Location | +| --- | --- | +| Calibrator (pure, engine) | `libs/engine/core/confidence-calibration.ts` | +| Fixtures | `tests/fixtures/confidence-calibration/` | +| Tests | `libs/engine/core/confidence-calibration.spec.ts` | + +## Public interface + +```ts +import { + buildCalibrationProfiles, + calibrateFindings, + confidenceBand, +} from '@engine/core/confidence-calibration'; + +// 1. Aggregate one tenant's reviewed outcomes for the active rule pack. +const { profiles } = buildCalibrationProfiles(outcomes, { rulePackVersion: '2.1.0' }); + +// 2. Calibrate a scan's findings (normalized or raw; anything with a `ruleId`). +const result = calibrateFindings(findings, { profiles, rulePackVersion: '2.1.0', minConfidence: 0.3 }); +result.findings; // kept, in input order, each with metadata.confidence / confidenceBand / ... +result.suppressed; // below the threshold, still carrying their confidence +result.issues; // every ignored profile, bad value or fallback +``` + +Each returned finding gets these `metadata` fields: `confidence` (0 to 1, 4 decimals), +`confidenceBand` (`high` / `medium` / `low`), `confidenceSource`, `calibrationSamples`, and +`rawConfidence` when the finding supplied one in `metadata.confidence`. + +## Model + +For a rule with `tp` true and `fp` false positives, prior `p` and prior strength `s`: + +``` +q = (tp + p*s) / (tp + fp + s) smoothed true-positive rate +confidence = sigmoid(logit(r) + logit(q) - logit(p)) r = raw confidence, or p if absent +``` + +With no raw confidence the result is `q`. With one, the raw value is shifted by how far the rule's +record is from its prior, so an accurate rule raises it and a noisy rule lowers it, and the result +always stays in (0, 1). Rules with fewer than `minSamples` reviewed outcomes are not calibrated: the +finding keeps its raw confidence (`confidenceSource: "raw"`) or the prior (`"prior"`). + +Worked example (prior 0.5, strength 10): 45 true and 5 false positives give `q = 50/60 = 0.8333`. +A finding with raw confidence 0.9 has odds 9, the rule's odds are 5, and the result is `45/46 = 0.9783`. + +## Configuration + +| Option | Default | Meaning | +| --- | --- | --- | +| `profiles` | `[]` | Per-rule `{ ruleId, truePositives, falsePositives, priorConfidence?, priorStrength?, rulePackVersion? }`. | +| `rulePackVersion` | none | When set, profiles recorded against another version are ignored (`STALE_PROFILE`). | +| `defaultPrior` | `0.5` | Prior for rules without their own. | +| `priorStrength` | `10` | Prior weight, in pseudo-observations. | +| `minSamples` | `5` | Reviewed outcomes needed before calibration applies. | +| `minConfidence` | `0` | Findings below this are moved to `suppressed`. `0` suppresses nothing. | +| `ruleOverrides` | `{}` | `{ [ruleId]: { confidence?, minConfidence? } }`. `confidence` pins the value (`source: "override"`); `minConfidence` replaces the global threshold for that rule. | +| `bandThresholds` | `{ high: 0.8, medium: 0.5 }` | Inclusive lower bounds of the bands. Must satisfy `0 <= medium < high <= 1`. | + +## Failure semantics + +The calibrator never throws for bad data; it ignores the bad part, falls back to defaults and +records an issue. + +| Situation | Behavior | +| --- | --- | +| Profile with bad counts, prior or strength | Ignored, `INVALID_PROFILE`. The rule is treated as having no history. | +| Profile for another rule-pack version | Ignored, `STALE_PROFILE`. | +| Several profiles for one rule | The one with the most samples is kept, `DUPLICATE_PROFILE`. | +| Raw `metadata.confidence` outside [0, 1] or not a number | Ignored, `INVALID_CONFIDENCE`. | +| Finding without a `ruleId` | Gets the default prior, `MISSING_RULE_ID`. | +| Invalid config value | Default used, `INVALID_CONFIG`. | +| Malformed review outcome | Skipped, `INVALID_OUTCOME`. | +| Unknown rule or no outcomes | Not an error: raw confidence or prior, `source` says which. | + +## Determinism, retries and tenancy + +- Pure function: no I/O, clock or state. The same findings, profiles and config always give + identical output, regardless of profile order. Retrying a failed job is safe. +- Idempotent: calibrating findings that were already calibrated gives the same result, because the + original raw confidence is kept in `rawConfidence`. +- Tenant isolation is by construction: only the outcomes and profiles passed in are used. Build + profiles from one tenant's reviews and cache them per tenant, rule-pack version and review window. +- Persist profiles (or the outcomes) with the rule-pack version they were computed for. Reproducing + a result needs the same commit, config and rule-pack version. + +## Backward compatibility + +Additive. Without config, findings only gain `confidence*` metadata; nothing is suppressed and no +existing field changes. Consumers that do not read the new metadata see no difference. diff --git a/libs/engine/core/ast-parser-fallback.spec.ts b/libs/engine/core/ast-parser-fallback.spec.ts new file mode 100644 index 00000000..286f1809 --- /dev/null +++ b/libs/engine/core/ast-parser-fallback.spec.ts @@ -0,0 +1,343 @@ +import * as fs from "fs"; +import * as path from "path"; +import { maskSource } from "./cross-file-resolver"; +import { + PARSER_FALLBACK_SCHEMA_VERSION, + TransientParserError, + createLexicalOutlineStrategy, + detectLanguage, + parseWithFallback, + type ParserSource, + type ParserStrategy, +} from "./ast-parser-fallback"; + +const FIXTURES = path.resolve(__dirname, "../../../tests/fixtures/ast-parser-fallback"); + +function loadRepo(name: string): ParserSource[] { + const root = path.join(FIXTURES, name); + const out: ParserSource[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else { + out.push({ + path: path.relative(root, full).split(path.sep).join("/"), + content: fs.readFileSync(full, "utf8"), + }); + } + } + }; + walk(root); + return out; +} + +const read = (rel: string) => fs.readFileSync(path.join(FIXTURES, rel), "utf8"); + +const LANGS = ["solidity", "rust", "vyper"]; + +/** Rejects the `transient` keyword (new syntax) and unbalanced braces, like a strict real parser. */ +const strict: ParserStrategy<{ kind: string }> = { + name: "strict", + languages: LANGS, + parse(source, ctx) { + if (/\btransient\b/.test(source)) throw new SyntaxError("unexpected keyword 'transient'"); + const masked = maskSource(source, ctx.language as "solidity"); + const opens = (masked.match(/{/g) ?? []).length; + const closes = (masked.match(/}/g) ?? []).length; + if (opens !== closes) throw new SyntaxError("unbalanced braces"); + return { ast: { kind: "full" } }; + }, +}; + +/** Keeps going past errors and returns a partial tree. */ +const recovering: ParserStrategy<{ kind: string }> = { + name: "recovering", + languages: LANGS, + parse() { + return { + ast: { kind: "recovered" }, + complete: false, + diagnostics: [{ code: "recovered_error", message: "skipped a bad declaration", line: 4 }], + }; + }, +}; + +const lexical = createLexicalOutlineStrategy() as ParserStrategy; +const chain: Array> = [strict, recovering, lexical]; + +const byPath = (res: { files: any[] }, p: string) => res.files.find((f) => f.path === p)!; + +describe("realistic mixed repository", () => { + let result: Awaited>; + beforeAll(async () => { + result = await parseWithFallback(loadRepo("realistic-repo"), chain); + }); + + it("parses valid files with the first strategy and marks them parsed", () => { + for (const p of ["contracts/Token.sol", "contracts/Vault.sol", "src/lib.rs", "vault.vy"]) { + expect(byPath(result, p)).toMatchObject({ + status: "parsed", + strategy: "strict", + fallbackUsed: false, + complete: true, + }); + } + }); + + it("falls back to the recovering parser for syntax the strict parser rejects", () => { + const future = byPath(result, "contracts/Future.sol"); + expect(future).toMatchObject({ + status: "degraded", + strategy: "recovering", + fallbackUsed: true, + complete: false, + }); + expect(future.attempts.map((a: any) => [a.strategy, a.outcome])).toEqual([ + ["strict", "error"], + ["recovering", "partial"], + ["lexical-outline", "partial"], + ]); + expect(future.attempts[0].message).toContain("unexpected keyword 'transient'"); + expect(future.diagnostics).toEqual([ + { code: "recovered_error", message: "skipped a bad declaration", line: 4 }, + ]); + }); + + it("reports files with no parser as unsupported rather than empty", () => { + expect(byPath(result, "NOTES.md")).toMatchObject({ status: "unsupported", attempts: [] }); + expect(byPath(result, "NOTES.md").ast).toBeUndefined(); + }); + + it("summarizes the run", () => { + expect(result.schemaVersion).toBe(PARSER_FALLBACK_SCHEMA_VERSION); + expect(result.summary).toEqual({ + files: 7, + parsed: 4, + degraded: 2, + failed: 0, + unsupported: 1, + skipped: 0, + fallbackUsed: 2, + timedOut: 0, + byStrategy: { recovering: 2, strict: 4 }, + }); + expect(result.files.map((f) => f.path)).toEqual([...result.files.map((f) => f.path)].sort()); + }); + + it("uses a configured strategy order and can fall through to the outline", async () => { + const res = await parseWithFallback(loadRepo("realistic-repo"), chain, { + strategyOrder: { solidity: ["strict", "lexical-outline"] }, + }); + const broken = byPath(res, "contracts/Broken.sol"); + expect(broken).toMatchObject({ status: "degraded", strategy: "lexical-outline", complete: false }); + expect(broken.ast.nodes.map((n: any) => [n.kind, n.name, n.startLine, n.endLine])).toEqual([ + ["contract", "Half", 3, 7], + ["function", "a", 4, 7], + ]); + expect(broken.diagnostics.map((d: any) => d.code)).toEqual(["lexical_outline", "unterminated_block"]); + }); + + it("is deterministic regardless of input order", async () => { + const sources = loadRepo("realistic-repo"); + const a = await parseWithFallback(sources, chain); + const b = await parseWithFallback([...sources].reverse(), chain); + expect(JSON.stringify({ ...b, telemetry: 0 })).toBe(JSON.stringify({ ...a, telemetry: 0 })); + }); +}); + +describe("lexical outline strategy", () => { + const outline = (rel: string, language: string) => + (lexical.parse(read(rel), { path: rel, language }) as any).ast.nodes.map((n: any) => [ + n.kind, + n.name, + n.startLine, + n.endLine, + n.depth, + ]); + + it("lists declarations and members with line ranges", () => { + expect(outline("realistic-repo/contracts/Token.sol", "solidity")).toEqual([ + ["interface", "IToken", 4, 6, 0], + ["function", "transfer", 5, 5, 1], + ["contract", "Token", 8, 19, 0], + ["event", "Moved", 11, 11, 1], + ["function", "transfer", 13, 18, 1], + ]); + }); + + it("reports itself as a partial tree", () => { + const out = lexical.parse(read("realistic-repo/contracts/Token.sol"), { + path: "Token.sol", + language: "solidity", + }) as any; + expect(out.complete).toBe(false); + expect(out.diagnostics!.map((d) => d.code)).toEqual(["lexical_outline"]); + }); + + it("regression: ignores braces inside strings and comments", () => { + expect(outline("regression/braces-in-strings.sol", "solidity")).toEqual([ + ["contract", "Messages", 4, 12, 0], + ["function", "say", 9, 11, 1], + ]); + }); + + it("regression: semicolons inside Rust array types do not end a declaration", () => { + expect(outline("regression/array-types.rs", "rust")).toEqual([ + ["struct", "Buffer", 1, 3, 0], + ["fn", "new", 6, 8, 1], + ["fn", "as_array", 10, 12, 1], + ["const", "LIMIT", 15, 15, 0], + ]); + }); + + it("regression: ends Vyper blocks at the next unindented line", () => { + expect(outline("regression/indented.vy", "vyper")).toEqual([ + ["struct", "Position", 5, 7, 0], + ["def", "deposit", 10, 12, 0], + ["def", "withdraw", 16, 17, 0], + ]); + }); +}); + +describe("failure semantics", () => { + const source = (content = "contract A {}"): ParserSource[] => [{ path: "a.sol", content }]; + const fixed = (name: string, impl: ParserStrategy["parse"]): ParserStrategy => ({ + name, + languages: ["solidity"], + parse: impl, + }); + + it("marks files failed when every strategy fails", async () => { + const res = await parseWithFallback(source(), [ + fixed("one", () => { throw new Error("boom"); }), + fixed("two", () => ({} as any)), + ]); + expect(res.files[0]).toMatchObject({ status: "failed", reason: "Every parser strategy failed." }); + expect(res.files[0]!.attempts.map((a) => a.outcome)).toEqual(["error", "invalid-output"]); + }); + + it("fails partial-only files when requireComplete is set", async () => { + const res = await parseWithFallback(source(), [recovering as any], { requireComplete: true }); + expect(res.files[0]).toMatchObject({ status: "failed" }); + expect(res.files[0]!.ast).toBeUndefined(); + }); + + it("moves on after a timeout and records it", async () => { + const res = await parseWithFallback( + source(), + [ + fixed("slow", () => new Promise(() => undefined) as any), + fixed("fast", () => ({ ast: "ok" })), + ], + { timeoutMs: 20 }, + ); + expect(res.files[0]).toMatchObject({ status: "degraded", strategy: "fast", fallbackUsed: true }); + expect(res.files[0]!.attempts[0]).toMatchObject({ strategy: "slow", outcome: "timeout" }); + expect(res.summary.timedOut).toBe(1); + }); + + it("retries transient errors only, up to maxAttemptsPerStrategy", async () => { + let transientCalls = 0; + let permanentCalls = 0; + const res = await parseWithFallback( + source(), + [ + fixed("flaky", () => { + transientCalls++; + if (transientCalls < 3) throw new TransientParserError("worker busy"); + return { ast: "ok" }; + }), + ], + { maxAttemptsPerStrategy: 3 }, + ); + expect(res.files[0]).toMatchObject({ status: "parsed", strategy: "flaky" }); + expect(res.files[0]!.attempts.map((a) => a.outcome)).toEqual(["transient-error", "transient-error", "complete"]); + + await parseWithFallback(source(), [ + fixed("broken", () => { + permanentCalls++; + throw new Error("syntax"); + }), + ]); + expect(permanentCalls).toBe(1); + }); + + it("skips strategies by file size so large files reach the outline", async () => { + const big = "contract A {}\n" + "// padding\n".repeat(50); + const limited: ParserStrategy = { ...fixed("small-only", () => ({ ast: 1 })), maxBytes: 100 }; + const res = await parseWithFallback(source(big), [limited, lexical]); + expect(res.files[0]).toMatchObject({ status: "degraded", strategy: "lexical-outline" }); + expect(res.files[0]!.attempts[0]).toMatchObject({ strategy: "small-only", outcome: "skipped-size" }); + }); + + it("skips files above maxFileBytes without invoking any strategy", async () => { + const parse = jest.fn(() => ({ ast: 1 })); + const res = await parseWithFallback(source("x".repeat(50)), [fixed("s", parse)], { maxFileBytes: 10 }); + expect(res.files[0]).toMatchObject({ status: "skipped", attempts: [] }); + expect(parse).not.toHaveBeenCalled(); + }); + + it("does not leak long error text and bounds diagnostics", async () => { + const res = await parseWithFallback( + source(), + [ + fixed("noisy", () => { throw new Error("secret line\n" + "x".repeat(1000)); }), + fixed("many", () => ({ + ast: 1, + diagnostics: Array.from({ length: 10 }, (_, i) => ({ code: "d", message: "m", line: i + 1 })), + })), + ], + { maxDiagnosticsPerFile: 3 }, + ); + const file = res.files[0]!; + expect(file.attempts[0]!.message).toBe("Error: secret line"); + expect(file.diagnostics).toHaveLength(3); + }); + + it("reports configuration problems and keeps working", async () => { + const res = await parseWithFallback( + [ + { path: "../escape.sol", content: "x" }, + { path: "a.sol", content: "x" }, + { path: "a.sol", content: "y" }, + ], + [lexical, lexical], + { timeoutMs: -1, strategyOrder: { solidity: ["nope"] } }, + ); + expect(res.issues.map((i) => i.code).sort()).toEqual([ + "DUPLICATE_PATH", + "DUPLICATE_STRATEGY", + "INVALID_CONFIG", + "INVALID_PATH", + "UNKNOWN_STRATEGY", + ]); + // Unknown names were dropped, leaving an empty override: nothing supports the file. + expect(res.files).toHaveLength(1); + expect(res.files[0]!.status).toBe("unsupported"); + }); + + it("honours disabledStrategies and explicit languages", async () => { + const res = await parseWithFallback( + [{ path: "contract.txt", content: "contract A {}", language: "solidity" }], + [strict as ParserStrategy, lexical], + { disabledStrategies: ["strict"] }, + ); + expect(res.files[0]).toMatchObject({ language: "solidity", strategy: "lexical-outline", status: "degraded" }); + }); + + it("returns an empty result for empty input", async () => { + const res = await parseWithFallback([], chain); + expect(res.files).toEqual([]); + expect(res.summary.files).toBe(0); + }); +}); + +describe("detectLanguage", () => { + it("maps extensions case-insensitively and rejects unknown ones", () => { + expect(detectLanguage("a/B.SOL")).toBe("solidity"); + expect(detectLanguage("x.vyi")).toBe("vyper"); + expect(detectLanguage("README.md")).toBeUndefined(); + expect(detectLanguage(".sol")).toBeUndefined(); + }); +}); diff --git a/libs/engine/core/ast-parser-fallback.ts b/libs/engine/core/ast-parser-fallback.ts new file mode 100644 index 00000000..eb22f652 --- /dev/null +++ b/libs/engine/core/ast-parser-fallback.ts @@ -0,0 +1,751 @@ +/** + * AST parser fallback strategy. + * + * Analyzers that need a syntax tree should not lose a whole file because the + * preferred parser rejects it (new syntax, a half-edited file, a parser bug or + * a timeout). This module runs an ordered chain of parser strategies per file + * and returns the best tree it can get, together with a record of what was + * tried, so degraded results are visible rather than silent. + * + * Selection + * ───────── + * Strategies for a file's language run in order (most faithful first): + * 1. The first strategy that returns a *complete* tree wins. It is `parsed` if + * it was the first strategy tried and `degraded` if an earlier one failed. + * 2. A strategy that returns a *partial* tree (`complete: false`, e.g. a + * recovering parser or the lexical outline) is remembered, and the chain + * continues in case a later strategy completes. + * 3. When no strategy completes, the earliest partial tree is returned as + * `degraded`, unless `requireComplete` is set, in which case the file is + * `failed`. + * 4. Files no strategy supports are `unsupported`, never an empty success. + * + * Design + * ────── + * - No module state: strategies are passed in, so each tenant or job can use + * its own chain. Files are processed in path order. + * - Deterministic: output depends only on sources, strategies and options. + * Wall-clock data lives in `telemetry`. A timeout is the one outcome that + * depends on machine speed; it is recorded as such in the attempts. + * - Failure isolation: a throwing, hanging or malformed strategy affects only + * its own attempt. Nothing in this module throws for bad input. + * - Retries: parsers are deterministic, so a failure moves on to the next + * strategy. Only errors flagged transient (`TransientParserError` or + * `error.transient === true`) are retried, up to `maxAttemptsPerStrategy`. + * A synchronous parser cannot be interrupted; on timeout its result is + * discarded and the chain continues. + * - Privacy: parser messages are truncated and diagnostics are reduced to + * code/line/column/message, so file content is not copied into results. + */ + +import { maskSource, type CrossFileLanguage } from "./cross-file-resolver"; +import { normalizeFindingPath } from "./result-normalizer"; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export const PARSER_FALLBACK_SCHEMA_VERSION = 1; +export const DEFAULT_PARSE_TIMEOUT_MS = 5000; +export const DEFAULT_MAX_ATTEMPTS_PER_STRATEGY = 2; +export const DEFAULT_MAX_PARSE_FILE_BYTES = 5 * 1024 * 1024; +export const DEFAULT_MAX_DIAGNOSTICS_PER_FILE = 50; +const MAX_TEXT_LENGTH = 200; + +export interface ParseDiagnostic { + code: string; + message: string; + /** 1-based. */ + line: number; + column?: number; + /** Default `error`. */ + severity?: "error" | "warning"; +} + +export interface ParseContext { + path: string; + language: string; +} + +export interface ParseOutput { + ast: TAst; + /** False when the tree covers only part of the file. Default true. */ + complete?: boolean; + diagnostics?: ParseDiagnostic[]; +} + +export interface ParserStrategy { + /** Unique, stable name; appears in results and in `strategyOrder`. */ + name: string; + /** Languages handled, lower-case, or `"*"` for any. */ + languages: string[] | "*"; + /** Strategies are skipped for files larger than this. */ + maxBytes?: number; + parse(source: string, context: ParseContext): ParseOutput | Promise>; +} + +/** Throw from a strategy for failures worth retrying (e.g. a busy worker). */ +export class TransientParserError extends Error { + readonly transient = true; + constructor(message: string) { + super(message); + this.name = "TransientParserError"; + } +} + +export interface ParserSource { + path: string; + content: string; + /** Overrides detection from the file extension. */ + language?: string; +} + +export interface ParserFallbackOptions { + /** Per-attempt budget in ms. Default 5000. */ + timeoutMs?: number; + /** Attempts per strategy for transient errors. Default 2. */ + maxAttemptsPerStrategy?: number; + /** Files larger than this are `skipped`. Default 5 MiB. */ + maxFileBytes?: number; + maxDiagnosticsPerFile?: number; + /** When true, a file with only a partial tree is `failed`, not `degraded`. */ + requireComplete?: boolean; + /** Per-language strategy order by name, overriding registration order. */ + strategyOrder?: Record; + disabledStrategies?: string[]; + /** Absolute root used to relativize absolute input paths. */ + rootDir?: string; + languageOf?: (path: string) => string | undefined; + now?: () => number; +} + +export type AttemptOutcome = + | "complete" + | "partial" + | "error" + | "transient-error" + | "timeout" + | "skipped-size" + | "invalid-output"; + +export interface ParseAttempt { + strategy: string; + attempt: number; + outcome: AttemptOutcome; + /** Truncated error text; never file content by design. */ + message?: string; + diagnostics: number; +} + +export type FileParseStatus = "parsed" | "degraded" | "failed" | "unsupported" | "skipped"; + +export interface FileParseResult { + path: string; + language?: string; + status: FileParseStatus; + /** Strategy that produced `ast`. */ + strategy?: string; + /** True when `ast` did not come from the first strategy tried. */ + fallbackUsed: boolean; + /** Whether `ast` covers the whole file. */ + complete: boolean; + ast?: TAst; + diagnostics: ParseDiagnostic[]; + attempts: ParseAttempt[]; + /** Why the file is `failed`, `unsupported` or `skipped`. */ + reason?: string; +} + +export type ParserFallbackIssueCode = + | "INVALID_PATH" + | "DUPLICATE_PATH" + | "DUPLICATE_STRATEGY" + | "UNKNOWN_STRATEGY" + | "INVALID_CONFIG"; + +export interface ParserFallbackIssue { + code: ParserFallbackIssueCode; + message: string; + file?: string; +} + +export interface ParserFallbackResult { + schemaVersion: number; + /** Sorted by path. */ + files: Array>; + issues: ParserFallbackIssue[]; + summary: { + files: number; + parsed: number; + degraded: number; + failed: number; + unsupported: number; + skipped: number; + /** Files whose tree came from a strategy other than the first tried. */ + fallbackUsed: number; + /** Files whose attempts include a timeout (the machine-dependent outcome). */ + timedOut: number; + byStrategy: Record; + }; + /** Run-dependent data, kept apart so the rest of the result stays deterministic. */ + telemetry: { elapsedMs: number }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +const EXTENSION_LANGUAGES: Record = { + ".sol": "solidity", + ".rs": "rust", + ".vy": "vyper", + ".vyi": "vyper", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".py": "python", + ".go": "go", +}; + +export function detectLanguage(path: string): string | undefined { + const base = path.slice(path.lastIndexOf("/") + 1); + const dot = base.lastIndexOf("."); + return dot <= 0 ? undefined : EXTENSION_LANGUAGES[base.slice(dot).toLowerCase()]; +} + +/** + * Parses every source with the strategy chain for its language. Never throws. + * + * @param strategies Registered strategies. Registration order is the default + * priority; `options.strategyOrder` can override it per language. + */ +export async function parseWithFallback( + sources: ParserSource[], + strategies: Array>, + options: ParserFallbackOptions = {}, +): Promise> { + const now = options.now ?? (() => Date.now()); + const started = now(); + const issues: ParserFallbackIssue[] = []; + const settings = resolveSettings(options, issues); + const registry = indexStrategies(strategies, settings.disabled, issues); + const orderOverrides = validateOrder(options.strategyOrder, registry, issues); + + // Normalize, order and de-duplicate so input order never matters. + const normalized: ParserSource[] = []; + for (const src of sources) { + const p = normalizeFindingPath(src?.path, options.rootDir); + if (p === undefined || typeof src.content !== "string") { + issues.push({ + code: "INVALID_PATH", + message: `Source path is invalid or outside the repository root: ${JSON.stringify(src?.path)}`, + }); + continue; + } + normalized.push({ ...src, path: p }); + } + normalized.sort((a, b) => cmp(a.path, b.path) || cmp(a.content, b.content)); + + const files: Array> = []; + let previous: string | undefined; + for (const src of normalized) { + if (src.path === previous) { + issues.push({ + code: "DUPLICATE_PATH", + file: src.path, + message: "Duplicate source path; keeping the first occurrence in content order.", + }); + continue; + } + previous = src.path; + files.push(await parseFile(src, registry, orderOverrides, settings)); + } + + issues.sort((a, b) => cmp(a.file ?? "", b.file ?? "") || cmp(a.code, b.code) || cmp(a.message, b.message)); + + return { + schemaVersion: PARSER_FALLBACK_SCHEMA_VERSION, + files, + issues, + summary: summarize(files), + telemetry: { elapsedMs: Math.max(0, now() - started) }, + }; +} + +// ─── Per-file chain ─────────────────────────────────────────────────────────── + +interface Settings { + timeoutMs: number; + maxAttempts: number; + maxFileBytes: number; + maxDiagnostics: number; + requireComplete: boolean; + disabled: Set; + languageOf: (path: string) => string | undefined; +} + +async function parseFile( + src: ParserSource, + registry: Map>, + orderOverrides: Map, + settings: Settings, +): Promise> { + const language = (src.language ?? settings.languageOf(src.path))?.toLowerCase(); + const base = { path: src.path, ...(language ? { language } : {}), diagnostics: [] as ParseDiagnostic[] }; + const fail = ( + status: FileParseStatus, + reason: string, + attempts: ParseAttempt[] = [], + ): FileParseResult => ({ ...base, status, fallbackUsed: false, complete: false, attempts, reason }); + + if (language === undefined) { + return fail("unsupported", "Language could not be determined from the file path."); + } + if (src.content.length > settings.maxFileBytes) { + return fail("skipped", `File exceeds maxFileBytes (${settings.maxFileBytes}).`); + } + + const chain = chainFor(language, registry, orderOverrides); + if (chain.length === 0) { + return fail("unsupported", `No parser strategy supports "${language}".`); + } + + const attempts: ParseAttempt[] = []; + // The earliest partial tree wins: strategies are ordered most faithful first. + let best: { strategy: string; output: ParseOutput } | undefined; + let firstTried: string | undefined; + + for (const strategy of chain) { + if (strategy.maxBytes !== undefined && src.content.length > strategy.maxBytes) { + attempts.push({ strategy: strategy.name, attempt: 0, outcome: "skipped-size", diagnostics: 0 }); + continue; + } + firstTried ??= strategy.name; + + for (let attempt = 1; attempt <= settings.maxAttempts; attempt++) { + const run = await runWithTimeout( + () => strategy.parse(src.content, { path: src.path, language }), + settings.timeoutMs, + ); + + if (run.kind === "timeout") { + attempts.push({ + strategy: strategy.name, + attempt, + outcome: "timeout", + message: `No result within ${settings.timeoutMs}ms.`, + diagnostics: 0, + }); + break; + } + if (run.kind === "error") { + const transient = isTransient(run.error); + attempts.push({ + strategy: strategy.name, + attempt, + outcome: transient ? "transient-error" : "error", + message: describeError(run.error), + diagnostics: 0, + }); + if (transient && attempt < settings.maxAttempts) continue; + break; + } + + const output = run.value; + if (!isParseOutput(output)) { + attempts.push({ + strategy: strategy.name, + attempt, + outcome: "invalid-output", + message: "Strategy returned no `ast`.", + diagnostics: 0, + }); + break; + } + + const diagnostics = sanitizeDiagnostics(output.diagnostics, settings.maxDiagnostics); + const complete = output.complete !== false; + attempts.push({ + strategy: strategy.name, + attempt, + outcome: complete ? "complete" : "partial", + diagnostics: diagnostics.length, + }); + + const result: ParseOutput = { ...output, diagnostics }; + if (complete) { + return { + ...base, + status: strategy.name === firstTried ? "parsed" : "degraded", + strategy: strategy.name, + fallbackUsed: strategy.name !== firstTried, + complete: true, + ast: output.ast, + diagnostics, + attempts, + }; + } + best ??= { strategy: strategy.name, output: result }; + break; + } + } + + if (best && !settings.requireComplete) { + return { + ...base, + status: "degraded", + strategy: best.strategy, + fallbackUsed: best.strategy !== firstTried, + complete: false, + ast: best.output.ast, + diagnostics: best.output.diagnostics ?? [], + attempts, + }; + } + return fail( + "failed", + best + ? "Only a partial tree was available and requireComplete is set." + : "Every parser strategy failed.", + attempts, + ); +} + +function chainFor( + language: string, + registry: Map>, + orderOverrides: Map, +): Array> { + const supports = (s: ParserStrategy) => + s.languages === "*" || s.languages.map((l) => l.toLowerCase()).includes(language); + const override = orderOverrides.get(language); + if (override) { + return override.map((name) => registry.get(name)!).filter((s) => s && supports(s)); + } + return [...registry.values()].filter(supports); +} + +// ─── Built-in lexical outline strategy ──────────────────────────────────────── + +export interface OutlineNode { + kind: string; + name: string; + /** 1-based, inclusive. */ + startLine: number; + endLine: number; + /** 0 for file level, 1 for members of a container. */ + depth: number; +} + +export interface OutlineAst { + kind: "outline"; + language: string; + nodes: OutlineNode[]; +} + +export interface LexicalOutlineOptions { + /** Strategy name. Default `lexical-outline`. */ + name?: string; + /** Default 20 MiB: this strategy is the last resort for large files. */ + maxBytes?: number; +} + +const OUTLINE_LANGUAGES = ["solidity", "rust", "vyper"]; + +/** + * A last-resort strategy that never fails on text input. It tokenizes just + * enough (comments, strings, braces, indentation) to list declarations with + * their line ranges. It always reports `complete: false`, so analyzers know + * they are working from an outline and not a full syntax tree. + */ +export function createLexicalOutlineStrategy( + options: LexicalOutlineOptions = {}, +): ParserStrategy { + return { + name: options.name ?? "lexical-outline", + languages: OUTLINE_LANGUAGES, + maxBytes: options.maxBytes ?? 20 * 1024 * 1024, + parse(source, context): ParseOutput { + const language = context.language as CrossFileLanguage; + const diagnostics: ParseDiagnostic[] = [ + { + code: "lexical_outline", + message: "Declarations were extracted lexically; the tree is not a full AST.", + line: 1, + severity: "warning", + }, + ]; + const nodes = + language === "vyper" + ? outlineIndented(source) + : outlineBraced(source, language, diagnostics); + nodes.sort((a, b) => a.startLine - b.startLine || cmp(a.name, b.name)); + return { ast: { kind: "outline", language, nodes }, complete: false, diagnostics }; + }, + }; +} + +const BRACED_DECLS: Record = { + solidity: { + top: /\b(contract|interface|library|struct|enum|error|event|function|modifier|type)\s+([A-Za-z_$][\w$]*)/g, + member: /\b(function|modifier|event|error|struct|enum)\s+([A-Za-z_$][\w$]*)/g, + }, + rust: { + top: /\b(fn|struct|enum|trait|mod|type|const|static|union)\s+(?!fn\b)([A-Za-z_]\w*)/g, + member: /\b(fn|const|type)\s+(?!fn\b)([A-Za-z_]\w*)/g, + }, +}; + +function outlineBraced( + source: string, + language: CrossFileLanguage, + diagnostics: ParseDiagnostic[], +): OutlineNode[] { + const masked = maskSource(source, language); + const starts = [0]; + for (let i = 0; i < masked.length; i++) if (masked[i] === "\n") starts.push(i + 1); + const lineOf = (offset: number) => { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid]! <= offset) lo = mid; + else hi = mid - 1; + } + return lo + 1; + }; + + // One pass: depth at every offset and the matching close for every open brace. + const depths = new Int32Array(masked.length); + const closeOf = new Map(); + const stack: number[] = []; + for (let i = 0; i < masked.length; i++) { + const c = masked[i]; + if (c === "}") { + const open = stack.pop(); + if (open === undefined) { + diagnostics.push({ code: "unmatched_close_brace", message: "Closing brace without a matching open.", line: lineOf(i) }); + } else { + closeOf.set(open, i); + } + } + depths[i] = stack.length; + if (c === "{") stack.push(i); + } + for (const open of stack) { + diagnostics.push({ code: "unterminated_block", message: "Block is never closed.", line: lineOf(open) }); + } + + const nodes: OutlineNode[] = []; + const decls = BRACED_DECLS[language]!; + for (const [depthLimit, regex] of [[0, decls.top], [1, decls.member]] as const) { + const re = new RegExp(regex.source, "g"); + for (let m = re.exec(masked); m; m = re.exec(masked)) { + const depth = depths[m.index]!; + if (depth !== depthLimit) continue; + if (depthLimit === 1 && !isInsideContainer(masked, m.index, depths)) continue; + const bodyEnd = findDeclarationEnd(masked, m.index + m[0].length, closeOf); + nodes.push({ + kind: m[1]!, + name: m[2]!, + startLine: lineOf(m.index), + endLine: lineOf(bodyEnd ?? masked.length - 1), + depth: depthLimit, + }); + } + } + return nodes; +} + +/** A depth-1 match only counts when its enclosing block belongs to a container declaration. */ +function isInsideContainer(masked: string, offset: number, depths: Int32Array): boolean { + let open = offset; + while (open > 0 && !(masked[open] === "{" && depths[open] === 0)) open--; + const boundary = Math.max(masked.lastIndexOf("}", open), masked.lastIndexOf(";", open)); + return /\b(contract|interface|library|impl|trait|mod)\b/.test(masked.slice(boundary + 1, open)); +} + +/** Offset of the `}` closing the declaration's body, or of its `;` when it has none. */ +function findDeclarationEnd(masked: string, from: number, closeOf: Map): number | undefined { + let parens = 0; + for (let i = from; i < masked.length; i++) { + const c = masked[i]; + if (c === "(" || c === "[") parens++; + else if (c === ")" || c === "]") parens--; + else if (parens <= 0 && c === ";") return i; + else if (parens <= 0 && c === "{") return closeOf.get(i); + } + return undefined; +} + +function outlineIndented(source: string): OutlineNode[] { + const masked = maskSource(source, "vyper").split(/\r?\n/); + const re = /^(def|struct|event|interface|flag|enum)\s+([A-Za-z_]\w*)/; + const nodes: OutlineNode[] = []; + const isBlank = (line: string) => line.trim() === ""; + for (let i = 0; i < masked.length; i++) { + const m = re.exec(masked[i]!); + if (!m) continue; + let last = i; + for (let j = i + 1; j < masked.length; j++) { + const line = masked[j]!; + if (isBlank(line)) continue; + if (!/^\s/.test(line)) break; + last = j; + } + nodes.push({ kind: m[1]!, name: m[2]!, startLine: i + 1, endLine: last + 1, depth: 0 }); + } + return nodes; +} + +// ─── Internals ──────────────────────────────────────────────────────────────── + +const TIMED_OUT = Symbol("timed-out"); + +type RunOutcome = + | { kind: "ok"; value: T } + | { kind: "error"; error: unknown } + | { kind: "timeout" }; + +async function runWithTimeout(fn: () => T | Promise, timeoutMs: number): Promise> { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs); + }); + try { + const value = await Promise.race([Promise.resolve().then(fn), timeout]); + return value === TIMED_OUT ? { kind: "timeout" } : { kind: "ok", value: value as T }; + } catch (error) { + return { kind: "error", error }; + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function resolveSettings(options: ParserFallbackOptions, issues: ParserFallbackIssue[]): Settings { + const pick = (key: keyof ParserFallbackOptions, fallback: number, valid: (v: number) => boolean): number => { + const value = options[key] as number | undefined; + if (value === undefined) return fallback; + if (typeof value === "number" && Number.isFinite(value) && valid(value)) return value; + issues.push({ + code: "INVALID_CONFIG", + message: `${key} is invalid (${JSON.stringify(value)}); using ${fallback}.`, + }); + return fallback; + }; + return { + timeoutMs: pick("timeoutMs", DEFAULT_PARSE_TIMEOUT_MS, (v) => v > 0), + maxAttempts: pick("maxAttemptsPerStrategy", DEFAULT_MAX_ATTEMPTS_PER_STRATEGY, (v) => Number.isInteger(v) && v >= 1), + maxFileBytes: pick("maxFileBytes", DEFAULT_MAX_PARSE_FILE_BYTES, (v) => v > 0), + maxDiagnostics: pick("maxDiagnosticsPerFile", DEFAULT_MAX_DIAGNOSTICS_PER_FILE, (v) => Number.isInteger(v) && v >= 0), + requireComplete: options.requireComplete === true, + disabled: new Set(options.disabledStrategies ?? []), + languageOf: options.languageOf ?? detectLanguage, + }; +} + +function indexStrategies( + strategies: Array>, + disabled: Set, + issues: ParserFallbackIssue[], +): Map> { + const registry = new Map>(); + for (const strategy of strategies) { + if (!strategy || typeof strategy.name !== "string" || strategy.name === "" || typeof strategy.parse !== "function") { + issues.push({ code: "INVALID_CONFIG", message: "A strategy without a name and parse() was ignored." }); + continue; + } + if (registry.has(strategy.name)) { + issues.push({ + code: "DUPLICATE_STRATEGY", + message: `Strategy "${strategy.name}" is registered more than once; keeping the first.`, + }); + continue; + } + if (!disabled.has(strategy.name)) registry.set(strategy.name, strategy); + } + return registry; +} + +function validateOrder( + order: Record | undefined, + registry: Map>, + issues: ParserFallbackIssue[], +): Map { + const out = new Map(); + for (const [language, names] of Object.entries(order ?? {})) { + const known: string[] = []; + for (const name of names) { + if (registry.has(name)) known.push(name); + else { + issues.push({ + code: "UNKNOWN_STRATEGY", + message: `strategyOrder for "${language}" names "${name}", which is not registered or is disabled; ignored.`, + }); + } + } + out.set(language.toLowerCase(), known); + } + return out; +} + +function isParseOutput(value: unknown): value is ParseOutput { + return typeof value === "object" && value !== null && (value as ParseOutput).ast !== undefined; +} + +function isTransient(error: unknown): boolean { + return typeof error === "object" && error !== null && (error as { transient?: unknown }).transient === true; +} + +function describeError(error: unknown): string { + const name = error instanceof Error ? error.name : "Error"; + const message = error instanceof Error ? error.message : String(error); + return truncate(`${name}: ${message.split("\n")[0]}`); +} + +function truncate(text: string): string { + return text.length > MAX_TEXT_LENGTH ? `${text.slice(0, MAX_TEXT_LENGTH - 1)}…` : text; +} + +function sanitizeDiagnostics(raw: ParseDiagnostic[] | undefined, max: number): ParseDiagnostic[] { + if (!Array.isArray(raw)) return []; + const out: ParseDiagnostic[] = []; + for (const d of raw) { + if (out.length >= max) break; + if (typeof d !== "object" || d === null) continue; + out.push({ + code: typeof d.code === "string" && d.code.trim() !== "" ? d.code : "parser_diagnostic", + message: truncate(typeof d.message === "string" ? d.message : ""), + line: Number.isInteger(d.line) && d.line >= 1 ? d.line : 1, + ...(Number.isInteger(d.column) && (d.column as number) >= 1 ? { column: d.column } : {}), + ...(d.severity === "warning" ? { severity: "warning" as const } : {}), + }); + } + return out; +} + +function summarize(files: Array>): ParserFallbackResult["summary"] { + const byStrategy: Record = {}; + const summary = { + files: files.length, + parsed: 0, + degraded: 0, + failed: 0, + unsupported: 0, + skipped: 0, + fallbackUsed: 0, + timedOut: 0, + byStrategy, + }; + for (const f of files) { + summary[f.status]++; + if (f.fallbackUsed) summary.fallbackUsed++; + if (f.attempts.some((a) => a.outcome === "timeout")) summary.timedOut++; + if (f.strategy) byStrategy[f.strategy] = (byStrategy[f.strategy] ?? 0) + 1; + } + summary.byStrategy = Object.fromEntries(Object.entries(byStrategy).sort((a, b) => cmp(a[0], b[0]))); + return summary; +} + +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/libs/engine/core/confidence-calibration.spec.ts b/libs/engine/core/confidence-calibration.spec.ts new file mode 100644 index 00000000..70c0c2c1 --- /dev/null +++ b/libs/engine/core/confidence-calibration.spec.ts @@ -0,0 +1,217 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + buildCalibrationProfiles, + calibrateFindings, + confidenceBand, + CALIBRATION_SCHEMA_VERSION, + type CalibrationConfig, +} from "./confidence-calibration"; + +const FIXTURES = path.resolve(__dirname, "../../../tests/fixtures/confidence-calibration"); +const load = (name: string) => JSON.parse(fs.readFileSync(path.join(FIXTURES, name), "utf8")); + +const metaOf = (result: { findings: any[]; suppressed: any[] }, index: number, message?: string) => + [...result.findings, ...result.suppressed].filter((f) => !message || f.message === message)[index] + .metadata; + +describe("confidenceBand", () => { + it("maps confidence to bands with inclusive lower bounds", () => { + expect(confidenceBand(0.8)).toBe("high"); + expect(confidenceBand(0.79)).toBe("medium"); + expect(confidenceBand(0.5)).toBe("medium"); + expect(confidenceBand(0.49)).toBe("low"); + }); + + it("honours custom thresholds and falls back on invalid ones", () => { + expect(confidenceBand(0.6, { high: 0.6, medium: 0.2 })).toBe("high"); + expect(confidenceBand(0.9, { high: 0.3, medium: 0.6 })).toBe("high"); // invalid -> defaults + expect(confidenceBand(0.4, { high: 0.3, medium: 0.6 })).toBe("low"); + }); +}); + +describe("buildCalibrationProfiles", () => { + const fixture = load("realistic-review-history.json"); + + it("aggregates verdicts per rule for the requested rule-pack version", () => { + const { profiles, counts, issues } = buildCalibrationProfiles(fixture.outcomes, { + rulePackVersion: fixture.rulePackVersion, + }); + expect(profiles).toEqual([ + { ruleId: "storage-in-loop", truePositives: 10, falsePositives: 30, rulePackVersion: "2.1.0" }, + { ruleId: "unchecked-return", truePositives: 45, falsePositives: 5, rulePackVersion: "2.1.0" }, + { ruleId: "unused-import", truePositives: 2, falsePositives: 1, rulePackVersion: "2.1.0" }, + ]); + expect(counts).toEqual({ received: 117, counted: 93, ignored: 3, otherVersion: 21, invalid: 0 }); + expect(issues).toEqual([]); + }); + + it("reports malformed outcomes instead of throwing", () => { + const { profiles, counts, issues } = buildCalibrationProfiles([ + { ruleId: "", verdict: "true_positive" }, + { ruleId: "r", verdict: "maybe" as any }, + { ruleId: "r", verdict: "true_positive" }, + ]); + expect(profiles).toHaveLength(1); + expect(counts.invalid).toBe(2); + expect(issues.map((i) => i.code)).toEqual(["INVALID_OUTCOME", "INVALID_OUTCOME"]); + }); +}); + +describe("realistic review history", () => { + const fixture = load("realistic-review-history.json"); + const { profiles } = buildCalibrationProfiles(fixture.outcomes, { + rulePackVersion: fixture.rulePackVersion, + }); + const config: CalibrationConfig = { + rulePackVersion: fixture.rulePackVersion, + profiles: profiles.map((p) => (p.ruleId === "storage-in-loop" ? { ...p, priorConfidence: 0.6 } : p)), + }; + const result = calibrateFindings(fixture.findings, config); + + it("calibrates an accurate rule up and a noisy rule down", () => { + const [plain, withRaw] = result.findings.filter((f: any) => f.ruleId === "unchecked-return"); + // (45 + 0.5*10) / (50 + 10) + expect(plain.metadata).toMatchObject({ + confidence: 0.8333, + confidenceBand: "high", + confidenceSource: "calibrated", + calibrationSamples: 50, + }); + // odds 9 (raw 0.9) x odds 5 (45/5 after smoothing) = 45 -> 45/46 + expect(withRaw.metadata).toMatchObject({ + confidence: 0.9783, + rawConfidence: 0.9, + confidenceSource: "calibrated", + }); + + const noisy = result.findings.find((f: any) => f.ruleId === "storage-in-loop")!; + // (10 + 0.6*10) / (40 + 10) + expect(noisy.metadata).toMatchObject({ confidence: 0.32, confidenceBand: "low" }); + }); + + it("leaves rules with too little history uncalibrated", () => { + const sparse = result.findings.find((f: any) => f.ruleId === "unused-import")!; + expect(sparse.metadata).toMatchObject({ + confidence: 0.5, + confidenceSource: "prior", + calibrationSamples: 3, + }); + const unknown = result.findings.find((f: any) => f.ruleId === "new-rule")!; + expect(unknown.metadata).toMatchObject({ confidence: 0.7, confidenceSource: "raw" }); + }); + + it("suppresses nothing by default and reports per-rule summaries", () => { + expect(result.schemaVersion).toBe(CALIBRATION_SCHEMA_VERSION); + expect(result.suppressed).toEqual([]); + expect(result.counts).toEqual({ received: 5, kept: 5, suppressed: 0, calibrated: 3, uncalibrated: 2 }); + expect(result.rules.find((r) => r.ruleId === "unchecked-return")).toMatchObject({ + findings: 2, + meanConfidence: 0.9058, + }); + expect(result.issues).toEqual([]); + }); + + it("preserves finding fields and does not mutate the input", () => { + const input = JSON.parse(JSON.stringify(fixture.findings)); + const frozen = JSON.parse(JSON.stringify(fixture.findings)); + calibrateFindings(input, config); + expect(input).toEqual(frozen); + expect(result.findings[0]).toMatchObject({ + message: "return value ignored", + location: { file: "contracts/Vault.sol", startLine: 42 }, + }); + }); + + it("suppresses below minConfidence while keeping the confidence visible", () => { + const filtered = calibrateFindings(fixture.findings, { ...config, minConfidence: 0.4 }); + expect(filtered.suppressed.map((f: any) => f.ruleId)).toEqual(["storage-in-loop"]); + expect(filtered.suppressed[0]!.metadata.confidence).toBe(0.32); + expect(filtered.counts).toMatchObject({ kept: 4, suppressed: 1 }); + expect(filtered.rules.find((r) => r.ruleId === "storage-in-loop")!.suppressed).toBe(1); + }); + + it("is deterministic and idempotent", () => { + const again = calibrateFindings(fixture.findings, { + ...config, + profiles: [...config.profiles!].reverse(), + }); + expect(JSON.stringify(again)).toBe(JSON.stringify(result)); + + const twice = calibrateFindings(result.findings, config); + expect(twice.findings).toEqual(result.findings); + }); + + it("ignores profiles recorded against a different rule-pack version", () => { + const stale = calibrateFindings(fixture.findings, { ...config, rulePackVersion: "3.0.0" }); + expect(stale.issues.filter((i) => i.code === "STALE_PROFILE")).toHaveLength(3); + expect(stale.counts.calibrated).toBe(0); + }); +}); + +describe("regression: edge cases", () => { + const fixture = load("regression-edge-cases.json"); + const result = calibrateFindings(fixture.findings, fixture.config); + const byRule = (ruleId: string) => + [...result.findings, ...result.suppressed].find((f: any) => f.ruleId === ruleId)!.metadata; + + it("falls back to the prior when a profile is stale or invalid, and says why", () => { + for (const ruleId of ["stale-rule", "bad-counts", "bad-prior"]) { + expect(byRule(ruleId)).toMatchObject({ confidence: 0.5, confidenceSource: "prior" }); + } + expect(result.issues.map((i) => i.code)).toEqual([ + "DUPLICATE_PROFILE", + "INVALID_CONFIDENCE", + "INVALID_PROFILE", + "INVALID_PROFILE", + "MISSING_RULE_ID", + "STALE_PROFILE", + ]); + }); + + it("keeps the duplicate profile with the most samples", () => { + // (90 + 5) / (100 + 10) + expect(byRule("dup-rule")).toMatchObject({ confidence: 0.8636, calibrationSamples: 100 }); + }); + + it("stays finite for saturated counts and extreme raw confidence", () => { + expect(byRule("always-wrong").confidence).toBeCloseTo(0.7071, 3); + expect(byRule("always-right").confidence).toBeCloseTo(0.2929, 3); + for (const f of [...result.findings, ...result.suppressed]) { + expect(Number.isFinite(f.metadata.confidence)).toBe(true); + expect(f.metadata.confidence).toBeGreaterThanOrEqual(0); + expect(f.metadata.confidence).toBeLessThanOrEqual(1); + } + }); + + it("applies pinned overrides and per-rule thresholds", () => { + expect(byRule("pinned-rule")).toMatchObject({ + confidence: 0.95, + confidenceSource: "override", + rawConfidence: 0.1, + }); + expect(result.suppressed.map((f: any) => f.ruleId).sort()).toEqual(["always-right", "noisy-rule"]); + }); + + it("ignores invalid raw confidence and handles findings without a ruleId", () => { + expect(byRule("garbage-confidence")).toMatchObject({ confidence: 0.5, confidenceSource: "prior" }); + expect(byRule("garbage-confidence").rawConfidence).toBeUndefined(); + expect(result.counts).toEqual({ received: 10, kept: 8, suppressed: 2, calibrated: 3, uncalibrated: 7 }); + }); + + it("reports invalid configuration and uses defaults", () => { + const res = calibrateFindings([{ ruleId: "r", message: "m" }], { + defaultPrior: 2, + minSamples: -1, + bandThresholds: { high: 0.2, medium: 0.9 }, + }); + expect(res.issues.map((i) => i.code)).toEqual(["INVALID_CONFIG", "INVALID_CONFIG", "INVALID_CONFIG"]); + expect(metaOf(res, 0)).toMatchObject({ confidence: 0.5, confidenceBand: "medium" }); + }); + + it("returns an empty result for empty input", () => { + const res = calibrateFindings([]); + expect(res.findings).toEqual([]); + expect(res.counts.received).toBe(0); + }); +}); diff --git a/libs/engine/core/confidence-calibration.ts b/libs/engine/core/confidence-calibration.ts new file mode 100644 index 00000000..c0cd70d4 --- /dev/null +++ b/libs/engine/core/confidence-calibration.ts @@ -0,0 +1,529 @@ +/** + * Rule-level confidence calibration. + * + * Analyzer rules differ widely in how often their findings turn out to be + * real. This module turns reviewed outcomes (true / false positive verdicts) + * into a per-rule track record and uses it to attach a calibrated confidence, + * and a coarse band, to every finding. + * + * Model + * ───── + * For a rule with `tp` true positives and `fp` false positives, a prior + * confidence `p` and prior strength `s` (pseudo-observations), the empirical + * rate is the smoothed estimate + * + * q = (tp + p·s) / (tp + fp + s) + * + * A finding that carries its own raw confidence `r` (in `metadata.confidence`) + * is shifted by how far the rule's rate is from its prior, in log-odds space: + * + * confidence = sigmoid(logit(r) + logit(q) - logit(p)) + * + * Without a raw confidence `r = p`, so the result is exactly `q`. Rules with + * fewer than `minSamples` reviewed outcomes are left uncalibrated (raw + * confidence, else the prior) so a handful of reviews cannot swing results. + * + * Design + * ────── + * - Pure: no I/O, clock or module state. The same findings, profiles and + * config always give the same output, and a call is safe to retry. + * - Tenant-safe: the module only sees the outcomes and profiles passed in. + * Build profiles from a single tenant's reviewed outcomes. + * - Rule-pack aware: when `rulePackVersion` is configured, profiles and + * outcomes recorded against a different version are ignored and reported, + * because a rule's behaviour can change between versions. + * - Backward compatible: with no config, findings only gain confidence + * metadata. Nothing is suppressed unless `minConfidence` is set. + * - Idempotent: re-calibrating already calibrated findings gives the same result. + */ + +// ─── Public types ───────────────────────────────────────────────────────────── + +export const CALIBRATION_SCHEMA_VERSION = 1; +export const DEFAULT_PRIOR_CONFIDENCE = 0.5; +export const DEFAULT_PRIOR_STRENGTH = 10; +export const DEFAULT_MIN_SAMPLES = 5; +export const DEFAULT_BAND_THRESHOLDS: ConfidenceBandThresholds = { high: 0.8, medium: 0.5 }; + +export type ConfidenceBand = "high" | "medium" | "low"; + +export type ConfidenceSource = + /** Shifted by the rule's reviewed track record. */ + | "calibrated" + /** The finding's own confidence; the rule has too little history. */ + | "raw" + /** No raw confidence and too little history: the rule's prior. */ + | "prior" + /** Pinned by `ruleOverrides`. */ + | "override"; + +export interface ConfidenceBandThresholds { + /** Confidence at or above this is `high`. */ + high: number; + /** Confidence at or above this (and below `high`) is `medium`. */ + medium: number; +} + +export interface RuleCalibrationProfile { + ruleId: string; + truePositives: number; + falsePositives: number; + /** Starting belief for this rule. Default: `CalibrationConfig.defaultPrior`. */ + priorConfidence?: number; + /** Prior weight in pseudo-observations. Default: `CalibrationConfig.priorStrength`. */ + priorStrength?: number; + /** Rule-pack version the outcomes were recorded against. */ + rulePackVersion?: string; +} + +export interface RuleOverride { + /** Pins the confidence of every finding of this rule, skipping calibration. */ + confidence?: number; + /** Per-rule suppression threshold; takes precedence over `minConfidence`. */ + minConfidence?: number; +} + +export interface CalibrationConfig { + profiles?: RuleCalibrationProfile[]; + ruleOverrides?: Record; + /** Version of the rule pack being run. Mismatching profiles are ignored. */ + rulePackVersion?: string; + /** Prior for rules without their own. Default 0.5. */ + defaultPrior?: number; + /** Prior strength for rules without their own. Default 10. */ + priorStrength?: number; + /** Reviewed outcomes needed before a rule is calibrated. Default 5. */ + minSamples?: number; + /** Findings below this confidence are suppressed. Default 0 (suppress nothing). */ + minConfidence?: number; + bandThresholds?: ConfidenceBandThresholds; +} + +export interface CalibratableFinding { + ruleId: string; + metadata?: Record; +} + +export interface ConfidenceMetadata { + /** Calibrated confidence in [0, 1], rounded to 4 decimals. */ + confidence: number; + confidenceBand: ConfidenceBand; + confidenceSource: ConfidenceSource; + /** Reviewed outcomes behind the rule's calibration (0 when none). */ + calibrationSamples: number; + /** The finding's confidence before calibration, when it had one. */ + rawConfidence?: number; +} + +export type CalibratedFinding = Omit & { + metadata: Record & ConfidenceMetadata; +}; + +export type CalibrationIssueCode = + | "INVALID_PROFILE" + | "DUPLICATE_PROFILE" + | "STALE_PROFILE" + | "INVALID_CONFIG" + | "INVALID_CONFIDENCE" + | "MISSING_RULE_ID" + | "INVALID_OUTCOME"; + +export interface CalibrationIssue { + code: CalibrationIssueCode; + message: string; + ruleId?: string; +} + +export interface RuleCalibrationSummary { + ruleId: string; + findings: number; + suppressed: number; + source: ConfidenceSource; + meanConfidence: number; +} + +export interface CalibrationResult { + schemaVersion: number; + /** Findings that met the confidence threshold, in input order. */ + findings: Array>; + /** Findings below the threshold, in input order, still carrying their confidence. */ + suppressed: Array>; + rules: RuleCalibrationSummary[]; + issues: CalibrationIssue[]; + counts: { + received: number; + kept: number; + suppressed: number; + calibrated: number; + uncalibrated: number; + }; +} + +export type OutcomeVerdict = "true_positive" | "false_positive" | "ignored"; + +export interface ReviewedOutcome { + ruleId: string; + verdict: OutcomeVerdict; + rulePackVersion?: string; +} + +export interface BuildProfilesOptions { + /** Only outcomes recorded against this version are counted. */ + rulePackVersion?: string; + priorConfidence?: number; + priorStrength?: number; +} + +export interface BuildProfilesResult { + /** Sorted by `ruleId`. */ + profiles: RuleCalibrationProfile[]; + issues: CalibrationIssue[]; + counts: { received: number; counted: number; ignored: number; otherVersion: number; invalid: number }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** Maps a confidence to its band. Invalid thresholds fall back to the defaults. */ +export function confidenceBand( + confidence: number, + thresholds: ConfidenceBandThresholds = DEFAULT_BAND_THRESHOLDS, +): ConfidenceBand { + const t = validThresholds(thresholds) ? thresholds : DEFAULT_BAND_THRESHOLDS; + return confidence >= t.high ? "high" : confidence >= t.medium ? "medium" : "low"; +} + +/** + * Aggregates reviewed finding outcomes into one profile per rule. + * `ignored` verdicts and outcomes from another rule-pack version are counted + * in `counts` but do not affect a profile. + */ +export function buildCalibrationProfiles( + outcomes: ReviewedOutcome[], + options: BuildProfilesOptions = {}, +): BuildProfilesResult { + const issues: CalibrationIssue[] = []; + const counts = { received: outcomes.length, counted: 0, ignored: 0, otherVersion: 0, invalid: 0 }; + const byRule = new Map(); + + for (const outcome of outcomes) { + const ruleId = typeof outcome?.ruleId === "string" ? outcome.ruleId.trim() : ""; + if (ruleId === "" || !isVerdict(outcome.verdict)) { + counts.invalid++; + issues.push({ + code: "INVALID_OUTCOME", + ruleId: ruleId || undefined, + message: `Outcome needs a ruleId and a verdict of true_positive, false_positive or ignored.`, + }); + continue; + } + if ( + options.rulePackVersion !== undefined && + outcome.rulePackVersion !== options.rulePackVersion + ) { + counts.otherVersion++; + continue; + } + if (outcome.verdict === "ignored") { + counts.ignored++; + continue; + } + const entry = byRule.get(ruleId) ?? { tp: 0, fp: 0 }; + if (outcome.verdict === "true_positive") entry.tp++; + else entry.fp++; + byRule.set(ruleId, entry); + counts.counted++; + } + + const profiles = [...byRule.entries()] + .sort((a, b) => cmp(a[0], b[0])) + .map(([ruleId, { tp, fp }]): RuleCalibrationProfile => ({ + ruleId, + truePositives: tp, + falsePositives: fp, + ...(options.priorConfidence !== undefined ? { priorConfidence: options.priorConfidence } : {}), + ...(options.priorStrength !== undefined ? { priorStrength: options.priorStrength } : {}), + ...(options.rulePackVersion !== undefined ? { rulePackVersion: options.rulePackVersion } : {}), + })); + + return { profiles, issues, counts }; +} + +/** + * Attaches a calibrated confidence and band to each finding. Input findings + * are not mutated. Invalid configuration or profiles never throw: the bad part + * is ignored, reported in `issues`, and defaults apply. + */ +export function calibrateFindings( + findings: T[], + config: CalibrationConfig = {}, +): CalibrationResult { + const issues: CalibrationIssue[] = []; + const settings = resolveSettings(config, issues); + const profiles = indexProfiles(config.profiles ?? [], settings, config.rulePackVersion, issues); + + const kept: Array> = []; + const suppressed: Array> = []; + const perRule = new Map< + string, + { findings: number; suppressed: number; total: number; source: ConfidenceSource } + >(); + const reportedBadConfidence = new Set(); + let calibrated = 0; + + for (const finding of findings) { + const ruleId = typeof finding?.ruleId === "string" ? finding.ruleId.trim() : ""; + if (ruleId === "") { + issues.push({ + code: "MISSING_RULE_ID", + message: "Finding has no ruleId; the default prior was used.", + }); + } + + const metadata = (finding.metadata ?? {}) as Record; + // A finding that was already calibrated keeps its original raw value, so + // calibrating twice does not shift twice. + const rawInput = "confidenceSource" in metadata ? metadata.rawConfidence : metadata.confidence; + const raw = validProbability(rawInput) ? rawInput : undefined; + if (rawInput !== undefined && raw === undefined && !reportedBadConfidence.has(ruleId)) { + reportedBadConfidence.add(ruleId); + issues.push({ + code: "INVALID_CONFIDENCE", + ruleId: ruleId || undefined, + message: `metadata.confidence must be a number in [0, 1]; ignored for this rule's findings with invalid values.`, + }); + } + + const override = config.ruleOverrides?.[ruleId]; + const profile = profiles.get(ruleId); + const prior = profile?.prior ?? settings.defaultPrior; + const samples = profile?.samples ?? 0; + + let confidence: number; + let source: ConfidenceSource; + if (override && validProbability(override.confidence)) { + confidence = override.confidence; + source = "override"; + } else if (profile && samples >= settings.minSamples) { + const rate = (profile.truePositives + prior * profile.strength) / (samples + profile.strength); + confidence = sigmoid(logit(raw ?? prior) + logit(rate) - logit(prior)); + source = "calibrated"; + } else if (raw !== undefined) { + confidence = raw; + source = "raw"; + } else { + confidence = prior; + source = "prior"; + } + confidence = round4(confidence); + if (source === "calibrated") calibrated++; + + const out = { + ...finding, + metadata: { + ...metadata, + confidence, + confidenceBand: confidenceBand(confidence, settings.bands), + confidenceSource: source, + calibrationSamples: samples, + ...(raw !== undefined ? { rawConfidence: raw } : {}), + }, + } as unknown as CalibratedFinding; + if (raw === undefined) delete (out.metadata as Record).rawConfidence; + + const overrideMin = override?.minConfidence; + const threshold = validProbability(overrideMin) ? overrideMin : settings.minConfidence; + const isSuppressed = confidence < threshold; + (isSuppressed ? suppressed : kept).push(out); + + const entry = perRule.get(ruleId) ?? { findings: 0, suppressed: 0, total: 0, source }; + entry.findings++; + entry.total += confidence; + if (isSuppressed) entry.suppressed++; + perRule.set(ruleId, entry); + } + + const rules = [...perRule.entries()] + .sort((a, b) => cmp(a[0], b[0])) + .map(([ruleId, e]): RuleCalibrationSummary => ({ + ruleId, + findings: e.findings, + suppressed: e.suppressed, + source: e.source, + meanConfidence: round4(e.total / e.findings), + })); + + issues.sort((a, b) => cmp(a.code, b.code) || cmp(a.ruleId ?? "", b.ruleId ?? "") || cmp(a.message, b.message)); + + return { + schemaVersion: CALIBRATION_SCHEMA_VERSION, + findings: kept, + suppressed, + rules, + issues, + counts: { + received: findings.length, + kept: kept.length, + suppressed: suppressed.length, + calibrated, + uncalibrated: findings.length - calibrated, + }, + }; +} + +// ─── Internals ──────────────────────────────────────────────────────────────── + +interface Settings { + defaultPrior: number; + priorStrength: number; + minSamples: number; + minConfidence: number; + bands: ConfidenceBandThresholds; +} + +interface IndexedProfile extends RuleCalibrationProfile { + prior: number; + strength: number; + samples: number; +} + +function resolveSettings(config: CalibrationConfig, issues: CalibrationIssue[]): Settings { + const pick = ( + key: K, + fallback: number, + valid: (v: unknown) => boolean, + ): number => { + const value = config[key]; + if (value === undefined) return fallback; + if (valid(value)) return value as number; + issues.push({ + code: "INVALID_CONFIG", + message: `${String(key)} is invalid (${JSON.stringify(value)}); using ${fallback}.`, + }); + return fallback; + }; + + let bands = DEFAULT_BAND_THRESHOLDS; + if (config.bandThresholds !== undefined) { + if (validThresholds(config.bandThresholds)) bands = config.bandThresholds; + else { + issues.push({ + code: "INVALID_CONFIG", + message: "bandThresholds must satisfy 0 <= medium < high <= 1; using defaults.", + }); + } + } + return { + defaultPrior: pick("defaultPrior", DEFAULT_PRIOR_CONFIDENCE, validProbability), + priorStrength: pick("priorStrength", DEFAULT_PRIOR_STRENGTH, isPositive), + minSamples: pick("minSamples", DEFAULT_MIN_SAMPLES, isNonNegativeInt), + minConfidence: pick("minConfidence", 0, validProbability), + bands, + }; +} + +function indexProfiles( + profiles: RuleCalibrationProfile[], + settings: Settings, + rulePackVersion: string | undefined, + issues: CalibrationIssue[], +): Map { + const out = new Map(); + const ordered = [...profiles].sort( + (a, b) => + cmp(String(a?.ruleId), String(b?.ruleId)) || + sampleCount(b) - sampleCount(a) || + cmp(String(a?.rulePackVersion), String(b?.rulePackVersion)), + ); + + for (const profile of ordered) { + const ruleId = typeof profile?.ruleId === "string" ? profile.ruleId.trim() : ""; + const prior = profile?.priorConfidence ?? settings.defaultPrior; + const strength = profile?.priorStrength ?? settings.priorStrength; + if ( + ruleId === "" || + !isNonNegativeInt(profile.truePositives) || + !isNonNegativeInt(profile.falsePositives) || + !validProbability(prior) || + !isPositive(strength) + ) { + issues.push({ + code: "INVALID_PROFILE", + ruleId: ruleId || undefined, + message: `Profile needs a ruleId, non-negative integer counts, a prior in [0, 1] and a positive strength; ignored.`, + }); + continue; + } + if (rulePackVersion !== undefined && profile.rulePackVersion !== rulePackVersion) { + issues.push({ + code: "STALE_PROFILE", + ruleId, + message: `Profile was recorded against rule pack ${JSON.stringify(profile.rulePackVersion ?? null)}, not ${JSON.stringify(rulePackVersion)}; ignored.`, + }); + continue; + } + if (out.has(ruleId)) { + issues.push({ + code: "DUPLICATE_PROFILE", + ruleId, + message: `More than one profile for this rule; keeping the one with the most samples.`, + }); + continue; + } + out.set(ruleId, { + ...profile, + ruleId, + prior, + strength, + samples: profile.truePositives + profile.falsePositives, + }); + } + return out; +} + +function sampleCount(p: RuleCalibrationProfile): number { + const n = (p?.truePositives ?? 0) + (p?.falsePositives ?? 0); + return Number.isFinite(n) ? n : 0; +} + +function validThresholds(t: ConfidenceBandThresholds): boolean { + return ( + !!t && + validProbability(t.high) && + validProbability(t.medium) && + t.medium < t.high + ); +} + +function validProbability(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1; +} + +function isPositive(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v) && v > 0; +} + +function isNonNegativeInt(v: unknown): v is number { + return typeof v === "number" && Number.isInteger(v) && v >= 0; +} + +function isVerdict(v: unknown): v is OutcomeVerdict { + return v === "true_positive" || v === "false_positive" || v === "ignored"; +} + +/** Clamped so 0 and 1 stay finite in log-odds space. */ +function logit(p: number): number { + const c = Math.min(0.999, Math.max(0.001, p)); + return Math.log(c / (1 - c)); +} + +function sigmoid(x: number): number { + return 1 / (1 + Math.exp(-x)); +} + +function round4(n: number): number { + return Math.round(n * 10000) / 10000; +} + +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/libs/engine/core/cross-file-resolver.spec.ts b/libs/engine/core/cross-file-resolver.spec.ts new file mode 100644 index 00000000..9e80a098 --- /dev/null +++ b/libs/engine/core/cross-file-resolver.spec.ts @@ -0,0 +1,320 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + CROSS_FILE_SCHEMA_VERSION, + getAffectedFiles, + getReferencesTo, + parseRemappings, + resolveCrossFileReferences, + type CrossFileSource, +} from "./cross-file-resolver"; + +const FIXTURES = path.resolve(__dirname, "../../../tests/fixtures/cross-file-resolution"); + +function loadRepo(name: string): CrossFileSource[] { + const root = path.join(FIXTURES, name); + const out: CrossFileSource[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.(sol|rs|vy|vyi)$/.test(entry.name)) { + out.push({ + path: path.relative(root, full).split(path.sep).join("/"), + content: fs.readFileSync(full, "utf8"), + }); + } + } + }; + walk(root); + return out; +} + +function remappingsOf(name: string): Record { + const file = path.join(FIXTURES, name, "remappings.txt"); + return fs.existsSync(file) ? parseRemappings(fs.readFileSync(file, "utf8")) : {}; +} + +const refsIn = (res: ReturnType, file: string) => + res.references.filter((r) => r.file === file); + +describe("parseRemappings", () => { + it("parses prefix=target lines, ignoring comments, blanks and contexts", () => { + expect( + parseRemappings("# c\n\n@a/=lib/a/\nctx:@b/=lib/b/src/\nbroken-line\n=nothing\n"), + ).toEqual({ "@a/": "lib/a/", "@b/": "lib/b/src/" }); + }); +}); + +describe("realistic Solidity repository", () => { + const sources = loadRepo("realistic-solidity"); + const res = resolveCrossFileReferences(sources, { + remappings: remappingsOf("realistic-solidity"), + }); + + it("indexes top-level symbols with kinds and lines", () => { + expect(res.schemaVersion).toBe(CROSS_FILE_SCHEMA_VERSION); + const find = (name: string) => res.symbols.find((s) => s.name === name); + expect(find("IERC20")).toMatchObject({ + kind: "interface", + file: "contracts/interfaces/IERC20.sol", + line: 4, + }); + expect(find("Ownable")).toMatchObject({ kind: "contract", file: "contracts/access/Ownable.sol" }); + expect(find("NotOwner")).toMatchObject({ kind: "error" }); + expect(find("SafeTransfer")).toMatchObject({ kind: "library" }); + // Members nested inside contracts are not file-level symbols. + expect(find("onlyOwner")).toBeUndefined(); + expect(find("safeTransfer")).toBeUndefined(); + }); + + it("resolves remapped, relative, aliased and namespace imports; marks vendored libs external", () => { + const vault = res.imports.filter((e) => e.from === "contracts/Vault.sol"); + expect(vault.map((e) => [e.specifier, e.status])).toEqual([ + ["@gasguard/access/Ownable.sol", "resolved"], + ["./interfaces/IERC20.sol", "resolved"], + ["./libraries/SafeTransfer.sol", "resolved"], + ["./Errors.sol", "resolved"], + ["@openzeppelin/security/ReentrancyGuard.sol", "external"], + ]); + expect(res.dependencies["contracts/Vault.sol"]).toEqual([ + "contracts/Errors.sol", + "contracts/access/Ownable.sol", + "contracts/interfaces/IERC20.sol", + "contracts/libraries/SafeTransfer.sol", + ]); + expect(res.counts.unresolvedImports).toBe(0); + expect(res.cycles).toEqual([]); + }); + + it("resolves uses to their definitions, honouring aliases and namespaces", () => { + const vault = refsIn(res, "contracts/Vault.sol"); + const byName = (name: string) => vault.filter((r) => r.name === name); + + expect(byName("Ownable")).toHaveLength(1); + expect(byName("Ownable")[0]!.target).toMatchObject({ file: "contracts/access/Ownable.sol" }); + + // `Token` is an alias of IERC20. + expect(byName("Token")).toHaveLength(2); + expect(byName("Token")[0]!.target).toMatchObject({ + name: "IERC20", + file: "contracts/interfaces/IERC20.sol", + }); + + // Comment and string mentions do not count; only the two real uses do. + expect(byName("SafeTransfer")).toHaveLength(2); + + expect(byName("ZeroAmount")[0]).toMatchObject({ + via: "Errors", + status: "resolved", + target: { file: "contracts/Errors.sol", kind: "error" }, + }); + + // Imported from outside the snapshot: reported, but not an error. + expect(byName("ReentrancyGuard")[0]!.status).toBe("external"); + expect(res.issues.filter((i) => i.code === "UNRESOLVED_SYMBOL")).toEqual([]); + }); + + it("follows imports inside imported files", () => { + const lib = refsIn(res, "contracts/libraries/SafeTransfer.sol"); + expect(lib).toHaveLength(1); + expect(lib[0]!.target).toMatchObject({ name: "IERC20", line: 4 }); + expect(getReferencesTo(res, "contracts/interfaces/IERC20.sol", "IERC20")).toHaveLength(2 + 1); + }); + + it("is deterministic regardless of input order", () => { + const opts = { remappings: remappingsOf("realistic-solidity") }; + const a = resolveCrossFileReferences(sources, opts); + const b = resolveCrossFileReferences([...sources].reverse(), opts); + expect(JSON.stringify(b)).toBe(JSON.stringify(a)); + }); +}); + +describe("regression: cycles, re-exports, ambiguity and unresolved imports", () => { + const res = resolveCrossFileReferences(loadRepo("regression")); + + it("reports import cycles once, without looping", () => { + expect(res.cycles).toEqual([["contracts/a.sol", "contracts/b.sol"]]); + expect(res.issues.filter((i) => i.code === "IMPORT_CYCLE")).toHaveLength(1); + expect(refsIn(res, "contracts/a.sol")[0]).toMatchObject({ + name: "B", + status: "resolved", + target: { file: "contracts/b.sol" }, + }); + expect(refsIn(res, "contracts/b.sol")[0]).toMatchObject({ + name: "A", + status: "resolved", + target: { file: "contracts/a.sol" }, + }); + }); + + it("resolves a symbol through a plain-import re-export chain", () => { + const base = refsIn(res, "contracts/consumer.sol").filter((r) => r.name === "Base"); + // Comment and string mentions are ignored. + expect(base).toHaveLength(1); + expect(base[0]).toMatchObject({ + line: 9, + status: "resolved", + target: { file: "contracts/base.sol", line: 3 }, + }); + expect(refsIn(res, "contracts/mid.sol")[0]!.target).toMatchObject({ file: "contracts/base.sol" }); + }); + + it("flags a name provided by two plain imports as ambiguous", () => { + const shared = refsIn(res, "contracts/consumer.sol").filter((r) => r.name === "Shared"); + expect(shared).toHaveLength(1); + expect(shared[0]!.status).toBe("ambiguous"); + expect(shared[0]!.candidates!.map((c) => c.file)).toEqual([ + "contracts/dupA.sol", + "contracts/dupB.sol", + ]); + expect(res.issues.some((i) => i.code === "AMBIGUOUS_SYMBOL")).toBe(true); + }); + + it("keeps resolving the rest of the repository when a relative import is missing", () => { + const missing = res.imports.find((e) => e.specifier === "./missing.sol")!; + expect(missing).toMatchObject({ status: "unresolved", line: 6 }); + expect(res.issues).toContainEqual( + expect.objectContaining({ code: "UNRESOLVED_IMPORT", file: "contracts/consumer.sol", line: 6 }), + ); + expect(res.counts.unresolvedImports).toBe(1); + }); +}); + +describe("Rust crate", () => { + const res = resolveCrossFileReferences(loadRepo("rust-crate")); + + it("resolves mod declarations and use paths to files", () => { + expect(res.dependencies["src/lib.rs"]).toEqual(["src/math.rs", "src/storage/mod.rs"]); + expect(res.dependencies["src/storage/mod.rs"]).toEqual(["src/math.rs"]); + const std = res.imports.find((e) => e.specifier === "std::collections::HashMap")!; + expect(std.status).toBe("external"); + expect(res.counts.unresolvedImports).toBe(0); + }); + + it("resolves calls through use, braces with aliases, and module paths", () => { + const lib = refsIn(res, "src/lib.rs"); + expect(lib.map((r) => [r.name, r.via, r.target?.file, r.target?.line])).toEqual([ + ["add", undefined, "src/math.rs", 1], + ["sub", "math", "src/math.rs", 5], + ]); + const storage = refsIn(res, "src/storage/mod.rs"); + expect(storage.map((r) => [r.name, r.target?.name, r.target?.file])).toEqual([ + ["add", "add", "src/math.rs"], + ["minus", "sub", "src/math.rs"], + ]); + }); +}); + +describe("Vyper", () => { + const sources: CrossFileSource[] = [ + { + path: "math.vy", + content: "def add(a: uint256, b: uint256) -> uint256:\n return a + b\n\nMAX: constant(uint256) = 100\n", + }, + { + path: "main.vy", + content: [ + "# math.add in a comment does not count", + "from . import math", + "from ethereum.ercs import IERC20", + "", + "@external", + "def run(t: IERC20) -> uint256:", + " return math.add(1, 2) + math.MAX", + "", + ].join("\n"), + }, + ]; + const res = resolveCrossFileReferences(sources); + + it("resolves module namespaces and flags stdlib imports as external", () => { + expect(res.dependencies["main.vy"]).toEqual(["math.vy"]); + expect(refsIn(res, "main.vy").map((r) => [r.name, r.status, r.target?.line])).toEqual([ + ["IERC20", "external", undefined], + ["add", "resolved", 1], + ["MAX", "resolved", 4], + ]); + }); +}); + +describe("failure semantics", () => { + it("skips unsupported languages with an issue instead of failing", () => { + const res = resolveCrossFileReferences([ + { path: "README.md", content: "# hi" }, + { path: "a.sol", content: "contract A {}" }, + ]); + expect(res.files).toEqual(["a.sol"]); + expect(res.skippedFiles).toEqual(["README.md"]); + expect(res.issues.map((i) => i.code)).toEqual(["UNSUPPORTED_LANGUAGE"]); + }); + + it("rejects paths that escape the repository root", () => { + const res = resolveCrossFileReferences([ + { path: "../outside.sol", content: "contract X {}" }, + { path: "/etc/evil.sol", content: "contract Y {}" }, + { path: "ok.sol", content: "contract Z {}" }, + ]); + expect(res.files).toEqual(["ok.sol"]); + expect(res.issues.filter((i) => i.code === "INVALID_PATH")).toHaveLength(2); + }); + + it("does not resolve relative imports above the root", () => { + const res = resolveCrossFileReferences([ + { path: "a.sol", content: 'import {X} from "../x.sol";\ncontract A is X {}' }, + ]); + expect(res.imports[0]!.status).toBe("unresolved"); + }); + + it("keeps one copy of duplicate paths, independent of input order", () => { + const one = { path: "a.sol", content: "contract One {}" }; + const two = { path: "a.sol", content: "contract Two {}" }; + const a = resolveCrossFileReferences([one, two]); + const b = resolveCrossFileReferences([two, one]); + expect(a.symbols.map((s) => s.name)).toEqual(["One"]); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + expect(a.issues.map((i) => i.code)).toEqual(["DUPLICATE_PATH"]); + }); + + it("enforces maxFiles and maxFileBytes", () => { + const sources = [ + { path: "a.sol", content: "contract A {}" }, + { path: "b.sol", content: "contract B {}" }, + { path: "c.sol", content: "contract C {}" + " ".repeat(100) }, + ]; + const res = resolveCrossFileReferences(sources, { maxFiles: 2, maxFileBytes: 50 }); + expect(res.files).toEqual(["a.sol", "b.sol"]); + expect(res.issues.map((i) => i.code).sort()).toEqual(["FILE_TOO_LARGE"]); + + const capped = resolveCrossFileReferences(sources.slice(0, 2), { maxFiles: 1 }); + expect(capped.files).toEqual(["a.sol"]); + expect(capped.issues.map((i) => i.code)).toEqual(["TOO_MANY_FILES"]); + }); + + it("returns an empty resolution for empty input", () => { + const res = resolveCrossFileReferences([]); + expect(res.files).toEqual([]); + expect(res.counts.references).toBe(0); + expect(res.issues).toEqual([]); + }); +}); + +describe("getAffectedFiles", () => { + const res = resolveCrossFileReferences(loadRepo("realistic-solidity"), { + remappings: remappingsOf("realistic-solidity"), + }); + + it("returns changed files plus transitive importers, sorted", () => { + expect(getAffectedFiles(res, ["contracts/interfaces/IERC20.sol"])).toEqual([ + "contracts/Vault.sol", + "contracts/interfaces/IERC20.sol", + "contracts/libraries/SafeTransfer.sol", + ]); + }); + + it("ignores invalid paths and leaf files with no importers", () => { + expect(getAffectedFiles(res, ["../x.sol", "contracts/Vault.sol"])).toEqual([ + "contracts/Vault.sol", + ]); + }); +}); diff --git a/libs/engine/core/cross-file-resolver.ts b/libs/engine/core/cross-file-resolver.ts new file mode 100644 index 00000000..8bef71f7 --- /dev/null +++ b/libs/engine/core/cross-file-resolver.ts @@ -0,0 +1,1277 @@ +/** + * Cross-file reference resolution. + * + * Given an in-memory snapshot of a repository (path + content pairs), builds + * the import graph, the table of top-level symbol definitions, and resolves + * every use of an imported symbol back to the file and line that defines it. + * Supported inputs: Solidity (`.sol`), Rust (`.rs`) and Vyper (`.vy`, `.vyi`). + * + * Design + * ────── + * - Pure and synchronous: no filesystem, network or clock access, so a call is + * idempotent and safe to retry, and results are byte-identical for the same + * sources and options regardless of input order. + * - Tenant-safe: the resolver only sees the sources it is handed and keeps no + * module-level state. Paths are normalized to repository-relative form and + * anything that escapes the root is rejected. + * - Unsupported input: files with an unknown extension are skipped and listed + * in `skippedFiles`; they never produce errors or silent "no reference" + * results. + * - Partial failure: a file that cannot be processed produces a + * `ResolverIssue` and the rest of the repository is still resolved. + * - Imports that point outside the snapshot (e.g. `@openzeppelin/...` with no + * remapping) are reported as `external`, not `unresolved`. + */ + +import { normalizeFindingPath } from "./result-normalizer"; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export const CROSS_FILE_SCHEMA_VERSION = 1; +export const DEFAULT_MAX_FILES = 5000; +export const DEFAULT_MAX_FILE_BYTES = 1024 * 1024; + +export type CrossFileLanguage = "solidity" | "rust" | "vyper"; + +export interface CrossFileSource { + /** Repository-relative path. Backslashes and `./` prefixes are normalized. */ + path: string; + content: string; +} + +export interface CrossFileResolverOptions { + /** + * Solidity import remappings, `prefix -> target` (the `remappings.txt` / + * `foundry.toml` form). The longest matching prefix wins. + */ + remappings?: Record; + /** Absolute root used to relativize absolute input paths. */ + rootDir?: string; + /** Files beyond this count (after path sorting) are skipped. */ + maxFiles?: number; + /** Files larger than this many UTF-16 code units are skipped. */ + maxFileBytes?: number; +} + +export type SymbolKind = + | "contract" + | "interface" + | "library" + | "struct" + | "enum" + | "event" + | "error" + | "function" + | "type" + | "constant" + | "trait" + | "module"; + +export interface SymbolDefinition { + name: string; + kind: SymbolKind; + file: string; + line: number; +} + +export type ImportStatus = "resolved" | "external" | "unresolved"; + +export interface ImportEdge { + from: string; + specifier: string; + line: number; + /** Imported symbol names; empty for whole-file / namespace / glob imports. */ + symbols: string[]; + /** Namespace alias (`import * as N`, `import a as N`, `mod n;`). */ + alias?: string; + status: ImportStatus; + resolvedPath?: string; +} + +/** `external` means the symbol comes from an import outside the snapshot. */ +export type ReferenceStatus = "resolved" | "ambiguous" | "unresolved" | "external"; + +export interface ResolvedReference { + file: string; + line: number; + column: number; + /** The symbol as written at the use site (the member name for `N.Member`). */ + name: string; + /** Namespace alias used to reach the symbol, if any. */ + via?: string; + status: ReferenceStatus; + target?: SymbolDefinition; + /** All candidate definitions when `status` is `ambiguous`. */ + candidates?: SymbolDefinition[]; +} + +export type ResolverIssueCode = + | "INVALID_PATH" + | "DUPLICATE_PATH" + | "FILE_TOO_LARGE" + | "TOO_MANY_FILES" + | "UNSUPPORTED_LANGUAGE" + | "UNRESOLVED_IMPORT" + | "UNRESOLVED_SYMBOL" + | "AMBIGUOUS_SYMBOL" + | "IMPORT_CYCLE" + | "PARSE_ERROR"; + +export interface ResolverIssue { + code: ResolverIssueCode; + message: string; + file?: string; + line?: number; +} + +export interface CrossFileResolution { + schemaVersion: number; + /** Files that were parsed, sorted. */ + files: string[]; + /** Files skipped because of an unsupported language, sorted. */ + skippedFiles: string[]; + symbols: SymbolDefinition[]; + imports: ImportEdge[]; + references: ResolvedReference[]; + /** file -> sorted list of files it directly imports. */ + dependencies: Record; + /** Strongly connected import groups (size > 1, or self-import), sorted. */ + cycles: string[][]; + issues: ResolverIssue[]; + counts: { + files: number; + symbols: number; + imports: number; + references: number; + unresolvedReferences: number; + unresolvedImports: number; + }; +} + +// ─── Internal types ─────────────────────────────────────────────────────────── + +interface Binding { + kind: "symbol" | "namespace"; + /** Target file; undefined when the import could not be resolved. */ + file?: string; + /** The import points outside the snapshot (not an error). */ + external?: boolean; + imported?: string; + reexport: boolean; +} + +interface Glob { + file: string; + reexport: boolean; +} + +interface ParsedFile { + path: string; + language: CrossFileLanguage; + /** Comments and string contents blanked; offsets and newlines preserved. */ + masked: string; + /** Only comments blanked; import specifiers stay readable. */ + importText: string; + defs: Map; + bindings: Map; + globs: Glob[]; + /** Offset ranges of import statements, excluded from reference scanning. */ + importRanges: Array<[number, number]>; +} + +const EXTENSIONS: Record = { + ".sol": "solidity", + ".rs": "rust", + ".vy": "vyper", + ".vyi": "vyper", +}; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Parses a `remappings.txt` body (`prefix=target` per line, `#` comments and + * blank lines ignored; a `context:` prefix is dropped) into the map accepted + * by `CrossFileResolverOptions.remappings`. + */ +export function parseRemappings(text: string): Record { + const out: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + let prefix = line.slice(0, eq).trim(); + const colon = prefix.indexOf(":"); + if (colon >= 0) prefix = prefix.slice(colon + 1); + if (prefix !== "") out[prefix] = line.slice(eq + 1).trim(); + } + return out; +} + +export function resolveCrossFileReferences( + sources: CrossFileSource[], + options: CrossFileResolverOptions = {}, +): CrossFileResolution { + const issues: ResolverIssue[] = []; + const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; + const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES; + + // 1. Normalize, de-duplicate and order the input so output never depends + // on the order the caller supplied it in. + const normalized: CrossFileSource[] = []; + for (const src of sources) { + const p = normalizeFindingPath(src?.path, options.rootDir); + if (p === undefined || typeof src.content !== "string") { + issues.push({ + code: "INVALID_PATH", + message: `Source path is invalid or outside the repository root: ${JSON.stringify(src?.path)}`, + }); + continue; + } + normalized.push({ path: p, content: src.content }); + } + normalized.sort((a, b) => cmp(a.path, b.path) || cmp(a.content, b.content)); + + const unique: CrossFileSource[] = []; + for (const src of normalized) { + if (unique.length > 0 && unique[unique.length - 1]!.path === src.path) { + issues.push({ + code: "DUPLICATE_PATH", + file: src.path, + message: `Duplicate source path; keeping the first occurrence in content order.`, + }); + continue; + } + unique.push(src); + } + + const parsed = new Map(); + const skippedFiles: string[] = []; + const available = new Set(unique.map((s) => s.path)); + + for (const src of unique) { + const language = EXTENSIONS[extname(src.path)]; + if (!language) { + skippedFiles.push(src.path); + issues.push({ + code: "UNSUPPORTED_LANGUAGE", + file: src.path, + message: `No cross-file resolution support for "${extname(src.path) || src.path}"; file skipped.`, + }); + continue; + } + if (src.content.length > maxFileBytes) { + issues.push({ + code: "FILE_TOO_LARGE", + file: src.path, + message: `File exceeds maxFileBytes (${maxFileBytes}); file skipped.`, + }); + continue; + } + if (parsed.size >= maxFiles) { + issues.push({ + code: "TOO_MANY_FILES", + file: src.path, + message: `maxFiles (${maxFiles}) reached; file skipped.`, + }); + continue; + } + try { + parsed.set(src.path, parseDefinitions(src.path, language, src.content)); + } catch (err) { + issues.push({ + code: "PARSE_ERROR", + file: src.path, + message: `Failed to parse file: ${String(err)}`, + }); + } + } + + // 2. Resolve imports (needs every file's definitions to exist first for + // bindings, but only the set of paths for locating targets). + const importEdges: ImportEdge[] = []; + const remappings = sortedRemappings(options.remappings); + const parsedPaths = new Set(parsed.keys()); + for (const file of parsed.values()) { + try { + const raw = extractImports(file, parsedPaths, remappings, available); + importEdges.push(...raw.edges); + issues.push(...raw.issues); + } catch (err) { + issues.push({ + code: "PARSE_ERROR", + file: file.path, + message: `Failed to resolve imports: ${String(err)}`, + }); + } + } + + // 3. Resolve identifier uses against the bindings. + const references: ResolvedReference[] = []; + for (const file of parsed.values()) { + try { + references.push(...collectReferences(file, parsed, issues)); + } catch (err) { + issues.push({ + code: "PARSE_ERROR", + file: file.path, + message: `Failed to resolve references: ${String(err)}`, + }); + } + } + + // 4. Graph-level output. + const dependencies: Record = {}; + for (const path of parsed.keys()) dependencies[path] = []; + for (const edge of importEdges) { + if (edge.resolvedPath !== undefined && dependencies[edge.from]) { + dependencies[edge.from]!.push(edge.resolvedPath); + } + } + for (const path of Object.keys(dependencies)) { + dependencies[path] = [...new Set(dependencies[path])].sort(cmp); + } + + const cycles = findCycles(dependencies); + for (const cycle of cycles) { + issues.push({ + code: "IMPORT_CYCLE", + file: cycle[0], + message: `Import cycle: ${[...cycle, cycle[0]].join(" -> ")}`, + }); + } + + const symbols = [...parsed.values()] + .flatMap((f) => [...f.defs.values()]) + .sort(compareDefs); + importEdges.sort( + (a, b) => cmp(a.from, b.from) || a.line - b.line || cmp(a.specifier, b.specifier), + ); + references.sort( + (a, b) => + cmp(a.file, b.file) || a.line - b.line || a.column - b.column || cmp(a.name, b.name), + ); + issues.sort( + (a, b) => + cmp(a.file ?? "", b.file ?? "") || + (a.line ?? 0) - (b.line ?? 0) || + cmp(a.code, b.code) || + cmp(a.message, b.message), + ); + + return { + schemaVersion: CROSS_FILE_SCHEMA_VERSION, + files: [...parsed.keys()].sort(cmp), + skippedFiles: skippedFiles.sort(cmp), + symbols, + imports: importEdges, + references, + dependencies, + cycles, + issues, + counts: { + files: parsed.size, + symbols: symbols.length, + imports: importEdges.length, + references: references.length, + unresolvedReferences: references.filter((r) => r.status === "unresolved").length, + unresolvedImports: importEdges.filter((e) => e.status === "unresolved").length, + }, + }; +} + +/** + * Returns every file whose analysis result can change when `changedFiles` + * change: the changed files plus all of their transitive importers. Intended + * to widen `AnalyzerConfig.changedFiles` for incremental runs. + */ +export function getAffectedFiles( + resolution: CrossFileResolution, + changedFiles: string[], +): string[] { + const importers = new Map(); + for (const [file, deps] of Object.entries(resolution.dependencies)) { + for (const dep of deps) { + if (!importers.has(dep)) importers.set(dep, []); + importers.get(dep)!.push(file); + } + } + const seen = new Set(); + const queue: string[] = []; + for (const raw of changedFiles) { + const p = normalizeFindingPath(raw); + if (p !== undefined && !seen.has(p)) { + seen.add(p); + queue.push(p); + } + } + while (queue.length > 0) { + const current = queue.shift()!; + for (const importer of importers.get(current) ?? []) { + if (!seen.has(importer)) { + seen.add(importer); + queue.push(importer); + } + } + } + return [...seen].sort(cmp); +} + +/** All resolved references that point at the given definition. */ +export function getReferencesTo( + resolution: CrossFileResolution, + file: string, + name: string, +): ResolvedReference[] { + return resolution.references.filter( + (r) => + (r.target?.file === file && r.target.name === name) || + (r.candidates?.some((c) => c.file === file && c.name === name) ?? false), + ); +} + +// ─── Text masking ───────────────────────────────────────────────────────────── + +interface MaskSyntax { + line: string; + block: boolean; + quotes: string; + triple: boolean; +} + +const SYNTAX: Record = { + solidity: { line: "//", block: true, quotes: "\"'", triple: false }, + // Single quotes are ambiguous in Rust (lifetimes), so only `"` is masked. + rust: { line: "//", block: true, quotes: '"', triple: false }, + vyper: { line: "#", block: false, quotes: "\"'", triple: true }, +}; + +/** Blanks comments (and optionally string contents), keeping offsets and newlines. */ +function mask(text: string, language: CrossFileLanguage, keepStrings: boolean): string { + const syn = SYNTAX[language]; + const out = text.split(""); + const blank = (from: number, to: number) => { + for (let k = from; k < to; k++) if (out[k] !== "\n" && out[k] !== "\r") out[k] = " "; + }; + let i = 0; + while (i < text.length) { + if (text.startsWith(syn.line, i)) { + let end = text.indexOf("\n", i); + if (end < 0) end = text.length; + blank(i, end); + i = end; + } else if (syn.block && text.startsWith("/*", i)) { + let end = text.indexOf("*/", i + 2); + end = end < 0 ? text.length : end + 2; + blank(i, end); + i = end; + } else if (syn.quotes.includes(text[i]!)) { + const q = text[i]!; + const triple = syn.triple && text.startsWith(q.repeat(3), i); + const delim = triple ? q.repeat(3) : q; + let j = i + delim.length; + while (j < text.length) { + if (text[j] === "\\") { + j += 2; + continue; + } + if (text.startsWith(delim, j)) break; + if (!triple && text[j] === "\n") break; + j++; + } + const closed = text.startsWith(delim, j); + const innerEnd = closed ? j : Math.min(j, text.length); + // Vyper triple-quoted strings are docstrings, so they are always blanked. + if (triple) blank(i, closed ? j + delim.length : innerEnd); + else if (!keepStrings) blank(i + delim.length, innerEnd); + i = closed ? j + delim.length : innerEnd; + } else { + i++; + } + } + return out.join(""); +} + +/** + * Blanks comments and string contents for a supported language, keeping every + * offset and newline. Shared with the lexical parser fallback. + */ +export function maskSource(text: string, language: CrossFileLanguage): string { + return mask(text, language, false); +} + +function lineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); + return starts; +} + +function positionOf(starts: number[], offset: number): { line: number; column: number } { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid]! <= offset) lo = mid; + else hi = mid - 1; + } + return { line: lo + 1, column: offset - starts[lo]! + 1 }; +} + +function braceDepths(masked: string): Int32Array { + const depths = new Int32Array(masked.length); + let depth = 0; + for (let i = 0; i < masked.length; i++) { + const c = masked[i]; + if (c === "}") depth = Math.max(0, depth - 1); + depths[i] = depth; + if (c === "{") depth++; + } + return depths; +} + +// ─── Definition parsing ─────────────────────────────────────────────────────── + +function parseDefinitions( + path: string, + language: CrossFileLanguage, + content: string, +): ParsedFile { + const masked = mask(content, language, false); + const starts = lineStarts(content); + const defs = new Map(); + const add = (name: string, kind: SymbolKind, offset: number) => { + if (!defs.has(name)) { + defs.set(name, { name, kind, file: path, line: positionOf(starts, offset).line }); + } + }; + + if (language === "solidity") { + const depths = braceDepths(masked); + const re = + /\b(contract|interface|library|struct|enum|event|error|function|type)\s+([A-Za-z_$][\w$]*)/g; + for (let m = re.exec(masked); m; m = re.exec(masked)) { + if (depths[m.index] !== 0) continue; + add(m[2]!, m[1] === "type" ? "type" : (m[1] as SymbolKind), m.index + m[0].length - m[2]!.length); + } + const constRe = /\b(?:u?int\d*|bytes\d*|address|bool|string)\s+constant\s+([A-Za-z_$][\w$]*)/g; + for (let m = constRe.exec(masked); m; m = constRe.exec(masked)) { + if (depths[m.index] === 0) add(m[1]!, "constant", m.index + m[0].length - m[1]!.length); + } + } else if (language === "rust") { + const depths = braceDepths(masked); + const re = /\b(fn|struct|enum|trait|type|const|static|union)\s+(?!fn\b)([A-Za-z_]\w*)/g; + const kinds: Record = { + fn: "function", + struct: "struct", + enum: "enum", + trait: "trait", + type: "type", + const: "constant", + static: "constant", + union: "struct", + }; + for (let m = re.exec(masked); m; m = re.exec(masked)) { + if (depths[m.index] !== 0) continue; + add(m[2]!, kinds[m[1]!]!, m.index + m[0].length - m[2]!.length); + } + } else { + const re = /^(def|struct|event|interface|flag|enum)\s+([A-Za-z_]\w*)/gm; + const kinds: Record = { + def: "function", + struct: "struct", + event: "event", + interface: "interface", + flag: "enum", + enum: "enum", + }; + for (let m = re.exec(masked); m; m = re.exec(masked)) { + add(m[2]!, kinds[m[1]!]!, m.index + m[0].length - m[2]!.length); + } + const constRe = /^([A-Za-z_]\w*)\s*:\s*constant\s*\(/gm; + for (let m = constRe.exec(masked); m; m = constRe.exec(masked)) { + add(m[1]!, "constant", m.index); + } + } + + return { + path, + language, + masked, + importText: mask(content, language, true), + defs, + bindings: new Map(), + globs: [], + importRanges: [], + }; +} + +// ─── Import extraction and path resolution ──────────────────────────────────── + +interface ExtractedImports { + edges: ImportEdge[]; + issues: ResolverIssue[]; +} + +function extractImports( + file: ParsedFile, + parsed: Set, + remappings: Array<[string, string]>, + available: Set, +): ExtractedImports { + const ctx: ImportContext = { + file, + parsed, + remappings, + available, + edges: [], + issues: [], + }; + return file.language === "solidity" + ? extractSolidity(ctx) + : file.language === "rust" + ? extractRust(ctx) + : extractVyper(ctx); +} + +interface ImportContext { + file: ParsedFile; + parsed: Set; + remappings: Array<[string, string]>; + available: Set; + edges: ImportEdge[]; + issues: ResolverIssue[]; +} + +function recordEdge( + ctx: ImportContext, + edge: Omit, + offsets: [number, number], + unresolvedMessage: string, +) { + ctx.edges.push({ from: ctx.file.path, ...edge }); + ctx.file.importRanges.push(offsets); + if (edge.status === "unresolved") { + ctx.issues.push({ + code: "UNRESOLVED_IMPORT", + file: ctx.file.path, + line: edge.line, + message: unresolvedMessage, + }); + } +} + +function addBinding(file: ParsedFile, local: string, binding: Binding) { + if (!file.bindings.has(local)) file.bindings.set(local, binding); +} + +// Solidity ───────────────────────────────────────────────────────────────────── + +function extractSolidity(ctx: ImportContext): ExtractedImports { + const { file } = ctx; + const text = file.importText; + const starts = lineStarts(text); + const re = + /\bimport\s+(?:(\*\s*as\s+([A-Za-z_$][\w$]*)|\{([^}]*)\})\s*from\s*)?(["'])([^"'\n]+)\4(?:\s*as\s+([A-Za-z_$][\w$]*))?\s*;/g; + for (let m = re.exec(text); m; m = re.exec(text)) { + const specifier = m[5]!; + const line = positionOf(starts, m.index).line; + const target = resolveSolidityPath(ctx, specifier); + const status: ImportStatus = + target.path !== undefined ? "resolved" : target.external ? "external" : "unresolved"; + const names = m[3] !== undefined ? parseSolidityNames(m[3]) : []; + const alias = m[2] ?? m[6]; + + recordEdge( + ctx, + { + specifier, + line, + symbols: names.map((n) => n.imported), + ...(alias ? { alias } : {}), + status, + ...(target.path !== undefined ? { resolvedPath: target.path } : {}), + }, + [m.index, m.index + m[0].length], + `Cannot resolve import "${specifier}".`, + ); + + const external = status === "external"; + if (m[3] !== undefined) { + for (const n of names) { + addBinding(file, n.local, { + kind: "symbol", + file: target.path, + external, + imported: n.imported, + reexport: true, + }); + } + } else if (alias) { + addBinding(file, alias, { kind: "namespace", file: target.path, external, reexport: false }); + } else if (target.path !== undefined) { + file.globs.push({ file: target.path, reexport: true }); + } + } + return { edges: ctx.edges, issues: ctx.issues }; +} + +function parseSolidityNames(body: string): Array<{ imported: string; local: string }> { + return body + .split(",") + .map((part) => part.trim()) + .filter((part) => part !== "") + .map((part) => { + const [imported, local] = part.split(/\s+as\s+/); + return { imported: imported!.trim(), local: (local ?? imported)!.trim() }; + }); +} + +function resolveSolidityPath( + ctx: ImportContext, + specifier: string, +): { path?: string; external?: boolean } { + const dir = dirname(ctx.file.path); + if (specifier.startsWith("./") || specifier.startsWith("../")) { + const p = joinPath(dir, specifier); + return p !== undefined && ctx.parsed.has(p) ? { path: p } : {}; + } + for (const [prefix, target] of ctx.remappings) { + if (specifier.startsWith(prefix)) { + const p = joinPath("", target + specifier.slice(prefix.length)); + if (p === undefined) return {}; + if (ctx.parsed.has(p)) return { path: p }; + // A target that is not in the snapshot at all (e.g. a vendored `lib/` + // dependency) is external; one that was skipped is a real failure. + return ctx.available.has(p) ? {} : { external: true }; + } + } + const direct = joinPath("", specifier); + if (direct !== undefined && ctx.parsed.has(direct)) return { path: direct }; + if (direct !== undefined && ctx.available.has(direct)) return {}; + return { external: true }; +} + +// Rust ───────────────────────────────────────────────────────────────────────── + +interface UseLeaf { + path: string[]; + alias?: string; + glob: boolean; +} + +function extractRust(ctx: ImportContext): ExtractedImports { + const { file } = ctx; + const text = file.importText; + const starts = lineStarts(text); + const re = + /(?:^|[;{}\s])(pub(?:\s*\([^)]*\))?\s+)?(use\s+([^;]+);|mod\s+([A-Za-z_]\w*)\s*;)/g; + for (let m = re.exec(text); m; m = re.exec(text)) { + const reexport = m[1] !== undefined; + const stmtStart = m.index + m[0].length - m[2]!.length - (m[1] ? m[1].length : 0); + const range: [number, number] = [stmtStart, m.index + m[0].length]; + const line = positionOf(starts, stmtStart).line; + + if (m[4] !== undefined) { + const name = m[4]; + const target = resolveRustModule(ctx, moduleDir(file.path), name); + recordEdge( + ctx, + { + specifier: name, + line, + symbols: [], + alias: name, + status: target ? "resolved" : "unresolved", + ...(target ? { resolvedPath: target } : {}), + }, + range, + `Cannot find module file for "mod ${name};".`, + ); + addBinding(file, name, { kind: "namespace", file: target, reexport }); + continue; + } + + for (const leaf of expandRustUse(m[3]!.trim())) { + const specifier = leaf.path.join("::") + (leaf.glob ? "::*" : ""); + const resolved = resolveRustUse(ctx, leaf.path); + const status: ImportStatus = resolved.external + ? "external" + : resolved.moduleFile !== undefined || resolved.symbolFile !== undefined + ? "resolved" + : "unresolved"; + const targetFile = resolved.moduleFile ?? resolved.symbolFile; + const local = leaf.alias ?? leaf.path[leaf.path.length - 1]!; + + recordEdge( + ctx, + { + specifier, + line, + symbols: !leaf.glob && resolved.moduleFile === undefined && resolved.symbol ? [resolved.symbol] : [], + ...(resolved.moduleFile !== undefined && !leaf.glob ? { alias: local } : {}), + status, + ...(targetFile !== undefined ? { resolvedPath: targetFile } : {}), + }, + range, + `Cannot resolve "use ${specifier};".`, + ); + + if (resolved.external || local === "self") continue; + if (leaf.glob) { + if (resolved.moduleFile !== undefined) { + file.globs.push({ file: resolved.moduleFile, reexport }); + } + } else if (resolved.moduleFile !== undefined) { + addBinding(file, local, { kind: "namespace", file: resolved.moduleFile, reexport }); + } else { + addBinding(file, local, { + kind: "symbol", + file: resolved.symbolFile, + imported: resolved.symbol, + reexport, + }); + } + } + } + return { edges: ctx.edges, issues: ctx.issues }; +} + +/** Flattens `a::{b, c as d, e::*}` into leaves. */ +function expandRustUse(text: string, prefix: string[] = []): UseLeaf[] { + const brace = text.indexOf("{"); + if (brace < 0) { + const asMatch = text.match(/^(.*?)\s+as\s+([A-Za-z_]\w*)$/); + const pathText = (asMatch ? asMatch[1]! : text).trim(); + const segs = pathText.split("::").map((s) => s.trim()).filter((s) => s !== ""); + if (segs[segs.length - 1] === "*") { + return [{ path: [...prefix, ...segs.slice(0, -1)], glob: true }]; + } + if (segs.length === 0) return []; + return [{ path: [...prefix, ...segs], ...(asMatch ? { alias: asMatch[2]! } : {}), glob: false }]; + } + const head = text + .slice(0, brace) + .split("::") + .map((s) => s.trim()) + .filter((s) => s !== ""); + let depth = 0; + let end = -1; + for (let i = brace; i < text.length; i++) { + if (text[i] === "{") depth++; + else if (text[i] === "}" && --depth === 0) { + end = i; + break; + } + } + if (end < 0) return []; + const parts: string[] = []; + let current = ""; + depth = 0; + for (const c of text.slice(brace + 1, end)) { + if (c === "{") depth++; + if (c === "}") depth--; + if (c === "," && depth === 0) { + parts.push(current); + current = ""; + } else current += c; + } + parts.push(current); + const out: UseLeaf[] = []; + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed === "") continue; + if (trimmed === "self") { + out.push({ path: [...prefix, ...head], glob: false }); + } else { + out.push(...expandRustUse(trimmed, [...prefix, ...head])); + } + } + return out; +} + +function moduleDir(path: string): string { + const base = basename(path); + if (base === "lib.rs" || base === "main.rs" || base === "mod.rs") return dirname(path); + return joinPath(dirname(path), base.replace(/\.rs$/, "")) ?? dirname(path); +} + +function resolveRustModule(ctx: ImportContext, dir: string, name: string): string | undefined { + for (const candidate of [`${name}.rs`, `${name}/mod.rs`]) { + const p = joinPath(dir, candidate); + if (p !== undefined && ctx.parsed.has(p)) return p; + } + return undefined; +} + +/** Nearest `lib.rs` / `main.rs` at or above the importing file's directory. */ +function crateRootFile(ctx: ImportContext): string | undefined { + let dir = dirname(ctx.file.path); + for (;;) { + for (const root of ["lib.rs", "main.rs"]) { + const p = joinPath(dir, root); + if (p !== undefined && ctx.parsed.has(p)) return p; + } + if (dir === "") return undefined; + dir = dirname(dir); + } +} + +function resolveRustUse( + ctx: ImportContext, + path: string[], +): { moduleFile?: string; symbolFile?: string; symbol?: string; external?: boolean } { + let dir: string | undefined; + let rest = path; + let current = ctx.file.path; + const first = path[0]!; + if (first === "crate") { + const rootFile = crateRootFile(ctx); + if (rootFile === undefined) return {}; + dir = dirname(rootFile); + current = rootFile; + rest = path.slice(1); + } else if (first === "self") { + dir = moduleDir(ctx.file.path); + rest = path.slice(1); + } else if (first === "super") { + dir = moduleDir(ctx.file.path); + while (rest[0] === "super") { + dir = dirname(dir); + rest = rest.slice(1); + } + const parentFile = [`${dir}.rs`, ...["mod.rs", "lib.rs", "main.rs"].map((f) => joinPath(dir!, f))].find( + (p) => p !== undefined && ctx.parsed.has(p), + ); + current = parentFile ?? current; + } else { + const binding = ctx.file.bindings.get(first); + if (binding?.kind === "namespace" && binding.file) { + dir = moduleDir(binding.file); + current = binding.file; + rest = path.slice(1); + } else { + return { external: true }; + } + } + + if (rest.length === 0) { + return ctx.parsed.has(current) && current !== ctx.file.path ? { moduleFile: current } : {}; + } + let i = 0; + while (i < rest.length) { + const next = resolveRustModule(ctx, dir, rest[i]!); + if (next === undefined) break; + current = next; + dir = moduleDir(next); + i++; + } + if (i === rest.length) return { moduleFile: current }; + return { symbolFile: current, symbol: rest[i]! }; +} + +// Vyper ──────────────────────────────────────────────────────────────────────── + +function extractVyper(ctx: ImportContext): ExtractedImports { + const { file } = ctx; + const text = file.importText; + const starts = lineStarts(text); + const importRe = /^import\s+([\w.]+)(?:\s+as\s+([A-Za-z_]\w*))?[ \t]*$/gm; + for (let m = importRe.exec(text); m; m = importRe.exec(text)) { + const specifier = m[1]!; + const target = resolveVyperModule(ctx, specifier); + const alias = m[2] ?? specifier.split(".").pop()!; + recordEdge( + ctx, + { + specifier, + line: positionOf(starts, m.index).line, + symbols: [], + alias, + status: target ? "resolved" : isExternalVyper(specifier) ? "external" : "unresolved", + ...(target ? { resolvedPath: target } : {}), + }, + [m.index, m.index + m[0].length], + `Cannot resolve import "${specifier}".`, + ); + addBinding(file, alias, { + kind: "namespace", + file: target, + external: !target && isExternalVyper(specifier), + reexport: false, + }); + } + + const fromRe = /^from\s+(\.*[\w.]*)\s+import\s+([A-Za-z_]\w*)(?:\s+as\s+([A-Za-z_]\w*))?[ \t]*$/gm; + for (let m = fromRe.exec(text); m; m = fromRe.exec(text)) { + const modulePath = m[1]!; + const name = m[2]!; + const local = m[3] ?? name; + const asModule = resolveVyperModule(ctx, joinDots(modulePath, name)); + const inModule = asModule ? undefined : resolveVyperModule(ctx, modulePath); + const target = asModule ?? inModule; + const specifier = `${modulePath} import ${name}`; + recordEdge( + ctx, + { + specifier, + line: positionOf(starts, m.index).line, + symbols: asModule ? [] : [name], + ...(asModule ? { alias: local } : {}), + status: target ? "resolved" : isExternalVyper(modulePath) ? "external" : "unresolved", + ...(target ? { resolvedPath: target } : {}), + }, + [m.index, m.index + m[0].length], + `Cannot resolve "from ${modulePath} import ${name}".`, + ); + const external = !target && isExternalVyper(modulePath); + addBinding( + file, + local, + asModule + ? { kind: "namespace", file: asModule, external, reexport: false } + : { kind: "symbol", file: inModule, external, imported: name, reexport: false }, + ); + } + return { edges: ctx.edges, issues: ctx.issues }; +} + +function joinDots(modulePath: string, name: string): string { + return modulePath.endsWith(".") ? modulePath + name : `${modulePath}.${name}`; +} + +/** Vyper's `ethereum.ercs` and `vyper.*` stdlib imports are never in the repo. */ +function isExternalVyper(specifier: string): boolean { + return /^(ethereum|vyper)(\.|$)/.test(specifier); +} + +function resolveVyperModule(ctx: ImportContext, specifier: string): string | undefined { + const dots = specifier.match(/^\.*/)![0].length; + const segs = specifier.slice(dots).split(".").filter((s) => s !== ""); + if (segs.length === 0) return undefined; + const relPath = segs.join("/"); + const bases: string[] = []; + if (dots > 0) { + let dir = dirname(ctx.file.path); + for (let i = 1; i < dots; i++) dir = dirname(dir); + bases.push(dir); + } else { + bases.push(dirname(ctx.file.path), ""); + } + for (const base of bases) { + for (const ext of [".vy", ".vyi"]) { + const p = joinPath(base, relPath + ext); + if (p !== undefined && ctx.parsed.has(p)) return p; + } + } + return undefined; +} + +// ─── Reference resolution ───────────────────────────────────────────────────── + +function lookupExport( + parsed: Map, + filePath: string, + name: string, + visiting: Set = new Set(), +): SymbolDefinition[] { + const key = `${filePath}\u0000${name}`; + if (visiting.has(key)) return []; + visiting.add(key); + try { + const file = parsed.get(filePath); + if (!file) return []; + + const own = file.defs.get(name); + if (own) return [own]; + + const binding = file.bindings.get(name); + if (binding?.reexport) { + if (binding.file === undefined) return []; + if (binding.kind === "namespace") { + return [{ name, kind: "module", file: binding.file, line: 1 }]; + } + return lookupExport(parsed, binding.file, binding.imported!, visiting); + } + + const found: SymbolDefinition[] = []; + for (const glob of file.globs) { + if (!glob.reexport) continue; + found.push(...lookupExport(parsed, glob.file, name, visiting)); + } + return uniqueDefs(found); + } finally { + visiting.delete(key); + } +} + +function collectReferences( + file: ParsedFile, + parsed: Map, + issues: ResolverIssue[], +): ResolvedReference[] { + const scan = file.masked.split(""); + for (const [from, to] of file.importRanges) { + for (let k = from; k < to; k++) if (scan[k] !== "\n" && scan[k] !== "\r") scan[k] = " "; + } + const text = scan.join(""); + const starts = lineStarts(text); + const memberSep = file.language === "rust" ? "::" : "."; + const refs: ResolvedReference[] = []; + const emit = (ref: ResolvedReference) => { + refs.push(ref); + if (ref.status === "unresolved") { + issues.push({ + code: "UNRESOLVED_SYMBOL", + file: ref.file, + line: ref.line, + message: `"${ref.name}"${ref.via ? ` (via ${ref.via})` : ""} is imported but no definition was found.`, + }); + } else if (ref.status === "ambiguous") { + issues.push({ + code: "AMBIGUOUS_SYMBOL", + file: ref.file, + line: ref.line, + message: `"${ref.name}" matches ${ref.candidates!.length} definitions: ${ref + .candidates!.map((c) => `${c.file}:${c.line}`) + .join(", ")}.`, + }); + } + }; + const classify = ( + binding: Binding, + defs: SymbolDefinition[], + ): Pick => { + if (defs.length === 1) return { status: "resolved", target: defs[0]! }; + if (defs.length > 1) return { status: "ambiguous", candidates: defs }; + return { status: binding.external ? "external" : "unresolved" }; + }; + const lookup = (binding: Binding, name: string): SymbolDefinition[] => + binding.file === undefined ? [] : lookupExport(parsed, binding.file, name); + + const idRe = /[A-Za-z_$][\w$]*/g; + for (let m = idRe.exec(text); m; m = idRe.exec(text)) { + const name = m[0]; + const before = text.slice(Math.max(0, m.index - 2), m.index).trimEnd(); + if (before.endsWith(".") || before.endsWith("::")) continue; + if (file.defs.has(name)) continue; + const { line, column } = positionOf(starts, m.index); + const base = { file: file.path, line, column }; + + const binding = file.bindings.get(name); + if (binding?.kind === "symbol") { + emit({ ...base, name, ...classify(binding, lookup(binding, binding.imported!)) }); + } else if (binding?.kind === "namespace") { + const rest = text.slice(m.index + name.length); + const member = rest.match( + memberSep === "::" ? /^\s*::\s*([A-Za-z_]\w*)/ : /^\s*\.\s*([A-Za-z_$][\w$]*)/, + ); + if (!member) continue; + const pos = positionOf(starts, m.index + name.length + rest.indexOf(member[1]!)); + emit({ + file: file.path, + line: pos.line, + column: pos.column, + name: member[1]!, + via: name, + ...classify(binding, lookup(binding, member[1]!)), + }); + } else if (file.globs.length > 0) { + const defs = uniqueDefs(file.globs.flatMap((glob) => lookupExport(parsed, glob.file, name))); + if (defs.length > 0) { + emit({ ...base, name, ...classify({ kind: "symbol", reexport: false }, defs) }); + } + } + } + return refs; +} + +// ─── Graph helpers ──────────────────────────────────────────────────────────── + +/** Tarjan's strongly connected components; returns groups with a real cycle. */ +function findCycles(graph: Record): string[][] { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const result: string[][] = []; + let counter = 0; + + const visit = (node: string) => { + index.set(node, counter); + low.set(node, counter); + counter++; + stack.push(node); + onStack.add(node); + for (const next of graph[node] ?? []) { + if (!graph[next]) continue; + if (!index.has(next)) { + visit(next); + low.set(node, Math.min(low.get(node)!, low.get(next)!)); + } else if (onStack.has(next)) { + low.set(node, Math.min(low.get(node)!, index.get(next)!)); + } + } + if (low.get(node) === index.get(node)) { + const group: string[] = []; + let top: string; + do { + top = stack.pop()!; + onStack.delete(top); + group.push(top); + } while (top !== node); + if (group.length > 1 || (graph[node] ?? []).includes(node)) result.push(group.sort(cmp)); + } + }; + + for (const node of Object.keys(graph).sort(cmp)) if (!index.has(node)) visit(node); + return result.sort((a, b) => cmp(a[0]!, b[0]!)); +} + +// ─── Small utilities ────────────────────────────────────────────────────────── + +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function compareDefs(a: SymbolDefinition, b: SymbolDefinition): number { + return cmp(a.file, b.file) || a.line - b.line || cmp(a.name, b.name); +} + +function uniqueDefs(defs: SymbolDefinition[]): SymbolDefinition[] { + const seen = new Set(); + const out: SymbolDefinition[] = []; + for (const d of [...defs].sort(compareDefs)) { + const key = `${d.file}\u0000${d.line}\u0000${d.name}`; + if (!seen.has(key)) { + seen.add(key); + out.push(d); + } + } + return out; +} + +function sortedRemappings(remappings?: Record): Array<[string, string]> { + return Object.entries(remappings ?? {}).sort( + (a, b) => b[0].length - a[0].length || cmp(a[0], b[0]), + ); +} + +function extname(path: string): string { + const base = basename(path); + const dot = base.lastIndexOf("."); + return dot <= 0 ? "" : base.slice(dot).toLowerCase(); +} + +function basename(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +function dirname(path: string): string { + const i = path.lastIndexOf("/"); + return i < 0 ? "" : path.slice(0, i); +} + +/** Joins and normalizes; returns undefined when the result escapes the root. */ +function joinPath(dir: string, rel: string): string | undefined { + return normalizeFindingPath(dir === "" ? rel : `${dir}/${rel}`); +} diff --git a/libs/engine/core/index.ts b/libs/engine/core/index.ts index 0bb90c48..d9e9d054 100644 --- a/libs/engine/core/index.ts +++ b/libs/engine/core/index.ts @@ -41,3 +41,12 @@ export * from "./rule-pack-lockfile"; // Language-specific analyzer capability registry export * from "./language-capability-registry"; + +// Cross-file reference resolution +export * from "./cross-file-resolver"; + +// Rule-level confidence calibration +export * from "./confidence-calibration"; + +// AST parser fallback strategy +export * from "./ast-parser-fallback"; diff --git a/tests/fixtures/ast-parser-fallback/README.md b/tests/fixtures/ast-parser-fallback/README.md new file mode 100644 index 00000000..c0d9e9ee --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/README.md @@ -0,0 +1,9 @@ +# AST parser fallback fixtures + +Used by `libs/engine/core/ast-parser-fallback.spec.ts`. These files only need to be readable text; +they are never compiled. + +| Fixture | Purpose | +| --- | --- | +| `realistic-repo/` | A small mixed repository. `Token.sol`, `Vault.sol`, `lib.rs` and `vault.vy` are valid; `Future.sol` uses a keyword a strict parser rejects (`transient`); `Broken.sol` is a half-edited file with an unclosed block; `NOTES.md` has no parser. | +| `regression/` | Inputs that broke naive outlining: braces inside strings and comments, `;` inside Rust array types, and indentation-delimited Vyper blocks. | diff --git a/tests/fixtures/ast-parser-fallback/realistic-repo/NOTES.md b/tests/fixtures/ast-parser-fallback/realistic-repo/NOTES.md new file mode 100644 index 00000000..cd69d4ba --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/realistic-repo/NOTES.md @@ -0,0 +1 @@ +# Notes (no parser for markdown) diff --git a/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Broken.sol b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Broken.sol new file mode 100644 index 00000000..40753548 --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Broken.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.20; + +contract Half { + function a() external { + uint256 x = 1; + // forgot to close the contract +} diff --git a/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Future.sol b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Future.sol new file mode 100644 index 00000000..3db5f866 --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Future.sol @@ -0,0 +1,9 @@ +pragma solidity ^0.8.28; + +contract Counter { + uint256 transient counter; + + function bump() external { + counter += 1; + } +} diff --git a/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Token.sol b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Token.sol new file mode 100644 index 00000000..fd55564c --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Token.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IToken { + function transfer(address to, uint256 amount) external returns (bool); +} + +contract Token is IToken { + mapping(address => uint256) public balances; + + event Moved(address indexed from, address indexed to); + + function transfer(address to, uint256 amount) external returns (bool) { + balances[msg.sender] -= amount; + balances[to] += amount; + emit Moved(msg.sender, to); + return true; + } +} diff --git a/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Vault.sol b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Vault.sol new file mode 100644 index 00000000..2f1c5510 --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/realistic-repo/contracts/Vault.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +// Deposits are tracked per account { see Token.sol }. +contract Vault { + mapping(address => uint256) public deposits; + + function deposit() external payable { + deposits[msg.sender] += msg.value; + } +} diff --git a/tests/fixtures/ast-parser-fallback/realistic-repo/src/lib.rs b/tests/fixtures/ast-parser-fallback/realistic-repo/src/lib.rs new file mode 100644 index 00000000..cc1f6f33 --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/realistic-repo/src/lib.rs @@ -0,0 +1,9 @@ +pub struct Counter { + value: u64, +} + +impl Counter { + pub fn bump(&mut self) { + self.value += 1; + } +} diff --git a/tests/fixtures/ast-parser-fallback/realistic-repo/vault.vy b/tests/fixtures/ast-parser-fallback/realistic-repo/vault.vy new file mode 100644 index 00000000..af7267af --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/realistic-repo/vault.vy @@ -0,0 +1,17 @@ +# @version ^0.3.9 + +owner: public(address) + +struct Position: + amount: uint256 + opened: uint256 + +@external +def deposit(amount: uint256): + assert amount > 0 + self.owner = msg.sender + +# trailing comment +@external +def withdraw(): + pass diff --git a/tests/fixtures/ast-parser-fallback/regression/array-types.rs b/tests/fixtures/ast-parser-fallback/regression/array-types.rs new file mode 100644 index 00000000..03fec978 --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/regression/array-types.rs @@ -0,0 +1,15 @@ +pub struct Buffer { + data: [u8; 32], +} + +impl Buffer { + pub fn new() -> Self { + Buffer { data: [0; 32] } + } + + pub fn as_array(&self) -> [u8; 32] { + self.data + } +} + +pub const LIMIT: [u8; 2] = [1, 2]; diff --git a/tests/fixtures/ast-parser-fallback/regression/braces-in-strings.sol b/tests/fixtures/ast-parser-fallback/regression/braces-in-strings.sol new file mode 100644 index 00000000..78568e18 --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/regression/braces-in-strings.sol @@ -0,0 +1,12 @@ +pragma solidity ^0.8.20; + +// contract Fake { function nope() { +contract Messages { + string public open = "{ not a block"; + string public close = "} also not"; + /* function ghost() { */ + + function say() external pure returns (string memory) { + return "}}}"; + } +} diff --git a/tests/fixtures/ast-parser-fallback/regression/indented.vy b/tests/fixtures/ast-parser-fallback/regression/indented.vy new file mode 100644 index 00000000..af7267af --- /dev/null +++ b/tests/fixtures/ast-parser-fallback/regression/indented.vy @@ -0,0 +1,17 @@ +# @version ^0.3.9 + +owner: public(address) + +struct Position: + amount: uint256 + opened: uint256 + +@external +def deposit(amount: uint256): + assert amount > 0 + self.owner = msg.sender + +# trailing comment +@external +def withdraw(): + pass diff --git a/tests/fixtures/confidence-calibration/README.md b/tests/fixtures/confidence-calibration/README.md new file mode 100644 index 00000000..f83a1fe1 --- /dev/null +++ b/tests/fixtures/confidence-calibration/README.md @@ -0,0 +1,8 @@ +# Confidence calibration fixtures + +Used by `libs/engine/core/confidence-calibration.spec.ts`. + +| Fixture | Purpose | +| --- | --- | +| `realistic-review-history.json` | Reviewed outcomes for three rules (one accurate, one noisy, one with too little history) plus an older rule-pack version that must be ignored, and the findings of a new scan. | +| `regression-edge-cases.json` | Stale/invalid/duplicate profiles, saturated counts, invalid raw confidence, pinned and per-rule threshold overrides, and a finding without a `ruleId`. | diff --git a/tests/fixtures/confidence-calibration/realistic-review-history.json b/tests/fixtures/confidence-calibration/realistic-review-history.json new file mode 100644 index 00000000..5e5c38e8 --- /dev/null +++ b/tests/fixtures/confidence-calibration/realistic-review-history.json @@ -0,0 +1,639 @@ +{ + "description": "Review history from a quarter of scans plus the findings of a new scan.", + "rulePackVersion": "2.1.0", + "outcomes": [ + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "ignored", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "ignored", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "ignored", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "storage-in-loop", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unused-import", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unused-import", + "verdict": "true_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unused-import", + "verdict": "false_positive", + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "true_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "unchecked-return", + "verdict": "false_positive", + "rulePackVersion": "2.0.0" + } + ], + "findings": [ + { + "ruleId": "unchecked-return", + "message": "return value ignored", + "location": { + "file": "contracts/Vault.sol", + "startLine": 42 + } + }, + { + "ruleId": "unchecked-return", + "message": "return value ignored", + "location": { + "file": "contracts/Pool.sol", + "startLine": 88 + }, + "metadata": { + "confidence": 0.9 + } + }, + { + "ruleId": "storage-in-loop", + "message": "SLOAD inside loop", + "location": { + "file": "contracts/Vault.sol", + "startLine": 57 + } + }, + { + "ruleId": "unused-import", + "message": "import never used", + "location": { + "file": "contracts/Vault.sol", + "startLine": 5 + } + }, + { + "ruleId": "new-rule", + "message": "no history yet", + "location": { + "file": "contracts/Pool.sol", + "startLine": 3 + }, + "metadata": { + "confidence": 0.7 + } + } + ] +} diff --git a/tests/fixtures/confidence-calibration/regression-edge-cases.json b/tests/fixtures/confidence-calibration/regression-edge-cases.json new file mode 100644 index 00000000..969c7620 --- /dev/null +++ b/tests/fixtures/confidence-calibration/regression-edge-cases.json @@ -0,0 +1,117 @@ +{ + "description": "Edge cases: stale and malformed profiles, duplicates, saturated counts, bad raw confidence, overrides.", + "config": { + "rulePackVersion": "2.1.0", + "minSamples": 5, + "profiles": [ + { + "ruleId": "stale-rule", + "truePositives": 0, + "falsePositives": 50, + "rulePackVersion": "2.0.0" + }, + { + "ruleId": "bad-counts", + "truePositives": -1, + "falsePositives": 3, + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "bad-prior", + "truePositives": 5, + "falsePositives": 3, + "priorConfidence": 1.5, + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "dup-rule", + "truePositives": 1, + "falsePositives": 1, + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "dup-rule", + "truePositives": 90, + "falsePositives": 10, + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "always-wrong", + "truePositives": 0, + "falsePositives": 200, + "rulePackVersion": "2.1.0" + }, + { + "ruleId": "always-right", + "truePositives": 200, + "falsePositives": 0, + "rulePackVersion": "2.1.0" + } + ], + "ruleOverrides": { + "pinned-rule": { + "confidence": 0.95 + }, + "noisy-rule": { + "minConfidence": 0.9 + } + }, + "minConfidence": 0.3 + }, + "findings": [ + { + "ruleId": "stale-rule", + "message": "m" + }, + { + "ruleId": "bad-counts", + "message": "m" + }, + { + "ruleId": "bad-prior", + "message": "m" + }, + { + "ruleId": "dup-rule", + "message": "m" + }, + { + "ruleId": "always-wrong", + "message": "m", + "metadata": { + "confidence": 0.99 + } + }, + { + "ruleId": "always-right", + "message": "m", + "metadata": { + "confidence": 0.01 + } + }, + { + "ruleId": "pinned-rule", + "message": "m", + "metadata": { + "confidence": 0.1 + } + }, + { + "ruleId": "noisy-rule", + "message": "m", + "metadata": { + "confidence": 0.8 + } + }, + { + "ruleId": "garbage-confidence", + "message": "m", + "metadata": { + "confidence": "high" + } + }, + { + "message": "no rule id" + } + ] +} diff --git a/tests/fixtures/cross-file-resolution/README.md b/tests/fixtures/cross-file-resolution/README.md new file mode 100644 index 00000000..6765057c --- /dev/null +++ b/tests/fixtures/cross-file-resolution/README.md @@ -0,0 +1,12 @@ +# Cross-file reference resolution fixtures + +Used by `libs/engine/core/cross-file-resolver.spec.ts`. Each directory is a repository snapshot; +the spec loads every file under it and runs `resolveCrossFileReferences`. + +| Fixture | Purpose | +| --- | --- | +| `realistic-solidity/` | Foundry-style layout: remappings, named/aliased/namespace/plain imports, an external (`@openzeppelin`) import, and symbols mentioned only in comments and strings. | +| `regression/` | Import cycle, transitive re-export through a plain import, ambiguous symbol from two plain imports, and an unresolved relative import. | +| `rust-crate/` | `mod`, `use crate::`, `use super::`, brace imports, `pub use` re-export and namespace paths. | + +Do not compile these files; they only need to be parseable by the resolver. diff --git a/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/Errors.sol b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/Errors.sol new file mode 100644 index 00000000..df3e0b72 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/Errors.sol @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +error Unauthorized(); +error ZeroAmount(); diff --git a/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/Vault.sol b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/Vault.sol new file mode 100644 index 00000000..fe5b64b6 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/Vault.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@gasguard/access/Ownable.sol"; +import {IERC20 as Token} from "./interfaces/IERC20.sol"; +import {SafeTransfer} from "./libraries/SafeTransfer.sol"; +import "./Errors.sol" as Errors; +import {ReentrancyGuard} from "@openzeppelin/security/ReentrancyGuard.sol"; + +// SafeTransfer is mentioned here only in a comment and must not count. +contract Vault is Ownable, ReentrancyGuard { + using SafeTransfer for Token; + + string public constant LABEL = "Ownable Vault"; + + function withdraw(Token token, uint256 amount) external onlyOwner { + if (amount == 0) revert Errors.ZeroAmount(); + SafeTransfer.safeTransfer(token, msg.sender, amount); + } +} diff --git a/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/access/Ownable.sol b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/access/Ownable.sol new file mode 100644 index 00000000..a622de6d --- /dev/null +++ b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/access/Ownable.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +error NotOwner(address caller); + +abstract contract Ownable { + address public owner; + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(msg.sender); + _; + } +} diff --git a/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/interfaces/IERC20.sol b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/interfaces/IERC20.sol new file mode 100644 index 00000000..c2eded52 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/interfaces/IERC20.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); + function balanceOf(address owner) external view returns (uint256); +} diff --git a/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/libraries/SafeTransfer.sol b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/libraries/SafeTransfer.sol new file mode 100644 index 00000000..9eddb5ae --- /dev/null +++ b/tests/fixtures/cross-file-resolution/realistic-solidity/contracts/libraries/SafeTransfer.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "../interfaces/IERC20.sol"; + +library SafeTransfer { + function safeTransfer(IERC20 token, address to, uint256 amount) internal { + require(token.transfer(to, amount), "transfer failed"); + } +} diff --git a/tests/fixtures/cross-file-resolution/realistic-solidity/remappings.txt b/tests/fixtures/cross-file-resolution/realistic-solidity/remappings.txt new file mode 100644 index 00000000..02b8f26c --- /dev/null +++ b/tests/fixtures/cross-file-resolution/realistic-solidity/remappings.txt @@ -0,0 +1,3 @@ +@gasguard/=contracts/ +# comment line +@openzeppelin/=lib/openzeppelin/contracts/ diff --git a/tests/fixtures/cross-file-resolution/regression/contracts/a.sol b/tests/fixtures/cross-file-resolution/regression/contracts/a.sol new file mode 100644 index 00000000..b86636d6 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/regression/contracts/a.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.20; + +import {B} from "./b.sol"; + +contract A { + B public peer; +} diff --git a/tests/fixtures/cross-file-resolution/regression/contracts/b.sol b/tests/fixtures/cross-file-resolution/regression/contracts/b.sol new file mode 100644 index 00000000..1a7a03b6 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/regression/contracts/b.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.20; + +import {A} from "./a.sol"; + +contract B { + A public peer; +} diff --git a/tests/fixtures/cross-file-resolution/regression/contracts/base.sol b/tests/fixtures/cross-file-resolution/regression/contracts/base.sol new file mode 100644 index 00000000..38e25168 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/regression/contracts/base.sol @@ -0,0 +1,3 @@ +pragma solidity ^0.8.20; + +contract Base {} diff --git a/tests/fixtures/cross-file-resolution/regression/contracts/consumer.sol b/tests/fixtures/cross-file-resolution/regression/contracts/consumer.sol new file mode 100644 index 00000000..e130d7c5 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/regression/contracts/consumer.sol @@ -0,0 +1,12 @@ +pragma solidity ^0.8.20; + +import {Base} from "./mid.sol"; +import "./dupA.sol"; +import "./dupB.sol"; +import "./missing.sol"; + +/* Base appears in this block comment and must not count. */ +contract Consumer is Base { + string public note = "Base and Shared in a string"; + Shared public shared; +} diff --git a/tests/fixtures/cross-file-resolution/regression/contracts/dupA.sol b/tests/fixtures/cross-file-resolution/regression/contracts/dupA.sol new file mode 100644 index 00000000..1f42be2c --- /dev/null +++ b/tests/fixtures/cross-file-resolution/regression/contracts/dupA.sol @@ -0,0 +1,3 @@ +pragma solidity ^0.8.20; + +contract Shared {} diff --git a/tests/fixtures/cross-file-resolution/regression/contracts/dupB.sol b/tests/fixtures/cross-file-resolution/regression/contracts/dupB.sol new file mode 100644 index 00000000..1f42be2c --- /dev/null +++ b/tests/fixtures/cross-file-resolution/regression/contracts/dupB.sol @@ -0,0 +1,3 @@ +pragma solidity ^0.8.20; + +contract Shared {} diff --git a/tests/fixtures/cross-file-resolution/regression/contracts/mid.sol b/tests/fixtures/cross-file-resolution/regression/contracts/mid.sol new file mode 100644 index 00000000..f0e01ba3 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/regression/contracts/mid.sol @@ -0,0 +1,5 @@ +pragma solidity ^0.8.20; + +import "./base.sol"; + +contract Mid is Base {} diff --git a/tests/fixtures/cross-file-resolution/rust-crate/src/lib.rs b/tests/fixtures/cross-file-resolution/rust-crate/src/lib.rs new file mode 100644 index 00000000..9d1823fb --- /dev/null +++ b/tests/fixtures/cross-file-resolution/rust-crate/src/lib.rs @@ -0,0 +1,10 @@ +pub mod math; +mod storage; + +use crate::math::add; +pub use storage::Store; + +pub fn total(a: u64, b: u64) -> u64 { + // add is called here; math::sub too + add(a, b) - math::sub(b, a) +} diff --git a/tests/fixtures/cross-file-resolution/rust-crate/src/math.rs b/tests/fixtures/cross-file-resolution/rust-crate/src/math.rs new file mode 100644 index 00000000..69ef58e0 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/rust-crate/src/math.rs @@ -0,0 +1,7 @@ +pub fn add(a: u64, b: u64) -> u64 { + a + b +} + +pub fn sub(a: u64, b: u64) -> u64 { + a.saturating_sub(b) +} diff --git a/tests/fixtures/cross-file-resolution/rust-crate/src/storage/mod.rs b/tests/fixtures/cross-file-resolution/rust-crate/src/storage/mod.rs new file mode 100644 index 00000000..33ff0fe8 --- /dev/null +++ b/tests/fixtures/cross-file-resolution/rust-crate/src/storage/mod.rs @@ -0,0 +1,13 @@ +use super::math::{add, sub as minus}; +use std::collections::HashMap; + +pub struct Store { + items: HashMap, +} + +impl Store { + pub fn bump(&mut self, key: u64) -> u64 { + let next = add(key, 1); + minus(next, 1) + } +}