Add skill version timeline (fingerprint lineage)
Task #14: show a full version timeline for each skill family, not just the single most-similar prior scan. What changed: - OpenAPI spec (lib/api-spec/openapi.yaml): new GET /scans/{id}/lineage (operationId getScanLineage) returning an array of ScanLineageEntry (id, name, verdict, riskScore, relation, similarity, comparedScanId, fingerprint, createdAt). Regenerated api-zod + api-client-react via codegen. - API (artifacts/api-server/src/routes/scans.ts): new lineage endpoint. Builds an undirected graph over all scans linked by the comparedScanId chain AND identical (non-empty) fingerprints, then BFS-walks the connected component containing the requested scan and returns it newest-first. Works purely from existing data, no re-scanning. 404 for unknown ids. - UI (artifacts/skillguard/src/pages/scan-report.tsx): new VersionTimeline card rendering the family as a vertical timeline; each entry shows verdict, relation badge, similarity, risk score and date. The viewed scan is marked "Aktuell angezeigt"; every other entry links to the existing comparison view /vergleich/{viewedId}/{entryId}. Card hidden when the family has <=1 member. Notes: - Lineage = connected component, so any member returns the full family. - Verified end-to-end locally (created new/modified/identical chain, checked lineage ordering + 404, confirmed timeline + compare links in the UI), then deleted the test scans. Replit-Task-Id: c7f87ce6-59d8-4396-b16b-f20846f42f0b
This commit is contained in:
parent
ba9788a93c
commit
54323706b5
13 changed files with 477 additions and 2 deletions
|
|
@ -2,3 +2,4 @@
|
||||||
- [OpenAI gpt-5 temperature](openai-temperature-gpt5.md) — gpt-5* reject `temperature != 1`; omit temperature in OpenAI-compatible clients or AI analysis silently fails.
|
- [OpenAI gpt-5 temperature](openai-temperature-gpt5.md) — gpt-5* reject `temperature != 1`; omit temperature in OpenAI-compatible clients or AI analysis silently fails.
|
||||||
- [NDJSON streaming on Replit](ndjson-streaming-express-replit.md) — use `res.on("close")`+`writableFinished` (NOT `req.on("close")`); persist on disconnect; proxy doesn't buffer; gate fallback to avoid dup rows.
|
- [NDJSON streaming on Replit](ndjson-streaming-express-replit.md) — use `res.on("close")`+`writableFinished` (NOT `req.on("close")`); persist on disconnect; proxy doesn't buffer; gate fallback to avoid dup rows.
|
||||||
- [Skill fingerprint & relation matching](skill-fingerprint-matching.md) — don't put display name in fingerprint path; match modified by file-path Jaccard (hash-Jaccard misses single-file edits), report content-aware similarity.
|
- [Skill fingerprint & relation matching](skill-fingerprint-matching.md) — don't put display name in fingerprint path; match modified by file-path Jaccard (hash-Jaccard misses single-file edits), report content-aware similarity.
|
||||||
|
- [Testing api-server from shell](api-server-local-curl.md) — external `$REPLIT_DEV_DOMAIN/api` curl returns HTTP 000; curl `http://localhost:<PORT>/api` instead (port from workflow log).
|
||||||
|
|
|
||||||
15
.agents/memory/api-server-local-curl.md
Normal file
15
.agents/memory/api-server-local-curl.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
name: Testing the SkillGuard api-server from the shell
|
||||||
|
description: How to reach the api-server with curl during development (external proxy fails).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing the api-server from the shell
|
||||||
|
|
||||||
|
Curling the api-server through the external proxy (`$REPLIT_DEV_DOMAIN/api/...`)
|
||||||
|
fails during development — it returns HTTP 000 / connection refused (the proxy
|
||||||
|
routes by the selected artifact + mTLS, so a bare curl does not reach it).
|
||||||
|
|
||||||
|
**How to apply:** curl the api-server directly on its local port instead, e.g.
|
||||||
|
`http://localhost:8080/api/scans`. Confirm the port from the api-server workflow
|
||||||
|
log line `Server listening port: <PORT>` (it reads `PORT`, defaulting to 8080).
|
||||||
|
The web artifact's preview, however, reaches the API fine through the proxy.
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
DeleteScanParams,
|
DeleteScanParams,
|
||||||
CompareScansParams,
|
CompareScansParams,
|
||||||
CompareScansResponse,
|
CompareScansResponse,
|
||||||
|
GetScanLineageResponse,
|
||||||
} from "@workspace/api-zod";
|
} from "@workspace/api-zod";
|
||||||
import {
|
import {
|
||||||
parseZip,
|
parseZip,
|
||||||
|
|
@ -639,6 +640,88 @@ router.get("/scans/:id/compare/:otherId", async (req, res) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/scans/:id/lineage", async (req, res) => {
|
||||||
|
const params = GetScanParams.safeParse(req.params);
|
||||||
|
if (!params.success)
|
||||||
|
return res.status(400).json({ message: "Ungültige ID" });
|
||||||
|
|
||||||
|
const [scan] = await db
|
||||||
|
.select()
|
||||||
|
.from(scansTable)
|
||||||
|
.where(eq(scansTable.id, params.data.id));
|
||||||
|
if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" });
|
||||||
|
|
||||||
|
// Load only the columns needed to reconstruct the lineage graph for every
|
||||||
|
// stored scan, then walk the connected component containing this scan.
|
||||||
|
const all = await db
|
||||||
|
.select({
|
||||||
|
id: scansTable.id,
|
||||||
|
name: scansTable.name,
|
||||||
|
verdict: scansTable.verdict,
|
||||||
|
riskScore: scansTable.riskScore,
|
||||||
|
relation: scansTable.relation,
|
||||||
|
similarity: scansTable.similarity,
|
||||||
|
comparedScanId: scansTable.comparedScanId,
|
||||||
|
fingerprint: scansTable.fingerprint,
|
||||||
|
createdAt: scansTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(scansTable);
|
||||||
|
|
||||||
|
const byId = new Map(all.map((s) => [s.id, s]));
|
||||||
|
|
||||||
|
// Build an undirected graph: scans are linked when one was compared against
|
||||||
|
// the other (comparedScanId chain) or when they share an identical
|
||||||
|
// fingerprint. The fingerprint family is the connected component.
|
||||||
|
const adjacency = new Map<number, Set<number>>();
|
||||||
|
const addEdge = (a: number, b: number) => {
|
||||||
|
if (!byId.has(a) || !byId.has(b) || a === b) return;
|
||||||
|
(adjacency.get(a) ?? adjacency.set(a, new Set()).get(a)!).add(b);
|
||||||
|
(adjacency.get(b) ?? adjacency.set(b, new Set()).get(b)!).add(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const byFingerprint = new Map<string, number[]>();
|
||||||
|
for (const s of all) {
|
||||||
|
if (s.comparedScanId != null) addEdge(s.id, s.comparedScanId);
|
||||||
|
if (s.fingerprint) {
|
||||||
|
const list = byFingerprint.get(s.fingerprint) ?? [];
|
||||||
|
list.push(s.id);
|
||||||
|
byFingerprint.set(s.fingerprint, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const ids of byFingerprint.values()) {
|
||||||
|
for (let i = 1; i < ids.length; i++) addEdge(ids[0], ids[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const family = new Set<number>([scan.id]);
|
||||||
|
const queue: number[] = [scan.id];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const cur = queue.shift()!;
|
||||||
|
for (const next of adjacency.get(cur) ?? []) {
|
||||||
|
if (!family.has(next)) {
|
||||||
|
family.add(next);
|
||||||
|
queue.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Array.from(family)
|
||||||
|
.map((fid) => byId.get(fid)!)
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
verdict: s.verdict,
|
||||||
|
riskScore: s.riskScore,
|
||||||
|
relation: s.relation,
|
||||||
|
similarity: s.similarity,
|
||||||
|
comparedScanId: s.comparedScanId,
|
||||||
|
fingerprint: s.fingerprint,
|
||||||
|
createdAt: s.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json(GetScanLineageResponse.parse(entries));
|
||||||
|
});
|
||||||
|
|
||||||
router.delete("/scans/:id", async (req, res) => {
|
router.delete("/scans/:id", async (req, res) => {
|
||||||
const params = DeleteScanParams.safeParse(req.params);
|
const params = DeleteScanParams.safeParse(req.params);
|
||||||
if (!params.success)
|
if (!params.success)
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 81 KiB |
|
|
@ -1,6 +1,11 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useRoute, Link } from "wouter";
|
import { useRoute, Link } from "wouter";
|
||||||
import { useGetScan, getGetScanQueryKey } from "@workspace/api-client-react";
|
import {
|
||||||
|
useGetScan,
|
||||||
|
getGetScanQueryKey,
|
||||||
|
useGetScanLineage,
|
||||||
|
getGetScanLineageQueryKey,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
@ -11,7 +16,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History } from "lucide-react";
|
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical } from "lucide-react";
|
||||||
import type { ScanDetail } from "@workspace/api-client-react";
|
import type { ScanDetail } from "@workspace/api-client-react";
|
||||||
|
|
||||||
export default function ScanReport() {
|
export default function ScanReport() {
|
||||||
|
|
@ -265,6 +270,8 @@ export default function ScanReport() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<VersionTimeline scanId={data.id} />
|
||||||
|
|
||||||
<Tabs defaultValue="findings" className="w-full">
|
<Tabs defaultValue="findings" className="w-full">
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-4">
|
||||||
<TabsTrigger value="findings" className="gap-2"><Shield className="w-4 h-4"/> Auffälligkeiten ({data.findings.length})</TabsTrigger>
|
<TabsTrigger value="findings" className="gap-2"><Shield className="w-4 h-4"/> Auffälligkeiten ({data.findings.length})</TabsTrigger>
|
||||||
|
|
@ -469,6 +476,87 @@ export default function ScanReport() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VersionTimeline({ scanId }: { scanId: number }) {
|
||||||
|
const { data, isLoading } = useGetScanLineage(scanId, {
|
||||||
|
query: {
|
||||||
|
enabled: Number.isFinite(scanId) && scanId > 0,
|
||||||
|
queryKey: getGetScanLineageQueryKey(scanId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-48 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing meaningful to show unless this skill has more than one known version.
|
||||||
|
if (!data || data.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<History className="w-5 h-5" /> Versionsverlauf
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Alle bekannten Versionen dieses Skills (verknüpft über Fingerprint-Abstammung), neueste zuerst. Wählen Sie eine Version, um den Vergleich anzuzeigen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="relative border-l border-border ml-3 space-y-6">
|
||||||
|
{data.map((entry) => {
|
||||||
|
const isCurrent = entry.id === scanId;
|
||||||
|
return (
|
||||||
|
<li key={entry.id} className="ml-6">
|
||||||
|
<span
|
||||||
|
className={`absolute -left-[9px] flex h-4 w-4 items-center justify-center rounded-full ring-4 ring-background ${
|
||||||
|
isCurrent ? "bg-primary" : "bg-muted-foreground/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitCommitVertical className="w-3 h-3 text-background" />
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{isCurrent ? (
|
||||||
|
<span className="font-medium">{entry.name || `Scan #${entry.id}`}</span>
|
||||||
|
) : (
|
||||||
|
<Link href={`/berichte/${entry.id}`} className="font-medium hover:underline">
|
||||||
|
{entry.name || `Scan #${entry.id}`}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<VerdictBadge verdict={entry.verdict} />
|
||||||
|
<RelationBadge relation={entry.relation} />
|
||||||
|
{entry.relation === "modified" && entry.similarity != null && (
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">{entry.similarity}% ähnlich</Badge>
|
||||||
|
)}
|
||||||
|
{isCurrent && (
|
||||||
|
<Badge variant="secondary" className="text-xs">Aktuell angezeigt</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{formatDate(entry.createdAt)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Risiko {entry.riskScore} / 100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isCurrent && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-2 shrink-0">
|
||||||
|
<Link href={`/vergleich/${scanId}/${entry.id}`}>
|
||||||
|
<GitCompare className="w-4 h-4" />
|
||||||
|
Vergleich
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const VERDICT_LABELS: Record<string, string> = {
|
const VERDICT_LABELS: Record<string, string> = {
|
||||||
pass: "Freigabe",
|
pass: "Freigabe",
|
||||||
review: "Manuelle Prüfung",
|
review: "Manuelle Prüfung",
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,52 @@ export type ScanDetail = Scan & ({
|
||||||
comparedScan: ComparedScan | null;
|
comparedScan: ComparedScan | null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type ScanLineageEntryVerdict = typeof ScanLineageEntryVerdict[keyof typeof ScanLineageEntryVerdict];
|
||||||
|
|
||||||
|
|
||||||
|
export const ScanLineageEntryVerdict = {
|
||||||
|
pass: 'pass',
|
||||||
|
review: 'review',
|
||||||
|
block: 'block',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relation of this version to the one it was compared against
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
export type ScanLineageEntryRelation = typeof ScanLineageEntryRelation[keyof typeof ScanLineageEntryRelation] | null;
|
||||||
|
|
||||||
|
|
||||||
|
export const ScanLineageEntryRelation = {
|
||||||
|
new: 'new',
|
||||||
|
identical: 'identical',
|
||||||
|
modified: 'modified',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface ScanLineageEntry {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
verdict: ScanLineageEntryVerdict;
|
||||||
|
riskScore: number;
|
||||||
|
/**
|
||||||
|
* Relation of this version to the one it was compared against
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
relation: ScanLineageEntryRelation;
|
||||||
|
/**
|
||||||
|
* Content-aware similarity (0-100) to its compared version
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
similarity: number | null;
|
||||||
|
/**
|
||||||
|
* The prior version this scan was compared against, if any
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
comparedScanId: number | null;
|
||||||
|
fingerprint: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ScanComparisonSideVerdict = typeof ScanComparisonSideVerdict[keyof typeof ScanComparisonSideVerdict];
|
export type ScanComparisonSideVerdict = typeof ScanComparisonSideVerdict[keyof typeof ScanComparisonSideVerdict];
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import type {
|
||||||
Scan,
|
Scan,
|
||||||
ScanComparison,
|
ScanComparison,
|
||||||
ScanDetail,
|
ScanDetail,
|
||||||
|
ScanLineageEntry,
|
||||||
SkillScanInput
|
SkillScanInput
|
||||||
} from './api.schemas';
|
} from './api.schemas';
|
||||||
|
|
||||||
|
|
@ -438,6 +439,84 @@ export function useCompareScans<TData = Awaited<ReturnType<typeof compareScans>>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetScanLineageUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/scans/${id}/lineage`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns every scan in the same fingerprint lineage as the given scan (linked by an identical fingerprint or by the comparedScanId chain), newest first, so the full version history of a skill can be shown on a timeline without re-scanning.
|
||||||
|
* @summary Get the version timeline for a skill family
|
||||||
|
*/
|
||||||
|
export const getScanLineage = async (id: number, options?: RequestInit): Promise<ScanLineageEntry[]> => {
|
||||||
|
|
||||||
|
return customFetch<ScanLineageEntry[]>(getGetScanLineageUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetScanLineageQueryKey = (id: number,) => {
|
||||||
|
return [
|
||||||
|
`/api/scans/${id}/lineage`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetScanLineageQueryOptions = <TData = Awaited<ReturnType<typeof getScanLineage>>, TError = ErrorType<ApiError>>(id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getScanLineage>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetScanLineageQueryKey(id);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getScanLineage>>> = ({ signal }) => getScanLineage(id, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getScanLineage>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetScanLineageQueryResult = NonNullable<Awaited<ReturnType<typeof getScanLineage>>>
|
||||||
|
export type GetScanLineageQueryError = ErrorType<ApiError>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get the version timeline for a skill family
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetScanLineage<TData = Awaited<ReturnType<typeof getScanLineage>>, TError = ErrorType<ApiError>>(
|
||||||
|
id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getScanLineage>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getGetScanLineageQueryOptions(id,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getGetScanUrl = (id: number,) => {
|
export const getGetScanUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,38 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ApiError"
|
$ref: "#/components/schemas/ApiError"
|
||||||
|
|
||||||
|
/scans/{id}/lineage:
|
||||||
|
get:
|
||||||
|
operationId: getScanLineage
|
||||||
|
tags: [scans]
|
||||||
|
summary: Get the version timeline for a skill family
|
||||||
|
description: >-
|
||||||
|
Returns every scan in the same fingerprint lineage as the given scan
|
||||||
|
(linked by an identical fingerprint or by the comparedScanId chain),
|
||||||
|
newest first, so the full version history of a skill can be shown on a
|
||||||
|
timeline without re-scanning.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Version timeline (most recent first)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ScanLineageEntry"
|
||||||
|
"404":
|
||||||
|
description: Not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ApiError"
|
||||||
|
|
||||||
/scans/{id}:
|
/scans/{id}:
|
||||||
get:
|
get:
|
||||||
operationId: getScan
|
operationId: getScan
|
||||||
|
|
@ -598,6 +630,43 @@ components:
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
ScanLineageEntry:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- verdict
|
||||||
|
- riskScore
|
||||||
|
- relation
|
||||||
|
- similarity
|
||||||
|
- comparedScanId
|
||||||
|
- fingerprint
|
||||||
|
- createdAt
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
verdict:
|
||||||
|
type: string
|
||||||
|
enum: [pass, review, block]
|
||||||
|
riskScore:
|
||||||
|
type: integer
|
||||||
|
relation:
|
||||||
|
type: ["string", "null"]
|
||||||
|
enum: [new, identical, modified, null]
|
||||||
|
description: Relation of this version to the one it was compared against
|
||||||
|
similarity:
|
||||||
|
type: ["integer", "null"]
|
||||||
|
description: Content-aware similarity (0-100) to its compared version
|
||||||
|
comparedScanId:
|
||||||
|
type: ["integer", "null"]
|
||||||
|
description: The prior version this scan was compared against, if any
|
||||||
|
fingerprint:
|
||||||
|
type: string
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
|
||||||
ScanComparisonSide:
|
ScanComparisonSide:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, verdict, riskScore, fileCount, fingerprint, createdAt]
|
required: [id, name, verdict, riskScore, fileCount, fingerprint, createdAt]
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,28 @@ export const CompareScansResponse = zod.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns every scan in the same fingerprint lineage as the given scan (linked by an identical fingerprint or by the comparedScanId chain), newest first, so the full version history of a skill can be shown on a timeline without re-scanning.
|
||||||
|
* @summary Get the version timeline for a skill family
|
||||||
|
*/
|
||||||
|
export const GetScanLineageParams = zod.object({
|
||||||
|
"id": zod.coerce.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GetScanLineageResponseItem = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"verdict": zod.enum(['pass', 'review', 'block']),
|
||||||
|
"riskScore": zod.number(),
|
||||||
|
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation of this version to the one it was compared against'),
|
||||||
|
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to its compared version'),
|
||||||
|
"comparedScanId": zod.number().nullable().describe('The prior version this scan was compared against, if any'),
|
||||||
|
"fingerprint": zod.string(),
|
||||||
|
"createdAt": zod.string()
|
||||||
|
})
|
||||||
|
export const GetScanLineageResponse = zod.array(GetScanLineageResponseItem)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get a scan report with findings
|
* @summary Get a scan report with findings
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ export * from './scanFile';
|
||||||
export * from './scanFileDiff';
|
export * from './scanFileDiff';
|
||||||
export * from './scanFileDiffStatus';
|
export * from './scanFileDiffStatus';
|
||||||
export * from './scanFileKind';
|
export * from './scanFileKind';
|
||||||
|
export * from './scanLineageEntry';
|
||||||
|
export * from './scanLineageEntryRelation';
|
||||||
|
export * from './scanLineageEntryVerdict';
|
||||||
export * from './scanRelation';
|
export * from './scanRelation';
|
||||||
export * from './scanSource';
|
export * from './scanSource';
|
||||||
export * from './scanStatus';
|
export * from './scanStatus';
|
||||||
|
|
|
||||||
33
lib/api-zod/src/generated/types/scanLineageEntry.ts
Normal file
33
lib/api-zod/src/generated/types/scanLineageEntry.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* API specification
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ScanLineageEntryRelation } from './scanLineageEntryRelation';
|
||||||
|
import type { ScanLineageEntryVerdict } from './scanLineageEntryVerdict';
|
||||||
|
|
||||||
|
export interface ScanLineageEntry {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
verdict: ScanLineageEntryVerdict;
|
||||||
|
riskScore: number;
|
||||||
|
/**
|
||||||
|
* Relation of this version to the one it was compared against
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
relation: ScanLineageEntryRelation;
|
||||||
|
/**
|
||||||
|
* Content-aware similarity (0-100) to its compared version
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
similarity: number | null;
|
||||||
|
/**
|
||||||
|
* The prior version this scan was compared against, if any
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
comparedScanId: number | null;
|
||||||
|
fingerprint: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
20
lib/api-zod/src/generated/types/scanLineageEntryRelation.ts
Normal file
20
lib/api-zod/src/generated/types/scanLineageEntryRelation.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* API specification
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relation of this version to the one it was compared against
|
||||||
|
* @nullable
|
||||||
|
*/
|
||||||
|
export type ScanLineageEntryRelation = typeof ScanLineageEntryRelation[keyof typeof ScanLineageEntryRelation] | null;
|
||||||
|
|
||||||
|
|
||||||
|
export const ScanLineageEntryRelation = {
|
||||||
|
new: 'new',
|
||||||
|
identical: 'identical',
|
||||||
|
modified: 'modified',
|
||||||
|
} as const;
|
||||||
16
lib/api-zod/src/generated/types/scanLineageEntryVerdict.ts
Normal file
16
lib/api-zod/src/generated/types/scanLineageEntryVerdict.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* API specification
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ScanLineageEntryVerdict = typeof ScanLineageEntryVerdict[keyof typeof ScanLineageEntryVerdict];
|
||||||
|
|
||||||
|
|
||||||
|
export const ScanLineageEntryVerdict = {
|
||||||
|
pass: 'pass',
|
||||||
|
review: 'review',
|
||||||
|
block: 'block',
|
||||||
|
} as const;
|
||||||
Loading…
Add table
Reference in a new issue