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
11 changes: 11 additions & 0 deletions apps/api/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
};
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.25",
"@types/jest": "^30.0.0",
"@types/node": "^20.19.39",
"@types/passport-jwt": "^3.0.13",
"@types/passport-local": "^1.0.38",
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/prompts/diff.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { DiffService } from './diff.service';

describe('DiffService', () => {
const mockPrisma = {} as any;
const service = new DiffService(mockPrisma);

it('identical content → all equal', () => {
const res = service['computeDiff']('a\nb', 'a\nb');
expect(res.length).toBe(2);
expect(res.every(l => l.type === 'equal')).toBe(true);
});

it('empty from → all inserts', () => {
const res = service['computeDiff']('', 'a\nb');
expect(res.length).toBe(2);
expect(res.every(l => l.type === 'insert')).toBe(true);
});

it('empty to → all deletes', () => {
const res = service['computeDiff']('a\nb', '');
expect(res.length).toBe(2);
expect(res.every(l => l.type === 'delete')).toBe(true);
});

it('completely different → delete + insert', () => {
const res = service['computeDiff']('a\nb', 'x\ny');
expect(res.some(l => l.type === 'delete')).toBe(true);
expect(res.some(l => l.type === 'insert')).toBe(true);
});

it('stats match line count', () => {
const res = service['computeDiff']('a\nb', 'a\nc');

const stats = {
added: res.filter(l => l.type === 'insert').length,
removed: res.filter(l => l.type === 'delete').length,
unchanged: res.filter(l => l.type === 'equal').length,
};

expect(stats.added + stats.removed + stats.unchanged).toBe(res.length);
});
});
101 changes: 85 additions & 16 deletions apps/api/src/prompts/diff.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export type ChangeType = 'equal' | 'insert' | 'delete';

export interface DiffLine {
type: ChangeType;
lineNumber?: number;
lineNumber: number; // old file
toLineNumber?: number; // new file
content: string;
}

Expand All @@ -20,7 +21,11 @@ export interface DiffResult {
export class DiffService {
constructor(private prisma: PrismaService) {}

async diffVersions(promptId: string, fromTag: string, toTag: string): Promise<DiffResult> {
async diffVersions(
promptId: string,
fromTag: string,
toTag: string
): Promise<DiffResult> {
const [from, to] = await Promise.all([
this.prisma.promptVersion.findUniqueOrThrow({
where: { promptId_versionTag: { promptId, versionTag: fromTag } },
Expand All @@ -31,48 +36,103 @@ export class DiffService {
]);

const lines = this.computeDiff(from.content, to.content);

const stats = {
added: lines.filter((l) => l.type === 'insert').length,
removed: lines.filter((l) => l.type === 'delete').length,
unchanged: lines.filter((l) => l.type === 'equal').length,
};

return { fromVersion: fromTag, toVersion: toTag, lines, stats };
return {
fromVersion: fromTag,
toVersion: toTag,
lines,
stats,
};
}

/**
* Myers diff algorithm — same algorithm Git uses internally.
* Returns line-level hunks suitable for side-by-side rendering.
*/
private computeDiff(a: string, b: string): DiffLine[] {
// ✅ handle empty cases properly (no phantom empty line)
if (!a) {
return b
.split('\n')
.filter((l) => l.length > 0)
.map((content, i) => ({
type: 'insert' as const,
content,
lineNumber: i + 1,
toLineNumber: i + 1,
}));
}

if (!b) {
return a
.split('\n')
.filter((l) => l.length > 0)
.map((content, i) => ({
type: 'delete' as const,
content,
lineNumber: i + 1,
}));
}

const aLines = a.split('\n');
const bLines = b.split('\n');
const result: DiffLine[] = [];

// LCS-based diff
const result: DiffLine[] = [];
const lcs = this.buildLCS(aLines, bLines);

let i = 0;
let j = 0;

for (const [ai, bi] of lcs) {
while (i < ai) {
result.push({ type: 'delete', lineNumber: i + 1, content: aLines[i] });
result.push({
type: 'delete',
content: aLines[i],
lineNumber: i + 1,
});
i++;
}

while (j < bi) {
result.push({ type: 'insert', lineNumber: j + 1, content: bLines[j] });
result.push({
type: 'insert',
content: bLines[j],
lineNumber: j + 1,
toLineNumber: j + 1,
});
j++;
}
result.push({ type: 'equal', content: aLines[ai] });

result.push({
type: 'equal',
content: aLines[ai],
lineNumber: ai + 1,
Comment thread
anasahhm marked this conversation as resolved.
toLineNumber: bi + 1,
});

i++;
j++;
}

while (i < aLines.length) {
result.push({ type: 'delete', lineNumber: i + 1, content: aLines[i++] });
result.push({
type: 'delete',
content: aLines[i],
lineNumber: i + 1,
});
i++;
}

while (j < bLines.length) {
result.push({ type: 'insert', lineNumber: j + 1, content: bLines[j++] });
result.push({
type: 'insert',
content: bLines[j],
lineNumber: j + 1,
toLineNumber: j + 1,
});
j++;
}

return result;
Expand All @@ -81,17 +141,25 @@ export class DiffService {
private buildLCS(a: string[], b: string[]): [number, number][] {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0)
);

for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
dp[i][j] =
a[i - 1] === b[j - 1]
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}

const result: [number, number][] = [];

let i = m;
let j = n;

while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
result.unshift([i - 1, j - 1]);
Expand All @@ -103,6 +171,7 @@ export class DiffService {
j--;
}
}

return result;
}
}
26 changes: 26 additions & 0 deletions apps/api/src/prompts/prompts.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { DiffService } from './diff.service';
import { IsString, IsNotEmpty } from 'class-validator';

class DiffQueryDto {
@IsString()
@IsNotEmpty()
from: string;

@IsString()
@IsNotEmpty()
to: string;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker — DTO validation will break every request.

The global ValidationPipe in main.ts is configured with whitelist: true + forbidNonWhitelisted: true. Without class-validator decorators, from and to are treated as non-whitelisted and get stripped — so query.from and query.to arrive as undefined, and Prisma throws a 500 on every call.

Quick fix:

import { IsString, IsNotEmpty } from 'class-validator';

class DiffQueryDto {
  @IsString()
  @IsNotEmpty()
  from: string;

  @IsString()
  @IsNotEmpty()
  to: string;
}

This will also give callers a proper 400 Bad Request with a clear message when from or to are missing, which is much friendlier than a 500.


@Controller('prompts')
export class PromptsController {
constructor(private readonly diffService: DiffService) {}

@Get(':id/diff')
diff(
@Param('id') id: string,
@Query() query: DiffQueryDto,
) {
return this.diffService.diffVersions(id, query.from, query.to);
}
}
34 changes: 19 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.