Skip to content

Commit d7f8910

Browse files
committed
feat: validate context feedback schema
1 parent 3ae24b7 commit d7f8910

3 files changed

Lines changed: 59 additions & 9 deletions

File tree

src/context-registry/feedback-store.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "node:path";
33
import { hashContextText } from "./hash.js";
44
import { createContextFeedback, type CreateContextFeedbackInput } from "./feedback.js";
55
import type { ContextFeedback, ContextFeedbackStore } from "./types.js";
6+
import { validateContextFeedbackStore } from "./validators.js";
67

78
const FEEDBACK_DIRECTORY = path.join(".agent-context", "context-registry", "feedback");
89

@@ -55,16 +56,9 @@ function emptyStore(repository: string): ContextFeedbackStore {
5556
}
5657

5758
function validateFeedbackStore(store: ContextFeedbackStore, repository: string): void {
58-
if (store.schemaVersion !== 1) throw new Error(`Unsupported Context feedback schemaVersion ${String(store.schemaVersion)}.`);
59-
if (!Number.isInteger(store.revision) || store.revision < 0) throw new Error("Context feedback store revision must be non-negative.");
59+
const result = validateContextFeedbackStore(store);
60+
if (!result.valid) throw new Error(`Invalid Context feedback store: ${result.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
6061
if (path.resolve(store.repository) !== repository) throw new Error("Context feedback store repository does not match the current repository.");
61-
if (!Array.isArray(store.feedback)) throw new Error("Context feedback store feedback must be an array.");
62-
const ids = new Set<string>();
63-
for (const item of store.feedback) {
64-
if (!item || item.schemaVersion !== 1 || typeof item.feedbackId !== "string") throw new Error("Context feedback store contains an invalid feedback record.");
65-
if (ids.has(item.feedbackId)) throw new Error(`Context feedback store contains duplicate feedbackId ${item.feedbackId}.`);
66-
ids.add(item.feedbackId);
67-
}
6862
}
6963

7064
function compareFeedback(left: ContextFeedback, right: ContextFeedback): number {

src/context-registry/validators.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import type {
1818
ContextEntry,
1919
ContextEntryKind,
2020
ContextFetchResult,
21+
ContextFeedback,
22+
ContextFeedbackLabel,
23+
ContextFeedbackStore,
24+
ContextFeedbackTarget,
2125
ContextFile,
2226
ContextFileRole,
2327
ContextPack,
@@ -31,6 +35,47 @@ const SOURCE_KINDS = new Set(["local", "remote", "bundled"]);
3135
const TRUST_LEVELS = new Set<ContextTrustLevel>(["official", "maintainer", "community", "private", "untrusted"]);
3236
const ENTRY_KINDS = new Set<ContextEntryKind>(["doc", "skill", "reference", "task-pack", "repository"]);
3337
const ANNOTATION_KINDS = new Set<ContextAnnotationKind>(["environment", "version-difference", "failure-cause", "convention", "workaround"]);
38+
const FEEDBACK_LABELS = new Set<ContextFeedbackLabel>(["useful", "not-useful", "outdated", "inaccurate", "incomplete", "wrong-version", "wrong-example", "irrelevant"]);
39+
const FEEDBACK_TARGETS = new Set<ContextFeedbackTarget>(["entry", "file", "retrieval-result", "intervention"]);
40+
41+
export function validateContextFeedback(input: unknown, path = "$"): ContextValidationResult<ContextFeedback> {
42+
const issues = validateSchemaEnvelope(input, path);
43+
if (!isRecord(input)) return invalidResult(issues);
44+
requiredString(input, "feedbackId", path, issues);
45+
requiredTimestamp(input, "createdAt", path, issues);
46+
const target = requiredString(input, "target", path, issues);
47+
const label = requiredString(input, "label", path, issues);
48+
requiredString(input, "entryId", path, issues);
49+
requiredString(input, "source", path, issues);
50+
optionalString(input, "version", path, issues);
51+
const file = optionalString(input, "file", path, issues);
52+
const retrievalId = optionalString(input, "retrievalId", path, issues);
53+
const interventionId = optionalString(input, "interventionId", path, issues);
54+
if (target && !FEEDBACK_TARGETS.has(target as ContextFeedbackTarget)) issues.push({ path: `${path}.target`, code: "value", message: `unsupported feedback target ${target}` });
55+
if (label && !FEEDBACK_LABELS.has(label as ContextFeedbackLabel)) issues.push({ path: `${path}.label`, code: "value", message: `unsupported feedback label ${label}` });
56+
if (file && !normalizeContextFilePath(file)) issues.push({ path: `${path}.file`, code: "path", message: "must be a normalized relative path" });
57+
if (target === "file" && !file) issues.push({ path: `${path}.file`, code: "required", message: "file feedback requires file" });
58+
if (target === "retrieval-result" && !retrievalId) issues.push({ path: `${path}.retrievalId`, code: "required", message: "retrieval feedback requires retrievalId" });
59+
if (target === "intervention" && !interventionId) issues.push({ path: `${path}.interventionId`, code: "required", message: "intervention feedback requires interventionId" });
60+
return issues.length ? invalidResult(issues) : validResult(input as unknown as ContextFeedback);
61+
}
62+
63+
export function validateContextFeedbackStore(input: unknown, path = "$"): ContextValidationResult<ContextFeedbackStore> {
64+
const issues = validateSchemaEnvelope(input, path);
65+
if (!isRecord(input)) return invalidResult(issues);
66+
requiredString(input, "repository", path, issues);
67+
if (!Array.isArray(input.feedback)) issues.push({ path: `${path}.feedback`, code: "type", message: "expected an array" });
68+
const ids = new Set<string>();
69+
for (const [index, item] of (Array.isArray(input.feedback) ? input.feedback : []).entries()) {
70+
const result = validateContextFeedback(item, `${path}.feedback[${index}]`);
71+
issues.push(...result.issues);
72+
if (result.value) {
73+
if (ids.has(result.value.feedbackId)) issues.push({ path: `${path}.feedback[${index}].feedbackId`, code: "value", message: "feedback IDs must be unique" });
74+
ids.add(result.value.feedbackId);
75+
}
76+
}
77+
return issues.length ? invalidResult(issues) : validResult(input as unknown as ContextFeedbackStore);
78+
}
3479

3580
export function validateContextFile(input: unknown, path = "$"): ContextValidationResult<ContextFile> {
3681
const issues = validateSchemaEnvelope(input, path);

test/context-feedback.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
33
import { createContextFeedback, CONTEXT_FEEDBACK_LABELS, CONTEXT_FEEDBACK_TARGETS } from "../src/context-registry/feedback.js";
4+
import { validateContextFeedback, validateContextFeedbackStore } from "../src/context-registry/validators.js";
45

56
test("feedback supports every label and target without task or content fields", () => {
67
for (const label of CONTEXT_FEEDBACK_LABELS) {
@@ -54,3 +55,13 @@ test("feedback identifiers are deterministic for the same metadata", () => {
5455
};
5556
assert.equal(createContextFeedback(input).feedbackId, createContextFeedback(input).feedbackId);
5657
});
58+
59+
test("feedback schema diagnostics identify unsafe paths and duplicate IDs", () => {
60+
const item = createContextFeedback({ repository: "C:/work/project", entryId: "entry", source: "local", revision: 1, target: "file", file: "docs/file.md", label: "useful" });
61+
const unsafe = validateContextFeedback({ ...item, file: "../secret.md" });
62+
assert.equal(unsafe.valid, false);
63+
assert.ok(unsafe.issues.some((issue) => issue.path === "$.file"));
64+
const store = validateContextFeedbackStore({ schemaVersion: 1, revision: 1, repository: "C:/work/project", feedback: [item, item] });
65+
assert.equal(store.valid, false);
66+
assert.ok(store.issues.some((issue) => /unique/.test(issue.message)));
67+
});

0 commit comments

Comments
 (0)