Skip to content

Commit 54f8995

Browse files
authored
Merge pull request #568 from Maryermarh/feat/soroban-metrics-fingerprinting-presets-functions
feat: implement Soroban metrics, fingerprinting, analysis presets, and function inventory
2 parents bd4141b + ac4c5a6 commit 54f8995

16 files changed

Lines changed: 627 additions & 0 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SorobanContractFingerprinter } from './soroban-contract-fingerprinter';
2+
export type { ContractFingerprint, DuplicateMatch, FingerprintReport } from './types';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { SorobanContractFingerprinter } from './soroban-contract-fingerprinter';
2+
3+
const CONTRACT_A = `
4+
impl TokenContract {
5+
pub fn new(env: Env) -> Self { Self {} }
6+
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {}
7+
fn validate(amount: i128) -> bool { amount > 0 }
8+
}
9+
`;
10+
11+
const CONTRACT_B = `
12+
impl TokenContract {
13+
pub fn new(env: Env) -> Self { Self {} }
14+
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {}
15+
fn validate(amount: i128) -> bool { amount > 0 }
16+
}
17+
`;
18+
19+
const CONTRACT_C = `
20+
impl StakingContract {
21+
pub fn stake(env: Env, amount: i128) {}
22+
}
23+
`;
24+
25+
describe('SorobanContractFingerprinter', () => {
26+
let fingerprinter: SorobanContractFingerprinter;
27+
28+
beforeEach(() => {
29+
fingerprinter = new SorobanContractFingerprinter();
30+
});
31+
32+
describe('fingerprint', () => {
33+
it('should generate a fingerprint with expected fields', () => {
34+
const fp = fingerprinter.fingerprint(CONTRACT_A, 'contracts/token.rs');
35+
expect(fp.fingerprint).toBeTruthy();
36+
expect(fp.structuralHash).toBeTruthy();
37+
expect(fp.contractName).toBe('TokenContract');
38+
expect(fp.functionCount).toBeGreaterThan(0);
39+
});
40+
41+
it('should produce the same fingerprint for identical sources', () => {
42+
const fp1 = fingerprinter.fingerprint(CONTRACT_A, 'a.rs');
43+
const fp2 = fingerprinter.fingerprint(CONTRACT_B, 'b.rs');
44+
expect(fp1.fingerprint).toBe(fp2.fingerprint);
45+
});
46+
47+
it('should produce different fingerprints for different sources', () => {
48+
const fp1 = fingerprinter.fingerprint(CONTRACT_A, 'a.rs');
49+
const fp2 = fingerprinter.fingerprint(CONTRACT_C, 'c.rs');
50+
expect(fp1.fingerprint).not.toBe(fp2.fingerprint);
51+
});
52+
});
53+
54+
describe('detectDuplicates', () => {
55+
it('should detect exact duplicates', () => {
56+
fingerprinter.fingerprint(CONTRACT_A, 'a.rs');
57+
fingerprinter.fingerprint(CONTRACT_B, 'b.rs');
58+
const duplicates = fingerprinter.detectDuplicates();
59+
expect(duplicates).toHaveLength(1);
60+
expect(duplicates[0].similarity).toBe('exact');
61+
});
62+
63+
it('should return no duplicates for unique contracts', () => {
64+
fingerprinter.fingerprint(CONTRACT_A, 'a.rs');
65+
fingerprinter.fingerprint(CONTRACT_C, 'c.rs');
66+
expect(fingerprinter.detectDuplicates()).toHaveLength(0);
67+
});
68+
});
69+
70+
describe('generateReport', () => {
71+
it('should report duplicate and unique counts', () => {
72+
fingerprinter.fingerprint(CONTRACT_A, 'a.rs');
73+
fingerprinter.fingerprint(CONTRACT_B, 'b.rs');
74+
fingerprinter.fingerprint(CONTRACT_C, 'c.rs');
75+
const report = fingerprinter.generateReport();
76+
expect(report.duplicateCount).toBe(1);
77+
expect(report.uniqueCount).toBe(2);
78+
});
79+
});
80+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createHash } from 'crypto';
2+
import { ContractFingerprint, DuplicateMatch, FingerprintReport } from './types';
3+
4+
export class SorobanContractFingerprinter {
5+
private registry: Map<string, ContractFingerprint> = new Map();
6+
7+
fingerprint(source: string, filePath: string): ContractFingerprint {
8+
const contractName = this.extractContractName(source);
9+
const normalized = this.normalizeSource(source);
10+
const fingerprint = this.hash(normalized);
11+
const structuralHash = this.hash(this.extractStructure(source));
12+
const functionCount = (source.match(/\bfn\s+\w+/g) ?? []).length;
13+
14+
const entry: ContractFingerprint = {
15+
contractName,
16+
filePath,
17+
fingerprint,
18+
structuralHash,
19+
functionCount,
20+
sourceLength: source.length,
21+
createdAt: new Date(),
22+
};
23+
24+
this.registry.set(filePath, entry);
25+
return entry;
26+
}
27+
28+
detectDuplicates(): DuplicateMatch[] {
29+
const entries = Array.from(this.registry.values());
30+
const duplicates: DuplicateMatch[] = [];
31+
32+
for (let i = 0; i < entries.length; i++) {
33+
for (let j = i + 1; j < entries.length; j++) {
34+
const a = entries[i];
35+
const b = entries[j];
36+
if (a.fingerprint === b.fingerprint) {
37+
duplicates.push({ original: a, duplicate: b, similarity: 'exact' });
38+
} else if (a.structuralHash === b.structuralHash) {
39+
duplicates.push({ original: a, duplicate: b, similarity: 'structural' });
40+
}
41+
}
42+
}
43+
44+
return duplicates;
45+
}
46+
47+
generateReport(): FingerprintReport {
48+
const fingerprints = Array.from(this.registry.values());
49+
const duplicates = this.detectDuplicates();
50+
const duplicateFiles = new Set(duplicates.map((d) => d.duplicate.filePath));
51+
52+
return {
53+
fingerprints,
54+
duplicates,
55+
uniqueCount: fingerprints.filter((f) => !duplicateFiles.has(f.filePath)).length,
56+
duplicateCount: duplicateFiles.size,
57+
generatedAt: new Date(),
58+
};
59+
}
60+
61+
clear(): void {
62+
this.registry.clear();
63+
}
64+
65+
private hash(input: string): string {
66+
return createHash('sha256').update(input).digest('hex');
67+
}
68+
69+
private normalizeSource(source: string): string {
70+
return source.replace(/\/\/[^\n]*/g, '').replace(/\s+/g, ' ').trim();
71+
}
72+
73+
private extractStructure(source: string): string {
74+
// Keep only fn signatures and struct/impl declarations for structural comparison
75+
return source
76+
.split('\n')
77+
.filter((l) => /^\s*(pub\s+)?(?:fn|struct|impl|trait|enum)\s/.test(l))
78+
.join('\n');
79+
}
80+
81+
private extractContractName(source: string): string {
82+
const match = source.match(/(?:impl|struct)\s+(\w+)/);
83+
return match?.[1] ?? 'UnknownContract';
84+
}
85+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface ContractFingerprint {
2+
contractName: string;
3+
filePath: string;
4+
fingerprint: string;
5+
structuralHash: string;
6+
functionCount: number;
7+
sourceLength: number;
8+
createdAt: Date;
9+
}
10+
11+
export interface DuplicateMatch {
12+
original: ContractFingerprint;
13+
duplicate: ContractFingerprint;
14+
similarity: 'exact' | 'structural';
15+
}
16+
17+
export interface FingerprintReport {
18+
fingerprints: ContractFingerprint[];
19+
duplicates: DuplicateMatch[];
20+
uniqueCount: number;
21+
duplicateCount: number;
22+
generatedAt: Date;
23+
}

src/metrics/rules/stellar/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SorobanRuleMetricsCollector } from './soroban-rule-metrics-collector';
2+
export type { SorobanRuleMetric, SorobanMetricsReport, SorobanMetricsConfig } from './types';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { SorobanRuleMetricsCollector } from './soroban-rule-metrics-collector';
2+
3+
describe('SorobanRuleMetricsCollector', () => {
4+
let collector: SorobanRuleMetricsCollector;
5+
6+
beforeEach(() => {
7+
collector = new SorobanRuleMetricsCollector();
8+
});
9+
10+
describe('record', () => {
11+
it('should record a new rule metric', () => {
12+
collector.record('soroban-storage', 50);
13+
const metric = collector.getMetric('soroban-storage');
14+
expect(metric).toBeDefined();
15+
expect(metric!.invocations).toBe(1);
16+
expect(metric!.avgDurationMs).toBe(50);
17+
});
18+
19+
it('should accumulate invocations and recalculate averages', () => {
20+
collector.record('rule-a', 100);
21+
collector.record('rule-a', 200);
22+
const metric = collector.getMetric('rule-a');
23+
expect(metric!.invocations).toBe(2);
24+
expect(metric!.totalDurationMs).toBe(300);
25+
expect(metric!.avgDurationMs).toBe(150);
26+
expect(metric!.minDurationMs).toBe(100);
27+
expect(metric!.maxDurationMs).toBe(200);
28+
});
29+
30+
it('should track errors', () => {
31+
collector.record('rule-b', 10, true);
32+
expect(collector.getMetric('rule-b')!.errorCount).toBe(1);
33+
});
34+
});
35+
36+
describe('getReport', () => {
37+
it('should return a report with correct totals', () => {
38+
collector.record('rule-a', 100);
39+
collector.record('rule-b', 200);
40+
const report = collector.getReport();
41+
expect(report.totalRules).toBe(2);
42+
expect(report.totalInvocations).toBe(2);
43+
expect(report.totalDurationMs).toBe(300);
44+
});
45+
46+
it('should list slowest rules first', () => {
47+
collector.record('slow', 500);
48+
collector.record('fast', 10);
49+
const report = collector.getReport();
50+
expect(report.slowestRules[0].ruleId).toBe('slow');
51+
});
52+
});
53+
54+
describe('reset', () => {
55+
it('should clear all metrics', () => {
56+
collector.record('rule-a', 50);
57+
collector.reset();
58+
expect(collector.getAllMetrics()).toHaveLength(0);
59+
});
60+
});
61+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { SorobanRuleMetric, SorobanMetricsConfig, SorobanMetricsReport } from './types';
2+
3+
const DEFAULT_CONFIG: Required<SorobanMetricsConfig> = {
4+
slowThresholdMs: 100,
5+
topN: 10,
6+
};
7+
8+
export class SorobanRuleMetricsCollector {
9+
private metrics: Map<string, SorobanRuleMetric> = new Map();
10+
private config: Required<SorobanMetricsConfig>;
11+
12+
constructor(config?: Partial<SorobanMetricsConfig>) {
13+
this.config = { ...DEFAULT_CONFIG, ...config };
14+
}
15+
16+
record(ruleId: string, durationMs: number, error = false): void {
17+
const existing = this.metrics.get(ruleId);
18+
if (!existing) {
19+
this.metrics.set(ruleId, {
20+
ruleId,
21+
invocations: 1,
22+
totalDurationMs: durationMs,
23+
minDurationMs: durationMs,
24+
maxDurationMs: durationMs,
25+
avgDurationMs: durationMs,
26+
errorCount: error ? 1 : 0,
27+
lastExecutedAt: new Date(),
28+
});
29+
return;
30+
}
31+
const invocations = existing.invocations + 1;
32+
const totalDurationMs = existing.totalDurationMs + durationMs;
33+
this.metrics.set(ruleId, {
34+
ruleId,
35+
invocations,
36+
totalDurationMs,
37+
minDurationMs: Math.min(existing.minDurationMs, durationMs),
38+
maxDurationMs: Math.max(existing.maxDurationMs, durationMs),
39+
avgDurationMs: totalDurationMs / invocations,
40+
errorCount: existing.errorCount + (error ? 1 : 0),
41+
lastExecutedAt: new Date(),
42+
});
43+
}
44+
45+
getMetric(ruleId: string): SorobanRuleMetric | undefined {
46+
return this.metrics.get(ruleId);
47+
}
48+
49+
getAllMetrics(): SorobanRuleMetric[] {
50+
return Array.from(this.metrics.values());
51+
}
52+
53+
getReport(): SorobanMetricsReport {
54+
const all = this.getAllMetrics();
55+
const totalInvocations = all.reduce((s, m) => s + m.invocations, 0);
56+
const totalDurationMs = all.reduce((s, m) => s + m.totalDurationMs, 0);
57+
return {
58+
totalRules: all.length,
59+
totalInvocations,
60+
totalDurationMs,
61+
avgDurationMs: all.length > 0 ? totalDurationMs / all.length : 0,
62+
slowestRules: [...all]
63+
.sort((a, b) => b.avgDurationMs - a.avgDurationMs)
64+
.slice(0, this.config.topN),
65+
mostInvokedRules: [...all]
66+
.sort((a, b) => b.invocations - a.invocations)
67+
.slice(0, this.config.topN),
68+
metrics: all,
69+
generatedAt: new Date(),
70+
};
71+
}
72+
73+
reset(): void {
74+
this.metrics.clear();
75+
}
76+
}

src/metrics/rules/stellar/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export interface SorobanRuleMetric {
2+
ruleId: string;
3+
invocations: number;
4+
totalDurationMs: number;
5+
minDurationMs: number;
6+
maxDurationMs: number;
7+
avgDurationMs: number;
8+
errorCount: number;
9+
lastExecutedAt: Date;
10+
}
11+
12+
export interface SorobanMetricsReport {
13+
totalRules: number;
14+
totalInvocations: number;
15+
totalDurationMs: number;
16+
avgDurationMs: number;
17+
slowestRules: SorobanRuleMetric[];
18+
mostInvokedRules: SorobanRuleMetric[];
19+
metrics: SorobanRuleMetric[];
20+
generatedAt: Date;
21+
}
22+
23+
export interface SorobanMetricsConfig {
24+
slowThresholdMs: number;
25+
topN: number;
26+
}

src/profiles/stellar/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SorobanAnalysisPresets } from './soroban-analysis-presets';
2+
export type { AnalysisPreset, PresetName, PresetResult } from './types';

0 commit comments

Comments
 (0)