Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apps/api/src/findings/__tests__/findings.relocation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import * as fs from 'fs';
import * as path from 'path';
import { createLocationAnchor } from '@engine/core/location-mapper';
import { findingsRepository } from '../findings.repository';
import { FindingsService } from '../findings.service';

const FIXTURES = path.resolve(__dirname, '../../../../../tests/fixtures/location-mapping');
const read = (name: string) => fs.readFileSync(path.join(FIXTURES, name), 'utf8');
const v1 = read('runner.v1.ts');
const v2 = read('runner.v2.ts');
const v4 = read('runner.v4-rewritten.ts');

const FILE = 'src/runner.ts';

function seed(service: FindingsService, org: string, repo: string, line: number) {
return service.create({
organizationId: org,
repositoryId: repo,
analysisJobId: 'job-1',
title: `exec at ${line}`,
description: 'd',
severity: 'high',
ruleId: 'cmd-001',
filePath: FILE,
line,
locationAnchor: createLocationAnchor(v1, { startLine: line }),
});
}

describe('finding location relocation', () => {
const service = new FindingsService(findingsRepository);

beforeEach(() => findingsRepository.clear());

it('stores the anchor and marks new findings exact', () => {
const f = seed(service, 'org-a', 'repo-1', 9);
expect(f.locationAnchor?.startLine).toBe(9);
expect(f.locationStatus).toBe('exact');
});

it('leaves findings created without an anchor unchanged (backward compatible)', () => {
const f = service.create({
organizationId: 'org-a', repositoryId: 'repo-1', analysisJobId: 'j',
title: 't', description: 'd', severity: 'low', ruleId: 'r', filePath: FILE, line: 3,
});
expect(f.locationAnchor).toBeUndefined();
expect(f.locationStatus).toBeUndefined();
const res = service.relocateFindings({ organizationId: 'org-a', repositoryId: 'repo-1', files: { [FILE]: v2 } });
expect(res).toMatchObject({ examined: 1, skippedNoAnchor: 1, relocated: 0 });
expect(service.getForTenant(f.id, 'org-a')!.line).toBe(3);
});

it('moves each finding to its new line without touching its fingerprint', () => {
const lookup = seed(service, 'org-a', 'repo-1', 9);
const tail = seed(service, 'org-a', 'repo-1', 14);
const fingerprints = [lookup.fingerprint, tail.fingerprint];

const res = service.relocateFindings({
organizationId: 'org-a', repositoryId: 'repo-1', files: new Map([[`./${FILE}`, v2]]),
});

expect(res).toMatchObject({ examined: 2, relocated: 2, lost: 0, unchanged: 0 });
const lines = v2.split('\n');
const execLines = lines.map((l, i) => (l.trim() === 'return exec(cmd);' ? i + 1 : 0)).filter(Boolean);
expect(service.getForTenant(lookup.id, 'org-a')!.line).toBe(execLines[0]);
expect(service.getForTenant(tail.id, 'org-a')!.line).toBe(execLines[1]);
expect(service.getForTenant(lookup.id, 'org-a')!.locationStatus).toBe('moved');
expect([lookup.fingerprint, tail.fingerprint]).toEqual(fingerprints);
});

it('is idempotent: a second run over the same contents changes nothing', () => {
seed(service, 'org-a', 'repo-1', 9);
const input = { organizationId: 'org-a', repositoryId: 'repo-1', files: { [FILE]: v2 } };
service.relocateFindings(input);
expect(service.relocateFindings(input)).toMatchObject({ relocated: 0, unchanged: 1, lost: 0 });
});

it('marks findings lost when their code is gone, then recovers them when it returns', () => {
const f = seed(service, 'org-a', 'repo-1', 9);
const base = { organizationId: 'org-a', repositoryId: 'repo-1' };

expect(service.relocateFindings({ ...base, files: { [FILE]: v4 } })).toMatchObject({ lost: 1 });
let current = service.getForTenant(f.id, 'org-a')!;
expect(current.locationStatus).toBe('lost');
expect(current.line).toBe(9);

service.relocateFindings({ ...base, files: { [FILE]: v2 } });
current = service.getForTenant(f.id, 'org-a')!;
expect(current.locationStatus).toBe('moved');
expect(current.line).not.toBe(9);
});

it('marks findings in deleted files lost, but ignores files missing from a partial snapshot', () => {
const f = seed(service, 'org-a', 'repo-1', 9);
const base = { organizationId: 'org-a', repositoryId: 'repo-1' };

expect(service.relocateFindings({ ...base, files: {} })).toMatchObject({ skippedFileNotProvided: 1, lost: 0 });
expect(service.getForTenant(f.id, 'org-a')!.locationStatus).toBe('exact');

expect(service.relocateFindings({ ...base, files: {}, deletedFiles: [FILE] })).toMatchObject({ lost: 1 });
expect(service.getForTenant(f.id, 'org-a')!.locationStatus).toBe('lost');
});

it('never touches another organization or repository', () => {
const own = seed(service, 'org-a', 'repo-1', 9);
const otherOrg = seed(service, 'org-b', 'repo-1', 9);
const otherRepo = seed(service, 'org-a', 'repo-2', 9);

const res = service.relocateFindings({ organizationId: 'org-a', repositoryId: 'repo-1', files: { [FILE]: v2 } });

expect(res.examined).toBe(1);
expect(service.getForTenant(own.id, 'org-a')!.line).not.toBe(9);
expect(service.getForTenant(otherOrg.id, 'org-b')!.line).toBe(9);
expect(service.getForTenant(otherRepo.id, 'org-a')!.line).toBe(9);
});

it('ignores corrupt anchors and unsafe paths instead of throwing', () => {
const f = seed(service, 'org-a', 'repo-1', 9);
(f as { locationAnchor: unknown }).locationAnchor = { version: 99 };
const res = service.relocateFindings({
organizationId: 'org-a', repositoryId: 'repo-1', files: { '../escape.ts': v2, [FILE]: v2 },
});
expect(res).toMatchObject({ examined: 1, skippedNoAnchor: 1 });
});

it('validates tenant scope', () => {
expect(() => service.relocateFindings({ organizationId: ' ', repositoryId: 'r', files: {} })).toThrow(
'organizationId is required',
);
expect(() => service.relocateFindings({ organizationId: 'o', repositoryId: '', files: {} })).toThrow(
'repositoryId is required',
);
});
});
114 changes: 114 additions & 0 deletions apps/api/src/findings/finding-location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Finding location relocation (pure planning half).
*
* Decides, per finding, where its flagged code lives in a newer revision of the
* file. Persistence stays in FindingsService so this module has no state and can
* be reused by workers and reports.
*/

import {
buildFileLineIndex,
createLocationAnchor,
FileLineIndex,
isLocationAnchor,
LocationMapOptions,
mapLocation,
} from '@engine/core/location-mapper';
import { normalizeFindingPath } from '@engine/core/result-normalizer';
import type { Finding, LocationAnchor, LocationStatus } from './finding.types';

export interface RelocateFindingsInput {
organizationId: string;
repositoryId: string;
/** Current contents by repository-relative path. Paths are normalized. */
files: Record<string, string> | Map<string, string>;
/** Paths that no longer exist; anchored findings in them become 'lost'. */
deletedFiles?: string[];
options?: LocationMapOptions;
}

export interface RelocateFindingsResult {
examined: number;
/** Anchored findings whose line and anchor did not need to change. */
unchanged: number;
/** Findings whose line, anchor or status changed to a located state. */
relocated: number;
/** Findings that could not be located (or whose file was deleted). */
lost: number;
skippedNoAnchor: number;
/** File content for the finding was not part of this snapshot. */
skippedFileNotProvided: number;
}

export interface RelocationContext {
files: Map<string, string>;
indexes: Map<string, FileLineIndex>;
deleted: Set<string>;
options: LocationMapOptions;
}

export type RelocationPlan =
| { kind: 'skip'; reason: 'no-anchor' | 'file-not-provided' }
| { kind: 'apply'; changed: boolean; line: number; anchor: LocationAnchor; status: LocationStatus };

export function buildRelocationContext(input: RelocateFindingsInput): RelocationContext {
const files = new Map<string, string>();
const entries = input.files instanceof Map ? [...input.files.entries()] : Object.entries(input.files ?? {});
for (const [path, content] of entries) {
const norm = normalizeFindingPath(path);
if (norm !== undefined && typeof content === 'string') files.set(norm, content);
}
const deleted = new Set<string>();
for (const path of input.deletedFiles ?? []) {
const norm = normalizeFindingPath(path);
if (norm !== undefined) deleted.add(norm);
}
return { files, indexes: new Map(), deleted, options: input.options ?? {} };
}

export function planFindingRelocation(finding: Finding, ctx: RelocationContext): RelocationPlan {
const anchor = finding.locationAnchor;
const path = normalizeFindingPath(finding.filePath);
if (!isLocationAnchor(anchor) || path === undefined) return { kind: 'skip', reason: 'no-anchor' };

const keep = (status: LocationStatus): RelocationPlan => ({
kind: 'apply',
changed: finding.locationStatus !== status,
line: finding.line ?? anchor.startLine,
anchor,
status,
});

if (ctx.deleted.has(path)) return keep('lost');

const content = ctx.files.get(path);
if (content === undefined) return { kind: 'skip', reason: 'file-not-provided' };

let index = ctx.indexes.get(path);
if (!index) {
index = buildFileLineIndex(content);
ctx.indexes.set(path, index);
}

const mapping = mapLocation(anchor, index, ctx.options);
// Keep the old anchor when lost so a later revision can still match it.
if (mapping.status === 'lost') return keep('lost');

const next =
createLocationAnchor(content, { startLine: mapping.startLine, endLine: mapping.endLine }, ctx.options) ??
anchor;
const line = mapping.startLine;
const sameAnchor = JSON.stringify(next) === JSON.stringify(anchor);
// Re-checking an already-relocated finding finds it exactly where we left it.
// Keep the earlier moved/modified/ambiguous label so reruns are no-ops; only a
// recovery from 'lost' (or a first run) takes the new status.
const settled =
mapping.status === 'exact' &&
finding.line === line &&
sameAnchor &&
finding.locationStatus !== undefined &&
finding.locationStatus !== 'lost';
const status = settled ? finding.locationStatus! : mapping.status;
const changed = finding.line !== line || finding.locationStatus !== status || !sameAnchor;
return { kind: 'apply', changed, line, anchor: next, status };
}
11 changes: 11 additions & 0 deletions apps/api/src/findings/finding.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
* Finding domain types and list query contract (#992).
*/

import type { LocationAnchor, LocationStatus } from '@engine/core/location-mapper';

export type { LocationAnchor, LocationStatus };

export type FindingSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
export type FindingStatus = 'open' | 'suppressed' | 'resolved' | 'accepted';

Expand All @@ -18,6 +22,13 @@ export interface Finding {
ruleId: string;
filePath?: string;
line?: number;
/**
* Content anchor for `line`, used to follow the code as the file changes.
* Absent on findings created before location mapping existed.
*/
locationAnchor?: LocationAnchor;
/** Result of the last relocation against a newer revision of the file. */
locationStatus?: LocationStatus;
/** Current assignee (user/team/module) (#1035). */
assignedTo?: string;
/** Actor who performed the last assignment/reassignment (#1035). */
Expand Down
72 changes: 72 additions & 0 deletions apps/api/src/findings/findings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ import {
} from './finding.types';
import { findingsToCsv } from './findings.csv';
import { computeFindingFingerprint } from './finding.fingerprint';
import {
RelocateFindingsInput,
RelocateFindingsResult,
planFindingRelocation,
buildRelocationContext,
} from './finding-location';
import type { LocationAnchor } from './finding.types';

export interface CreateFindingInput {
organizationId: string;
Expand All @@ -41,6 +48,8 @@ export interface CreateFindingInput {
ruleId: string;
filePath?: string;
line?: number;
/** Content anchor captured at `line`; enables relocation across revisions. */
locationAnchor?: LocationAnchor;
expiresAt?: string;
}

Expand Down Expand Up @@ -128,6 +137,9 @@ export class FindingsService {
ruleId: input.ruleId,
filePath: input.filePath,
line: input.line,
...(input.locationAnchor
? { locationAnchor: input.locationAnchor, locationStatus: 'exact' as const }
: {}),
expiresAt: input.expiresAt,
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -165,6 +177,66 @@ export class FindingsService {
return this.repo.list(query);
}

/**
* Re-point anchored findings of one repository at their new lines after the
* files changed. Scoped to (organizationId, repositoryId); idempotent for the
* same file contents. Files absent from `files` are left untouched, so a
* partial snapshot never marks findings as lost.
*/
relocateFindings(input: RelocateFindingsInput): RelocateFindingsResult {
for (const field of ['organizationId', 'repositoryId'] as const) {
if (typeof input?.[field] !== 'string' || !input[field].trim()) {
throw Object.assign(new Error(`${field} is required`), {
code: 'VALIDATION_ERROR',
status: 400,
});
}
}
const ctx = buildRelocationContext(input);
const targets: Finding[] = [];
let cursor: string | undefined;
do {
const page = this.repo.list({
organizationId: input.organizationId,
repositoryId: input.repositoryId,
limit: MAX_PAGE_LIMIT,
cursor,
});
targets.push(...page.items);
cursor = page.nextCursor ?? undefined;
} while (cursor);

const result: RelocateFindingsResult = {
examined: targets.length,
unchanged: 0,
relocated: 0,
lost: 0,
skippedNoAnchor: 0,
skippedFileNotProvided: 0,
};
const now = new Date().toISOString();
for (const finding of targets) {
const plan = planFindingRelocation(finding, ctx);
if (plan.kind === 'skip') {
if (plan.reason === 'no-anchor') result.skippedNoAnchor++;
else result.skippedFileNotProvided++;
continue;
}
if (!plan.changed) {
result.unchanged++;
continue;
}
finding.line = plan.line;
finding.locationAnchor = plan.anchor;
finding.locationStatus = plan.status;
finding.updatedAt = now;
this.repo.upsert(finding);
if (plan.status === 'lost') result.lost++;
else result.relocated++;
}
return result;
}

getForTenant(id: string, organizationId: string): Finding | undefined {
return this.repo.getForTenant(id, organizationId);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/findings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ export * from './findings.routes';

// Symbol-aware finding context (#1164)
export * from './symbol-context';

// Stable location mapping
export * from './finding-location';
Loading
Loading