diff --git a/.changeset/lucky-donkeys-listen.md b/.changeset/lucky-donkeys-listen.md new file mode 100644 index 000000000..4d12482f8 --- /dev/null +++ b/.changeset/lucky-donkeys-listen.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Instrumentation lifecycle events now carry eve-owned payloads instead of the AI SDK's callback types. Internal groundwork for a public provider surface; no change to the spans or attributes eve records. diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts index 4093b51ca..d3897946e 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts @@ -199,30 +199,136 @@ describe("createAiSdkHookBridge", () => { ); }); - it("publishes frozen callback snapshots", async () => { + it("projects the operation callback onto eve fields only", async () => { const started = vi.fn(); const hooks = createInstrumentationHooks([{ events: { "attempt.started": started } }]); const bridge = createAiSdkHookBridge(scope, hooks); - const operation = { - callId: "call-1", - modelId: "model", - operationId: "ai.streamText", - provider: "test", - }; - const step = { callId: "call-1", stepNumber: 0 }; - - Reflect.apply(bridge.onStart!, bridge, [operation]); - await Reflect.apply(bridge.onStepStart!, bridge, [step]); - - expect(started).toHaveBeenCalledOnce(); - const event = started.mock.calls[0]?.[0]; - expect(event).toEqual({ operation, scope, step, type: "attempt.started" }); - expect(event.operation).not.toBe(operation); - expect(event.step).not.toBe(step); - expect(Object.isFrozen(event.operation)).toBe(true); - expect(Object.isFrozen(event.step)).toBe(true); + + Reflect.apply(bridge.onStart!, bridge, [ + { callId: "call-1", modelId: "model", operationId: "ai.streamText", provider: "test" }, + ]); + await Reflect.apply(bridge.onStepStart!, bridge, [{ callId: "call-1", stepNumber: 0 }]); + + expect(started).toHaveBeenCalledExactlyOnceWith({ + operation: { modelId: "model", operationId: "ai.streamText", provider: "test" }, + scope, + type: "attempt.started", + }); + }); + + it("projects the model call callbacks onto eve fields only", async () => { + const before = vi.fn(() => "state"); + const after = vi.fn(); + const hooks = createInstrumentationHooks([{ events: { "model.call": { after, before } } }]); + const bridge = createAiSdkHookBridge(scope, hooks); + + await Reflect.apply(bridge.onLanguageModelCallStart!, bridge, [ + { + callId: "call-1", + instructions: "be brief", + messages: [{ content: "hi", role: "user" }], + modelId: "model", + provider: "test", + tools: undefined, + }, + ]); + await Reflect.apply(bridge.onLanguageModelCallEnd!, bridge, [ + { + callId: "call-1", + content: [ + { text: "thinking", type: "reasoning" }, + { text: "hello", type: "text" }, + { input: { a: 1 }, toolName: "search", type: "tool-call" }, + { input: { a: 1 }, output: "ok", toolName: "search", type: "tool-result" }, + { error: "boom", input: { a: 2 }, toolName: "search", type: "tool-error" }, + { type: "some-future-kind" }, + ], + finishReason: "tool-calls", + performance: { responseTimeMs: 1 }, + responseId: "response-1", + usage: { + inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 4 }, + inputTokens: 1, + outputTokens: 2, + }, + }, + ]); + + expect(before).toHaveBeenCalledExactlyOnceWith({ + id: `${scope.attemptId}:model:call-1:0`, + input: { instructions: "be brief", messages: [{ content: "hi", role: "user" }] }, + model: { modelId: "model", provider: "test" }, + scope, + type: "model.call.started", + }); + // An unrecognized part kind is dropped rather than forwarded, so widening + // InstrumentationContentPart is what makes a new kind reachable. + expect(after).toHaveBeenCalledExactlyOnceWith( + { + content: [ + { text: "thinking", type: "reasoning" }, + { text: "hello", type: "text" }, + { input: { a: 1 }, toolName: "search", type: "tool-call" }, + { input: { a: 1 }, output: "ok", toolName: "search", type: "tool-result" }, + { error: "boom", input: { a: 2 }, toolName: "search", type: "tool-error" }, + ], + finishReason: "tool-calls", + id: `${scope.attemptId}:model:call-1:0`, + scope, + type: "model.call.completed", + usage: { + inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 4 }, + inputTokens: 1, + outputTokens: 2, + }, + }, + "state", + ); }); + it.each([ + { + expected: { output: "ok", type: "result" }, + toolOutput: { output: "ok", type: "tool-result" }, + }, + { + expected: { error: "boom", type: "error" }, + toolOutput: { error: "boom", type: "tool-error" }, + }, + ])( + "collapses tool output $toolOutput.type onto $expected.type", + async ({ expected, toolOutput }) => { + const before = vi.fn(() => "state"); + const after = vi.fn(); + const hooks = createInstrumentationHooks([{ events: { "tool.call": { after, before } } }]); + const bridge = createAiSdkHookBridge(scope, hooks); + const toolCall = { input: { q: "eve" }, toolCallId: "tool-1", toolName: "search" }; + + await Reflect.apply(bridge.onToolExecutionStart!, bridge, [{ callId: "call-1", toolCall }]); + await Reflect.apply(bridge.onToolExecutionEnd!, bridge, [ + { callId: "call-1", toolCall, toolExecutionMs: 1, toolOutput }, + ]); + + expect(before).toHaveBeenCalledExactlyOnceWith({ + callId: "tool-1", + id: `${scope.attemptId}:tool:tool-1:0`, + input: { q: "eve" }, + scope, + toolName: "search", + type: "tool.call.started", + }); + expect(after).toHaveBeenCalledExactlyOnceWith( + { + id: `${scope.attemptId}:tool:tool-1:0`, + output: expected, + scope, + type: "tool.call.completed", + }, + "state", + ); + }, + ); + it("retains state for parallel tool starts", async () => { const resolvers = new Map void>(); const terminalStates = new Map(); diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.ts index 80945ae65..d72162e0f 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.ts @@ -3,12 +3,16 @@ import type { Telemetry } from "ai"; import type { InstrumentationAttemptScope, InstrumentationAttemptStartedEvent, + InstrumentationContentPart, InstrumentationContextRunner, InstrumentationHooks, InstrumentationModelCallCompletedEvent, InstrumentationModelCallStartedEvent, + InstrumentationOperationRef, InstrumentationToolCallCompletedEvent, InstrumentationToolCallStartedEvent, + InstrumentationToolOutput, + InstrumentationUsage, } from "#harness/instrumentation-lifecycle.js"; type TelemetryEvent = Parameters>[0]; @@ -17,8 +21,9 @@ interface AttemptState { readonly modelIds: Map; readonly scope: InstrumentationAttemptScope; readonly toolIds: Map; - operationStart?: Readonly>; - stepStart?: Readonly>; + operation?: InstrumentationOperationRef; + // Only the number is kept: it disambiguates call identities within an attempt. + stepNumber?: number; } /** Creates one provider-neutral AI SDK bridge for one actual model attempt. */ @@ -35,10 +40,14 @@ export function createAiSdkHookBridge( return { onStart(event) { - state.operationStart = snapshot(event); + state.operation = { + modelId: event.modelId, + operationId: event.operationId, + provider: event.provider, + }; }, async onStepStart(event) { - state.stepStart = snapshot(event); + state.stepNumber = event.stepNumber; const started = toAttemptStarted(state); if (started !== undefined) await hooks.publish(started); }, @@ -114,22 +123,17 @@ export function createAiSdkHookBridge( const directRunInContext: InstrumentationContextRunner = (_operation, execute) => execute(); -function snapshot(event: T): Readonly { - return Object.freeze({ ...event }); -} - function toAttemptStarted(state: AttemptState): InstrumentationAttemptStartedEvent | undefined { - if (state.operationStart === undefined || state.stepStart === undefined) return undefined; + if (state.operation === undefined || state.stepNumber === undefined) return undefined; return { - operation: state.operationStart, + operation: state.operation, scope: state.scope, - step: state.stepStart, type: "attempt.started", }; } function createModelCallIdentity(state: AttemptState, callId: string): string { - return `${state.scope.attemptId}:model:${callId}:${state.stepStart?.stepNumber ?? 0}`; + return `${state.scope.attemptId}:model:${callId}:${state.stepNumber ?? 0}`; } function toModelCallStarted( @@ -139,8 +143,9 @@ function toModelCallStarted( ): InstrumentationModelCallStartedEvent { return { id, + input: { instructions: source.instructions, messages: source.messages }, + model: { modelId: source.modelId, provider: source.provider }, scope: state.scope, - source: snapshot(source), type: "model.call.started", }; } @@ -151,15 +156,65 @@ function toModelCallCompleted( source: TelemetryEvent<"onLanguageModelCallEnd">, ): InstrumentationModelCallCompletedEvent { return { + content: toContentParts(source.content), + finishReason: source.finishReason, id, scope: state.scope, - source: snapshot(source), type: "model.call.completed", + usage: toUsage(source.usage), + }; +} + +function toUsage(usage: TelemetryEvent<"onLanguageModelCallEnd">["usage"]): InstrumentationUsage { + return { + inputTokenDetails: { + cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens, + cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens, + }, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, }; } +/** Drops kinds eve does not record; see {@link InstrumentationContentPart}. */ +function toContentParts( + content: TelemetryEvent<"onLanguageModelCallEnd">["content"], +): readonly InstrumentationContentPart[] { + const parts: InstrumentationContentPart[] = []; + for (const part of content) { + switch (part.type) { + case "text": + case "reasoning": + parts.push({ text: part.text, type: part.type }); + break; + case "tool-call": + parts.push({ input: part.input, toolName: part.toolName, type: "tool-call" }); + break; + case "tool-result": + parts.push({ + input: part.input, + output: part.output, + toolName: part.toolName, + type: "tool-result", + }); + break; + case "tool-error": + parts.push({ + error: part.error, + input: part.input, + toolName: part.toolName, + type: "tool-error", + }); + break; + default: + break; + } + } + return parts; +} + function createToolCallIdentity(state: AttemptState, toolCallId: string): string { - return `${state.scope.attemptId}:tool:${toolCallId}:${state.stepStart?.stepNumber ?? 0}`; + return `${state.scope.attemptId}:tool:${toolCallId}:${state.stepNumber ?? 0}`; } function toToolCallStarted( @@ -168,9 +223,11 @@ function toToolCallStarted( source: TelemetryEvent<"onToolExecutionStart">, ): InstrumentationToolCallStartedEvent { return { + callId: source.toolCall.toolCallId, id, + input: source.toolCall.input, scope: state.scope, - source: snapshot(source), + toolName: source.toolCall.toolName, type: "tool.call.started", }; } @@ -182,8 +239,16 @@ function toToolCallCompleted( ): InstrumentationToolCallCompletedEvent { return { id, + output: toToolOutput(source.toolOutput), scope: state.scope, - source: snapshot(source), type: "tool.call.completed", }; } + +function toToolOutput( + toolOutput: TelemetryEvent<"onToolExecutionEnd">["toolOutput"], +): InstrumentationToolOutput { + return toolOutput.type === "tool-result" + ? { output: toolOutput.output, type: "result" } + : { error: toolOutput.error, type: "error" }; +} diff --git a/packages/eve/src/harness/instrumentation-lifecycle.ts b/packages/eve/src/harness/instrumentation-lifecycle.ts index b352b3b22..055f38903 100644 --- a/packages/eve/src/harness/instrumentation-lifecycle.ts +++ b/packages/eve/src/harness/instrumentation-lifecycle.ts @@ -1,9 +1,5 @@ -import type { Telemetry } from "ai"; - import { createLogger, formatError } from "#internal/logging.js"; -type TelemetryEvent = Parameters>[0]; - /** Stable eve identity for one actual model attempt. */ export interface InstrumentationAttemptScope { readonly attemptId: string; @@ -15,11 +11,65 @@ export interface InstrumentationAttemptScope { readonly turnId: string; } +/** The model SDK operation an attempt runs through. */ +export interface InstrumentationOperationRef { + readonly modelId: string; + readonly operationId: string; + readonly provider: string; +} + +export interface InstrumentationModelRef { + readonly modelId: string; + readonly provider: string; +} + +/** Token usage for one model call. A field is absent when the provider omits it. */ +export interface InstrumentationUsage { + readonly inputTokenDetails?: { + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; + }; + readonly inputTokens?: number; + readonly outputTokens?: number; +} + +/** Final model input for one call. Message shape stays opaque to this layer. */ +export interface InstrumentationModelInput { + readonly instructions?: unknown; + readonly messages: readonly unknown[]; +} + +/** + * The model response parts eve records. A kind outside this union is dropped + * when the bridge maps a response, so widening the union is what makes a new + * kind reachable by a provider. + */ +export type InstrumentationContentPart = + | { readonly type: "text"; readonly text: string } + | { readonly type: "reasoning"; readonly text: string } + | { readonly type: "tool-call"; readonly input: unknown; readonly toolName: string } + | { + readonly type: "tool-result"; + readonly input: unknown; + readonly output: unknown; + readonly toolName: string; + } + | { + readonly type: "tool-error"; + readonly error: unknown; + readonly input: unknown; + readonly toolName: string; + }; + +/** How one tool execution ended. */ +export type InstrumentationToolOutput = + | { readonly type: "result"; readonly output: unknown } + | { readonly type: "error"; readonly error: unknown }; + export interface InstrumentationAttemptStartedEvent { readonly type: "attempt.started"; + readonly operation: InstrumentationOperationRef; readonly scope: InstrumentationAttemptScope; - readonly operation: TelemetryEvent<"onStart">; - readonly step: TelemetryEvent<"onStepStart">; } export interface InstrumentationSessionStartedEvent { @@ -92,15 +142,18 @@ export interface InstrumentationAttemptMetadataEvent { export interface InstrumentationModelCallStartedEvent { readonly type: "model.call.started"; readonly id: string; + readonly input: InstrumentationModelInput; + readonly model: InstrumentationModelRef; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onLanguageModelCallStart">; } export interface InstrumentationModelCallCompletedEvent { readonly type: "model.call.completed"; + readonly content: readonly InstrumentationContentPart[]; + readonly finishReason: string; readonly id: string; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onLanguageModelCallEnd">; + readonly usage: InstrumentationUsage; } export interface InstrumentationModelCallFailedEvent { @@ -116,16 +169,18 @@ export type InstrumentationModelCallTerminalEvent = export interface InstrumentationToolCallStartedEvent { readonly type: "tool.call.started"; + readonly callId: string; readonly id: string; + readonly input: unknown; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onToolExecutionStart">; + readonly toolName: string; } export interface InstrumentationToolCallCompletedEvent { readonly type: "tool.call.completed"; readonly id: string; + readonly output: InstrumentationToolOutput; readonly scope: InstrumentationAttemptScope; - readonly source: TelemetryEvent<"onToolExecutionEnd">; } export interface InstrumentationToolCallFailedEvent { diff --git a/packages/eve/src/tracing/agent-otel-provider.ts b/packages/eve/src/tracing/agent-otel-provider.ts index 73ac55810..9f479b58c 100644 --- a/packages/eve/src/tracing/agent-otel-provider.ts +++ b/packages/eve/src/tracing/agent-otel-provider.ts @@ -33,6 +33,7 @@ import type { InstrumentationToolCallTerminalEvent, InstrumentationTurnStartedEvent, InstrumentationTurnTerminalEvent, + InstrumentationUsage, } from "#harness/instrumentation-lifecycle.js"; interface SpanState { @@ -303,23 +304,23 @@ export function createAgentOtelInstrumentation( const beforeModelCall = (event: InstrumentationModelCallStartedEvent): SpanState | undefined => { const attempt = steps.get(event.scope); if (attempt === undefined) return undefined; - attempt.step.span.setAttribute("agent.model.id", event.source.modelId); - attempt.step.span.setAttribute("agent.model.provider", event.source.provider); + attempt.step.span.setAttribute("agent.model.id", event.model.modelId); + attempt.step.span.setAttribute("agent.model.provider", event.model.provider); const span = input.tracer.startSpan( modelSpanName(attempt.operation.name), { attributes: { "gen_ai.operation.name": attempt.operation.name, - "gen_ai.provider.name": event.source.provider, - "gen_ai.request.model": event.source.modelId, + "gen_ai.provider.name": event.model.provider, + "gen_ai.request.model": event.model.modelId, }, }, attempt.operation.context, ); if (captureContent) { - const messages = messagesContentAttribute(event.source.messages); + const messages = messagesContentAttribute(event.input.messages); if (messages !== undefined) span.setAttribute("ai.prompt.messages", messages); - const system = systemPromptAttribute(event.source.instructions); + const system = systemPromptAttribute(event.input.instructions); if (system !== undefined) span.setAttribute("ai.prompt.system", system); } const state = { context: trace.setSpan(attempt.operation.context, span), span }; @@ -333,13 +334,13 @@ export function createAgentOtelInstrumentation( if (event.type === "model.call.failed") { recordError(state.span, event.error); } else { - setUsage(state.span, event.source.usage); + setUsage(state.span, event.usage); const attempt = steps.get(event.scope); - if (attempt !== undefined) setUsage(attempt.step.span, event.source.usage); + if (attempt !== undefined) setUsage(attempt.step.span, event.usage); if (captureContent) { - state.span.setAttribute("ai.response.finish_reason", event.source.finishReason); + state.span.setAttribute("ai.response.finish_reason", event.finishReason); const reasoning = textContentAttribute( - event.source.content + event.content .filter((part) => part.type === "reasoning") .map((part) => part.text) .filter((part) => part.trim().length > 0) @@ -347,13 +348,13 @@ export function createAgentOtelInstrumentation( ); if (reasoning !== undefined) state.span.setAttribute("ai.response.reasoning", reasoning); const text = textContentAttribute( - event.source.content + event.content .filter((part) => part.type === "text") .map((part) => part.text) .join(""), ); if (text !== undefined) state.span.setAttribute("ai.response.text", text); - const toolCalls = event.source.content + const toolCalls = event.content .filter((part) => part.type === "tool-call") .map((part) => ({ input: part.input, toolName: part.toolName })); if (toolCalls.length > 0) { @@ -363,7 +364,7 @@ export function createAgentOtelInstrumentation( // Provider-executed tools (e.g. web_search) run inside the model call, // never reach eve's tool loop, and so never get an ai.toolCall span. // Their results only exist as content parts on the model response. - const toolResults = event.source.content + const toolResults = event.content .filter((part) => part.type === "tool-result" || part.type === "tool-error") .map((part) => part.type === "tool-result" @@ -388,9 +389,9 @@ export function createAgentOtelInstrumentation( "agent.action", { attributes: { - "agent.action.call_id": event.source.toolCall.toolCallId, + "agent.action.call_id": event.callId, "agent.action.kind": "tool", - "agent.action.name": event.source.toolCall.toolName, + "agent.action.name": event.toolName, "agent.framework.name": "eve", "agent.framework.version": input.frameworkVersion, "agent.root.session.id": event.scope.rootSessionId ?? event.scope.sessionId, @@ -408,14 +409,14 @@ export function createAgentOtelInstrumentation( { attributes: { "gen_ai.operation.name": "execute_tool", - "gen_ai.tool.call.id": event.source.toolCall.toolCallId, - "gen_ai.tool.name": event.source.toolCall.toolName, + "gen_ai.tool.call.id": event.callId, + "gen_ai.tool.name": event.toolName, }, }, actionContext, ); if (captureContent) { - const args = contentAttribute(event.source.toolCall.input, false); + const args = contentAttribute(event.input, false); if (args !== undefined) toolSpan.setAttribute("gen_ai.tool.call.arguments", args); } const state: ToolSpanState = { @@ -433,11 +434,11 @@ export function createAgentOtelInstrumentation( if (event.type === "tool.call.failed") { recordError(state.toolSpan, event.error); recordError(state.span, event.error); - } else if (event.source.toolOutput.type !== "tool-result") { - recordError(state.toolSpan, event.source.toolOutput.error); - recordError(state.span, event.source.toolOutput.error); + } else if (event.output.type === "error") { + recordError(state.toolSpan, event.output.error); + recordError(state.span, event.output.error); } else if (captureContent) { - const result = contentAttribute(event.source.toolOutput.output, false); + const result = contentAttribute(event.output.output, false); if (result !== undefined) state.toolSpan.setAttribute("gen_ai.tool.call.result", result); } state.toolSpan.end(); @@ -658,17 +659,7 @@ function modelSpanName(operationName: string): string { : "ai.streamText.doStream"; } -function setUsage( - span: Span, - usage: { - readonly inputTokenDetails?: { - readonly cacheReadTokens?: number; - readonly cacheWriteTokens?: number; - }; - readonly inputTokens?: number; - readonly outputTokens?: number; - }, -): void { +function setUsage(span: Span, usage: InstrumentationUsage): void { if (usage.inputTokens !== undefined) { span.setAttribute("agent.usage.input_tokens", usage.inputTokens); } diff --git a/scripts/guard-invariants.mjs b/scripts/guard-invariants.mjs index 9405c2835..655063123 100644 --- a/scripts/guard-invariants.mjs +++ b/scripts/guard-invariants.mjs @@ -93,6 +93,12 @@ * dropped, every retained epoch needs a compiling fixture, and * every public authoring value must belong to a capability. * + * rule 37 — The instrumentation lifecycle contract stays provider-neutral. + * `harness/instrumentation-lifecycle.ts` must not import from + * `ai`: its event payloads are eve's published shape, so deriving + * them from the model SDK's callback types would make an SDK + * upgrade a breaking change for every provider. Map at the bridge. + * * Baselines for rules with pre-existing violations live in * `guard-invariants-baseline.json`. Counts and allowlists in that file * may only shrink (as offenders are removed) — they may never grow. @@ -182,6 +188,7 @@ function isTsLike(relPath) { * rule28: Violation[]; * rule33: Violation[]; * rule35: Violation[]; + * rule37: Violation[]; * symlinks: string[]; * }} state */ @@ -210,6 +217,7 @@ async function scanRepo(state) { checkRule28(posix, lines, state.rule28); checkRule33(posix, lines, state.rule33); checkRule35(posix, lines, state.rule35); + checkRule37(posix, lines, state.rule37); } } @@ -332,6 +340,33 @@ function checkRule35(posix, lines, violations) { }); } +// ---------- Rule 37: provider-neutral lifecycle contract ---------- + +const LIFECYCLE_CONTRACT = "packages/eve/src/harness/instrumentation-lifecycle.ts"; +const AI_SPECIFIER_RE = /["']ai(?:\/[^"']+)?["']/; + +/** + * @param {string} posix + * @param {string[]} lines + * @param {Violation[]} violations + */ +function checkRule37(posix, lines, violations) { + if (posix !== LIFECYCLE_CONTRACT) return; + lines.forEach((line, idx) => { + const isImport = + /^(?:import|export)\b|^}\s*from\b|\b(?:import|require)\s*\(/.test(line.trimStart()) && + AI_SPECIFIER_RE.test(line); + if (isImport) { + violations.push({ + rule: 37, + file: posix, + line: idx + 1, + message: `imports from "ai". Lifecycle event payloads are eve's own shape, so an AI SDK type reaching them makes an SDK upgrade a breaking change for every provider. Add an eve type here and map to it in ai-sdk-hook-bridge.ts.`, + }); + } + }); +} + // ---------- Rule 19: AsyncLocalStorage instances ---------- const NEW_ALS_RE = /new\s+AsyncLocalStorage\s*[<(]/; @@ -1066,6 +1101,7 @@ async function main() { rule28: /** @type {Violation[]} */ ([]), rule33: /** @type {Violation[]} */ ([]), rule35: /** @type {Violation[]} */ ([]), + rule37: /** @type {Violation[]} */ ([]), symlinks: /** @type {string[]} */ ([]), }; @@ -1156,6 +1192,7 @@ async function main() { // Rule 35 violations.push(...state.rule35); + violations.push(...state.rule37); // Rule 36 for (const issue of await checkExtensionCapabilityContracts()) {