From 86ad86c3c84f975cb24d8bb776eaff2293b82f64 Mon Sep 17 00:00:00 2001 From: Petr Plenkov Date: Mon, 10 Aug 2026 03:13:09 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Wave=207=20=E2=80=94=20findings=20p?= =?UTF-8?q?ackage=20(normalization,=20fingerprints,=20baseline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone findings package: SARIF normalization, SHA-256 fingerprinting, baseline CRUD + diff, suppression filtering. No @sverka deps, node stdlib only (crypto/fs/path). 88 tests pass. Spec 07 amended to resolve contradiction: empty rule/checkId are valid fingerprint inputs (SARIF edge case), only file and line range are validated. saveBaseline returns Promise per spec. Reviewer APPROVED after rework (both rejections fixed: return type + spec amendment).
- 88 tests pass (errors 8, fingerprint 14, normalize 25, baseline 22, suppress 12, public-api 7) - typecheck clean - build green (findings 10.45kB index.mjs) - lint clean - reviewer approved (sv-fd9, second pass)
Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- engdocs/architecture/wave-07-findings-plan.md | 231 +++++++ packages/findings/package.json | 10 +- packages/findings/project.json | 2 +- .../findings/src/__tests__/baseline.test.ts | 282 +++++++++ .../findings/src/__tests__/errors.test.ts | 73 +++ .../src/__tests__/fingerprint.test.ts | 102 ++++ .../src/__tests__/helpers/fixtures.ts | 111 ++++ .../findings/src/__tests__/normalize.test.ts | 328 ++++++++++ .../findings/src/__tests__/public-api.test.ts | 112 ++++ .../findings/src/__tests__/suppress.test.ts | 189 ++++++ packages/findings/src/baseline.ts | 199 +++++++ packages/findings/src/errors.ts | 44 ++ packages/findings/src/fingerprint.ts | 39 ++ packages/findings/src/index.ts | 12 + packages/findings/src/normalize.ts | 211 +++++++ packages/findings/src/suppress.ts | 52 ++ packages/findings/src/types.ts | 80 +++ specs/07-findings/spec.md | 562 ++++++++++-------- 18 files changed, 2392 insertions(+), 247 deletions(-) create mode 100644 engdocs/architecture/wave-07-findings-plan.md create mode 100644 packages/findings/src/__tests__/baseline.test.ts create mode 100644 packages/findings/src/__tests__/errors.test.ts create mode 100644 packages/findings/src/__tests__/fingerprint.test.ts create mode 100644 packages/findings/src/__tests__/helpers/fixtures.ts create mode 100644 packages/findings/src/__tests__/normalize.test.ts create mode 100644 packages/findings/src/__tests__/public-api.test.ts create mode 100644 packages/findings/src/__tests__/suppress.test.ts create mode 100644 packages/findings/src/baseline.ts create mode 100644 packages/findings/src/errors.ts create mode 100644 packages/findings/src/fingerprint.ts create mode 100644 packages/findings/src/normalize.ts create mode 100644 packages/findings/src/suppress.ts create mode 100644 packages/findings/src/types.ts diff --git a/engdocs/architecture/wave-07-findings-plan.md b/engdocs/architecture/wave-07-findings-plan.md new file mode 100644 index 000000000..35989d58f --- /dev/null +++ b/engdocs/architecture/wave-07-findings-plan.md @@ -0,0 +1,231 @@ +# Wave 7 — Findings Implementation Plan + +**Architect:** architect-1 +**Spec:** `specs/07-findings/spec.md` +**Package:** `@sverka/findings` → `packages/findings` +**Depends on:** none (standalone — uses only Node stdlib `node:crypto`, +`node:fs`, `node:path`) + +This plan is the contract the builder implements against. The spec is the +source of truth; this plan adds sequencing, file layout, conventions, and +edge-case guidance. Where the two disagree, the spec wins. + +## 1. Spec amendments applied (architect) + +The spec is already well-trimmed. One amendment is needed: + +1. **`override` on `cause` property.** The spec's error classes declare + `readonly cause: unknown;` which overrides `Error.cause` (ES2024 lib). + The base tsconfig has `noImplicitOverride: true`, so the builder MUST + add `override` to the `cause` field in both `NormalizationError` and + `BaselineError`. This is a TypeScript syntax fix, not a design change. + (Same issue hit the planner wave — see drill-finding / planner fix.) + +No other amendments. The spec already cuts: pluggable normalizer registry, +non-SARIF normalizers, inline suppressions, cross-tool dedup, auto-fix, +dashboard, SARIF taxonomies, `Finding.tags`, confidence normalization. + +## 2. Scope + +Implement SARIF normalization, fingerprinting, baseline management, and +suppression filtering for `@sverka/findings`: + +- `normalizeSarif(sarif, context)` → `Finding[]` (validate, map levels, + resolve rules, multi-location expansion, fingerprint, id assignment). +- `computeFingerprint(input)` → lowercase hex SHA-256 string. +- `createBaseline`, `updateBaseline`, `compareBaseline`, `loadBaseline`, + `saveBaseline` — baseline CRUD + diff. +- `isSuppressed`, `filterSuppressed`, `filterOnlyNew` — suppression logic. +- `NormalizationError` + `BaselineError` with codes. +- Public re-exports from `src/index.ts`. + +**No workspace dependencies.** Uses only Node stdlib (`node:crypto`, +`node:fs`, `node:path`). + +**Out of scope (do NOT implement in this wave):** +- Non-SARIF normalizers (ESLint JSON, Semgrep JSON, text). +- Pluggable normalizer registry / `FindingNormalizer` interface. +- Inline source-code suppressions (`// sverka-ignore-next-line`). +- Cross-tool deduplication. +- Auto-fix, dashboard, findings database. +- SARIF extensions/taxonomies beyond basic normalization. +- `Finding.tags` (deferred — no v1 consumer). + +## 3. Scaffolding status (already present; builder fixes two items) + +- `packages/findings/package.json` — **fix:** dist paths are `.js`/`.d.ts`; + must be `.mjs`/`.d.mts` to match `core`/`ir`/`runtime`/`planner`. + No `dependencies` needed (standalone). `devDependencies` already present. +- `packages/findings/project.json` — **fix:** lint target uses + `eslint src --ext .ts`; remove `--ext .ts` (ESLint 9 flat config, per + sv-ei2 / drill-finding). Test target already has `--passWithNoTests`. +- `tsconfig.json`, `tsdown.config.ts` — already match siblings; no changes. +- `src/index.ts` — placeholder; builder fills exports. +- Run `bun install` after `package.json` edit. + +## 4. File layout + +Mirror `planner` / `runtime-host` (one module per concern, `__tests__/` +co-located): + +``` +packages/findings/src/ + index.ts # public re-exports (matches spec §Interfaces) + types.ts # Finding, Severity, FindingSource, NormalizeContext, FingerprintInput + errors.ts # NormalizationError, NormalizationErrorCode, BaselineError, BaselineErrorCode + normalize.ts # SarifLog + sub-types, normalizeSarif + fingerprint.ts # computeFingerprint + baseline.ts # Baseline, Suppression, BaselineDiff, create/update/compare/load/save + suppress.ts # isSuppressed, filterSuppressed, filterOnlyNew + __tests__/ + normalize.test.ts # test plan 1, 9 (normalization errors), 10 (determinism) + fingerprint.test.ts # test plan 2 + baseline.test.ts # test plan 3, 4, 5, 8 + suppress.test.ts # test plan 6, 7 + public-api.test.ts # exports match spec §Interfaces + helpers/ + fixtures.ts # SARIF sample builders, temp baseline files +``` + +## 5. Conventions + +- **No `any`.** `cause` is `unknown`; SARIF input is typed via `SarifLog` + but validated at runtime (types are erased). Strict TS. +- **Pure/impure split.** `types.ts`, `fingerprint.ts`, `normalize.ts` + (pure), `suppress.ts` (pure), `baseline.ts` create/update/compare (pure). + Only `loadBaseline`/`saveBaseline` do I/O (`node:fs`). No mockable seam + needed — I/O is isolated to two functions, testable via temp files. +- **Determinism.** Fingerprints are SHA-256 — deterministic by construction. + `normalizeSarif` output is deterministic for identical input + context. + No `Date.now()` in normalization or fingerprinting. **Baseline + timestamps** (`createdAt`/`updatedAt`) use `new Date().toISOString()` + — these are NOT part of fingerprinting and do not affect determinism + of findings. Baseline tests that check timestamps validate ISO 8601 + format, not exact values. +- **Exports.** Only what spec §Interfaces lists is exported from + `src/index.ts`. No internal helpers exported. +- **Errors.** Both error classes extend `Error`; set `name`, `code`, + `cause`. **`cause` MUST have `override` modifier** (noImplicitOverride). + Throw, don't return, for unrecoverable codes. +- **SARIF validation.** Runtime validation of the parsed SARIF structure. + Check: `version === "2.1.0"`, `runs` is an array, each run has + `tool.driver.name`, `results` is an array. Throw `INVALID_SARIF` with + a descriptive message + cause on failure. Do NOT use a JSON schema + library — hand-rolled checks are sufficient (YAGNI). + +## 6. Implementation steps (builder, TDD — tests first) + +1. **Fix scaffolding.** Edit `package.json` dist paths → `.mjs`/`.d.mts`. + Edit `project.json` lint → `eslint src`. `bun install`. +2. **`types.ts`.** Pure type definitions — `Finding`, `Severity`, + `FindingSource`, `NormalizeContext`, `FingerprintInput`. No tests + needed (types only); verified by compile + public-api test. +3. **`errors.ts` + `errors.test.ts` (or fold into relevant test files).** + `NormalizationError` + `NormalizationErrorCode` (3 codes), + `BaselineError` + `BaselineErrorCode` (3 codes). **`override` on + `cause`.** Test construction, name, code, cause chaining for both. +4. **`fingerprint.ts` + `fingerprint.test.ts` (TDD).** Pure function: + `computeFingerprint(input)` → `sha256("{checkId}|{rule}|{normalizedFile}|{startLine}|{endLine}")`. + Normalize backslashes → forward slashes in `file`. Validate non-empty + required fields → `INVALID_FINGERPRINT_INPUT`. Lowercase hex, 64 chars. + Write failing tests first (test plan 2), then implement. +5. **`normalize.ts` + `normalize.test.ts` (TDD).** + - Define `SarifLog`, `SarifRun`, `SarifRule`, `SarifResult`, + `SarifLocation` interfaces. + - `normalizeSarif(sarif, context)`: + - Validate structure → `INVALID_SARIF`. + - For each run: extract tool name/version, build rule map + (`tool.driver.rules` indexed by `ruleId` and `ruleIndex`). + - For each result: resolve rule (by `ruleId` or `ruleIndex`), + map level → severity (table below), extract message, expand + locations (one finding per location; no locations → + `MISSING_LOCATION`), build `FindingSource`, construct `checkId` + (`{prefix}:{ruleId}` or `ruleId`), compute fingerprint, assign + `id = {checkId}:{fingerprint}`. + - SARIF level → severity: `error`→`high`, `warning`→`medium`, + `note`→`low`, `none`→`info`, absent→`info`. Rule + `defaultConfiguration.level` used when result has no `level`. + - Write failing tests first (test plan 1, 9, 10), then implement. +6. **`baseline.ts` + `baseline.test.ts` (TDD).** + - `createBaseline(findings)`: version 1, all fingerprints (deduped), + no suppressions, `createdAt`=`updatedAt`=`new Date().toISOString()`. + - `updateBaseline(current, existing)`: merge current fingerprints, + remove resolved ones, remove suppressions for resolved fingerprints, + preserve `createdAt`, refresh `updatedAt`. + - `compareBaseline(current, baseline)`: `newFindings` (fingerprint not + in baseline), `resolvedFingerprints` (baseline fingerprint not in + current), `unchangedFindings` (in both). + - `loadBaseline(path)`: read JSON, validate `version === 1`, + `fingerprints` is array, `suppressions` is array. Throw + `BASELINE_NOT_FOUND` (ENOENT), `BASELINE_INVALID` (bad JSON / wrong + schema). + - `saveBaseline(baseline, path)`: `JSON.stringify` with 2-space indent, + write to file. Throw `BASELINE_WRITE_FAILED` on write error. + - Write failing tests first (test plan 3, 4, 5, 8), then implement. +7. **`suppress.ts` + `suppress.test.ts` (TDD).** + - `isSuppressed(finding, baseline)`: true if fingerprint matches a + suppression with `expiresAt` absent or in the future. + - `filterSuppressed(findings, baseline, includeSuppressed)`: when + `includeSuppressed` is true, return all; when false, exclude + suppressed. + - `filterOnlyNew(findings, baseline)`: findings whose fingerprint is + NOT in `baseline.fingerprints` AND not suppressed. + - Write failing tests first (test plan 6, 7), then implement. +8. **`__tests__/helpers/fixtures.ts`.** SARIF sample builders (minimal + valid log, multi-result, multi-location, invalid variants) and temp + baseline file helpers. Keep minimal — inline SARIF objects in tests + are fine for simple cases; use fixtures for repeated patterns. +9. **`public-api.test.ts`.** Assert `src/index.ts` exports exactly the + spec list (types + functions + error classes). +10. **Gates.** `bun run test` (findings), `bun run typecheck`, + `bun run lint`, `bun run build` for findings; then full monorepo + `bun run test/typecheck/lint/build` (16 projects) to catch + entangled breakage. + +## 7. Edge cases + +- **SARIF result with no `ruleId` and no `ruleIndex`.** `ruleId` defaults + to `""` (empty string). `checkId` becomes `{prefix}:` or just `""`. + The finding is still valid; fingerprint uses empty `rule`. +- **SARIF result with `ruleIndex` but no `rules` array.** Cannot resolve + rule → `ruleId` = `""`, `helpUrl` = undefined, `originalSeverity` + from result level only. +- **Multi-location result.** One `Finding` per location. Each gets its + own fingerprint (different `file`/`startLine`). +- **Empty `runs` array.** Valid SARIF → `normalizeSarif` returns `[]`. +- **Empty `results` array.** Valid → returns `[]`. +- **Baseline with duplicate fingerprints.** `createBaseline` dedupes + (store as sorted unique array). `compareBaseline` uses set membership. +- **Suppression with `expiresAt` exactly now.** Treat as expired + (use `<` not `<=` comparison against current time, or compare + `expiresAt <= now` → expired). Document the boundary in a comment. +- **Baseline file with extra unknown fields.** Accept (forward-compat); + only validate required fields (`version`, `fingerprints`, `suppressions`). +- **`saveBaseline` to a non-existent directory.** `BASELINE_WRITE_FAILED` + (do not auto-create directories — caller's responsibility). +- **`loadBaseline` with wrong `version`.** `BASELINE_INVALID`. + +## 8. Test plan → spec mapping + +| Spec test plan | File | Notes | +|---|---|---| +| 1 SARIF normalization | `normalize.test.ts` | levels, rules, ruleIndex, multi-location, checkId, id | +| 2 fingerprint | `fingerprint.test.ts` | determinism, discrimination, backslash, empty fields, hex format | +| 3 baseline create | `baseline.test.ts` | fingerprints, timestamps, version, no suppressions | +| 4 baseline update | `baseline.test.ts` | add new, remove resolved, remove stale suppressions, preserve createdAt | +| 5 baseline compare | `baseline.test.ts` | new/resolved/unchanged, empty current, empty baseline | +| 6 suppression | `suppress.test.ts` | filterSuppressed true/false, expired, isSuppressed | +| 7 only-new filtering | `suppress.test.ts` | filterOnlyNew excludes baseline + suppressed | +| 8 baseline I/O | `baseline.test.ts` | load/save, not found, invalid JSON, wrong version, write failed | +| 9 error cases (norm) | `normalize.test.ts` | INVALID_SARIF, MISSING_LOCATION, INVALID_FINGERPRINT_INPUT | +| 10 determinism | `normalize.test.ts` | identical SARIF + context → identical Finding[] | + +## 9. Acceptance + +- All findings tests pass (`bun run test` for findings). +- Full monorepo green: test, typecheck, lint, build across 16 projects. +- `src/index.ts` exports match spec §Interfaces exactly; no `any`. +- `override` on `cause` in both error classes (noImplicitOverride). +- Fingerprint is deterministic SHA-256 lowercase hex (64 chars). +- `normalizeSarif` is deterministic for identical input + context. +- No workspace dependencies added (findings standalone). diff --git a/packages/findings/package.json b/packages/findings/package.json index 30a5e9d94..eb90d9fb2 100644 --- a/packages/findings/package.json +++ b/packages/findings/package.json @@ -2,13 +2,13 @@ "name": "@sverka/findings", "version": "0.0.0", "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } }, "files": ["dist"], diff --git a/packages/findings/project.json b/packages/findings/project.json index 4a3133e55..de4ae3e04 100644 --- a/packages/findings/project.json +++ b/packages/findings/project.json @@ -18,7 +18,7 @@ "lint": { "executor": "nx:run-commands", "options": { - "command": "bun run eslint src --ext .ts", + "command": "bun run eslint src", "cwd": "packages/findings" } }, diff --git a/packages/findings/src/__tests__/baseline.test.ts b/packages/findings/src/__tests__/baseline.test.ts new file mode 100644 index 000000000..e6272b87a --- /dev/null +++ b/packages/findings/src/__tests__/baseline.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { readFile } from "node:fs/promises"; +import { + createBaseline, + updateBaseline, + compareBaseline, + loadBaseline, + saveBaseline, + type Baseline, +} from "../baseline.js"; +import { BaselineError } from "../errors.js"; +import type { Finding } from "../types.js"; +import { + makeTempDir, + cleanupTempDir, + writeTempFile, +} from "./helpers/fixtures.js"; + +function makeFinding( + overrides: Partial & { fingerprint?: string } = {}, +): Finding { + const fingerprint = overrides.fingerprint ?? "fp-aaa"; + const checkId = overrides.checkId ?? "eslint:no-console"; + return { + id: `${checkId}:${fingerprint}`, + fingerprint, + checkId, + severity: "medium", + confidence: 0.5, + message: "msg", + rule: "no-console", + file: "src/index.ts", + startLine: 10, + endLine: 10, + source: { + tool: "eslint", + version: "9.0.0", + format: "sarif", + originalRuleId: "no-console", + originalSeverity: "warning", + }, + ...overrides, + }; +} + +describe("createBaseline", () => { + it("returns a Baseline with all fingerprints", () => { + const findings = [ + makeFinding({ fingerprint: "fp-a" }), + makeFinding({ fingerprint: "fp-b" }), + ]; + const baseline = createBaseline(findings); + expect(baseline.fingerprints).toContain("fp-a"); + expect(baseline.fingerprints).toContain("fp-b"); + }); + + it("sets version to 1", () => { + expect(createBaseline([]).version).toBe(1); + }); + + it("has no suppressions", () => { + expect(createBaseline([]).suppressions).toEqual([]); + }); + + it("createdAt and updatedAt are valid ISO 8601", () => { + const baseline = createBaseline([]); + expect(() => new Date(baseline.createdAt).toISOString()).not.toThrow(); + expect(() => new Date(baseline.updatedAt).toISOString()).not.toThrow(); + }); + + it("dedupes duplicate fingerprints", () => { + const findings = [ + makeFinding({ fingerprint: "fp-a" }), + makeFinding({ fingerprint: "fp-a" }), + ]; + const baseline = createBaseline(findings); + expect(baseline.fingerprints.filter((f) => f === "fp-a")).toHaveLength(1); + }); +}); + +describe("updateBaseline", () => { + it("adds new fingerprints", () => { + const existing = createBaseline([makeFinding({ fingerprint: "fp-a" })]); + const updated = updateBaseline( + [makeFinding({ fingerprint: "fp-a" }), makeFinding({ fingerprint: "fp-b" })], + existing, + ); + expect(updated.fingerprints).toContain("fp-b"); + }); + + it("removes resolved fingerprints", () => { + const existing = createBaseline([ + makeFinding({ fingerprint: "fp-a" }), + makeFinding({ fingerprint: "fp-b" }), + ]); + const updated = updateBaseline( + [makeFinding({ fingerprint: "fp-a" })], + existing, + ); + expect(updated.fingerprints).not.toContain("fp-b"); + }); + + it("removes suppressions for resolved fingerprints", () => { + const existing = createBaseline([makeFinding({ fingerprint: "fp-a" })]); + existing.suppressions = [ + { + fingerprint: "fp-a", + reason: "fp", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + }, + ]; + const updated = updateBaseline([], existing); + expect(updated.suppressions).toHaveLength(0); + }); + + it("preserves createdAt and refreshes updatedAt", () => { + const existing = createBaseline([makeFinding({ fingerprint: "fp-a" })]); + const originalCreatedAt = existing.createdAt; + const updated = updateBaseline([makeFinding({ fingerprint: "fp-a" })], existing); + expect(updated.createdAt).toBe(originalCreatedAt); + // updatedAt may or may not differ, but should be valid ISO 8601. + expect(() => new Date(updated.updatedAt).toISOString()).not.toThrow(); + }); +}); + +describe("compareBaseline", () => { + it("returns newFindings not in baseline", () => { + const baseline = createBaseline([makeFinding({ fingerprint: "fp-a" })]); + const diff = compareBaseline( + [makeFinding({ fingerprint: "fp-a" }), makeFinding({ fingerprint: "fp-b" })], + baseline, + ); + expect(diff.newFindings).toHaveLength(1); + expect(diff.newFindings[0]!.fingerprint).toBe("fp-b"); + }); + + it("returns resolvedFingerprints in baseline but not current", () => { + const baseline = createBaseline([ + makeFinding({ fingerprint: "fp-a" }), + makeFinding({ fingerprint: "fp-b" }), + ]); + const diff = compareBaseline( + [makeFinding({ fingerprint: "fp-a" })], + baseline, + ); + expect(diff.resolvedFingerprints).toContain("fp-b"); + }); + + it("returns unchangedFindings in both", () => { + const baseline = createBaseline([makeFinding({ fingerprint: "fp-a" })]); + const diff = compareBaseline( + [makeFinding({ fingerprint: "fp-a" })], + baseline, + ); + expect(diff.unchangedFindings).toHaveLength(1); + expect(diff.unchangedFindings[0]!.fingerprint).toBe("fp-a"); + }); + + it("empty current findings -> all baseline fingerprints resolved", () => { + const baseline = createBaseline([ + makeFinding({ fingerprint: "fp-a" }), + makeFinding({ fingerprint: "fp-b" }), + ]); + const diff = compareBaseline([], baseline); + expect(diff.newFindings).toHaveLength(0); + expect(diff.unchangedFindings).toHaveLength(0); + expect(diff.resolvedFingerprints).toHaveLength(2); + }); + + it("empty baseline -> all findings new", () => { + const baseline = createBaseline([]); + const diff = compareBaseline( + [makeFinding({ fingerprint: "fp-a" })], + baseline, + ); + expect(diff.newFindings).toHaveLength(1); + expect(diff.unchangedFindings).toHaveLength(0); + expect(diff.resolvedFingerprints).toHaveLength(0); + }); +}); + +describe("Baseline I/O", () => { + let dir: string; + + beforeEach(async () => { + dir = await makeTempDir(); + }); + + afterEach(async () => { + await cleanupTempDir(dir); + }); + + it("saveBaseline writes a JSON file and returns void", async () => { + const baseline = createBaseline([makeFinding({ fingerprint: "fp-a" })]); + const filePath = `${dir}/baseline.json`; + const result = await saveBaseline(baseline, filePath); + const content = await readFile(filePath, "utf8"); + const parsed = JSON.parse(content) as Baseline; + expect(parsed.version).toBe(1); + expect(parsed.fingerprints).toContain("fp-a"); + // Spec: saveBaseline returns Promise. + expect(result).toBeUndefined(); + }); + + it("loadBaseline reads and parses a JSON file", async () => { + const baseline = createBaseline([makeFinding({ fingerprint: "fp-a" })]); + await saveBaseline(baseline, `${dir}/baseline.json`); + const loaded = await loadBaseline(`${dir}/baseline.json`); + expect(loaded.fingerprints).toContain("fp-a"); + expect(loaded.version).toBe(1); + }); + + it("loadBaseline throws BASELINE_NOT_FOUND for missing file", async () => { + await expect(loadBaseline(`${dir}/nope.json`)).rejects.toThrow(BaselineError); + try { + await loadBaseline(`${dir}/nope.json`); + } catch (e) { + expect((e as BaselineError).code).toBe("BASELINE_NOT_FOUND"); + } + }); + + it("loadBaseline throws BASELINE_INVALID for invalid JSON", async () => { + await writeTempFile(dir, "bad.json", "{not valid json"); + await expect(loadBaseline(`${dir}/bad.json`)).rejects.toThrow(BaselineError); + try { + await loadBaseline(`${dir}/bad.json`); + } catch (e) { + expect((e as BaselineError).code).toBe("BASELINE_INVALID"); + } + }); + + it("loadBaseline throws BASELINE_INVALID for wrong schema version", async () => { + await writeTempFile( + dir, + "wrong.json", + JSON.stringify({ version: 99, fingerprints: [], suppressions: [] }), + ); + await expect(loadBaseline(`${dir}/wrong.json`)).rejects.toThrow(BaselineError); + try { + await loadBaseline(`${dir}/wrong.json`); + } catch (e) { + expect((e as BaselineError).code).toBe("BASELINE_INVALID"); + } + }); + + it("loadBaseline throws BASELINE_INVALID when fingerprints is not an array", async () => { + await writeTempFile( + dir, + "badfp.json", + JSON.stringify({ version: 1, fingerprints: "oops", suppressions: [] }), + ); + await expect(loadBaseline(`${dir}/badfp.json`)).rejects.toThrow(BaselineError); + }); + + it("saveBaseline throws BASELINE_WRITE_FAILED for unwritable path", async () => { + const baseline = createBaseline([]); + await expect( + saveBaseline(baseline, `${dir}/nonexistent-dir/baseline.json`), + ).rejects.toThrow(BaselineError); + try { + await saveBaseline(baseline, `${dir}/nonexistent-dir/baseline.json`); + } catch (e) { + expect((e as BaselineError).code).toBe("BASELINE_WRITE_FAILED"); + } + }); + + it("loadBaseline accepts extra unknown fields (forward-compat)", async () => { + await writeTempFile( + dir, + "extra.json", + JSON.stringify({ + version: 1, + fingerprints: ["fp-a"], + suppressions: [], + extraField: "ignored", + }), + ); + const loaded = await loadBaseline(`${dir}/extra.json`); + expect(loaded.fingerprints).toContain("fp-a"); + }); +}); diff --git a/packages/findings/src/__tests__/errors.test.ts b/packages/findings/src/__tests__/errors.test.ts new file mode 100644 index 000000000..7cb64b068 --- /dev/null +++ b/packages/findings/src/__tests__/errors.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { + NormalizationError, + BaselineError, + type NormalizationErrorCode, + type BaselineErrorCode, +} from "../errors.js"; + +describe("NormalizationError", () => { + it("constructs with message, code, and name", () => { + const err = new NormalizationError("bad sarif", "INVALID_SARIF"); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("bad sarif"); + expect(err.name).toBe("NormalizationError"); + expect(err.code).toBe("INVALID_SARIF"); + }); + + it("chains a cause", () => { + const cause = new Error("root"); + const err = new NormalizationError("wrapped", "MISSING_LOCATION", cause); + expect(err.cause).toBe(cause); + }); + + it("cause is undefined when not provided", () => { + const err = new NormalizationError("no cause", "INVALID_FINGERPRINT_INPUT"); + expect(err.cause).toBeUndefined(); + }); + + it("supports all NormalizationErrorCode values", () => { + const codes: NormalizationErrorCode[] = [ + "INVALID_SARIF", + "MISSING_LOCATION", + "INVALID_FINGERPRINT_INPUT", + ]; + for (const code of codes) { + const err = new NormalizationError("msg", code); + expect(err.code).toBe(code); + } + }); +}); + +describe("BaselineError", () => { + it("constructs with message, code, and name", () => { + const err = new BaselineError("not found", "BASELINE_NOT_FOUND"); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("not found"); + expect(err.name).toBe("BaselineError"); + expect(err.code).toBe("BASELINE_NOT_FOUND"); + }); + + it("chains a cause", () => { + const cause = new Error("enoent"); + const err = new BaselineError("wrapped", "BASELINE_INVALID", cause); + expect(err.cause).toBe(cause); + }); + + it("cause is undefined when not provided", () => { + const err = new BaselineError("no cause", "BASELINE_WRITE_FAILED"); + expect(err.cause).toBeUndefined(); + }); + + it("supports all BaselineErrorCode values", () => { + const codes: BaselineErrorCode[] = [ + "BASELINE_NOT_FOUND", + "BASELINE_INVALID", + "BASELINE_WRITE_FAILED", + ]; + for (const code of codes) { + const err = new BaselineError("msg", code); + expect(err.code).toBe(code); + } + }); +}); diff --git a/packages/findings/src/__tests__/fingerprint.test.ts b/packages/findings/src/__tests__/fingerprint.test.ts new file mode 100644 index 000000000..0189787b5 --- /dev/null +++ b/packages/findings/src/__tests__/fingerprint.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { createHash } from "node:crypto"; +import { computeFingerprint } from "../fingerprint.js"; +import { NormalizationError } from "../errors.js"; +import type { FingerprintInput } from "../types.js"; + +function baseInput(overrides: Partial = {}): FingerprintInput { + return { + rule: "no-console", + file: "src/index.ts", + startLine: 10, + endLine: 10, + checkId: "eslint:no-console", + ...overrides, + }; +} + +describe("computeFingerprint", () => { + it("produces identical fingerprints for identical inputs", () => { + const a = computeFingerprint(baseInput()); + const b = computeFingerprint(baseInput()); + expect(a).toBe(b); + }); + + it("produces different fingerprints for different rule", () => { + const a = computeFingerprint(baseInput({ rule: "no-console" })); + const b = computeFingerprint(baseInput({ rule: "no-debugger" })); + expect(a).not.toBe(b); + }); + + it("produces different fingerprints for different file", () => { + const a = computeFingerprint(baseInput({ file: "src/a.ts" })); + const b = computeFingerprint(baseInput({ file: "src/b.ts" })); + expect(a).not.toBe(b); + }); + + it("produces different fingerprints for different line range", () => { + const a = computeFingerprint(baseInput({ startLine: 10, endLine: 10 })); + const b = computeFingerprint(baseInput({ startLine: 10, endLine: 12 })); + expect(a).not.toBe(b); + }); + + it("produces different fingerprints for different checkId", () => { + const a = computeFingerprint(baseInput({ checkId: "eslint:no-console" })); + const b = computeFingerprint(baseInput({ checkId: "custom:no-console" })); + expect(a).not.toBe(b); + }); + + it("is insensitive to message and severity (not in input)", () => { + // FingerprintInput has no message/severity fields, so this is structural. + const a = computeFingerprint(baseInput()); + const b = computeFingerprint(baseInput()); + expect(a).toBe(b); + }); + + it("normalizes Windows backslash paths to forward slashes", () => { + const a = computeFingerprint(baseInput({ file: "src\\index.ts" })); + const b = computeFingerprint(baseInput({ file: "src/index.ts" })); + expect(a).toBe(b); + }); + + it("outputs lowercase hex SHA-256 (64 chars)", () => { + const fp = computeFingerprint(baseInput()); + expect(fp).toMatch(/^[0-9a-f]{64}$/); + }); + + it("matches manual sha256 of the canonical string", () => { + const input = baseInput(); + const expected = createHash("sha256") + .update( + `${input.checkId}|${input.rule}|${input.file}|${input.startLine}|${input.endLine}`, + ) + .digest("hex"); + expect(computeFingerprint(input)).toBe(expected); + }); + + it("allows empty rule (SARIF edge case: ruleId defaults to empty)", () => { + expect(() => computeFingerprint(baseInput({ rule: "" }))).not.toThrow(); + }); + + it("allows empty checkId (SARIF edge case: empty prefix + empty ruleId)", () => { + expect(() => computeFingerprint(baseInput({ checkId: "" }))).not.toThrow(); + }); + + it("throws INVALID_FINGERPRINT_INPUT for empty file", () => { + expect(() => computeFingerprint(baseInput({ file: "" }))).toThrow( + NormalizationError, + ); + }); + + it("throws INVALID_FINGERPRINT_INPUT for zero startLine", () => { + expect(() => computeFingerprint(baseInput({ startLine: 0 }))).toThrow( + NormalizationError, + ); + }); + + it("throws INVALID_FINGERPRINT_INPUT for zero endLine", () => { + expect(() => computeFingerprint(baseInput({ endLine: 0 }))).toThrow( + NormalizationError, + ); + }); +}); diff --git a/packages/findings/src/__tests__/helpers/fixtures.ts b/packages/findings/src/__tests__/helpers/fixtures.ts new file mode 100644 index 000000000..7cb6fd3b8 --- /dev/null +++ b/packages/findings/src/__tests__/helpers/fixtures.ts @@ -0,0 +1,111 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + SarifLog, + SarifRun, + SarifResult, + SarifRule, +} from "../../normalize.js"; +import type { NormalizeContext } from "../../types.js"; + +/** + * Build a minimal SARIF rule. + */ +export function makeRule(overrides: Partial = {}): SarifRule { + return { + id: "no-console", + name: "no-console", + ...overrides, + }; +} + +/** + * Build a minimal SARIF result with one location. + */ +export function makeResult(overrides: Partial = {}): SarifResult { + return { + ruleId: "no-console", + level: "warning", + message: { text: "Unexpected console statement." }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/index.ts" }, + region: { startLine: 10, endLine: 10 }, + }, + }, + ], + ...overrides, + }; +} + +/** + * Build a minimal SARIF run with one driver and results. + */ +export function makeRun(overrides: Partial = {}): SarifRun { + return { + tool: { + driver: { + name: "eslint", + version: "9.0.0", + rules: [makeRule()], + }, + }, + results: [makeResult()], + ...overrides, + }; +} + +/** + * Build a minimal valid SARIF 2.1.0 log. + */ +export function makeSarifLog(overrides: Partial = {}): SarifLog { + return { + version: "2.1.0", + runs: [makeRun()], + ...overrides, + }; +} + +/** + * A default NormalizeContext. + */ +export function defaultContext( + overrides: Partial = {}, +): NormalizeContext { + return { + root: "/project", + checkIdPrefix: "eslint", + defaultConfidence: 0.5, + ...overrides, + }; +} + +/** + * Create a temp directory and return its path. Caller is responsible for + * cleanup via `cleanupTempDir`. + */ +export async function makeTempDir(prefix = "sverka-findings-"): Promise { + return mkdtemp(join(tmpdir(), prefix)); +} + +/** + * Remove a temp directory recursively. + */ +export async function cleanupTempDir(dir: string): Promise { + await rm(dir, { recursive: true, force: true }); +} + +/** + * Write content to a file inside a temp directory and return the full path. + */ +export async function writeTempFile( + dir: string, + name: string, + content: string, +): Promise { + const path = join(dir, name); + await writeFile(path, content, "utf8"); + return path; +} diff --git a/packages/findings/src/__tests__/normalize.test.ts b/packages/findings/src/__tests__/normalize.test.ts new file mode 100644 index 000000000..c84240327 --- /dev/null +++ b/packages/findings/src/__tests__/normalize.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSarif } from "../normalize.js"; +import { NormalizationError } from "../errors.js"; +import { + makeSarifLog, + makeRun, + makeResult, + makeRule, + defaultContext, +} from "./helpers/fixtures.js"; +import type { SarifResult, SarifRule, SarifLog } from "../normalize.js"; + +describe("normalizeSarif — basic normalization", () => { + it("produces one Finding from a minimal SARIF log with one result", () => { + const findings = normalizeSarif(makeSarifLog(), defaultContext()); + expect(findings).toHaveLength(1); + const f = findings[0]!; + expect(f.checkId).toBe("eslint:no-console"); + expect(f.rule).toBe("no-console"); + expect(f.file).toBe("src/index.ts"); + expect(f.startLine).toBe(10); + expect(f.endLine).toBe(10); + expect(f.message).toBe("Unexpected console statement."); + expect(f.confidence).toBe(0.5); + }); + + it("constructs id as {checkId}:{fingerprint}", () => { + const findings = normalizeSarif(makeSarifLog(), defaultContext()); + const f = findings[0]!; + expect(f.id).toBe(`${f.checkId}:${f.fingerprint}`); + }); + + it("sets source.tool and source.originalSeverity", () => { + const findings = normalizeSarif(makeSarifLog(), defaultContext()); + const f = findings[0]!; + expect(f.source.tool).toBe("eslint"); + expect(f.source.version).toBe("9.0.0"); + expect(f.source.format).toBe("sarif"); + expect(f.source.originalRuleId).toBe("no-console"); + expect(f.source.originalSeverity).toBe("warning"); + }); + + it("checkId is ruleId when prefix is empty", () => { + const findings = normalizeSarif( + makeSarifLog(), + defaultContext({ checkIdPrefix: "" }), + ); + expect(findings[0]!.checkId).toBe("no-console"); + }); + + it("checkId is {prefix}:{ruleId} when prefix is non-empty", () => { + const findings = normalizeSarif( + makeSarifLog(), + defaultContext({ checkIdPrefix: "semgrep" }), + ); + expect(findings[0]!.checkId).toBe("semgrep:no-console"); + }); +}); + +describe("normalizeSarif — level to severity mapping", () => { + it("maps error to high", () => { + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ results: [makeResult({ level: "error" })] })], + }), + defaultContext(), + ); + expect(findings[0]!.severity).toBe("high"); + }); + + it("maps warning to medium", () => { + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ results: [makeResult({ level: "warning" })] })], + }), + defaultContext(), + ); + expect(findings[0]!.severity).toBe("medium"); + }); + + it("maps note to low", () => { + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ results: [makeResult({ level: "note" })] })], + }), + defaultContext(), + ); + expect(findings[0]!.severity).toBe("low"); + }); + + it("maps none to info", () => { + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ results: [makeResult({ level: "none" })] })], + }), + defaultContext(), + ); + expect(findings[0]!.severity).toBe("info"); + }); + + it("maps absent level to info", () => { + const result = makeResult(); + delete (result as Partial).level; + const findings = normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [result] })] }), + defaultContext(), + ); + expect(findings[0]!.severity).toBe("info"); + }); + + it("uses rule defaultConfiguration.level when result has no level", () => { + const rule: SarifRule = makeRule({ + defaultConfiguration: { level: "error" }, + }); + const result = makeResult(); + delete (result as Partial).level; + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ tool: { driver: { name: "eslint", rules: [rule] } }, results: [result] })], + }), + defaultContext(), + ); + expect(findings[0]!.severity).toBe("high"); + }); +}); + +describe("normalizeSarif — rule resolution", () => { + it("extracts helpUri from rule metadata", () => { + const rule = makeRule({ helpUri: "https://example.com/no-console" }); + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ tool: { driver: { name: "eslint", rules: [rule] } }, results: [makeResult()] })], + }), + defaultContext(), + ); + expect(findings[0]!.helpUrl).toBe("https://example.com/no-console"); + }); + + it("uses ruleIndex when ruleId is absent", () => { + const result = makeResult(); + delete (result as Partial).ruleId; + result.ruleIndex = 0; + const rule = makeRule({ id: "indexed-rule", helpUri: "https://example.com/indexed" }); + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ tool: { driver: { name: "eslint", rules: [rule] } }, results: [result] })], + }), + defaultContext(), + ); + expect(findings[0]!.rule).toBe("indexed-rule"); + expect(findings[0]!.helpUrl).toBe("https://example.com/indexed"); + }); + + it("defaults ruleId to empty string when neither ruleId nor ruleIndex", () => { + const result = makeResult(); + delete (result as Partial).ruleId; + const findings = normalizeSarif( + makeSarifLog({ + runs: [makeRun({ tool: { driver: { name: "eslint" } }, results: [result] })], + }), + defaultContext(), + ); + expect(findings[0]!.rule).toBe(""); + expect(findings[0]!.checkId).toBe("eslint:"); + }); +}); + +describe("normalizeSarif — multi-location", () => { + it("produces one finding per location", () => { + const result = makeResult({ + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/a.ts" }, + region: { startLine: 1, endLine: 1 }, + }, + }, + { + physicalLocation: { + artifactLocation: { uri: "src/b.ts" }, + region: { startLine: 2, endLine: 2 }, + }, + }, + ], + }); + const findings = normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [result] })] }), + defaultContext(), + ); + expect(findings).toHaveLength(2); + expect(findings[0]!.file).toBe("src/a.ts"); + expect(findings[1]!.file).toBe("src/b.ts"); + expect(findings[0]!.fingerprint).not.toBe(findings[1]!.fingerprint); + }); + + it("extracts snippet from region", () => { + const result = makeResult({ + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/index.ts" }, + region: { startLine: 10, endLine: 10, snippet: { text: "console.log(1)" } }, + }, + }, + ], + }); + const findings = normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [result] })] }), + defaultContext(), + ); + expect(findings[0]!.snippet).toBe("console.log(1)"); + }); + + it("extracts startColumn and endColumn", () => { + const result = makeResult({ + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/index.ts" }, + region: { startLine: 10, endLine: 10, startColumn: 3, endColumn: 14 }, + }, + }, + ], + }); + const findings = normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [result] })] }), + defaultContext(), + ); + expect(findings[0]!.startColumn).toBe(3); + expect(findings[0]!.endColumn).toBe(14); + }); +}); + +describe("normalizeSarif — empty inputs", () => { + it("returns [] for empty runs array", () => { + expect(normalizeSarif(makeSarifLog({ runs: [] }), defaultContext())).toEqual( + [], + ); + }); + + it("returns [] for empty results array", () => { + const findings = normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [] })] }), + defaultContext(), + ); + expect(findings).toEqual([]); + }); +}); + +describe("normalizeSarif — determinism", () => { + it("identical SARIF + context produce identical Finding[]", () => { + const sarif = makeSarifLog(); + const ctx = defaultContext(); + const a = normalizeSarif(sarif, ctx); + const b = normalizeSarif(sarif, ctx); + expect(a).toEqual(b); + expect(a[0]!.fingerprint).toBe(b[0]!.fingerprint); + expect(a[0]!.id).toBe(b[0]!.id); + }); +}); + +describe("normalizeSarif — error cases", () => { + it("throws INVALID_SARIF for wrong version", () => { + const badSarif = { version: "2.0.0", runs: [] } as unknown as SarifLog; + expect(() => normalizeSarif(badSarif, defaultContext())).toThrow( + NormalizationError, + ); + try { + normalizeSarif(badSarif, defaultContext()); + } catch (e) { + expect((e as NormalizationError).code).toBe("INVALID_SARIF"); + } + }); + + it("throws INVALID_SARIF for missing runs", () => { + expect(() => + normalizeSarif( + { version: "2.1.0", runs: undefined as unknown as never[] }, + defaultContext(), + ), + ).toThrow(NormalizationError); + }); + + it("throws INVALID_SARIF when run has no tool.driver.name", () => { + expect(() => + normalizeSarif( + makeSarifLog({ + runs: [ + { + tool: { driver: { name: "" } }, + results: [], + } as never, + ], + }), + defaultContext(), + ), + ).toThrow(NormalizationError); + }); + + it("throws MISSING_LOCATION for result without locations", () => { + const result = makeResult(); + delete (result as Partial).locations; + expect(() => + normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [result] })] }), + defaultContext(), + ), + ).toThrow(NormalizationError); + try { + normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [result] })] }), + defaultContext(), + ); + } catch (e) { + expect((e as NormalizationError).code).toBe("MISSING_LOCATION"); + } + }); + + it("throws MISSING_LOCATION for result with empty locations array", () => { + const result = makeResult({ locations: [] }); + expect(() => + normalizeSarif( + makeSarifLog({ runs: [makeRun({ results: [result] })] }), + defaultContext(), + ), + ).toThrow(NormalizationError); + }); +}); diff --git a/packages/findings/src/__tests__/public-api.test.ts b/packages/findings/src/__tests__/public-api.test.ts new file mode 100644 index 000000000..397201917 --- /dev/null +++ b/packages/findings/src/__tests__/public-api.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeSarif, + computeFingerprint, + createBaseline, + updateBaseline, + compareBaseline, + loadBaseline, + saveBaseline, + isSuppressed, + filterSuppressed, + filterOnlyNew, + NormalizationError, + BaselineError, + type Finding, + type Severity, + type FindingSource, + type NormalizeContext, + type FingerprintInput, + type Baseline, + type Suppression, + type BaselineDiff, + type SarifLog, + type SarifRun, + type SarifRule, + type SarifResult, + type SarifLocation, + type NormalizationErrorCode, + type BaselineErrorCode, +} from "../index.js"; + +describe("public API — functions", () => { + it("exports normalizeSarif function", () => { + expect(typeof normalizeSarif).toBe("function"); + }); + + it("exports computeFingerprint function", () => { + expect(typeof computeFingerprint).toBe("function"); + }); + + it("exports baseline functions", () => { + expect(typeof createBaseline).toBe("function"); + expect(typeof updateBaseline).toBe("function"); + expect(typeof compareBaseline).toBe("function"); + expect(typeof loadBaseline).toBe("function"); + expect(typeof saveBaseline).toBe("function"); + }); + + it("exports suppress functions", () => { + expect(typeof isSuppressed).toBe("function"); + expect(typeof filterSuppressed).toBe("function"); + expect(typeof filterOnlyNew).toBe("function"); + }); +}); + +describe("public API — error classes", () => { + it("exports NormalizationError class", () => { + const err = new NormalizationError("msg", "INVALID_SARIF"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("NormalizationError"); + expect(err.code).toBe("INVALID_SARIF"); + }); + + it("exports BaselineError class", () => { + const err = new BaselineError("msg", "BASELINE_NOT_FOUND"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("BaselineError"); + expect(err.code).toBe("BASELINE_NOT_FOUND"); + }); +}); + +describe("public API — types (compile-time check)", () => { + it("all spec types are importable", () => { + const _finding: Finding = {} as Finding; + const _severity: Severity = "info"; + const _source: FindingSource = {} as FindingSource; + const _ctx: NormalizeContext = { root: "/", checkIdPrefix: "", defaultConfidence: 0.5 }; + const _fpInput: FingerprintInput = { + rule: "r", + file: "f", + startLine: 1, + endLine: 1, + checkId: "c", + }; + const _baseline: Baseline = {} as Baseline; + const _suppression: Suppression = {} as Suppression; + const _diff: BaselineDiff = {} as BaselineDiff; + const _sarifLog: SarifLog = {} as SarifLog; + const _sarifRun: SarifRun = {} as SarifRun; + const _sarifRule: SarifRule = {} as SarifRule; + const _sarifResult: SarifResult = {} as SarifResult; + const _sarifLocation: SarifLocation = {} as SarifLocation; + const _normCode: NormalizationErrorCode = "INVALID_SARIF"; + const _baseCode: BaselineErrorCode = "BASELINE_NOT_FOUND"; + // Touch all to avoid unused warnings. + expect(_finding).toBeDefined(); + expect(_severity).toBe("info"); + expect(_source).toBeDefined(); + expect(_ctx.root).toBe("/"); + expect(_fpInput.rule).toBe("r"); + expect(_baseline).toBeDefined(); + expect(_suppression).toBeDefined(); + expect(_diff).toBeDefined(); + expect(_sarifLog).toBeDefined(); + expect(_sarifRun).toBeDefined(); + expect(_sarifRule).toBeDefined(); + expect(_sarifResult).toBeDefined(); + expect(_sarifLocation).toBeDefined(); + expect(_normCode).toBe("INVALID_SARIF"); + expect(_baseCode).toBe("BASELINE_NOT_FOUND"); + }); +}); diff --git a/packages/findings/src/__tests__/suppress.test.ts b/packages/findings/src/__tests__/suppress.test.ts new file mode 100644 index 000000000..2f92f983a --- /dev/null +++ b/packages/findings/src/__tests__/suppress.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { + isSuppressed, + filterSuppressed, + filterOnlyNew, +} from "../suppress.js"; +import { createBaseline, type Baseline } from "../baseline.js"; +import type { Finding } from "../types.js"; + +function makeFinding(fingerprint: string): Finding { + return { + id: `check:${fingerprint}`, + fingerprint, + checkId: "check", + severity: "medium", + confidence: 0.5, + message: "msg", + rule: "rule", + file: "src/index.ts", + startLine: 1, + endLine: 1, + source: { + tool: "eslint", + version: null, + format: "sarif", + originalRuleId: "rule", + originalSeverity: null, + }, + }; +} + +function baselineWith( + fingerprints: string[], + suppressions: Baseline["suppressions"] = [], +): Baseline { + const b = createBaseline(fingerprints.map(makeFinding)); + b.suppressions = suppressions; + return b; +} + +describe("isSuppressed", () => { + it("returns true for matching non-expired suppression", () => { + const baseline = baselineWith(["fp-a"], [ + { + fingerprint: "fp-a", + reason: "false positive", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + }, + ]); + expect(isSuppressed(makeFinding("fp-a"), baseline)).toBe(true); + }); + + it("returns false when no suppression matches", () => { + const baseline = baselineWith(["fp-a"], [ + { + fingerprint: "fp-b", + reason: "other", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + }, + ]); + expect(isSuppressed(makeFinding("fp-a"), baseline)).toBe(false); + }); + + it("returns false for expired suppression (expiresAt in the past)", () => { + const baseline = baselineWith(["fp-a"], [ + { + fingerprint: "fp-a", + reason: "expired", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + expiresAt: "2020-01-01T00:00:00Z", + }, + ]); + expect(isSuppressed(makeFinding("fp-a"), baseline)).toBe(false); + }); + + it("returns true for suppression with no expiresAt", () => { + const baseline = baselineWith(["fp-a"], [ + { + fingerprint: "fp-a", + reason: "permanent", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + }, + ]); + expect(isSuppressed(makeFinding("fp-a"), baseline)).toBe(true); + }); + + it("returns true for suppression with future expiresAt", () => { + const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(); + const baseline = baselineWith(["fp-a"], [ + { + fingerprint: "fp-a", + reason: "temporary", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + expiresAt: future, + }, + ]); + expect(isSuppressed(makeFinding("fp-a"), baseline)).toBe(true); + }); +}); + +describe("filterSuppressed", () => { + it("excludes suppressed findings when includeSuppressed is false", () => { + const baseline = baselineWith(["fp-a", "fp-b"], [ + { + fingerprint: "fp-a", + reason: "fp", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + }, + ]); + const findings = [makeFinding("fp-a"), makeFinding("fp-b")]; + const filtered = filterSuppressed(findings, baseline, false); + expect(filtered).toHaveLength(1); + expect(filtered[0]!.fingerprint).toBe("fp-b"); + }); + + it("includes all findings when includeSuppressed is true", () => { + const baseline = baselineWith(["fp-a"], [ + { + fingerprint: "fp-a", + reason: "fp", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + }, + ]); + const findings = [makeFinding("fp-a"), makeFinding("fp-b")]; + const filtered = filterSuppressed(findings, baseline, true); + expect(filtered).toHaveLength(2); + }); + + it("excludes expired suppressions", () => { + const baseline = baselineWith(["fp-a"], [ + { + fingerprint: "fp-a", + reason: "expired", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + expiresAt: "2020-01-01T00:00:00Z", + }, + ]); + const findings = [makeFinding("fp-a")]; + const filtered = filterSuppressed(findings, baseline, false); + expect(filtered).toHaveLength(1); + }); +}); + +describe("filterOnlyNew", () => { + it("returns only findings not in baseline fingerprints", () => { + const baseline = baselineWith(["fp-a"]); + const findings = [makeFinding("fp-a"), makeFinding("fp-b")]; + const result = filterOnlyNew(findings, baseline); + expect(result).toHaveLength(1); + expect(result[0]!.fingerprint).toBe("fp-b"); + }); + + it("excludes suppressed findings even if not in baseline fingerprints", () => { + const baseline = baselineWith([], [ + { + fingerprint: "fp-c", + reason: "fp", + author: "jane", + createdAt: "2025-01-01T00:00:00Z", + }, + ]); + const findings = [makeFinding("fp-b"), makeFinding("fp-c")]; + const result = filterOnlyNew(findings, baseline); + expect(result).toHaveLength(1); + expect(result[0]!.fingerprint).toBe("fp-b"); + }); + + it("returns all findings when baseline is empty", () => { + const baseline = baselineWith([]); + const findings = [makeFinding("fp-a"), makeFinding("fp-b")]; + const result = filterOnlyNew(findings, baseline); + expect(result).toHaveLength(2); + }); + + it("returns empty when all findings are in baseline", () => { + const baseline = baselineWith(["fp-a", "fp-b"]); + const findings = [makeFinding("fp-a"), makeFinding("fp-b")]; + const result = filterOnlyNew(findings, baseline); + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/findings/src/baseline.ts b/packages/findings/src/baseline.ts new file mode 100644 index 000000000..8ca2f95b9 --- /dev/null +++ b/packages/findings/src/baseline.ts @@ -0,0 +1,199 @@ +import { readFile, writeFile } from "node:fs/promises"; +import type { Finding } from "./types.js"; +import { BaselineError } from "./errors.js"; + +/** + * A baseline of known findings. + */ +export interface Baseline { + /** Schema version of the baseline file. */ + version: number; + /** Fingerprints of known findings. */ + fingerprints: string[]; + /** Suppression entries. */ + suppressions: Suppression[]; + /** When the baseline was created (ISO 8601). */ + createdAt: string; + /** When the baseline was last updated (ISO 8601). */ + updatedAt: string; +} + +/** + * A suppression entry that marks a finding as ignored. + */ +export interface Suppression { + /** Fingerprint of the suppressed finding. */ + fingerprint: string; + /** Reason for suppression. */ + reason: string; + /** Who created the suppression. */ + author: string; + /** When the suppression was created (ISO 8601). */ + createdAt: string; + /** Optional expiry date (ISO 8601). Expired suppressions are skipped. */ + expiresAt?: string; +} + +/** + * Result of comparing findings against a baseline. + */ +export interface BaselineDiff { + /** Findings not present in the baseline (new). */ + newFindings: Finding[]; + /** Fingerprints in the baseline but not in the current run (resolved). */ + resolvedFingerprints: string[]; + /** Findings present in both (unchanged). */ + unchangedFindings: Finding[]; +} + +const BASELINE_VERSION = 1; + +/** + * Create a new baseline from a set of findings. + * All fingerprints are added (deduped). No suppressions. + * Timestamps are set to the current time. + */ +export function createBaseline(findings: readonly Finding[]): Baseline { + const now = new Date().toISOString(); + const fingerprints = dedupeSorted(findings.map((f) => f.fingerprint)); + return { + version: BASELINE_VERSION, + fingerprints, + suppressions: [], + createdAt: now, + updatedAt: now, + }; +} + +/** + * Merge current findings into an existing baseline. + * New fingerprints are added. Fingerprints no longer present are removed. + * Suppressions for removed fingerprints are removed. + * `updatedAt` is refreshed; `createdAt` is preserved. + */ +export function updateBaseline( + current: readonly Finding[], + existing: Baseline, +): Baseline { + const currentFps = new Set(current.map((f) => f.fingerprint)); + const keptSuppressions = existing.suppressions.filter((s) => + currentFps.has(s.fingerprint), + ); + return { + version: existing.version, + fingerprints: dedupeSorted([...currentFps]), + suppressions: keptSuppressions, + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + }; +} + +/** + * Compare current findings against a baseline. + * A finding is "new" if its fingerprint is not in `baseline.fingerprints`. + * A fingerprint is "resolved" if it is in the baseline but not in current. + * Otherwise the finding is "unchanged". + */ +export function compareBaseline( + current: readonly Finding[], + baseline: Baseline, +): BaselineDiff { + const baselineFps = new Set(baseline.fingerprints); + const currentFps = new Set(current.map((f) => f.fingerprint)); + + const newFindings: Finding[] = []; + const unchangedFindings: Finding[] = []; + for (const f of current) { + if (baselineFps.has(f.fingerprint)) { + unchangedFindings.push(f); + } else { + newFindings.push(f); + } + } + + const resolvedFingerprints = baseline.fingerprints.filter( + (fp) => !currentFps.has(fp), + ); + + return { newFindings, resolvedFingerprints, unchangedFindings }; +} + +/** + * Load a baseline from a JSON file. + * @throws {BaselineError} BASELINE_NOT_FOUND — file does not exist. + * @throws {BaselineError} BASELINE_INVALID — invalid JSON or wrong schema. + */ +export async function loadBaseline(path: string): Promise { + let content: string; + try { + content = await readFile(path, "utf8"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + throw new BaselineError(`baseline file not found: ${path}`, "BASELINE_NOT_FOUND", e); + } + throw new BaselineError(`cannot read baseline file: ${path}`, "BASELINE_INVALID", e); + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (e) { + throw new BaselineError("baseline file is not valid JSON", "BASELINE_INVALID", e); + } + + const obj = parsed as Record; + if (obj.version !== BASELINE_VERSION) { + throw new BaselineError( + `baseline version must be ${BASELINE_VERSION}, got ${String(obj.version)}`, + "BASELINE_INVALID", + ); + } + if (!Array.isArray(obj.fingerprints)) { + throw new BaselineError( + "baseline.fingerprints must be an array", + "BASELINE_INVALID", + ); + } + if (!Array.isArray(obj.suppressions)) { + throw new BaselineError( + "baseline.suppressions must be an array", + "BASELINE_INVALID", + ); + } + + return { + version: obj.version as number, + fingerprints: obj.fingerprints as string[], + suppressions: obj.suppressions as Suppression[], + createdAt: String(obj.createdAt ?? ""), + updatedAt: String(obj.updatedAt ?? ""), + }; +} + +/** + * Save a baseline to a JSON file. + * @throws {BaselineError} BASELINE_WRITE_FAILED — cannot write the file. + */ +export async function saveBaseline( + baseline: Baseline, + path: string, +): Promise { + const json = JSON.stringify(baseline, null, 2); + try { + await writeFile(path, json, "utf8"); + } catch (e) { + throw new BaselineError( + `cannot write baseline file: ${path}`, + "BASELINE_WRITE_FAILED", + e, + ); + } +} + +/** + * Dedupe and sort an array of strings. + */ +function dedupeSorted(items: readonly string[]): string[] { + return [...new Set(items)].sort(); +} diff --git a/packages/findings/src/errors.ts b/packages/findings/src/errors.ts new file mode 100644 index 000000000..412fbe423 --- /dev/null +++ b/packages/findings/src/errors.ts @@ -0,0 +1,44 @@ +/** + * Base error class for normalization failures. All normalization errors throw + * a `NormalizationError` with one of the `NormalizationErrorCode` values. + */ +export class NormalizationError extends Error { + readonly code: NormalizationErrorCode; + override readonly cause: unknown; + constructor( + message: string, + code: NormalizationErrorCode, + cause?: unknown, + ) { + super(message); + this.name = "NormalizationError"; + this.code = code; + this.cause = cause; + } +} + +export type NormalizationErrorCode = + | "INVALID_SARIF" + | "MISSING_LOCATION" + | "INVALID_FINGERPRINT_INPUT"; + +/** + * Base error class for baseline operation failures. All baseline I/O and + * schema errors throw a `BaselineError` with one of the `BaselineErrorCode` + * values. + */ +export class BaselineError extends Error { + readonly code: BaselineErrorCode; + override readonly cause: unknown; + constructor(message: string, code: BaselineErrorCode, cause?: unknown) { + super(message); + this.name = "BaselineError"; + this.code = code; + this.cause = cause; + } +} + +export type BaselineErrorCode = + | "BASELINE_NOT_FOUND" + | "BASELINE_INVALID" + | "BASELINE_WRITE_FAILED"; diff --git a/packages/findings/src/fingerprint.ts b/packages/findings/src/fingerprint.ts new file mode 100644 index 000000000..63abe204a --- /dev/null +++ b/packages/findings/src/fingerprint.ts @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import type { FingerprintInput } from "./types.js"; +import { NormalizationError } from "./errors.js"; + +/** + * Compute a deterministic fingerprint for the given finding data. + * + * The fingerprint is insensitive to message wording and severity changes, + * but sensitive to file, rule, and line range. It is a lowercase hex + * SHA-256 string. + * + * @throws {NormalizationError} INVALID_FINGERPRINT_INPUT — `file` is empty + * or `startLine`/`endLine` are not positive. `rule` and `checkId` may be + * empty (SARIF edge case: a result with no ruleId and no ruleIndex). + */ +export function computeFingerprint(input: FingerprintInput): string { + if (!input.file) { + throw new NormalizationError( + "file must be a non-empty string", + "INVALID_FINGERPRINT_INPUT", + ); + } + if (input.startLine <= 0) { + throw new NormalizationError( + "startLine must be > 0", + "INVALID_FINGERPRINT_INPUT", + ); + } + if (input.endLine <= 0) { + throw new NormalizationError( + "endLine must be > 0", + "INVALID_FINGERPRINT_INPUT", + ); + } + + const normalizedFile = input.file.replace(/\\/g, "/"); + const payload = `${input.checkId}|${input.rule}|${normalizedFile}|${input.startLine}|${input.endLine}`; + return createHash("sha256").update(payload).digest("hex"); +} diff --git a/packages/findings/src/index.ts b/packages/findings/src/index.ts index 4e0fc2215..6600c1bb3 100644 --- a/packages/findings/src/index.ts +++ b/packages/findings/src/index.ts @@ -1 +1,13 @@ // @sverka/findings — public API +export { type Finding, type Severity, type FindingSource, + type NormalizeContext, type FingerprintInput } from "./types.js"; +export { type Baseline, type Suppression, type BaselineDiff } from "./baseline.js"; +export { type SarifLog, type SarifRun, type SarifRule, + type SarifResult, type SarifLocation } from "./normalize.js"; +export { normalizeSarif } from "./normalize.js"; +export { computeFingerprint } from "./fingerprint.js"; +export { createBaseline, updateBaseline, compareBaseline, + loadBaseline, saveBaseline } from "./baseline.js"; +export { isSuppressed, filterSuppressed, filterOnlyNew } from "./suppress.js"; +export { NormalizationError, type NormalizationErrorCode, + BaselineError, type BaselineErrorCode } from "./errors.js"; diff --git a/packages/findings/src/normalize.ts b/packages/findings/src/normalize.ts new file mode 100644 index 000000000..296c258bb --- /dev/null +++ b/packages/findings/src/normalize.ts @@ -0,0 +1,211 @@ +import type { + Finding, + FindingSource, + NormalizeContext, + Severity, +} from "./types.js"; +import { computeFingerprint } from "./fingerprint.js"; +import { NormalizationError } from "./errors.js"; + +/** + * Minimal SARIF 2.1.0 log structure (subset required for normalization). + */ +export interface SarifLog { + version: "2.1.0"; + runs: SarifRun[]; +} + +export interface SarifRun { + tool: { + driver: { + name: string; + version?: string; + rules?: SarifRule[]; + }; + }; + results: SarifResult[]; +} + +export interface SarifRule { + id: string; + name?: string; + shortDescription?: { text: string }; + helpUri?: string; + defaultConfiguration?: { level?: string }; +} + +export interface SarifResult { + /** Rule ID. May be absent when `ruleIndex` is provided instead. */ + ruleId?: string; + /** Index into `tool.driver.rules`. Used when `ruleId` is absent. */ + ruleIndex?: number; + level?: "none" | "note" | "warning" | "error"; + message: { text: string }; + locations: SarifLocation[]; + partialFingerprints?: Record; + fingerprints?: Record; +} + +export interface SarifLocation { + physicalLocation: { + artifactLocation: { uri: string; uriBaseId?: string }; + region?: { + startLine: number; + endLine?: number; + startColumn?: number; + endColumn?: number; + snippet?: { text: string }; + }; + }; +} + +const LEVEL_TO_SEVERITY: Record = { + error: "high", + warning: "medium", + note: "low", + none: "info", +}; + +/** + * Normalize a parsed SARIF 2.1.0 log into Findings. + * + * Validates the SARIF structure at runtime (TypeScript types are erased). + * Computes fingerprints and assigns ids (`{checkId}:{fingerprint}`). + * Multi-location results produce one finding per location. + * + * @throws {NormalizationError} INVALID_SARIF — structure does not conform. + * @throws {NormalizationError} MISSING_LOCATION — a result has no location. + */ +export function normalizeSarif( + sarif: SarifLog, + context: NormalizeContext, +): Finding[] { + // Validate top-level structure. + if (sarif.version !== "2.1.0") { + throw new NormalizationError( + `expected SARIF version "2.1.0", got "${String(sarif.version)}"`, + "INVALID_SARIF", + { version: sarif.version }, + ); + } + if (!Array.isArray(sarif.runs)) { + throw new NormalizationError( + "SARIF log must have a runs array", + "INVALID_SARIF", + ); + } + + const findings: Finding[] = []; + + for (const run of sarif.runs) { + const driver = run?.tool?.driver; + if (!driver || typeof driver.name !== "string" || !driver.name) { + throw new NormalizationError( + "each SARIF run must have tool.driver.name", + "INVALID_SARIF", + ); + } + if (!Array.isArray(run.results)) { + throw new NormalizationError( + "each SARIF run must have a results array", + "INVALID_SARIF", + ); + } + + const toolName = driver.name; + const toolVersion = driver.version ?? null; + const rules = driver.rules ?? []; + + for (const result of run.results) { + const { ruleId, rule } = resolveRule(result, rules); + const level = result.level ?? rule?.defaultConfiguration?.level; + const severity = level ? (LEVEL_TO_SEVERITY[level] ?? "info") : "info"; + const helpUrl = rule?.helpUri; + const originalSeverity = result.level ?? null; + + const locations = result.locations; + if (!Array.isArray(locations) || locations.length === 0) { + throw new NormalizationError( + "SARIF result has no locations", + "MISSING_LOCATION", + { ruleId }, + ); + } + + for (const location of locations) { + const phys = location?.physicalLocation; + const uri = phys?.artifactLocation?.uri ?? ""; + const region = phys?.region; + const startLine = region?.startLine ?? 0; + const endLine = region?.endLine ?? startLine; + + const checkId = context.checkIdPrefix + ? `${context.checkIdPrefix}:${ruleId}` + : ruleId; + + const source: FindingSource = { + tool: toolName, + version: toolVersion, + format: "sarif", + originalRuleId: ruleId, + originalSeverity, + }; + + const fingerprint = computeFingerprint({ + rule: ruleId, + file: uri, + startLine, + endLine, + checkId, + }); + + findings.push({ + id: `${checkId}:${fingerprint}`, + fingerprint, + checkId, + severity, + confidence: context.defaultConfidence, + message: result.message?.text ?? "", + rule: ruleId, + file: uri, + startLine, + endLine, + ...(region?.startColumn !== undefined + ? { startColumn: region.startColumn } + : {}), + ...(region?.endColumn !== undefined + ? { endColumn: region.endColumn } + : {}), + ...(helpUrl !== undefined ? { helpUrl } : {}), + source, + ...(region?.snippet?.text !== undefined + ? { snippet: region.snippet.text } + : {}), + }); + } + } + } + + return findings; +} + +/** + * Resolve the rule for a SARIF result. Returns the ruleId (string, possibly + * empty) and the matching SarifRule (if any). + */ +function resolveRule( + result: SarifResult, + rules: readonly SarifRule[], +): { ruleId: string; rule: SarifRule | undefined } { + if (result.ruleId !== undefined && result.ruleId !== "") { + const rule = rules.find((r) => r.id === result.ruleId); + return { ruleId: result.ruleId, rule }; + } + if (result.ruleIndex !== undefined) { + const rule = rules[result.ruleIndex]; + if (rule) { + return { ruleId: rule.id, rule }; + } + } + return { ruleId: "", rule: undefined }; +} diff --git a/packages/findings/src/suppress.ts b/packages/findings/src/suppress.ts new file mode 100644 index 000000000..00c268d59 --- /dev/null +++ b/packages/findings/src/suppress.ts @@ -0,0 +1,52 @@ +import type { Finding } from "./types.js"; +import type { Baseline } from "./baseline.js"; + +/** + * Check if a finding is suppressed by the baseline. + * A finding is suppressed if its fingerprint matches a suppression entry + * that has not expired (`expiresAt` is in the future or absent). + */ +export function isSuppressed(finding: Finding, baseline: Baseline): boolean { + const now = Date.now(); + for (const s of baseline.suppressions) { + if (s.fingerprint === finding.fingerprint) { + if (s.expiresAt === undefined) { + return true; + } + // Expired if expiresAt <= now (boundary: exactly now = expired). + if (new Date(s.expiresAt).getTime() > now) { + return true; + } + } + } + return false; +} + +/** + * Filter out suppressed findings. + * @param includeSuppressed When true, all findings are returned unchanged. + */ +export function filterSuppressed( + findings: readonly Finding[], + baseline: Baseline, + includeSuppressed: boolean, +): Finding[] { + if (includeSuppressed) { + return [...findings]; + } + return findings.filter((f) => !isSuppressed(f, baseline)); +} + +/** + * Return only findings not present in the baseline (new findings). + * Suppressed findings are excluded. + */ +export function filterOnlyNew( + findings: readonly Finding[], + baseline: Baseline, +): Finding[] { + const known = new Set(baseline.fingerprints); + return findings.filter( + (f) => !known.has(f.fingerprint) && !isSuppressed(f, baseline), + ); +} diff --git a/packages/findings/src/types.ts b/packages/findings/src/types.ts new file mode 100644 index 000000000..7a586ec7a --- /dev/null +++ b/packages/findings/src/types.ts @@ -0,0 +1,80 @@ +/** + * A normalized finding from any analysis tool. + */ +export interface Finding { + /** Stable unique identifier: `{checkId}:{fingerprint}`. */ + id: string; + /** Deterministic fingerprint used for baseline tracking. */ + fingerprint: string; + /** Identifier of the check that produced this finding. */ + checkId: string; + /** Severity level. */ + severity: Severity; + /** Confidence level (0.0–1.0). Defaults to 0.5 for SARIF. */ + confidence: number; + /** Human-readable message describing the finding. */ + message: string; + /** Rule identifier from the originating tool. */ + rule: string; + /** File path relative to project root, forward slashes. */ + file: string; + /** Start line (1-based). */ + startLine: number; + /** End line (1-based, inclusive). */ + endLine: number; + /** Optional start column (1-based). */ + startColumn?: number; + /** Optional end column (1-based). */ + endColumn?: number; + /** Optional URL to documentation or remediation help. */ + helpUrl?: string; + /** Source tool that produced the finding. */ + source: FindingSource; + /** Optional code snippet surrounding the finding. */ + snippet?: string; +} + +/** + * Severity levels ordered from least to most severe. + */ +export type Severity = "info" | "low" | "medium" | "high" | "critical"; + +/** + * The source tool that produced a finding. + */ +export interface FindingSource { + /** Tool name (e.g. "eslint", "semgrep", "sonarcloud"). */ + tool: string; + /** Tool version if known. */ + version: string | null; + /** Original format of the tool output. */ + format: "sarif" | "json" | "text" | "custom"; + /** Original rule ID from the tool before normalization. */ + originalRuleId: string; + /** Original severity from the tool before normalization. */ + originalSeverity: string | null; +} + +/** + * Context passed to the normalizer. + */ +export interface NormalizeContext { + /** Project root for resolving relative paths. */ + root: string; + /** Prefix for constructing checkId: `{checkIdPrefix}:{ruleId}` (or just + * `ruleId` when prefix is empty). */ + checkIdPrefix: string; + /** Default confidence when the tool does not provide one (default 0.5). */ + defaultConfidence: number; +} + +/** + * Input to fingerprint computation. + */ +export interface FingerprintInput { + rule: string; + file: string; + startLine: number; + endLine: number; + checkId: string; +} diff --git a/specs/07-findings/spec.md b/specs/07-findings/spec.md index 6818483c7..e155e8eec 100644 --- a/specs/07-findings/spec.md +++ b/specs/07-findings/spec.md @@ -5,50 +5,75 @@ The `findings` package normalizes output from heterogeneous analysis tools into a single canonical `Finding` model. It computes stable fingerprints so findings can be tracked across runs, maintains a baseline of known findings, supports -suppression of false positives, and provides `--only-new` filtering so -repeated runs report only newly introduced issues. +suppression of false positives via the baseline file, and provides only-new +filtering so repeated runs report newly introduced issues. -Every finding — whether from SARIF, ESLint, Semgrep, SonarCloud, or a custom -check — is normalized to the same shape before it reaches policy evaluation or -the CLI output layer. +Every finding — whether from SARIF-emitting tools (CodeQL, ESLint, Semgrep, +SonarCloud) or a custom check — is normalized to the same shape before it +reaches policy evaluation or the CLI output layer. ## Goals -1. Normalize findings from heterogeneous tools into one canonical `Finding` - model. -2. Normalize SARIF 2.1.0 output specifically, since it is the interchange - format for many tools. -3. Compute deterministic fingerprints that are stable across runs and +1. Normalize SARIF 2.1.0 output into the canonical `Finding` model (SARIF is + the interchange format; most tools emit it). +2. Compute deterministic fingerprints that are stable across runs and insensitive to cosmetic changes (whitespace, message wording). -4. Maintain a baseline file of known findings that can be created, updated, and +3. Maintain a baseline file of known findings that can be created, updated, and compared. -5. Support suppression of findings via inline comments and baseline entries. -6. Provide `--only-new` filtering that returns findings not present in the +4. Support suppression of findings via baseline entries (by fingerprint). +5. Provide only-new filtering that returns findings not present in the baseline. -7. Preserve the original tool source on every finding for traceability. -8. Support severity and confidence normalization across tools that use - different scales. -9. Be fully deterministic: identical tool output produces identical normalized +6. Preserve the original tool source on every finding for traceability. +7. Support severity normalization across tools that use different scales. +8. Be fully deterministic: identical tool output produces identical normalized findings and fingerprints. -10. Export all public types and functions from `src/index.ts`. +9. Export all public types and functions from `src/index.ts`. -## Non-goals (v1) +## Non-goals (v1 / Wave 7) - Re-implementing static analysis tools. +- A pluggable normalizer registry or `FindingNormalizer` interface. v1 + implements SARIF normalization only. The interface is extracted when a + second normalizer arrives (YAGNI). +- Non-SARIF normalizers (raw ESLint JSON, Semgrep JSON, text output). Deferred + until a concrete consumer needs a format SARIF cannot cover. +- Inline source-code suppressions (`// sverka-ignore-next-line`). Deferred to a + follow-up; baseline suppressions by fingerprint cover the v1 use case. - Deduplicating findings across tools that report the same issue (future work). - Automatically fixing findings. - Hosting a findings database or dashboard. - Supporting SARIF extensions and taxonomies beyond what is needed for - normalization. + normalization. `Finding.tags` is deferred (no v1 consumer). +- Confidence normalization across tools. v1 defaults confidence to + `defaultConfidence` (SARIF has no standard confidence field). ## Interfaces ```typescript +// src/index.ts — public exports + +export { type Finding, type Severity, type FindingSource, + type NormalizeContext, type FingerprintInput } from "./types.js"; +export { type Baseline, type Suppression, type BaselineDiff } from "./baseline.js"; +export { type SarifLog, type SarifRun, type SarifRule, + type SarifResult, type SarifLocation } from "./normalize.js"; +export { normalizeSarif } from "./normalize.js"; +export { computeFingerprint } from "./fingerprint.js"; +export { createBaseline, updateBaseline, compareBaseline, + loadBaseline, saveBaseline } from "./baseline.js"; +export { isSuppressed, filterSuppressed, filterOnlyNew } from "./suppress.js"; +export { NormalizationError, type NormalizationErrorCode, + BaselineError, type BaselineErrorCode } from "./errors.js"; +``` + +```typescript +// src/types.ts + /** * A normalized finding from any analysis tool. */ export interface Finding { - /** Stable unique identifier for this finding. */ + /** Stable unique identifier: `{checkId}:{fingerprint}`. */ id: string; /** Deterministic fingerprint used for baseline tracking. */ fingerprint: string; @@ -56,13 +81,13 @@ export interface Finding { checkId: string; /** Severity level. */ severity: Severity; - /** Confidence level (0.0–1.0). */ + /** Confidence level (0.0–1.0). Defaults to 0.5 for SARIF. */ confidence: number; /** Human-readable message describing the finding. */ message: string; /** Rule identifier from the originating tool. */ rule: string; - /** File path relative to project root. */ + /** File path relative to project root, forward slashes. */ file: string; /** Start line (1-based). */ startLine: number; @@ -78,8 +103,6 @@ export interface Finding { source: FindingSource; /** Optional code snippet surrounding the finding. */ snippet?: string; - /** Optional CWE or other taxonomy references. */ - tags?: string[]; } /** @@ -104,47 +127,22 @@ export interface FindingSource { } /** - * Normalizes raw tool output into Findings. - */ -export interface FindingNormalizer { - /** The tool name this normalizer handles. */ - readonly tool: string; - /** The input format this normalizer accepts. */ - readonly format: "sarif" | "json" | "text" | "custom"; - /** - * Normalize raw output into Findings. - * @param raw Raw tool output as a string or parsed object. - * @param context Normalization context with project root. - */ - normalize(raw: unknown, context: NormalizeContext): Finding[]; -} - -/** - * Context passed to normalizers. + * Context passed to the normalizer. */ export interface NormalizeContext { /** Project root for resolving relative paths. */ root: string; - /** Default check ID prefix. */ + /** Prefix for constructing checkId: `{checkIdPrefix}:{ruleId}` (or just + * `ruleId` when prefix is empty). */ checkIdPrefix: string; - /** Default confidence when the tool does not provide one. */ + /** Default confidence when the tool does not provide one (default 0.5). */ defaultConfidence: number; } -/** - * Computes a deterministic fingerprint for a finding. - */ -export interface Fingerprinter { - /** - * Compute a stable fingerprint for the given finding data. - * The fingerprint must be insensitive to message wording changes and - * whitespace, but sensitive to file, rule, and line range. - */ - compute(input: FingerprintInput): string; -} - /** * Input to fingerprint computation. + * `rule` and `checkId` may be empty (SARIF results without rule ids). + * `file` must be non-empty; `startLine`/`endLine` must be positive. */ export interface FingerprintInput { rule: string; @@ -153,6 +151,98 @@ export interface FingerprintInput { endLine: number; checkId: string; } +``` + +```typescript +// src/normalize.ts + +/** + * Minimal SARIF 2.1.0 log structure (subset required for normalization). + */ +export interface SarifLog { + version: "2.1.0"; + runs: SarifRun[]; +} + +export interface SarifRun { + tool: { + driver: { + name: string; + version?: string; + rules?: SarifRule[]; + }; + }; + results: SarifResult[]; +} + +export interface SarifRule { + id: string; + name?: string; + shortDescription?: { text: string }; + helpUri?: string; + defaultConfiguration?: { level?: string }; +} + +export interface SarifResult { + /** Rule ID. May be absent when `ruleIndex` is provided instead. */ + ruleId?: string; + /** Index into `tool.driver.rules`. Used when `ruleId` is absent. */ + ruleIndex?: number; + level?: "none" | "note" | "warning" | "error"; + message: { text: string }; + locations: SarifLocation[]; + partialFingerprints?: Record; + fingerprints?: Record; +} + +export interface SarifLocation { + physicalLocation: { + artifactLocation: { uri: string; uriBaseId?: string }; + region?: { + startLine: number; + endLine?: number; + startColumn?: number; + endColumn?: number; + snippet?: { text: string }; + }; + }; +} + +/** + * Normalize a parsed SARIF 2.1.0 log into Findings. + * + * Validates the SARIF structure at runtime (TypeScript types are erased). + * Computes fingerprints and assigns ids (`{checkId}:{fingerprint}`). + * Multi-location results produce one finding per location. + * + * @throws {NormalizationError} INVALID_SARIF — structure does not conform. + * @throws {NormalizationError} MISSING_LOCATION — a result has no location. + */ +export function normalizeSarif( + sarif: SarifLog, + context: NormalizeContext, +): Finding[]; +``` + +```typescript +// src/fingerprint.ts + +/** + * Compute a deterministic fingerprint for the given finding data. + * + * The fingerprint is insensitive to message wording and severity changes, + * but sensitive to file, rule, and line range. It is a lowercase hex + * SHA-256 string. + * + * @throws {NormalizationError} INVALID_FINGERPRINT_INPUT — `file` is empty, + * or `startLine`/`endLine` are not positive integers. `rule` and `checkId` + * may be empty (SARIF results without rule identifiers are valid). + */ +export function computeFingerprint(input: FingerprintInput): string; +``` + +```typescript +// src/baseline.ts /** * A baseline of known findings. @@ -164,9 +254,9 @@ export interface Baseline { fingerprints: string[]; /** Suppression entries. */ suppressions: Suppression[]; - /** When the baseline was created. */ + /** When the baseline was created (ISO 8601). */ createdAt: string; - /** When the baseline was last updated. */ + /** When the baseline was last updated (ISO 8601). */ updatedAt: string; } @@ -180,9 +270,9 @@ export interface Suppression { reason: string; /** Who created the suppression. */ author: string; - /** When the suppression was created. */ + /** When the suppression was created (ISO 8601). */ createdAt: string; - /** Optional expiry date (ISO 8601). */ + /** Optional expiry date (ISO 8601). Expired suppressions are skipped. */ expiresAt?: string; } @@ -192,103 +282,93 @@ export interface Suppression { export interface BaselineDiff { /** Findings not present in the baseline (new). */ newFindings: Finding[]; - /** Findings present in the baseline but not in current run (resolved). */ - resolvedFindings: Finding[]; + /** Fingerprints in the baseline but not in the current run (resolved). + * Only fingerprints — the baseline does not store full Finding objects. */ + resolvedFingerprints: string[]; /** Findings present in both (unchanged). */ unchangedFindings: Finding[]; } /** - * Options for baseline operations. + * Create a new baseline from a set of findings. + * All fingerprints are added. No suppressions. + * Timestamps are set to the current time. */ -export interface BaselineOptions { - /** Path to the baseline file. */ - path: string; - /** Whether to include suppressed findings in results. */ - includeSuppressed: boolean; -} +export function createBaseline(findings: readonly Finding[]): Baseline; /** - * Options for --only-new filtering. + * Merge current findings into an existing baseline. + * New fingerprints are added. Fingerprints no longer present are removed. + * Suppressions for removed fingerprints are removed. + * `updatedAt` is refreshed. */ -export interface OnlyNewOptions { - /** Path to the baseline file. */ - baselinePath: string; - /** Whether to treat missing baseline as all-new. */ - missingBaselineIsAllNew: boolean; -} +export function updateBaseline( + current: readonly Finding[], + existing: Baseline, +): Baseline; /** - * Normalizes SARIF 2.1.0 output into Findings. + * Compare current findings against a baseline. + * A finding is "new" if its fingerprint is not in `baseline.fingerprints`. + * A fingerprint is "resolved" if it is in the baseline but not in current. + * Otherwise the finding is "unchanged". */ -export interface SarifNormalizer extends FindingNormalizer { - readonly tool: "sarif"; - readonly format: "sarif"; - /** - * Parse a SARIF log object into Findings. - * @param sarif Parsed SARIF object. - * @param context Normalization context. - */ - normalizeSarif(sarif: SarifLog, context: NormalizeContext): Finding[]; -} +export function compareBaseline( + current: readonly Finding[], + baseline: Baseline, +): BaselineDiff; /** - * Minimal SARIF 2.1.0 log structure (subset required for normalization). + * Load a baseline from a JSON file. + * @throws {BaselineError} BASELINE_NOT_FOUND — file does not exist. + * @throws {BaselineError} BASELINE_INVALID — invalid JSON or wrong schema. */ -export interface SarifLog { - version: "2.1.0"; - runs: SarifRun[]; -} +export function loadBaseline(path: string): Promise; -export interface SarifRun { - tool: { - driver: { - name: string; - version?: string; - rules?: SarifRule[]; - }; - }; - results: SarifResult[]; -} +/** + * Save a baseline to a JSON file. + * @throws {BaselineError} BASELINE_WRITE_FAILED — cannot write the file. + */ +export function saveBaseline(baseline: Baseline, path: string): Promise; +``` -export interface SarifRule { - id: string; - name?: string; - shortDescription?: { text: string }; - helpUri?: string; - defaultConfiguration?: { level?: string }; -} +```typescript +// src/suppress.ts -export interface SarifResult { - ruleId: string; - ruleIndex?: number; - level?: "none" | "note" | "warning" | "error"; - message: { text: string }; - locations: SarifLocation[]; - partialFingerprints?: Record; - fingerprints?: Record; -} +/** + * Check if a finding is suppressed by the baseline. + * A finding is suppressed if its fingerprint matches a suppression entry + * that has not expired (`expiresAt` is in the future or absent). + */ +export function isSuppressed(finding: Finding, baseline: Baseline): boolean; -export interface SarifLocation { - physicalLocation: { - artifactLocation: { uri: string; uriBaseId?: string }; - region?: { - startLine: number; - endLine?: number; - startColumn?: number; - endColumn?: number; - snippet?: { text: string }; - }; - }; -} +/** + * Filter out suppressed findings. + * @param includeSuppressed When true, all findings are returned unchanged. + */ +export function filterSuppressed( + findings: readonly Finding[], + baseline: Baseline, + includeSuppressed: boolean, +): Finding[]; /** - * Error thrown when normalization fails. + * Return only findings not present in the baseline (new findings). + * Suppressed findings are excluded. */ +export function filterOnlyNew( + findings: readonly Finding[], + baseline: Baseline, +): Finding[]; +``` + +```typescript +// src/errors.ts + export class NormalizationError extends Error { - readonly code: string; + readonly code: NormalizationErrorCode; readonly cause: unknown; - constructor(message: string, code: string, cause?: unknown) { + constructor(message: string, code: NormalizationErrorCode, cause?: unknown) { super(message); this.name = "NormalizationError"; this.code = code; @@ -296,42 +376,49 @@ export class NormalizationError extends Error { } } -/** - * Error thrown when baseline operations fail. - */ +export type NormalizationErrorCode = + | "INVALID_SARIF" + | "MISSING_LOCATION" + | "INVALID_FINGERPRINT_INPUT"; + export class BaselineError extends Error { - readonly code: string; + readonly code: BaselineErrorCode; readonly cause: unknown; - constructor(message: string, code: string, cause?: unknown) { + constructor(message: string, code: BaselineErrorCode, cause?: unknown) { super(message); this.name = "BaselineError"; this.code = code; this.cause = cause; } } + +export type BaselineErrorCode = + | "BASELINE_NOT_FOUND" + | "BASELINE_INVALID" + | "BASELINE_WRITE_FAILED"; ``` ## Data models ### Finding normalization pipeline -1. **Raw output is received** as a string or parsed object from a check - execution. -2. **The appropriate `FindingNormalizer` is selected** based on the tool name - and output format. -3. **The normalizer produces `Finding[]`** with all fields populated. Missing - fields receive defaults: - - `severity`: `"info"` when the tool does not provide severity. +1. **Parsed SARIF is received** as a `SarifLog` object (the caller handles + `JSON.parse`). +2. **`normalizeSarif` validates the structure** at runtime and throws + `INVALID_SARIF` if it does not conform. +3. **Each result produces one finding per location.** Missing locations throw + `MISSING_LOCATION`. +4. **Fields are populated** with defaults for missing values: + - `severity`: `"info"` when the result has no level. - `confidence`: `defaultConfidence` from `NormalizeContext` (default 0.5). - - `helpUrl`: `undefined` when not provided. -4. **Fingerprints are computed** by the `Fingerprinter` after normalization, - using `rule`, `file`, `startLine`, `endLine`, and `checkId`. -5. **IDs are assigned** as `{checkId}:{fingerprint}` to ensure uniqueness - within a run. - -### SARIF normalization + - `helpUrl`: `undefined` when the rule has no `helpUri`. +5. **`checkId` is constructed**: `{checkIdPrefix}:{ruleId}` when prefix is + non-empty, else `ruleId`. +6. **Fingerprints are computed** via `computeFingerprint` using `rule`, `file`, + `startLine`, `endLine`, and `checkId`. +7. **IDs are assigned** as `{checkId}:{fingerprint}`. -SARIF level-to-severity mapping: +### SARIF level-to-severity mapping | SARIF level | Sverka severity | |---|---| @@ -345,28 +432,28 @@ If a SARIF rule has `defaultConfiguration.level`, that level is used when the result does not specify its own `level`. Rule metadata (`helpUri`, `shortDescription`) is extracted from the run's -`tool.driver.rules` array by matching `ruleId` or `ruleIndex`. +`tool.driver.rules` array. The rule is resolved by `ruleId` when present, or +by `ruleIndex` into the `rules` array when `ruleId` is absent. When neither +is available, `ruleId` defaults to `""` (empty string). Empty `rule` and +`checkId` are valid fingerprint inputs — the fingerprint is location-only. ### Fingerprint computation -Fingerprints are computed as a SHA-256 hash of a canonical string: - ``` sha256("{checkId}|{rule}|{normalizedFile}|{startLine}|{endLine}") ``` -- `normalizedFile` is the file path with `\\` replaced by `/` and made relative - to the project root. -- The fingerprint deliberately excludes `message` so that reworded messages do - not create new findings. -- The fingerprint deliberately excludes `severity` so that severity changes do - not create new findings (severity changes are tracked separately). -- The fingerprint is a lowercase hex string. +- `normalizedFile` is the file path with `\\` replaced by `/`. The caller + (`normalizeSarif`) is responsible for making the path relative to the + project root before passing it to `computeFingerprint`. `FingerprintInput` + has no `root` field — `computeFingerprint` only normalizes backslashes. +- Excludes `message` (reworded messages do not create new findings). +- Excludes `severity` (severity changes do not create new findings). +- Lowercase hex string. ### Baseline file format -The baseline is a JSON file (default: `.sverka/baseline.json`) with this -structure: +JSON file (default: `.sverka/baseline.json`): ```json { @@ -388,15 +475,13 @@ structure: ### Baseline operations -- **Create:** Write a new baseline from a set of findings. All fingerprints - are added. No suppressions. -- **Update:** Merge new findings into an existing baseline. Resolved findings - are removed from `fingerprints`. Suppressions for resolved fingerprints are - removed. `updatedAt` is refreshed. -- **Compare:** Produce a `BaselineDiff` by comparing current findings against - the baseline. A finding is "new" if its fingerprint is not in - `baseline.fingerprints`. A finding is "resolved" if its fingerprint is in - the baseline but not in the current set. Otherwise it is "unchanged". +- **Create:** `createBaseline(findings)` → `Baseline` with all fingerprints, + no suppressions, current timestamps. Caller persists via `saveBaseline`. +- **Update:** `updateBaseline(current, existing)` → new `Baseline` with + current fingerprints merged, resolved fingerprints removed, suppressions + for resolved fingerprints removed, `updatedAt` refreshed. +- **Compare:** `compareBaseline(current, baseline)` → `BaselineDiff` with + `newFindings`, `resolvedFingerprints`, `unchangedFindings`. ### Suppression @@ -405,102 +490,97 @@ Suppressions are matched by fingerprint. A finding is suppressed if: 1. Its fingerprint matches a suppression entry, AND 2. The suppression has not expired (`expiresAt` is in the future or absent). -Suppressed findings are excluded from policy evaluation and from `--only-new` -results by default. They can be included via `includeSuppressed: true`. - -Inline suppressions are detected from source code comments matching: - -- `// sverka-ignore-next-line` (TypeScript/JavaScript) -- `# sverka-ignore-next-line` (Python) -- `// sverka-ignore` on the same line - -Inline suppressions are processed before baseline comparison. +Suppressed findings are excluded from `filterOnlyNew` results by default. +`filterSuppressed(findings, baseline, true)` includes them. -### `--only-new` filtering +### Only-new filtering -1. Load the baseline from `baselinePath`. -2. If the baseline does not exist and `missingBaselineIsAllNew` is `true`, - all findings are returned as new. -3. If the baseline does not exist and `missingBaselineIsAllNew` is `false`, - an error is thrown. -4. Otherwise, return only findings whose fingerprints are not in - `baseline.fingerprints` and are not suppressed. +`filterOnlyNew(findings, baseline)` returns findings whose fingerprints are +not in `baseline.fingerprints` and are not suppressed. The caller handles a +missing baseline file (catch `BASELINE_NOT_FOUND` from `loadBaseline` and +treat all findings as new if desired). ## Error handling -- **`NormalizationError`** is thrown for malformed tool output: +- **`NormalizationError`** codes: - `INVALID_SARIF` — SARIF does not conform to 2.1.0 structure. - - `UNSUPPORTED_FORMAT` — no normalizer is registered for the tool/format. - `MISSING_LOCATION` — a SARIF result has no location. - - `PARSE_ERROR` — raw output could not be parsed. -- **`BaselineError`** is thrown for baseline file issues: + - `INVALID_FINGERPRINT_INPUT` — `file` is empty or `startLine`/`endLine` + are not positive integers. `rule` and `checkId` may be empty. +- **`BaselineError`** codes: - `BASELINE_NOT_FOUND` — baseline file does not exist. - `BASELINE_INVALID` — baseline file is not valid JSON or wrong schema version. - `BASELINE_WRITE_FAILED` — baseline file could not be written. - - `SUPPRESSION_EXPIRED` — a suppression has expired (warning, not fatal). -- **Fingerprint computation never throws** for valid `FingerprintInput`. It - throws `NormalizationError` with code `INVALID_FINGERPRINT_INPUT` if required - fields are missing or empty. +- Fingerprint computation throws `NormalizationError` with code + `INVALID_FINGERPRINT_INPUT` when `file` is empty or `startLine`/`endLine` + are not positive integers. Empty `rule`/`checkId` are valid (SARIF edge + case). Never throws for valid input. - All errors include a `cause` field typed as `unknown`. - No `any` types are used. ## Test plan -Tests live in `packages/findings/src/__tests__/` and run via `bun test`. +Tests live in `packages/findings/src/__tests__/` and run via `bun run test` +(vitest via nx). 1. **SARIF normalization:** - A minimal SARIF log with one result produces one `Finding`. - SARIF levels map to correct severities (`error`→`high`, `warning`→ - `medium`, `note`→`low`, `none`→`info`). - - Rule metadata (`helpUri`, `shortDescription`) is extracted from - `tool.driver.rules`. + `medium`, `note`→`low`, `none`→`info`, absent→`info`). + - Rule `defaultConfiguration.level` is used when result has no level. + - Rule metadata (`helpUri`) is extracted from `tool.driver.rules`. - `ruleIndex` is used when `ruleId` is absent. - Multi-location results produce one finding per location. - Results without locations throw `MISSING_LOCATION`. - - Invalid SARIF throws `INVALID_SARIF`. + - Invalid SARIF (wrong version, missing `runs`) throws `INVALID_SARIF`. + - `checkId` is `{prefix}:{ruleId}` when prefix is non-empty, else `ruleId`. + - `id` is `{checkId}:{fingerprint}`. + - `source.tool` and `source.originalSeverity` are set correctly. 2. **Fingerprint computation:** - Identical inputs produce identical fingerprints. - - Different `rule` values produce different fingerprints. - - Different `file` paths produce different fingerprints. - - Different line ranges produce different fingerprints. - - Message wording changes do not affect the fingerprint. - - Severity changes do not affect the fingerprint. + - Different `rule`/`file`/line-range/`checkId` values produce different + fingerprints. + - Message and severity changes do not affect the fingerprint. - Windows backslash paths are normalized to forward slashes. - - Empty required fields throw `INVALID_FINGERPRINT_INPUT`. + - Empty `file` or non-positive `startLine`/`endLine` throw + `INVALID_FINGERPRINT_INPUT`; empty `rule`/`checkId` are valid. + - Output is lowercase hex SHA-256 (64 chars). 3. **Baseline create:** - - A new baseline is written with all fingerprints. - - `createdAt` and `updatedAt` are set. - - `version` is 1. - - No suppressions are present initially. + - `createBaseline(findings)` returns a `Baseline` with all fingerprints. + - `createdAt` and `updatedAt` are valid ISO 8601 strings. + - `version` is 1. No suppressions. 4. **Baseline update:** - - New findings are added to the baseline. - - Resolved findings are removed. + - New fingerprints are added. + - Resolved fingerprints are removed. - Suppressions for resolved fingerprints are removed. - - `updatedAt` is refreshed. + - `updatedAt` is refreshed; `createdAt` is preserved. 5. **Baseline compare:** - - New findings are identified correctly. - - Resolved findings are identified correctly. - - Unchanged findings are identified correctly. - - Empty current findings produce all-resolved. - - Empty baseline produces all-new. + - `newFindings` are findings not in the baseline. + - `resolvedFingerprints` are baseline fingerprints not in current. + - `unchangedFindings` are findings in both. + - Empty current findings → all fingerprints resolved. + - Empty baseline → all findings new. 6. **Suppression:** - - Suppressed findings are excluded by default. - - `includeSuppressed: true` includes them. - - Expired suppressions are not applied. - - Inline `sverka-ignore-next-line` suppresses the next line's findings. - - Inline `sverka-ignore` on the same line suppresses that line's findings. -7. **`--only-new` filtering:** - - Only findings not in the baseline are returned. - - Missing baseline with `missingBaselineIsAllNew: true` returns all. - - Missing baseline with `missingBaselineIsAllNew: false` throws - `BASELINE_NOT_FOUND`. + - Suppressed findings are excluded by `filterSuppressed(..., false)`. + - `filterSuppressed(..., true)` includes them. + - Expired suppressions (`expiresAt` in the past) are not applied. + - `isSuppressed` returns true for matching, non-expired suppressions. +7. **Only-new filtering:** + - `filterOnlyNew` returns only findings not in the baseline. - Suppressed findings are excluded from new findings. -8. **Determinism:** - - Identical tool output produces identical normalized findings and - fingerprints across runs. -9. **Error cases:** - - `UNSUPPORTED_FORMAT` when no normalizer is registered. - - `PARSE_ERROR` for malformed JSON input. - - `BASELINE_INVALID` for wrong schema version. - - `BASELINE_WRITE_FAILED` when the baseline directory does not exist. +8. **Baseline I/O:** + - `loadBaseline` reads and parses a JSON file. + - `saveBaseline` writes a JSON file. + - Missing file throws `BASELINE_NOT_FOUND`. + - Invalid JSON throws `BASELINE_INVALID`. + - Wrong schema version throws `BASELINE_INVALID`. + - Unwritable path throws `BASELINE_WRITE_FAILED`. +9. **Determinism:** + - Identical SARIF input + identical context produce identical `Finding[]` + (same fingerprints, same ids, same field values). +10. **Error cases:** + - `INVALID_SARIF` for malformed structure. + - `MISSING_LOCATION` for result without locations. + - `INVALID_FINGERPRINT_INPUT` for empty `file` or non-positive lines. + - `BASELINE_NOT_FOUND`, `BASELINE_INVALID`, `BASELINE_WRITE_FAILED`. From 7e4cafaa0b96a07944c89c61907c9f9f3e640778 Mon Sep 17 00:00:00 2001 From: Petr Plenkov Date: Tue, 11 Aug 2026 02:30:50 +0200 Subject: [PATCH 2/4] fix(findings): resolve SonarCloud quality gate findings --- .../findings/src/__tests__/public-api.test.ts | 6 +- packages/findings/src/baseline.ts | 20 +- packages/findings/src/fingerprint.ts | 2 +- packages/findings/src/normalize.ts | 227 +++++++++++------- 4 files changed, 159 insertions(+), 96 deletions(-) diff --git a/packages/findings/src/__tests__/public-api.test.ts b/packages/findings/src/__tests__/public-api.test.ts index 397201917..662e47f8b 100644 --- a/packages/findings/src/__tests__/public-api.test.ts +++ b/packages/findings/src/__tests__/public-api.test.ts @@ -94,7 +94,7 @@ describe("public API — types (compile-time check)", () => { const _baseCode: BaselineErrorCode = "BASELINE_NOT_FOUND"; // Touch all to avoid unused warnings. expect(_finding).toBeDefined(); - expect(_severity).toBe("info"); + expect(_severity).toBeDefined(); expect(_source).toBeDefined(); expect(_ctx.root).toBe("/"); expect(_fpInput.rule).toBe("r"); @@ -106,7 +106,7 @@ describe("public API — types (compile-time check)", () => { expect(_sarifRule).toBeDefined(); expect(_sarifResult).toBeDefined(); expect(_sarifLocation).toBeDefined(); - expect(_normCode).toBe("INVALID_SARIF"); - expect(_baseCode).toBe("BASELINE_NOT_FOUND"); + expect(_normCode).toBeDefined(); + expect(_baseCode).toBeDefined(); }); }); diff --git a/packages/findings/src/baseline.ts b/packages/findings/src/baseline.ts index 8ca2f95b9..fda1b467f 100644 --- a/packages/findings/src/baseline.ts +++ b/packages/findings/src/baseline.ts @@ -143,7 +143,7 @@ export async function loadBaseline(path: string): Promise { } const obj = parsed as Record; - if (obj.version !== BASELINE_VERSION) { + if (typeof obj.version !== "number" || obj.version !== BASELINE_VERSION) { throw new BaselineError( `baseline version must be ${BASELINE_VERSION}, got ${String(obj.version)}`, "BASELINE_INVALID", @@ -161,13 +161,15 @@ export async function loadBaseline(path: string): Promise { "BASELINE_INVALID", ); } + const createdAt = asString(obj.createdAt); + const updatedAt = asString(obj.updatedAt); return { - version: obj.version as number, + version: obj.version, fingerprints: obj.fingerprints as string[], suppressions: obj.suppressions as Suppression[], - createdAt: String(obj.createdAt ?? ""), - updatedAt: String(obj.updatedAt ?? ""), + createdAt, + updatedAt, }; } @@ -191,9 +193,17 @@ export async function saveBaseline( } } +/** + * Coerce an unknown JSON value to a string. + * Returns the value when it is already a string, otherwise an empty string. + */ +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + /** * Dedupe and sort an array of strings. */ function dedupeSorted(items: readonly string[]): string[] { - return [...new Set(items)].sort(); + return [...new Set(items)].sort((a, b) => a.localeCompare(b)); } diff --git a/packages/findings/src/fingerprint.ts b/packages/findings/src/fingerprint.ts index 63abe204a..02c953248 100644 --- a/packages/findings/src/fingerprint.ts +++ b/packages/findings/src/fingerprint.ts @@ -33,7 +33,7 @@ export function computeFingerprint(input: FingerprintInput): string { ); } - const normalizedFile = input.file.replace(/\\/g, "/"); + const normalizedFile = input.file.replaceAll("\\", "/"); const payload = `${input.checkId}|${input.rule}|${normalizedFile}|${input.startLine}|${input.endLine}`; return createHash("sha256").update(payload).digest("hex"); } diff --git a/packages/findings/src/normalize.ts b/packages/findings/src/normalize.ts index 296c258bb..68ac766ff 100644 --- a/packages/findings/src/normalize.ts +++ b/packages/findings/src/normalize.ts @@ -80,7 +80,20 @@ export function normalizeSarif( sarif: SarifLog, context: NormalizeContext, ): Finding[] { - // Validate top-level structure. + validateSarifLog(sarif); + + const findings: Finding[] = []; + for (const run of sarif.runs) { + findings.push(...normalizeRun(run, context)); + } + return findings; +} + +/** + * Validate the top-level SARIF log structure. + * @throws {NormalizationError} INVALID_SARIF + */ +function validateSarifLog(sarif: SarifLog): void { if (sarif.version !== "2.1.0") { throw new NormalizationError( `expected SARIF version "2.1.0", got "${String(sarif.version)}"`, @@ -94,101 +107,141 @@ export function normalizeSarif( "INVALID_SARIF", ); } +} + +/** + * Normalize a single SARIF run into Findings. + * @throws {NormalizationError} INVALID_SARIF — missing driver or results. + */ +function normalizeRun( + run: SarifRun, + context: NormalizeContext, +): Finding[] { + const driver = run?.tool?.driver; + if (!driver || typeof driver.name !== "string" || !driver.name) { + throw new NormalizationError( + "each SARIF run must have tool.driver.name", + "INVALID_SARIF", + ); + } + if (!Array.isArray(run.results)) { + throw new NormalizationError( + "each SARIF run must have a results array", + "INVALID_SARIF", + ); + } + + const toolName = driver.name; + const toolVersion = driver.version ?? null; + const rules = driver.rules ?? []; const findings: Finding[] = []; + for (const result of run.results) { + findings.push(...normalizeResult(result, context, toolName, toolVersion, rules)); + } + return findings; +} - for (const run of sarif.runs) { - const driver = run?.tool?.driver; - if (!driver || typeof driver.name !== "string" || !driver.name) { - throw new NormalizationError( - "each SARIF run must have tool.driver.name", - "INVALID_SARIF", - ); - } - if (!Array.isArray(run.results)) { - throw new NormalizationError( - "each SARIF run must have a results array", - "INVALID_SARIF", - ); - } +/** + * Normalize a single SARIF result into one Finding per location. + * @throws {NormalizationError} MISSING_LOCATION — no locations. + */ +function normalizeResult( + result: SarifResult, + context: NormalizeContext, + toolName: string, + toolVersion: string | null, + rules: readonly SarifRule[], +): Finding[] { + const { ruleId, rule } = resolveRule(result, rules); + const level = result.level ?? rule?.defaultConfiguration?.level; + const severity = level ? (LEVEL_TO_SEVERITY[level] ?? "info") : "info"; + const helpUrl = rule?.helpUri; + const originalSeverity = result.level ?? null; - const toolName = driver.name; - const toolVersion = driver.version ?? null; - const rules = driver.rules ?? []; - - for (const result of run.results) { - const { ruleId, rule } = resolveRule(result, rules); - const level = result.level ?? rule?.defaultConfiguration?.level; - const severity = level ? (LEVEL_TO_SEVERITY[level] ?? "info") : "info"; - const helpUrl = rule?.helpUri; - const originalSeverity = result.level ?? null; - - const locations = result.locations; - if (!Array.isArray(locations) || locations.length === 0) { - throw new NormalizationError( - "SARIF result has no locations", - "MISSING_LOCATION", - { ruleId }, - ); - } - - for (const location of locations) { - const phys = location?.physicalLocation; - const uri = phys?.artifactLocation?.uri ?? ""; - const region = phys?.region; - const startLine = region?.startLine ?? 0; - const endLine = region?.endLine ?? startLine; - - const checkId = context.checkIdPrefix - ? `${context.checkIdPrefix}:${ruleId}` - : ruleId; - - const source: FindingSource = { - tool: toolName, - version: toolVersion, - format: "sarif", - originalRuleId: ruleId, - originalSeverity, - }; - - const fingerprint = computeFingerprint({ - rule: ruleId, - file: uri, - startLine, - endLine, - checkId, - }); - - findings.push({ - id: `${checkId}:${fingerprint}`, - fingerprint, - checkId, - severity, - confidence: context.defaultConfidence, - message: result.message?.text ?? "", - rule: ruleId, - file: uri, - startLine, - endLine, - ...(region?.startColumn !== undefined - ? { startColumn: region.startColumn } - : {}), - ...(region?.endColumn !== undefined - ? { endColumn: region.endColumn } - : {}), - ...(helpUrl !== undefined ? { helpUrl } : {}), - source, - ...(region?.snippet?.text !== undefined - ? { snippet: region.snippet.text } - : {}), - }); - } - } + const locations = result.locations; + if (!Array.isArray(locations) || locations.length === 0) { + throw new NormalizationError( + "SARIF result has no locations", + "MISSING_LOCATION", + { ruleId }, + ); } + const findings: Finding[] = []; + for (const location of locations) { + findings.push( + buildFinding(location, result, context, ruleId, severity, helpUrl, originalSeverity, toolName, toolVersion), + ); + } return findings; } +/** + * Build a single Finding from a SARIF location. + */ +function buildFinding( + location: SarifLocation, + result: SarifResult, + context: NormalizeContext, + ruleId: string, + severity: Severity, + helpUrl: string | undefined, + originalSeverity: "none" | "note" | "warning" | "error" | null, + toolName: string, + toolVersion: string | null, +): Finding { + const phys = location?.physicalLocation; + const uri = phys?.artifactLocation?.uri ?? ""; + const region = phys?.region; + const startLine = region?.startLine ?? 0; + const endLine = region?.endLine ?? startLine; + + const checkId = context.checkIdPrefix + ? `${context.checkIdPrefix}:${ruleId}` + : ruleId; + + const source: FindingSource = { + tool: toolName, + version: toolVersion, + format: "sarif", + originalRuleId: ruleId, + originalSeverity, + }; + + const fingerprint = computeFingerprint({ + rule: ruleId, + file: uri, + startLine, + endLine, + checkId, + }); + + return { + id: `${checkId}:${fingerprint}`, + fingerprint, + checkId, + severity, + confidence: context.defaultConfidence, + message: result.message?.text ?? "", + rule: ruleId, + file: uri, + startLine, + endLine, + ...(region?.startColumn !== undefined + ? { startColumn: region.startColumn } + : {}), + ...(region?.endColumn !== undefined + ? { endColumn: region.endColumn } + : {}), + ...(helpUrl !== undefined ? { helpUrl } : {}), + source, + ...(region?.snippet?.text !== undefined + ? { snippet: region.snippet.text } + : {}), + }; +} + /** * Resolve the rule for a SARIF result. Returns the ruleId (string, possibly * empty) and the matching SarifRule (if any). From d858065304a21061e771415421744c8b52f65974 Mon Sep 17 00:00:00 2001 From: Petr Plenkov Date: Tue, 11 Aug 2026 02:45:41 +0200 Subject: [PATCH 3/4] fix(findings): resolve Codacy quality gate findings --- packages/findings/src/fingerprint.ts | 2 +- packages/findings/src/normalize.ts | 105 +++++++++++++++------------ 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/packages/findings/src/fingerprint.ts b/packages/findings/src/fingerprint.ts index 02c953248..268da75e3 100644 --- a/packages/findings/src/fingerprint.ts +++ b/packages/findings/src/fingerprint.ts @@ -33,7 +33,7 @@ export function computeFingerprint(input: FingerprintInput): string { ); } - const normalizedFile = input.file.replaceAll("\\", "/"); + const normalizedFile = input.file.split("\\").join("/"); const payload = `${input.checkId}|${input.rule}|${normalizedFile}|${input.startLine}|${input.endLine}`; return createHash("sha256").update(payload).digest("hex"); } diff --git a/packages/findings/src/normalize.ts b/packages/findings/src/normalize.ts index 68ac766ff..025cc65fc 100644 --- a/packages/findings/src/normalize.ts +++ b/packages/findings/src/normalize.ts @@ -142,6 +142,29 @@ function normalizeRun( return findings; } +/** + * Resolved rule context shared across all locations of a result. + */ +interface ResultContext { + ruleId: string; + severity: Severity; + helpUrl: string | undefined; + originalSeverity: "none" | "note" | "warning" | "error" | null; + toolName: string; + toolVersion: string | null; +} + +/** + * Resolve the severity for a SARIF result from its level or rule default. + */ +function resolveSeverity( + result: SarifResult, + rule: SarifRule | undefined, +): Severity { + const level = result.level ?? rule?.defaultConfiguration?.level; + return level ? (LEVEL_TO_SEVERITY[level] ?? "info") : "info"; +} + /** * Normalize a single SARIF result into one Finding per location. * @throws {NormalizationError} MISSING_LOCATION — no locations. @@ -154,10 +177,14 @@ function normalizeResult( rules: readonly SarifRule[], ): Finding[] { const { ruleId, rule } = resolveRule(result, rules); - const level = result.level ?? rule?.defaultConfiguration?.level; - const severity = level ? (LEVEL_TO_SEVERITY[level] ?? "info") : "info"; - const helpUrl = rule?.helpUri; - const originalSeverity = result.level ?? null; + const rc: ResultContext = { + ruleId, + severity: resolveSeverity(result, rule), + helpUrl: rule?.helpUri, + originalSeverity: result.level ?? null, + toolName, + toolVersion, + }; const locations = result.locations; if (!Array.isArray(locations) || locations.length === 0) { @@ -168,13 +195,7 @@ function normalizeResult( ); } - const findings: Finding[] = []; - for (const location of locations) { - findings.push( - buildFinding(location, result, context, ruleId, severity, helpUrl, originalSeverity, toolName, toolVersion), - ); - } - return findings; + return locations.map((loc) => buildFinding(loc, result, context, rc)); } /** @@ -184,61 +205,53 @@ function buildFinding( location: SarifLocation, result: SarifResult, context: NormalizeContext, - ruleId: string, - severity: Severity, - helpUrl: string | undefined, - originalSeverity: "none" | "note" | "warning" | "error" | null, - toolName: string, - toolVersion: string | null, + rc: ResultContext, ): Finding { const phys = location?.physicalLocation; const uri = phys?.artifactLocation?.uri ?? ""; const region = phys?.region; const startLine = region?.startLine ?? 0; const endLine = region?.endLine ?? startLine; - const checkId = context.checkIdPrefix - ? `${context.checkIdPrefix}:${ruleId}` - : ruleId; - + ? `${context.checkIdPrefix}:${rc.ruleId}` + : rc.ruleId; const source: FindingSource = { - tool: toolName, - version: toolVersion, + tool: rc.toolName, + version: rc.toolVersion, format: "sarif", - originalRuleId: ruleId, - originalSeverity, + originalRuleId: rc.ruleId, + originalSeverity: rc.originalSeverity, }; - const fingerprint = computeFingerprint({ - rule: ruleId, - file: uri, - startLine, - endLine, - checkId, + rule: rc.ruleId, file: uri, startLine, endLine, checkId, }); - return { id: `${checkId}:${fingerprint}`, - fingerprint, - checkId, - severity, + fingerprint, checkId, + severity: rc.severity, confidence: context.defaultConfidence, message: result.message?.text ?? "", - rule: ruleId, - file: uri, - startLine, - endLine, + rule: rc.ruleId, file: uri, startLine, endLine, + source, + ...optionalFields(region, rc.helpUrl), + }; +} + +/** + * Build optional finding fields (columns, helpUrl, snippet) from a region. + */ +function optionalFields( + region: SarifLocation["physicalLocation"]["region"] | undefined, + helpUrl: string | undefined, +): Partial { + return { ...(region?.startColumn !== undefined - ? { startColumn: region.startColumn } - : {}), + ? { startColumn: region.startColumn } : {}), ...(region?.endColumn !== undefined - ? { endColumn: region.endColumn } - : {}), + ? { endColumn: region.endColumn } : {}), ...(helpUrl !== undefined ? { helpUrl } : {}), - source, ...(region?.snippet?.text !== undefined - ? { snippet: region.snippet.text } - : {}), + ? { snippet: region.snippet.text } : {}), }; } From 56e0be42557c5df24ad5504124a8678c0a1b886d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:38:23 +0000 Subject: [PATCH 4/4] ci: retrigger SonarCloud analysis Co-Authored-By: Petr Plenkov