@conform-ed/qti-react 0.0.16 → 0.0.18

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 CHANGED
@@ -2,6 +2,7 @@ export declare const qtiReactPackageName = "@conform-ed/qti-react";
2
2
  export { v0ContentModel, v0InteractionKinds, isAllowedFlowElement, isInteractionKind, sanitizeAttributes, type ContentModel, type V0InteractionKind, } from "./content-model";
3
3
  export { foldString, mapResponse, matchCorrect, mapResponsePoint, scoreResponse } from "./response-processing";
4
4
  export { assessmentItemViewFromNormalized, assessmentTestViewFromNormalized, stimulusContentFromNormalized, } from "./normalized-item";
5
+ export { referenceInteractionKinds, reportItemCapability, type ItemCapabilityOptions } from "./item-capability";
5
6
  export { formatPoint, parseCoords, parsePoint, pointInShape, type Point, type QtiShape } from "./graphic";
6
7
  export { applyCorrectResponseOverrides, collectRpIssues, collectTemplateIssues, executeResponseProcessing, executeTemplateProcessing, mulberry32, resolveTemplate, } from "./rp";
7
8
  export type { CustomOperatorImplementation, InterpolationTableEntryView, InterpolationTableView, MatchTableEntryView, MatchTableView, MaybeRpValue, OutcomeDeclarationView, OutcomeValue, ResponseNormalization, ResponseProcessingContext, ResponseProcessingResult, ResponseProcessingView, RpConditionBranch, RpExpressionView, RpRecordField, RpRuleView, RpScalar, RpValue, TemplateConditionBranch, TemplateDeclarationView, TemplateProcessingContext, TemplateProcessingResult, TemplateProcessingView, TemplateRuleView, } from "./rp";
package/dist/index.js CHANGED
@@ -2930,6 +2930,127 @@ function collectTemplateIssues(view, options) {
2930
2930
  walkRules(view.rules);
2931
2931
  return issues;
2932
2932
  }
2933
+ // src/item-capability.ts
2934
+ var feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
2935
+ var templateContentKinds = new Set(["templateInline", "templateBlock"]);
2936
+ var intrinsicLeafKinds = new Set(["text", "printedVariable"]);
2937
+ function isFeedbackNode(node) {
2938
+ return feedbackKinds.has(node.kind);
2939
+ }
2940
+ function isTemplateContentNode(node) {
2941
+ return templateContentKinds.has(node.kind);
2942
+ }
2943
+ function isInteractionNode(node) {
2944
+ return node.kind !== "xml" && typeof node.responseIdentifier === "string";
2945
+ }
2946
+ function reportItemCapability(item, options) {
2947
+ const model = options.model ?? v0ContentModel;
2948
+ const customOperatorClasses = options.customOperatorClasses ?? new Set;
2949
+ const issues = [];
2950
+ const seen = new Set;
2951
+ function report(issue) {
2952
+ const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
2953
+ if (!seen.has(dedupeKey)) {
2954
+ seen.add(dedupeKey);
2955
+ issues.push(issue);
2956
+ }
2957
+ }
2958
+ function walk(node) {
2959
+ if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
2960
+ for (const child of node.content ?? []) {
2961
+ walk(child);
2962
+ }
2963
+ return;
2964
+ }
2965
+ if (isInteractionNode(node)) {
2966
+ if (!options.supportedInteractions.has(node.kind)) {
2967
+ report({ type: "unsupported-interaction", name: node.kind, responseIdentifier: node.responseIdentifier });
2968
+ return;
2969
+ }
2970
+ const schema = options.interactionSchemas?.get(node.kind);
2971
+ if (schema) {
2972
+ const parsed = schema.safeParse(node);
2973
+ if (!parsed.success) {
2974
+ const detail = parsed.error.issues[0]?.message;
2975
+ report({
2976
+ type: "invalid-interaction",
2977
+ name: node.kind,
2978
+ responseIdentifier: node.responseIdentifier,
2979
+ ...detail !== undefined ? { detail } : {}
2980
+ });
2981
+ }
2982
+ }
2983
+ return;
2984
+ }
2985
+ if (node.kind === "xml") {
2986
+ const xmlNode = node;
2987
+ if (xmlNode.name === model.mathRoot) {
2988
+ return;
2989
+ }
2990
+ if (!isAllowedFlowElement(model, xmlNode.name)) {
2991
+ report({ type: "unsupported-element", name: xmlNode.name });
2992
+ }
2993
+ for (const child of xmlNode.children ?? []) {
2994
+ walk(child);
2995
+ }
2996
+ return;
2997
+ }
2998
+ if (intrinsicLeafKinds.has(node.kind)) {
2999
+ return;
3000
+ }
3001
+ report({ type: "unsupported-element", name: node.kind });
3002
+ }
3003
+ for (const node of item.itemBody.content ?? []) {
3004
+ walk(node);
3005
+ }
3006
+ for (const ref of item.assessmentStimulusRefs ?? []) {
3007
+ const stimulus = options.resolveStimulus?.(ref) ?? null;
3008
+ if (stimulus === null) {
3009
+ report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
3010
+ continue;
3011
+ }
3012
+ for (const node of stimulus.content) {
3013
+ walk(node);
3014
+ }
3015
+ }
3016
+ for (const feedback of item.modalFeedbacks ?? []) {
3017
+ for (const child of feedback.content ?? []) {
3018
+ walk(child);
3019
+ }
3020
+ }
3021
+ for (const issue of collectRpIssues(item.responseProcessing, {
3022
+ customOperatorClasses,
3023
+ outcomeDeclarations: item.outcomeDeclarations ?? []
3024
+ })) {
3025
+ report(issue);
3026
+ }
3027
+ for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
3028
+ report(issue);
3029
+ }
3030
+ return { deliverable: issues.length === 0, issues };
3031
+ }
3032
+ var referenceInteractionKinds = [
3033
+ "associateInteraction",
3034
+ "choiceInteraction",
3035
+ "drawingInteraction",
3036
+ "endAttemptInteraction",
3037
+ "extendedTextInteraction",
3038
+ "gapMatchInteraction",
3039
+ "graphicAssociateInteraction",
3040
+ "graphicGapMatchInteraction",
3041
+ "graphicOrderInteraction",
3042
+ "hotspotInteraction",
3043
+ "hottextInteraction",
3044
+ "inlineChoiceInteraction",
3045
+ "matchInteraction",
3046
+ "mediaInteraction",
3047
+ "orderInteraction",
3048
+ "positionObjectStage",
3049
+ "selectPointInteraction",
3050
+ "sliderInteraction",
3051
+ "textEntryInteraction",
3052
+ "uploadInteraction"
3053
+ ];
2933
3054
  // src/response-validity.ts
2934
3055
  var countConstraintKinds = ["minChoices", "maxChoices", "minAssociations", "maxAssociations", "minStrings"];
2935
3056
  function collectInteractionConstraints(content) {
@@ -4669,22 +4790,21 @@ function responseIncludes(value, optionIdentifier) {
4669
4790
  function isCorrectOption(declaration, optionIdentifier) {
4670
4791
  return Boolean(declaration?.correctResponse?.values.some((entry) => entry.value === optionIdentifier));
4671
4792
  }
4672
- function isInteractionNode(node) {
4793
+ function isInteractionNode2(node) {
4673
4794
  return node.kind !== "xml" && typeof node.responseIdentifier === "string";
4674
4795
  }
4675
- var feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
4676
- function isFeedbackNode(node) {
4677
- return feedbackKinds.has(node.kind);
4796
+ var feedbackKinds2 = new Set(["feedbackInline", "feedbackBlock"]);
4797
+ function isFeedbackNode2(node) {
4798
+ return feedbackKinds2.has(node.kind);
4678
4799
  }
4679
- var templateContentKinds = new Set(["templateInline", "templateBlock"]);
4680
- function isTemplateContentNode(node) {
4681
- return templateContentKinds.has(node.kind);
4800
+ var templateContentKinds2 = new Set(["templateInline", "templateBlock"]);
4801
+ function isTemplateContentNode2(node) {
4802
+ return templateContentKinds2.has(node.kind);
4682
4803
  }
4683
4804
  function templateVisible(value, view) {
4684
4805
  const matched = Array.isArray(value) ? value.includes(view.identifier) : value === view.identifier;
4685
4806
  return matched !== (view.showHide === "hide");
4686
4807
  }
4687
- var intrinsicLeafKinds = new Set(["text", "printedVariable"]);
4688
4808
  function createStaticStore(outcomes) {
4689
4809
  const snapshot = {
4690
4810
  responses: {},
@@ -4782,13 +4902,13 @@ function createQtiRuntime(config) {
4782
4902
  if (override) {
4783
4903
  return createElement(Fragment, { key }, override(node, key));
4784
4904
  }
4785
- if (isInteractionNode(node)) {
4905
+ if (isInteractionNode2(node)) {
4786
4906
  if (descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
4787
4907
  return createElement(InteractionHost, { key, node });
4788
4908
  }
4789
4909
  return renderUnsupported(node, key);
4790
4910
  }
4791
- if (isFeedbackNode(node)) {
4911
+ if (isFeedbackNode2(node)) {
4792
4912
  return createElement(FeedbackHost, {
4793
4913
  key,
4794
4914
  feedback: node,
@@ -4796,7 +4916,7 @@ function createQtiRuntime(config) {
4796
4916
  overrides
4797
4917
  });
4798
4918
  }
4799
- if (isTemplateContentNode(node)) {
4919
+ if (isTemplateContentNode2(node)) {
4800
4920
  return createElement(TemplateContentHost, {
4801
4921
  key,
4802
4922
  view: node,
@@ -5039,91 +5159,15 @@ function createQtiRuntime(config) {
5039
5159
  return catalogIdref !== undefined ? catalogSupports.get(catalogIdref) ?? noSupports : noSupports;
5040
5160
  }
5041
5161
  function canDeliver(item) {
5042
- const issues = [];
5043
- const seen = new Set;
5044
- function report(issue) {
5045
- const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
5046
- if (!seen.has(dedupeKey)) {
5047
- seen.add(dedupeKey);
5048
- issues.push(issue);
5049
- }
5050
- }
5051
- function walk(node) {
5052
- if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
5053
- for (const child of node.content ?? []) {
5054
- walk(child);
5055
- }
5056
- return;
5057
- }
5058
- if (isInteractionNode(node)) {
5059
- const descriptor = descriptorsByKind.get(node.kind);
5060
- if (!descriptor || !config.skin[node.kind]) {
5061
- report({
5062
- type: "unsupported-interaction",
5063
- name: node.kind,
5064
- responseIdentifier: node.responseIdentifier
5065
- });
5066
- return;
5067
- }
5068
- const parsed = descriptor.schema.safeParse(node);
5069
- if (!parsed.success) {
5070
- const detail = parsed.error.issues[0]?.message;
5071
- report({
5072
- type: "invalid-interaction",
5073
- name: node.kind,
5074
- responseIdentifier: node.responseIdentifier,
5075
- ...detail !== undefined ? { detail } : {}
5076
- });
5077
- }
5078
- return;
5079
- }
5080
- if (node.kind === "xml") {
5081
- const xmlNode = node;
5082
- if (xmlNode.name === model.mathRoot) {
5083
- return;
5084
- }
5085
- if (!isAllowedFlowElement(model, xmlNode.name)) {
5086
- report({ type: "unsupported-element", name: xmlNode.name });
5087
- }
5088
- for (const child of xmlNode.children ?? []) {
5089
- walk(child);
5090
- }
5091
- return;
5092
- }
5093
- if (intrinsicLeafKinds.has(node.kind)) {
5094
- return;
5095
- }
5096
- report({ type: "unsupported-element", name: node.kind });
5097
- }
5098
- for (const node of item.itemBody.content ?? []) {
5099
- walk(node);
5100
- }
5101
- for (const ref of item.assessmentStimulusRefs ?? []) {
5102
- const stimulus = config.resolveStimulus?.(ref) ?? null;
5103
- if (stimulus === null) {
5104
- report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
5105
- continue;
5106
- }
5107
- for (const node of stimulus.content) {
5108
- walk(node);
5109
- }
5110
- }
5111
- for (const feedback of item.modalFeedbacks ?? []) {
5112
- for (const child of feedback.content ?? []) {
5113
- walk(child);
5114
- }
5115
- }
5116
- const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
5117
- for (const issue of collectRpIssues(item.responseProcessing, {
5118
- customOperatorClasses,
5119
- outcomeDeclarations: item.outcomeDeclarations ?? []
5120
- })) {
5121
- report(issue);
5122
- }
5123
- for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
5124
- report(issue);
5125
- }
5126
- return { deliverable: issues.length === 0, issues };
5162
+ const supportedInteractions = new Set([...descriptorsByKind.keys()].filter((kind) => Boolean(config.skin[kind])));
5163
+ const interactionSchemas = new Map([...descriptorsByKind].map(([kind, descriptor]) => [kind, descriptor.schema]));
5164
+ return reportItemCapability(item, {
5165
+ supportedInteractions,
5166
+ interactionSchemas,
5167
+ model,
5168
+ customOperatorClasses: new Set(Object.keys(config.customOperators ?? {})),
5169
+ ...config.resolveStimulus !== undefined ? { resolveStimulus: config.resolveStimulus } : {}
5170
+ });
5127
5171
  }
5128
5172
  return { ItemRenderer, ContentRenderer, useAttempt, useCatalogSupports, canDeliver };
5129
5173
  }
@@ -6758,7 +6802,9 @@ export {
6758
6802
  resolveTemplate,
6759
6803
  resolvePnpActivation,
6760
6804
  resolveCatalogSupports,
6805
+ reportItemCapability,
6761
6806
  referenceSkin,
6807
+ referenceInteractionKinds,
6762
6808
  qtiReactPackageName,
6763
6809
  qtiCoreInteractions,
6764
6810
  positionObjectStage,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Headless capability gate (ADR-0003): "can a runtime that supports `supportedInteractions`
3
+ * deliver this item, and if not, why" — extracted from the React runtime's `canDeliver`
4
+ * so server-side callers (e.g. an ingest pipeline) can reach the *same* decision without
5
+ * importing React. The React runtime delegates to this, passing the interaction set its
6
+ * descriptors + skins cover; a headless caller passes the set its delivery supports.
7
+ *
8
+ * This module is React-free by construction (content-model + RP collectors only; the view
9
+ * shapes are type-only imports), so it ships through the `@conform-ed/qti-react/headless`
10
+ * entry alongside the normalize → view adapters.
11
+ */
12
+ import type { ZodType } from "zod";
13
+ import type { CapabilityReport } from "./capability";
14
+ import { type ContentModel } from "./content-model";
15
+ import type { AssessmentItemView, AssessmentStimulusRefView, StimulusContentView } from "./runtime";
16
+ export interface ItemCapabilityOptions {
17
+ /** Interaction kinds the target runtime can render (descriptor + skin both present). */
18
+ readonly supportedInteractions: ReadonlySet<string>;
19
+ /** Content model deciding the flow-element allowlist + math root; defaults to v0. */
20
+ readonly model?: ContentModel;
21
+ /** Custom-operator classes the target runtime registers (for RP capability). */
22
+ readonly customOperatorClasses?: ReadonlySet<string>;
23
+ /** Resolver for shared-stimulus refs; unresolved refs are not deliverable. */
24
+ readonly resolveStimulus?: (ref: AssessmentStimulusRefView) => StimulusContentView | null;
25
+ /**
26
+ * Optional per-kind schemas for the stricter `invalid-interaction` check. The React
27
+ * runtime supplies its descriptor schemas; a headless caller that has already validated
28
+ * structure (e.g. against the qti-xml contracts schema) can omit them.
29
+ */
30
+ readonly interactionSchemas?: ReadonlyMap<string, ZodType>;
31
+ }
32
+ /**
33
+ * Report whether `item` can be delivered by a runtime with the given capabilities, and
34
+ * every reason it cannot. Pure and React-free; the React runtime's `canDeliver` is a thin
35
+ * wrapper over this.
36
+ */
37
+ export declare function reportItemCapability(item: AssessmentItemView, options: ItemCapabilityOptions): CapabilityReport;
38
+ /**
39
+ * Interaction kinds the bundled reference skin renders — the default "supported set" for
40
+ * callers that deliver with the reference skin. Kept in parity with the reference skin by
41
+ * a test; pass an explicit set to `reportItemCapability` for a custom delivery surface.
42
+ */
43
+ export declare const referenceInteractionKinds: readonly string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conform-ed/qti-react",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "files": [
5
5
  "src",
6
6
  "dist"
@@ -9,12 +9,18 @@
9
9
  "module": "src/index.ts",
10
10
  "exports": {
11
11
  ".": {
12
+ "development": "./src/index.ts",
12
13
  "types": "./dist/index.d.ts",
13
14
  "import": "./dist/index.js"
15
+ },
16
+ "./headless": {
17
+ "development": "./src/headless.ts",
18
+ "types": "./dist/headless.d.ts",
19
+ "import": "./dist/headless.js"
14
20
  }
15
21
  },
16
22
  "scripts": {
17
- "build": "bun build ./src/index.ts --outdir dist --format esm --target browser --external react --external react-dom --external zod && tsgo -p tsconfig.build.json",
23
+ "build": "bun build ./src/index.ts ./src/headless.ts --outdir dist --format esm --target browser --external react --external react-dom --external zod && tsgo -p tsconfig.build.json",
18
24
  "format": "oxfmt --config ../../.oxfmtrc.jsonc --check .",
19
25
  "lint": "oxlint --config ../../.oxlintrc.jsonc .",
20
26
  "test": "bun test",
@@ -24,8 +30,8 @@
24
30
  "xspattern": "^3.1.0"
25
31
  },
26
32
  "devDependencies": {
27
- "@conform-ed/contracts": "0.0.16",
28
- "@conform-ed/qti-xml": "0.0.13",
33
+ "@conform-ed/contracts": "0.0.18",
34
+ "@conform-ed/qti-xml": "0.0.18",
29
35
  "@types/react": "^19.2.17",
30
36
  "@types/react-dom": "^19",
31
37
  "happy-dom": "^20.10.2",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Headless (React-free) surface of @conform-ed/qti-react: the normalize → view adapters
3
+ * and the capability gate, importable on a server (e.g. a QTI ingest pipeline) without
4
+ * pulling React. Exposed at `@conform-ed/qti-react/headless`; everything here is also
5
+ * re-exported from the package root for React consumers.
6
+ *
7
+ * Keep this entry free of React-coupled imports — only ./normalized-item and
8
+ * ./item-capability (value) plus type-only re-exports.
9
+ */
10
+
11
+ export {
12
+ assessmentItemViewFromNormalized,
13
+ assessmentTestViewFromNormalized,
14
+ stimulusContentFromNormalized,
15
+ } from "./normalized-item";
16
+ export { referenceInteractionKinds, reportItemCapability, type ItemCapabilityOptions } from "./item-capability";
17
+ export type { CapabilityIssue, CapabilityIssueType, CapabilityReport } from "./capability";
18
+ export type {
19
+ AssessmentItemView,
20
+ AssessmentStimulusRefView,
21
+ BodyNode,
22
+ InteractionNode,
23
+ StimulusContentView,
24
+ XmlContentNode,
25
+ } from "./runtime";
26
+ export type { AssessmentTestView } from "./test";
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ export {
20
20
  stimulusContentFromNormalized,
21
21
  } from "./normalized-item";
22
22
 
23
+ export { referenceInteractionKinds, reportItemCapability, type ItemCapabilityOptions } from "./item-capability";
24
+
23
25
  export { formatPoint, parseCoords, parsePoint, pointInShape, type Point, type QtiShape } from "./graphic";
24
26
 
25
27
  export {
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Headless capability gate (ADR-0003): "can a runtime that supports `supportedInteractions`
3
+ * deliver this item, and if not, why" — extracted from the React runtime's `canDeliver`
4
+ * so server-side callers (e.g. an ingest pipeline) can reach the *same* decision without
5
+ * importing React. The React runtime delegates to this, passing the interaction set its
6
+ * descriptors + skins cover; a headless caller passes the set its delivery supports.
7
+ *
8
+ * This module is React-free by construction (content-model + RP collectors only; the view
9
+ * shapes are type-only imports), so it ships through the `@conform-ed/qti-react/headless`
10
+ * entry alongside the normalize → view adapters.
11
+ */
12
+
13
+ import type { ZodType } from "zod";
14
+
15
+ import type { CapabilityIssue, CapabilityReport } from "./capability";
16
+ import { isAllowedFlowElement, v0ContentModel, type ContentModel } from "./content-model";
17
+ import { collectRpIssues, collectTemplateIssues } from "./rp";
18
+ import type {
19
+ AssessmentItemView,
20
+ AssessmentStimulusRefView,
21
+ BodyNode,
22
+ InteractionNode,
23
+ StimulusContentView,
24
+ XmlContentNode,
25
+ } from "./runtime";
26
+
27
+ const feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
28
+ const templateContentKinds = new Set(["templateInline", "templateBlock"]);
29
+
30
+ /** Body node kinds that render without a descriptor, skin, or content-model entry. */
31
+ const intrinsicLeafKinds = new Set(["text", "printedVariable"]);
32
+
33
+ function isFeedbackNode(node: BodyNode): boolean {
34
+ return feedbackKinds.has(node.kind);
35
+ }
36
+
37
+ function isTemplateContentNode(node: BodyNode): boolean {
38
+ return templateContentKinds.has(node.kind);
39
+ }
40
+
41
+ /**
42
+ * An interaction node is any non-xml node carrying a `responseIdentifier` — including
43
+ * kinds this runtime has never heard of. The discriminator must not depend on the
44
+ * supported set, or unknown interactions would be indistinguishable from text.
45
+ */
46
+ function isInteractionNode(node: BodyNode): node is InteractionNode {
47
+ return node.kind !== "xml" && typeof (node as { responseIdentifier?: unknown }).responseIdentifier === "string";
48
+ }
49
+
50
+ export interface ItemCapabilityOptions {
51
+ /** Interaction kinds the target runtime can render (descriptor + skin both present). */
52
+ readonly supportedInteractions: ReadonlySet<string>;
53
+ /** Content model deciding the flow-element allowlist + math root; defaults to v0. */
54
+ readonly model?: ContentModel;
55
+ /** Custom-operator classes the target runtime registers (for RP capability). */
56
+ readonly customOperatorClasses?: ReadonlySet<string>;
57
+ /** Resolver for shared-stimulus refs; unresolved refs are not deliverable. */
58
+ readonly resolveStimulus?: (ref: AssessmentStimulusRefView) => StimulusContentView | null;
59
+ /**
60
+ * Optional per-kind schemas for the stricter `invalid-interaction` check. The React
61
+ * runtime supplies its descriptor schemas; a headless caller that has already validated
62
+ * structure (e.g. against the qti-xml contracts schema) can omit them.
63
+ */
64
+ readonly interactionSchemas?: ReadonlyMap<string, ZodType>;
65
+ }
66
+
67
+ /**
68
+ * Report whether `item` can be delivered by a runtime with the given capabilities, and
69
+ * every reason it cannot. Pure and React-free; the React runtime's `canDeliver` is a thin
70
+ * wrapper over this.
71
+ */
72
+ export function reportItemCapability(item: AssessmentItemView, options: ItemCapabilityOptions): CapabilityReport {
73
+ const model = options.model ?? v0ContentModel;
74
+ const customOperatorClasses = options.customOperatorClasses ?? new Set<string>();
75
+ const issues: CapabilityIssue[] = [];
76
+ const seen = new Set<string>();
77
+
78
+ function report(issue: CapabilityIssue): void {
79
+ const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
80
+
81
+ if (!seen.has(dedupeKey)) {
82
+ seen.add(dedupeKey);
83
+ issues.push(issue);
84
+ }
85
+ }
86
+
87
+ function walk(node: BodyNode): void {
88
+ if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
89
+ for (const child of (node as unknown as { content?: readonly BodyNode[] }).content ?? []) {
90
+ walk(child);
91
+ }
92
+
93
+ return;
94
+ }
95
+
96
+ if (isInteractionNode(node)) {
97
+ if (!options.supportedInteractions.has(node.kind)) {
98
+ report({ type: "unsupported-interaction", name: node.kind, responseIdentifier: node.responseIdentifier });
99
+
100
+ return;
101
+ }
102
+
103
+ const schema = options.interactionSchemas?.get(node.kind);
104
+
105
+ if (schema) {
106
+ const parsed = schema.safeParse(node);
107
+
108
+ if (!parsed.success) {
109
+ const detail = parsed.error.issues[0]?.message;
110
+
111
+ report({
112
+ type: "invalid-interaction",
113
+ name: node.kind,
114
+ responseIdentifier: node.responseIdentifier,
115
+ ...(detail !== undefined ? { detail } : {}),
116
+ });
117
+ }
118
+ }
119
+
120
+ return;
121
+ }
122
+
123
+ if (node.kind === "xml") {
124
+ const xmlNode = node as XmlContentNode;
125
+
126
+ if (xmlNode.name === model.mathRoot) {
127
+ return;
128
+ }
129
+
130
+ if (!isAllowedFlowElement(model, xmlNode.name)) {
131
+ report({ type: "unsupported-element", name: xmlNode.name });
132
+ }
133
+
134
+ for (const child of xmlNode.children ?? []) {
135
+ walk(child);
136
+ }
137
+
138
+ return;
139
+ }
140
+
141
+ if (intrinsicLeafKinds.has(node.kind)) {
142
+ return;
143
+ }
144
+
145
+ report({ type: "unsupported-element", name: node.kind });
146
+ }
147
+
148
+ for (const node of item.itemBody.content ?? []) {
149
+ walk(node);
150
+ }
151
+
152
+ for (const ref of item.assessmentStimulusRefs ?? []) {
153
+ const stimulus = options.resolveStimulus?.(ref) ?? null;
154
+
155
+ if (stimulus === null) {
156
+ report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
157
+ continue;
158
+ }
159
+
160
+ for (const node of stimulus.content) {
161
+ walk(node);
162
+ }
163
+ }
164
+
165
+ for (const feedback of item.modalFeedbacks ?? []) {
166
+ for (const child of feedback.content ?? []) {
167
+ walk(child);
168
+ }
169
+ }
170
+
171
+ for (const issue of collectRpIssues(item.responseProcessing, {
172
+ customOperatorClasses,
173
+ outcomeDeclarations: item.outcomeDeclarations ?? [],
174
+ })) {
175
+ report(issue);
176
+ }
177
+
178
+ for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
179
+ report(issue);
180
+ }
181
+
182
+ return { deliverable: issues.length === 0, issues };
183
+ }
184
+
185
+ /**
186
+ * Interaction kinds the bundled reference skin renders — the default "supported set" for
187
+ * callers that deliver with the reference skin. Kept in parity with the reference skin by
188
+ * a test; pass an explicit set to `reportItemCapability` for a custom delivery surface.
189
+ */
190
+ export const referenceInteractionKinds: readonly string[] = [
191
+ "associateInteraction",
192
+ "choiceInteraction",
193
+ "drawingInteraction",
194
+ "endAttemptInteraction",
195
+ "extendedTextInteraction",
196
+ "gapMatchInteraction",
197
+ "graphicAssociateInteraction",
198
+ "graphicGapMatchInteraction",
199
+ "graphicOrderInteraction",
200
+ "hotspotInteraction",
201
+ "hottextInteraction",
202
+ "inlineChoiceInteraction",
203
+ "matchInteraction",
204
+ "mediaInteraction",
205
+ "orderInteraction",
206
+ "positionObjectStage",
207
+ "selectPointInteraction",
208
+ "sliderInteraction",
209
+ "textEntryInteraction",
210
+ "uploadInteraction",
211
+ ];