diffpanel 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +408 -0
- package/dist/index.js +394 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -0
- package/src/format.ts +58 -0
- package/src/hash.ts +15 -0
- package/src/index.ts +6 -0
- package/src/schemas.ts +167 -0
- package/src/title.test.ts +28 -0
- package/src/title.ts +20 -0
- package/src/validation.test.ts +121 -0
- package/src/validation.ts +164 -0
- package/tsconfig.json +9 -0
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const reviewScopeSchema = z.discriminatedUnion("type", [
|
|
4
|
+
z.object({ type: z.literal("worktree"), baseRef: z.string(), baseSha: z.string() }),
|
|
5
|
+
z.object({ type: z.literal("staged"), baseRef: z.string(), baseSha: z.string() }),
|
|
6
|
+
z.object({
|
|
7
|
+
type: z.literal("range"),
|
|
8
|
+
expression: z.string(),
|
|
9
|
+
baseRef: z.string(),
|
|
10
|
+
compareRef: z.string(),
|
|
11
|
+
baseSha: z.string(),
|
|
12
|
+
compareSha: z.string(),
|
|
13
|
+
mergeBase: z.boolean(),
|
|
14
|
+
}),
|
|
15
|
+
z.object({
|
|
16
|
+
type: z.literal("repository"),
|
|
17
|
+
ref: z.string(),
|
|
18
|
+
sha: z.string(),
|
|
19
|
+
maxFiles: z.number().int().positive(),
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const fileStatusSchema = z.enum([
|
|
24
|
+
"added",
|
|
25
|
+
"modified",
|
|
26
|
+
"deleted",
|
|
27
|
+
"renamed",
|
|
28
|
+
"copied",
|
|
29
|
+
"unmerged",
|
|
30
|
+
"snapshot",
|
|
31
|
+
"unknown",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const reviewItemSchema = z.object({
|
|
35
|
+
id: z.string().min(1),
|
|
36
|
+
kind: z.enum(["hunk", "file"]),
|
|
37
|
+
filePath: z.string().min(1),
|
|
38
|
+
oldPath: z.string().nullable().default(null),
|
|
39
|
+
status: fileStatusSchema,
|
|
40
|
+
ordinal: z.number().int().nonnegative(),
|
|
41
|
+
oldStart: z.number().int().nonnegative().nullable(),
|
|
42
|
+
oldLines: z.number().int().nonnegative().nullable(),
|
|
43
|
+
newStart: z.number().int().nonnegative().nullable(),
|
|
44
|
+
newLines: z.number().int().nonnegative().nullable(),
|
|
45
|
+
patch: z.string(),
|
|
46
|
+
contentHash: z.string().min(1),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const reviewFileSchema = z.object({
|
|
50
|
+
id: z.string().min(1),
|
|
51
|
+
filePath: z.string().min(1),
|
|
52
|
+
oldPath: z.string().nullable().default(null),
|
|
53
|
+
status: fileStatusSchema,
|
|
54
|
+
beforeBlob: z.string().nullable(),
|
|
55
|
+
afterBlob: z.string().nullable(),
|
|
56
|
+
additions: z.number().int().nonnegative(),
|
|
57
|
+
deletions: z.number().int().nonnegative(),
|
|
58
|
+
language: z.string().nullable(),
|
|
59
|
+
size: z.number().int().nonnegative(),
|
|
60
|
+
items: z.array(reviewItemSchema),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const skippedEntrySchema = z.object({
|
|
64
|
+
filePath: z.string(),
|
|
65
|
+
reason: z.string(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const reviewManifestSchema = z.object({
|
|
69
|
+
schemaVersion: z.literal(1),
|
|
70
|
+
runId: z.string().min(1),
|
|
71
|
+
repositoryId: z.string().min(1),
|
|
72
|
+
repositoryRoot: z.string().min(1),
|
|
73
|
+
repositoryName: z.string().min(1),
|
|
74
|
+
createdAt: z.string().datetime(),
|
|
75
|
+
snapshotHash: z.string().min(1),
|
|
76
|
+
scope: reviewScopeSchema,
|
|
77
|
+
files: z.array(reviewFileSchema),
|
|
78
|
+
skipped: z.array(skippedEntrySchema),
|
|
79
|
+
requirements: z.object({ diagramAssessment: z.literal(true) }).optional(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const lineReferenceSchema = z.object({
|
|
83
|
+
filePath: z.string().min(1),
|
|
84
|
+
side: z.enum(["before", "after"]),
|
|
85
|
+
startLine: z.number().int().positive(),
|
|
86
|
+
endLine: z.number().int().positive(),
|
|
87
|
+
}).refine((value) => value.endLine >= value.startLine, {
|
|
88
|
+
message: "endLine must be greater than or equal to startLine",
|
|
89
|
+
path: ["endLine"],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const keyChangeSchema = z.object({
|
|
93
|
+
content: z.string().min(1),
|
|
94
|
+
lineRefs: z.array(lineReferenceSchema).min(1),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const chapterSchema = z.object({
|
|
98
|
+
id: z.string().min(1),
|
|
99
|
+
parentId: z.string().min(1).nullable().default(null),
|
|
100
|
+
order: z.number().int().positive(),
|
|
101
|
+
title: z.string().min(1).max(100),
|
|
102
|
+
summary: z.string().min(1),
|
|
103
|
+
diagram: z.string().max(20_000).nullable().optional(),
|
|
104
|
+
diagramItemRefs: z.array(z.string().min(1)).optional(),
|
|
105
|
+
itemRefs: z.array(z.string().min(1)).default([]),
|
|
106
|
+
keyChanges: z.array(keyChangeSchema).default([]),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const focusAreaSchema = z.object({
|
|
110
|
+
type: z.enum([
|
|
111
|
+
"security",
|
|
112
|
+
"breaking-change",
|
|
113
|
+
"high-complexity",
|
|
114
|
+
"data-integrity",
|
|
115
|
+
"new-pattern",
|
|
116
|
+
"architecture",
|
|
117
|
+
"performance",
|
|
118
|
+
"testing-gap",
|
|
119
|
+
]),
|
|
120
|
+
severity: z.enum(["critical", "high", "medium", "info"]),
|
|
121
|
+
title: z.string().min(1),
|
|
122
|
+
description: z.string().min(1),
|
|
123
|
+
locations: z.array(z.string().min(1)).min(1),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const prologueSchema = z.object({
|
|
127
|
+
motivation: z.string().nullable(),
|
|
128
|
+
outcome: z.string().nullable(),
|
|
129
|
+
diagram: z.string().nullable(),
|
|
130
|
+
diagramItemRefs: z.array(z.string().min(1)).optional(),
|
|
131
|
+
keyChanges: z.array(z.object({
|
|
132
|
+
summary: z.string().min(1),
|
|
133
|
+
description: z.string().min(1),
|
|
134
|
+
})).min(1).max(8),
|
|
135
|
+
focusAreas: z.array(focusAreaSchema).min(1).max(8),
|
|
136
|
+
complexity: z.object({
|
|
137
|
+
level: z.enum(["low", "medium", "high", "very-high"]),
|
|
138
|
+
reasoning: z.string().min(1),
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const REVIEW_TITLE_MAX_LENGTH = 80;
|
|
143
|
+
|
|
144
|
+
export const reviewTitleSchema = z.string().trim().min(1).max(REVIEW_TITLE_MAX_LENGTH);
|
|
145
|
+
|
|
146
|
+
export const generatedReviewSchema = z.object({
|
|
147
|
+
schemaVersion: z.literal(1),
|
|
148
|
+
runId: z.string().min(1),
|
|
149
|
+
generator: z.string().min(1).optional(),
|
|
150
|
+
title: reviewTitleSchema.optional(),
|
|
151
|
+
diagramAssessment: z.object({
|
|
152
|
+
kind: z.enum(["architectural", "other"]),
|
|
153
|
+
reasoning: z.string().trim().min(1),
|
|
154
|
+
overviewOmissionReason: z.string().trim().min(1).optional(),
|
|
155
|
+
chapterDiagramOmissionReason: z.string().trim().min(1).optional(),
|
|
156
|
+
}).optional(),
|
|
157
|
+
chapters: z.array(chapterSchema).min(1),
|
|
158
|
+
prologue: prologueSchema,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export type ReviewScope = z.infer<typeof reviewScopeSchema>;
|
|
162
|
+
export type ReviewItem = z.infer<typeof reviewItemSchema>;
|
|
163
|
+
export type ReviewFile = z.infer<typeof reviewFileSchema>;
|
|
164
|
+
export type ReviewManifest = z.infer<typeof reviewManifestSchema>;
|
|
165
|
+
export type Chapter = z.infer<typeof chapterSchema>;
|
|
166
|
+
export type GeneratedReview = z.infer<typeof generatedReviewSchema>;
|
|
167
|
+
export type Prologue = z.infer<typeof prologueSchema>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { displayReviewTitle, parseReviewTitle, titleForScope } from "./title.js";
|
|
3
|
+
|
|
4
|
+
describe("review titles", () => {
|
|
5
|
+
it("trims and accepts a custom title", () => {
|
|
6
|
+
expect(parseReviewTitle(" PR 567 retired surfaces ")).toBe("PR 567 retired surfaces");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("rejects an empty or oversized title", () => {
|
|
10
|
+
expect(() => parseReviewTitle(" ")).toThrow(/1 to 80/);
|
|
11
|
+
expect(() => parseReviewTitle("x".repeat(81))).toThrow(/1 to 80/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("falls back to the git scope label", () => {
|
|
15
|
+
expect(titleForScope({ type: "worktree", baseRef: "HEAD", baseSha: "abc" })).toBe("Working tree");
|
|
16
|
+
expect(titleForScope({
|
|
17
|
+
type: "range",
|
|
18
|
+
expression: "41a8f8db..c33a9b59",
|
|
19
|
+
baseRef: "41a8f8db",
|
|
20
|
+
compareRef: "c33a9b59",
|
|
21
|
+
baseSha: "abc",
|
|
22
|
+
compareSha: "def",
|
|
23
|
+
mergeBase: false,
|
|
24
|
+
})).toBe("41a8f8db..c33a9b59");
|
|
25
|
+
expect(displayReviewTitle(null, { type: "staged", baseRef: "HEAD", baseSha: "abc" })).toBe("Staged changes");
|
|
26
|
+
expect(displayReviewTitle("Account runtime", { type: "staged", baseRef: "HEAD", baseSha: "abc" })).toBe("Account runtime");
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/title.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { REVIEW_TITLE_MAX_LENGTH, reviewTitleSchema, type ReviewScope } from "./schemas.js";
|
|
2
|
+
|
|
3
|
+
export function parseReviewTitle(value: string): string {
|
|
4
|
+
const result = reviewTitleSchema.safeParse(value);
|
|
5
|
+
if (!result.success) {
|
|
6
|
+
throw new Error(`Review title must be 1 to ${REVIEW_TITLE_MAX_LENGTH} characters.`);
|
|
7
|
+
}
|
|
8
|
+
return result.data;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function titleForScope(scope: ReviewScope): string {
|
|
12
|
+
if (scope.type === "worktree") return "Working tree";
|
|
13
|
+
if (scope.type === "staged") return "Staged changes";
|
|
14
|
+
if (scope.type === "range") return scope.expression;
|
|
15
|
+
return `Repository at ${scope.ref}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function displayReviewTitle(title: string | null | undefined, scope: ReviewScope): string {
|
|
19
|
+
return title?.trim() || titleForScope(scope);
|
|
20
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { validateGeneratedReview } from "./validation.js";
|
|
3
|
+
|
|
4
|
+
const manifest = {
|
|
5
|
+
schemaVersion: 1 as const,
|
|
6
|
+
runId: "run-1",
|
|
7
|
+
repositoryId: "repo-1",
|
|
8
|
+
repositoryRoot: "/tmp/repo",
|
|
9
|
+
repositoryName: "repo",
|
|
10
|
+
createdAt: "2026-08-08T00:00:00.000Z",
|
|
11
|
+
snapshotHash: "snapshot",
|
|
12
|
+
scope: { type: "worktree" as const, baseRef: "HEAD", baseSha: "abc" },
|
|
13
|
+
files: [{
|
|
14
|
+
id: "file-1",
|
|
15
|
+
filePath: "src/a.ts",
|
|
16
|
+
oldPath: null,
|
|
17
|
+
status: "modified" as const,
|
|
18
|
+
beforeBlob: "before",
|
|
19
|
+
afterBlob: "after",
|
|
20
|
+
additions: 1,
|
|
21
|
+
deletions: 1,
|
|
22
|
+
language: "typescript",
|
|
23
|
+
size: 20,
|
|
24
|
+
items: [
|
|
25
|
+
{ id: "item-1", kind: "hunk" as const, filePath: "src/a.ts", oldPath: null, status: "modified" as const, ordinal: 0, oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, patch: "patch", contentHash: "h1" },
|
|
26
|
+
{ id: "item-2", kind: "hunk" as const, filePath: "src/a.ts", oldPath: null, status: "modified" as const, ordinal: 1, oldStart: 5, oldLines: 1, newStart: 5, newLines: 1, patch: "patch", contentHash: "h2" },
|
|
27
|
+
],
|
|
28
|
+
}],
|
|
29
|
+
skipped: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const review = {
|
|
33
|
+
schemaVersion: 1 as const,
|
|
34
|
+
runId: "run-1",
|
|
35
|
+
chapters: [{ id: "chapter-1", order: 1, title: "Explain the change", summary: "The behavior changes coherently.", itemRefs: ["item-1", "item-2"], keyChanges: [] }],
|
|
36
|
+
prologue: {
|
|
37
|
+
motivation: null,
|
|
38
|
+
outcome: null,
|
|
39
|
+
diagram: null,
|
|
40
|
+
keyChanges: [{ summary: "Behavior follows the new path", description: "Both related hunks now share one review chapter." }],
|
|
41
|
+
focusAreas: [{ type: "architecture" as const, severity: "info" as const, title: "Shared control path", description: "The related behavior moved together; confirm the boundary remains appropriate.", locations: ["src/a.ts"] }],
|
|
42
|
+
complexity: { level: "low" as const, reasoning: "One file with two related hunks." },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("validateGeneratedReview", () => {
|
|
47
|
+
it("accepts exact item coverage", () => {
|
|
48
|
+
expect(validateGeneratedReview(manifest, review)).toMatchObject({ valid: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("accepts an optional review title", () => {
|
|
52
|
+
expect(validateGeneratedReview(manifest, { ...review, title: "PR 568 DataFn foundations" })).toMatchObject({ valid: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reports missing and duplicate items", () => {
|
|
56
|
+
const invalid = structuredClone(review);
|
|
57
|
+
invalid.chapters[0]!.itemRefs = ["item-1", "item-1"];
|
|
58
|
+
const result = validateGeneratedReview(manifest, invalid);
|
|
59
|
+
expect(result.valid).toBe(false);
|
|
60
|
+
expect(result.missingItemRefs).toEqual(["item-2"]);
|
|
61
|
+
expect(result.duplicateItemRefs).toEqual(["item-1"]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
describe("diagram evidence", () => {
|
|
67
|
+
it("accepts supporting refs without counting them as duplicate ownership", () => {
|
|
68
|
+
const input = { ...review, chapters: [{ ...review.chapters[0], diagram: "flowchart LR\nA-->B", diagramItemRefs: ["item-1"] }] };
|
|
69
|
+
expect(validateGeneratedReview(manifest, input).valid).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it("requires evidence for new chapter diagrams", () => {
|
|
72
|
+
expect(validateGeneratedReview(manifest, { ...review, chapters: [{ ...review.chapters[0], diagram: "flowchart LR\nA-->B" }] }).errors).toContain("chapter chapter-1 diagram requires evidence item refs");
|
|
73
|
+
});
|
|
74
|
+
it("rejects unknown and duplicate prologue evidence", () => {
|
|
75
|
+
const input = { ...review, prologue: { ...review.prologue, diagramItemRefs: ["missing", "item-1", "item-1"] } };
|
|
76
|
+
expect(validateGeneratedReview(manifest, input).errors).toEqual(expect.arrayContaining(["prologue diagram references out-of-scope item missing", "prologue diagram repeats item item-1"]));
|
|
77
|
+
});
|
|
78
|
+
it("allows descendant evidence but rejects sibling evidence", () => {
|
|
79
|
+
const input = { ...review, chapters: [
|
|
80
|
+
{ ...review.chapters[0], id: "parent", itemRefs: [], diagram: "flowchart LR\nA-->B", diagramItemRefs: ["item-1"] },
|
|
81
|
+
{ ...review.chapters[0], id: "child", parentId: "parent", order: 2, itemRefs: ["item-1"] },
|
|
82
|
+
{ ...review.chapters[0], id: "sibling", order: 3, itemRefs: ["item-2"] },
|
|
83
|
+
] };
|
|
84
|
+
expect(validateGeneratedReview(manifest, input).valid).toBe(true);
|
|
85
|
+
const invalid = { ...input, chapters: input.chapters.map((chapter, index) => index === 0 ? { ...chapter, diagramItemRefs: ["item-2"] } : chapter) };
|
|
86
|
+
expect(validateGeneratedReview(manifest, invalid).errors).toContain("chapter parent diagram references out-of-scope item item-2");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("required visual assessment for new snapshots", () => {
|
|
91
|
+
const currentManifest = { ...manifest, requirements: { diagramAssessment: true as const } };
|
|
92
|
+
const assessment = { kind: "architectural" as const, reasoning: "Ownership and host initialization move across packages." };
|
|
93
|
+
|
|
94
|
+
it("keeps historical reviews valid but rejects an absent decision for new captures", () => {
|
|
95
|
+
expect(validateGeneratedReview(manifest, review).valid).toBe(true);
|
|
96
|
+
expect(validateGeneratedReview(currentManifest, review).errors).toContain("review requires diagramAssessment for this snapshot");
|
|
97
|
+
});
|
|
98
|
+
it("requires an overview and focused diagrams, or explicit omission reasons", () => {
|
|
99
|
+
const result = validateGeneratedReview(currentManifest, { ...review, diagramAssessment: assessment });
|
|
100
|
+
expect(result.errors).toContain("provide an overview diagram or overviewOmissionReason");
|
|
101
|
+
expect(result.errors).toContain("architectural reviews require focused chapter diagrams or chapterDiagramOmissionReason");
|
|
102
|
+
expect(validateGeneratedReview(currentManifest, { ...review, diagramAssessment: { ...assessment,
|
|
103
|
+
overviewOmissionReason: "The captured subset does not contain enough dependency evidence for a truthful overview.",
|
|
104
|
+
chapterDiagramOmissionReason: "These chapters contain declarations only; no flow is established by this snapshot.",
|
|
105
|
+
} }).valid).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
it("requires evidence for new overview diagrams, and rejects contradictory omissions", () => {
|
|
108
|
+
const input = { ...review, diagramAssessment: { kind: "other", reasoning: "Show a simple transition." },
|
|
109
|
+
prologue: { ...review.prologue, diagram: "flowchart LR\nA-->B" } };
|
|
110
|
+
expect(validateGeneratedReview(currentManifest, input).errors).toContain("prologue diagram requires evidence item refs");
|
|
111
|
+
expect(validateGeneratedReview(currentManifest, { ...input, prologue: { ...input.prologue, diagramItemRefs: ["item-1"] } }).valid).toBe(true);
|
|
112
|
+
expect(validateGeneratedReview(currentManifest, { ...input, diagramAssessment: { ...input.diagramAssessment, overviewOmissionReason: "omitted" } }).errors).toContain("overview diagram and overviewOmissionReason are mutually exclusive");
|
|
113
|
+
});
|
|
114
|
+
it("accepts an evidenced overview and chapter diagram without duplicate ownership", () => {
|
|
115
|
+
const input = { ...review, diagramAssessment: assessment,
|
|
116
|
+
prologue: { ...review.prologue, diagram: "flowchart LR\nA-->B", diagramItemRefs: ["item-1"] },
|
|
117
|
+
chapters: [{ ...review.chapters[0], diagram: "flowchart LR\nB-->C", diagramItemRefs: ["item-2"] }],
|
|
118
|
+
};
|
|
119
|
+
expect(validateGeneratedReview(currentManifest, input)).toMatchObject({ valid: true, duplicateItemRefs: [] });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { GeneratedReview, ReviewManifest } from "./schemas.js";
|
|
2
|
+
import { generatedReviewSchema, reviewManifestSchema } from "./schemas.js";
|
|
3
|
+
|
|
4
|
+
export interface ReviewValidationResult {
|
|
5
|
+
valid: boolean;
|
|
6
|
+
errors: string[];
|
|
7
|
+
missingItemRefs: string[];
|
|
8
|
+
duplicateItemRefs: string[];
|
|
9
|
+
extraItemRefs: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateGeneratedReview(
|
|
13
|
+
manifestInput: unknown,
|
|
14
|
+
reviewInput: unknown,
|
|
15
|
+
): ReviewValidationResult {
|
|
16
|
+
const manifestResult = reviewManifestSchema.safeParse(manifestInput);
|
|
17
|
+
const reviewResult = generatedReviewSchema.safeParse(reviewInput);
|
|
18
|
+
const errors: string[] = [];
|
|
19
|
+
|
|
20
|
+
if (!manifestResult.success) {
|
|
21
|
+
errors.push(...manifestResult.error.issues.map((issue) =>
|
|
22
|
+
`manifest.${issue.path.join(".")}: ${issue.message}`,
|
|
23
|
+
));
|
|
24
|
+
}
|
|
25
|
+
if (!reviewResult.success) {
|
|
26
|
+
errors.push(...reviewResult.error.issues.map((issue) =>
|
|
27
|
+
`review.${issue.path.join(".")}: ${issue.message}`,
|
|
28
|
+
));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!manifestResult.success || !reviewResult.success) {
|
|
32
|
+
return {
|
|
33
|
+
valid: false,
|
|
34
|
+
errors,
|
|
35
|
+
missingItemRefs: [],
|
|
36
|
+
duplicateItemRefs: [],
|
|
37
|
+
extraItemRefs: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const manifest = manifestResult.data;
|
|
42
|
+
const review = reviewResult.data;
|
|
43
|
+
const visualAssessment = review.diagramAssessment;
|
|
44
|
+
const enforceVisuals = !!manifest.requirements?.diagramAssessment || !!visualAssessment;
|
|
45
|
+
const hasOverview = !!review.prologue.diagram?.trim();
|
|
46
|
+
const hasChapterDiagrams = review.chapters.some((chapter) => !!chapter.diagram?.trim());
|
|
47
|
+
if (enforceVisuals) {
|
|
48
|
+
if (!visualAssessment) errors.push("review requires diagramAssessment for this snapshot");
|
|
49
|
+
else {
|
|
50
|
+
if (!hasOverview && !visualAssessment.overviewOmissionReason) errors.push("provide an overview diagram or overviewOmissionReason");
|
|
51
|
+
if (hasOverview && visualAssessment.overviewOmissionReason) errors.push("overview diagram and overviewOmissionReason are mutually exclusive");
|
|
52
|
+
if (visualAssessment.kind === "architectural" && !hasChapterDiagrams && !visualAssessment.chapterDiagramOmissionReason) {
|
|
53
|
+
errors.push("architectural reviews require focused chapter diagrams or chapterDiagramOmissionReason");
|
|
54
|
+
}
|
|
55
|
+
if (hasChapterDiagrams && visualAssessment.chapterDiagramOmissionReason) errors.push("chapter diagrams and chapterDiagramOmissionReason are mutually exclusive");
|
|
56
|
+
}
|
|
57
|
+
if (hasOverview && !review.prologue.diagramItemRefs?.length) errors.push("prologue diagram requires evidence item refs");
|
|
58
|
+
}
|
|
59
|
+
if (manifest.runId !== review.runId) {
|
|
60
|
+
errors.push(`review.runId ${review.runId} does not match manifest runId ${manifest.runId}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const chapterIds = new Set<string>();
|
|
64
|
+
const chapterOrders = new Set<number>();
|
|
65
|
+
for (const chapter of review.chapters) {
|
|
66
|
+
if (chapterIds.has(chapter.id)) errors.push(`duplicate chapter id: ${chapter.id}`);
|
|
67
|
+
if (chapterOrders.has(chapter.order)) errors.push(`duplicate chapter order: ${chapter.order}`);
|
|
68
|
+
chapterIds.add(chapter.id);
|
|
69
|
+
chapterOrders.add(chapter.order);
|
|
70
|
+
}
|
|
71
|
+
for (const chapter of review.chapters) {
|
|
72
|
+
if (chapter.parentId === chapter.id) errors.push(`chapter ${chapter.id} cannot be its own parent`);
|
|
73
|
+
if (chapter.parentId && !chapterIds.has(chapter.parentId)) {
|
|
74
|
+
errors.push(`chapter ${chapter.id} has unknown parent ${chapter.parentId}`);
|
|
75
|
+
}
|
|
76
|
+
const ancestry = new Set([chapter.id]);
|
|
77
|
+
let parentId = chapter.parentId;
|
|
78
|
+
while (parentId) {
|
|
79
|
+
if (ancestry.has(parentId)) {
|
|
80
|
+
errors.push(`chapter hierarchy contains a cycle at ${parentId}`);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
ancestry.add(parentId);
|
|
84
|
+
parentId = review.chapters.find((candidate) => candidate.id === parentId)?.parentId ?? null;
|
|
85
|
+
}
|
|
86
|
+
const hasChildren = review.chapters.some((candidate) => candidate.parentId === chapter.id);
|
|
87
|
+
if (chapter.itemRefs.length === 0 && !hasChildren) {
|
|
88
|
+
errors.push(`chapter ${chapter.id} has no review items or child chapters`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const expected = new Set(manifest.files.flatMap((file) => file.items.map((item) => item.id)));
|
|
93
|
+
const checkDiagramRefs = (label: string, refs: string[] | undefined, allowed: Set<string>): void => {
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
for (const ref of refs ?? []) {
|
|
96
|
+
if (!allowed.has(ref)) errors.push(`${label} diagram references out-of-scope item ${ref}`);
|
|
97
|
+
if (seen.has(ref)) errors.push(`${label} diagram repeats item ${ref}`);
|
|
98
|
+
seen.add(ref);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
checkDiagramRefs("prologue", review.prologue.diagramItemRefs, expected);
|
|
102
|
+
for (const chapter of review.chapters) {
|
|
103
|
+
const allowed = new Set<string>();
|
|
104
|
+
const visited = new Set<string>();
|
|
105
|
+
const collect = (id: string): void => {
|
|
106
|
+
if (visited.has(id)) return;
|
|
107
|
+
visited.add(id);
|
|
108
|
+
for (const candidate of review.chapters) {
|
|
109
|
+
if (candidate.id === id) for (const ref of candidate.itemRefs) if (expected.has(ref)) allowed.add(ref);
|
|
110
|
+
if (candidate.parentId === id) collect(candidate.id);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
collect(chapter.id);
|
|
114
|
+
checkDiagramRefs(`chapter ${chapter.id}`, chapter.diagramItemRefs, allowed);
|
|
115
|
+
if (chapter.diagram && !chapter.diagramItemRefs?.length) {
|
|
116
|
+
errors.push(`chapter ${chapter.id} diagram requires evidence item refs`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const observed = new Map<string, number>();
|
|
120
|
+
for (const itemRef of review.chapters.flatMap((chapter) => chapter.itemRefs)) {
|
|
121
|
+
observed.set(itemRef, (observed.get(itemRef) ?? 0) + 1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const missingItemRefs = [...expected].filter((id) => !observed.has(id)).sort();
|
|
125
|
+
const duplicateItemRefs = [...observed.entries()]
|
|
126
|
+
.filter(([, count]) => count > 1)
|
|
127
|
+
.map(([id]) => id)
|
|
128
|
+
.sort();
|
|
129
|
+
const extraItemRefs = [...observed.keys()].filter((id) => !expected.has(id)).sort();
|
|
130
|
+
|
|
131
|
+
if (missingItemRefs.length > 0) errors.push(`missing item refs: ${missingItemRefs.join(", ")}`);
|
|
132
|
+
if (duplicateItemRefs.length > 0) errors.push(`duplicate item refs: ${duplicateItemRefs.join(", ")}`);
|
|
133
|
+
if (extraItemRefs.length > 0) errors.push(`unknown item refs: ${extraItemRefs.join(", ")}`);
|
|
134
|
+
|
|
135
|
+
const files = new Set(manifest.files.map((file) => file.filePath));
|
|
136
|
+
for (const chapter of review.chapters) {
|
|
137
|
+
for (const change of chapter.keyChanges) {
|
|
138
|
+
for (const ref of change.lineRefs) {
|
|
139
|
+
if (!files.has(ref.filePath)) {
|
|
140
|
+
errors.push(`chapter ${chapter.id} references unknown file ${ref.filePath}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
valid: errors.length === 0,
|
|
148
|
+
errors,
|
|
149
|
+
missingItemRefs,
|
|
150
|
+
duplicateItemRefs,
|
|
151
|
+
extraItemRefs,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function assertGeneratedReview(
|
|
156
|
+
manifest: ReviewManifest,
|
|
157
|
+
reviewInput: unknown,
|
|
158
|
+
): GeneratedReview {
|
|
159
|
+
const result = validateGeneratedReview(manifest, reviewInput);
|
|
160
|
+
if (!result.valid) {
|
|
161
|
+
throw new Error(`Generated review validation failed:\n${result.errors.join("\n")}`);
|
|
162
|
+
}
|
|
163
|
+
return generatedReviewSchema.parse(reviewInput);
|
|
164
|
+
}
|