Skip to content

Commit 939cf98

Browse files
hhkaosclaude
andcommitted
validate: validateEventInFeed — feed-context check for event files
issue-to-pr validated proposals with the standalone-event schema, which demands specVersion and license. events/<slug>.json is a feed fragment: both fields are inherited from the feed, the editor deliberately omits them, and build-feed --check applies feed rules. Editor-generated proposals were therefore always rejected. validateEventInFeed wraps the event in a minimal valid feed envelope and rewrites error paths to be event-relative; the workflow script now uses it instead of validateEvent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bac3101 commit 939cf98

3 files changed

Lines changed: 87 additions & 3 deletions

File tree

‎.github/scripts/issue-to-pr.mjs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import { appendFileSync, writeFileSync } from "node:fs";
2323
import { join } from "node:path";
2424

25-
import { validateEvent } from "../../packages/validate/dist/index.js";
25+
import { validateEventInFeed } from "../../packages/validate/dist/index.js";
2626

2727
const marker = process.env.OTE_COMMENT_MARKER ?? "<!-- ote-issue-to-pr -->";
2828
const tmp = process.env.RUNNER_TEMP ?? ".";
@@ -104,7 +104,10 @@ try {
104104
]);
105105
}
106106

107-
const result = validateEvent(event);
107+
// Feed context, not standalone: events/<slug>.json is a feed fragment that
108+
// inherits specVersion and license from the feed (same rules build-feed
109+
// --check applies — the editor deliberately omits both fields).
110+
const result = validateEventInFeed(event);
108111
if (!result.valid) {
109112
reject("The event JSON is not valid against the OTE event schema", [
110113
...result.errors.map((e) => `- \`${e.path}\` — ${e.message}`),

‎packages/validate/src/index.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ajvFormats from "ajv-formats";
55
const addFormats = ajvFormats as unknown as typeof ajvFormats.default;
66

77
import { formatAjvErrors, type ValidationError } from "./errors.js";
8+
import { specVersion } from "./schemas.generated.js";
89
import { eventSchema, feedSchema } from "./schemas.js";
910

1011
export type { ValidationError } from "./errors.js";
@@ -51,3 +52,37 @@ export function validateEvent(json: unknown): ValidationResult {
5152
export function validateFeed(json: unknown): ValidationResult {
5253
return run(validateFeedFn!, json);
5354
}
55+
56+
// Minimal valid feed envelope for validateEventInFeed. Constant values are
57+
// never reported: any error a wrapped validation yields comes from the event.
58+
const FEED_ENVELOPE = {
59+
specVersion,
60+
title: "validateEventInFeed envelope",
61+
license: "CC0-1.0",
62+
updatedAt: "2026-01-01T00:00:00Z",
63+
};
64+
65+
const EVENT_PATH_PREFIX = "events[0]";
66+
67+
/**
68+
* Validates a single event in FEED context: specVersion and license are
69+
* inherited from the feed, so they are not required (unlike a standalone
70+
* event document). This is the check for an events/<slug>.json file, which
71+
* is a feed fragment — `build-feed --check` applies the same rules by
72+
* validating the assembled feed. Error paths are relative to the event.
73+
*/
74+
export function validateEventInFeed(json: unknown): ValidationResult {
75+
const result = run(validateFeedFn!, { ...FEED_ENVELOPE, events: [json] });
76+
return {
77+
valid: result.valid,
78+
errors: result.errors.map(({ path, message }) => ({
79+
message,
80+
path:
81+
path === EVENT_PATH_PREFIX
82+
? "(document)"
83+
: path.startsWith(`${EVENT_PATH_PREFIX}.`)
84+
? path.slice(EVENT_PATH_PREFIX.length + 1)
85+
: path,
86+
})),
87+
};
88+
}

‎packages/validate/test/validate.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url";
44

55
import { describe, expect, it } from "vitest";
66

7-
import { validateEvent, validateFeed } from "../src/index.js";
7+
import {
8+
validateEvent,
9+
validateEventInFeed,
10+
validateFeed,
11+
} from "../src/index.js";
812

913
const fixturesDir = fileURLToPath(new URL("../fixtures/", import.meta.url));
1014

@@ -120,10 +124,52 @@ describe("validateEvent — invalid fixtures", () => {
120124
});
121125
});
122126

127+
describe("validateEventInFeed — feed-context rules for a single event", () => {
128+
it("an event file without specVersion/license is valid (both inherited)", () => {
129+
const feed = loadFixture("valid", "feed.json") as {
130+
events: Record<string, unknown>[];
131+
};
132+
const event = feed.events[0]!;
133+
expect(event.specVersion).toBeUndefined();
134+
expect(event.license).toBeUndefined();
135+
expect(validateEvent(event).valid).toBe(false); // standalone demands both…
136+
const result = validateEventInFeed(event); // …feed context does not
137+
expect(result.errors).toEqual([]);
138+
expect(result.valid).toBe(true);
139+
});
140+
141+
it("a standalone-shaped event (with specVersion/license) is also valid", () => {
142+
const result = validateEventInFeed(loadFixture("valid", "event-minimal.json"));
143+
expect(result.errors).toEqual([]);
144+
expect(result.valid).toBe(true);
145+
});
146+
147+
it("error paths are relative to the event, not the envelope", () => {
148+
const result = validateEventInFeed({ name: "No id, date or timezone" });
149+
expect(result.valid).toBe(false);
150+
expect(result.errors).toContainEqual({
151+
path: "(document)",
152+
message: 'is missing required property "id"',
153+
});
154+
const { errors } = validateEventInFeed({
155+
id: "https://example.org/events/x",
156+
name: "Bad geo",
157+
startDate: "2026-06-01",
158+
timezone: "UTC",
159+
location: { venue: "Somewhere", geo: { lat: 91, lon: 0 } },
160+
});
161+
expect(errors).toContainEqual({
162+
path: "location.geo.lat",
163+
message: "must be <= 90",
164+
});
165+
});
166+
});
167+
123168
describe("non-object inputs", () => {
124169
it.each([null, "text", 42, []])("%o is invalid", (input) => {
125170
expect(validateEvent(input).valid).toBe(false);
126171
expect(validateFeed(input).valid).toBe(false);
172+
expect(validateEventInFeed(input).valid).toBe(false);
127173
});
128174
});
129175

0 commit comments

Comments
 (0)