@longsightgroup/qti3-core 0.9.1 → 0.9.3

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.
Files changed (244) hide show
  1. package/README.md +59 -3
  2. package/dist/adaptive-turn-materializer.d.ts +14 -0
  3. package/dist/adaptive-turn-materializer.d.ts.map +1 -0
  4. package/dist/adaptive-turn-materializer.js +56 -0
  5. package/dist/adaptive-turn-materializer.js.map +1 -0
  6. package/dist/adaptive-turn.d.ts +23 -0
  7. package/dist/adaptive-turn.d.ts.map +1 -0
  8. package/dist/adaptive-turn.js +60 -0
  9. package/dist/adaptive-turn.js.map +1 -0
  10. package/dist/assert-never.d.ts +3 -0
  11. package/dist/assert-never.d.ts.map +1 -0
  12. package/dist/assert-never.js +5 -0
  13. package/dist/assert-never.js.map +1 -0
  14. package/dist/attempt-state-constants.d.ts +6 -0
  15. package/dist/attempt-state-constants.d.ts.map +1 -0
  16. package/dist/attempt-state-constants.js +6 -0
  17. package/dist/attempt-state-constants.js.map +1 -0
  18. package/dist/attempt-state.d.ts +5 -0
  19. package/dist/attempt-state.d.ts.map +1 -0
  20. package/dist/attempt-state.js +190 -0
  21. package/dist/attempt-state.js.map +1 -0
  22. package/dist/catalog.js +1 -1
  23. package/dist/catalog.js.map +1 -1
  24. package/dist/content-text.d.ts.map +1 -1
  25. package/dist/content-text.js +3 -0
  26. package/dist/content-text.js.map +1 -1
  27. package/dist/custom-operators.d.ts +13 -0
  28. package/dist/custom-operators.d.ts.map +1 -0
  29. package/dist/custom-operators.js +2 -0
  30. package/dist/custom-operators.js.map +1 -0
  31. package/dist/delivery-redaction.d.ts +50 -0
  32. package/dist/delivery-redaction.d.ts.map +1 -0
  33. package/dist/delivery-redaction.js +176 -0
  34. package/dist/delivery-redaction.js.map +1 -0
  35. package/dist/delivery-security.d.ts +3 -18
  36. package/dist/delivery-security.d.ts.map +1 -1
  37. package/dist/delivery-security.js +35 -189
  38. package/dist/delivery-security.js.map +1 -1
  39. package/dist/index.d.ts +4 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/parser-custom-interactions.js +1 -1
  44. package/dist/parser-custom-interactions.js.map +1 -1
  45. package/dist/parser-declarations.d.ts.map +1 -1
  46. package/dist/parser-declarations.js +4 -4
  47. package/dist/parser-declarations.js.map +1 -1
  48. package/dist/parser-item-metadata.d.ts.map +1 -1
  49. package/dist/parser-item-metadata.js +8 -7
  50. package/dist/parser-item-metadata.js.map +1 -1
  51. package/dist/parser-processing.js +2 -2
  52. package/dist/parser-processing.js.map +1 -1
  53. package/dist/parser-values.d.ts +5 -1
  54. package/dist/parser-values.d.ts.map +1 -1
  55. package/dist/parser-values.js +33 -0
  56. package/dist/parser-values.js.map +1 -1
  57. package/dist/processing-evaluator-boolean.d.ts +8 -0
  58. package/dist/processing-evaluator-boolean.d.ts.map +1 -0
  59. package/dist/processing-evaluator-boolean.js +47 -0
  60. package/dist/processing-evaluator-boolean.js.map +1 -0
  61. package/dist/processing-evaluator-collection.d.ts +12 -0
  62. package/dist/processing-evaluator-collection.d.ts.map +1 -0
  63. package/dist/processing-evaluator-collection.js +35 -0
  64. package/dist/processing-evaluator-collection.js.map +1 -0
  65. package/dist/processing-evaluator-comparison.d.ts +8 -0
  66. package/dist/processing-evaluator-comparison.d.ts.map +1 -0
  67. package/dist/processing-evaluator-comparison.js +88 -0
  68. package/dist/processing-evaluator-comparison.js.map +1 -0
  69. package/dist/processing-evaluator-custom.d.ts +8 -0
  70. package/dist/processing-evaluator-custom.d.ts.map +1 -0
  71. package/dist/processing-evaluator-custom.js +14 -0
  72. package/dist/processing-evaluator-custom.js.map +1 -0
  73. package/dist/processing-evaluator-geometry.d.ts +8 -0
  74. package/dist/processing-evaluator-geometry.d.ts.map +1 -0
  75. package/dist/processing-evaluator-geometry.js +21 -0
  76. package/dist/processing-evaluator-geometry.js.map +1 -0
  77. package/dist/processing-evaluator-numeric.d.ts +8 -0
  78. package/dist/processing-evaluator-numeric.d.ts.map +1 -0
  79. package/dist/processing-evaluator-numeric.js +112 -0
  80. package/dist/processing-evaluator-numeric.js.map +1 -0
  81. package/dist/processing-evaluator-string.d.ts +8 -0
  82. package/dist/processing-evaluator-string.d.ts.map +1 -0
  83. package/dist/processing-evaluator-string.js +33 -0
  84. package/dist/processing-evaluator-string.js.map +1 -0
  85. package/dist/processing-evaluator-variable.d.ts +8 -0
  86. package/dist/processing-evaluator-variable.d.ts.map +1 -0
  87. package/dist/processing-evaluator-variable.js +39 -0
  88. package/dist/processing-evaluator-variable.js.map +1 -0
  89. package/dist/processing-evaluator.d.ts +22 -0
  90. package/dist/processing-evaluator.d.ts.map +1 -0
  91. package/dist/processing-evaluator.js +137 -0
  92. package/dist/processing-evaluator.js.map +1 -0
  93. package/dist/processing-expression-children.d.ts +3 -0
  94. package/dist/processing-expression-children.d.ts.map +1 -0
  95. package/dist/processing-expression-children.js +68 -0
  96. package/dist/processing-expression-children.js.map +1 -0
  97. package/dist/processing-mapping.d.ts +13 -0
  98. package/dist/processing-mapping.d.ts.map +1 -0
  99. package/dist/processing-mapping.js +120 -0
  100. package/dist/processing-mapping.js.map +1 -0
  101. package/dist/processing-operators.d.ts +11 -0
  102. package/dist/processing-operators.d.ts.map +1 -0
  103. package/dist/processing-operators.js +178 -0
  104. package/dist/processing-operators.js.map +1 -0
  105. package/dist/processing-random.d.ts +2 -0
  106. package/dist/processing-random.d.ts.map +1 -0
  107. package/dist/processing-random.js +16 -0
  108. package/dist/processing-random.js.map +1 -0
  109. package/dist/processing-state.d.ts +8 -0
  110. package/dist/processing-state.d.ts.map +1 -0
  111. package/dist/processing-state.js +44 -0
  112. package/dist/processing-state.js.map +1 -0
  113. package/dist/processing-templates.d.ts +3 -0
  114. package/dist/processing-templates.d.ts.map +1 -0
  115. package/dist/processing-templates.js +12 -0
  116. package/dist/processing-templates.js.map +1 -0
  117. package/dist/processing-values.d.ts +15 -0
  118. package/dist/processing-values.d.ts.map +1 -0
  119. package/dist/processing-values.js +150 -0
  120. package/dist/processing-values.js.map +1 -0
  121. package/dist/processing-variables.d.ts +7 -0
  122. package/dist/processing-variables.d.ts.map +1 -0
  123. package/dist/processing-variables.js +40 -0
  124. package/dist/processing-variables.js.map +1 -0
  125. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  126. package/dist/serializer-processing-expressions.js +2 -8
  127. package/dist/serializer-processing-expressions.js.map +1 -1
  128. package/dist/serializer-processing-xml.js +2 -2
  129. package/dist/serializer-processing-xml.js.map +1 -1
  130. package/dist/serializer-processing.d.ts.map +1 -1
  131. package/dist/serializer-processing.js +3 -0
  132. package/dist/serializer-processing.js.map +1 -1
  133. package/dist/server-scoring.d.ts +4 -14
  134. package/dist/server-scoring.d.ts.map +1 -1
  135. package/dist/server-scoring.js +19 -157
  136. package/dist/server-scoring.js.map +1 -1
  137. package/dist/session.d.ts +5 -14
  138. package/dist/session.d.ts.map +1 -1
  139. package/dist/session.js +76 -1158
  140. package/dist/session.js.map +1 -1
  141. package/dist/shared-vocabulary-coverage-policy.js +1 -1
  142. package/dist/shared-vocabulary-coverage-policy.js.map +1 -1
  143. package/dist/shared-vocabulary-generated-families.js +1 -1
  144. package/dist/shared-vocabulary-generated-families.js.map +1 -1
  145. package/dist/shared-vocabulary-support.js +3 -3
  146. package/dist/shared-vocabulary-support.js.map +1 -1
  147. package/dist/shared-vocabulary.d.ts.map +1 -1
  148. package/dist/shared-vocabulary.js +4 -3
  149. package/dist/shared-vocabulary.js.map +1 -1
  150. package/dist/support-evidence.d.ts +22 -0
  151. package/dist/support-evidence.d.ts.map +1 -0
  152. package/dist/support-evidence.js +93 -0
  153. package/dist/support-evidence.js.map +1 -0
  154. package/dist/support.d.ts.map +1 -1
  155. package/dist/support.js +38 -47
  156. package/dist/support.js.map +1 -1
  157. package/dist/trusted-item-session.d.ts +49 -0
  158. package/dist/trusted-item-session.d.ts.map +1 -0
  159. package/dist/trusted-item-session.js +261 -0
  160. package/dist/trusted-item-session.js.map +1 -0
  161. package/dist/tts.d.ts.map +1 -1
  162. package/dist/tts.js +16 -2
  163. package/dist/tts.js.map +1 -1
  164. package/dist/validation-geometry.d.ts +8 -0
  165. package/dist/validation-geometry.d.ts.map +1 -0
  166. package/dist/validation-geometry.js +187 -0
  167. package/dist/validation-geometry.js.map +1 -0
  168. package/dist/validation-interactions.d.ts +3 -0
  169. package/dist/validation-interactions.d.ts.map +1 -0
  170. package/dist/validation-interactions.js +644 -0
  171. package/dist/validation-interactions.js.map +1 -0
  172. package/dist/validation-primitives.d.ts +11 -0
  173. package/dist/validation-primitives.d.ts.map +1 -0
  174. package/dist/validation-primitives.js +48 -0
  175. package/dist/validation-primitives.js.map +1 -0
  176. package/dist/validation-processing.d.ts +4 -0
  177. package/dist/validation-processing.d.ts.map +1 -0
  178. package/dist/validation-processing.js +569 -0
  179. package/dist/validation-processing.js.map +1 -0
  180. package/dist/validation-shared-vocabulary-layout.d.ts +10 -0
  181. package/dist/validation-shared-vocabulary-layout.d.ts.map +1 -0
  182. package/dist/validation-shared-vocabulary-layout.js +102 -0
  183. package/dist/validation-shared-vocabulary-layout.js.map +1 -0
  184. package/dist/validation.d.ts.map +1 -1
  185. package/dist/validation.js +27 -1641
  186. package/dist/validation.js.map +1 -1
  187. package/dist/xml.js +1 -1
  188. package/dist/xml.js.map +1 -1
  189. package/package.json +4 -2
  190. package/src/adaptive-turn-materializer.ts +85 -0
  191. package/src/adaptive-turn.ts +109 -0
  192. package/src/assert-never.ts +4 -0
  193. package/src/attempt-state-constants.ts +5 -0
  194. package/src/attempt-state.ts +245 -0
  195. package/src/catalog.ts +1 -1
  196. package/src/content-text.ts +3 -0
  197. package/src/custom-operators.ts +12 -0
  198. package/src/delivery-redaction.ts +268 -0
  199. package/src/delivery-security.ts +59 -245
  200. package/src/index.ts +9 -0
  201. package/src/parser-custom-interactions.ts +1 -1
  202. package/src/parser-declarations.ts +4 -3
  203. package/src/parser-item-metadata.ts +8 -7
  204. package/src/parser-processing.ts +2 -2
  205. package/src/parser-values.ts +36 -0
  206. package/src/processing-evaluator-boolean.ts +48 -0
  207. package/src/processing-evaluator-collection.ts +50 -0
  208. package/src/processing-evaluator-comparison.ts +106 -0
  209. package/src/processing-evaluator-custom.ts +20 -0
  210. package/src/processing-evaluator-geometry.ts +26 -0
  211. package/src/processing-evaluator-numeric.ts +155 -0
  212. package/src/processing-evaluator-string.ts +60 -0
  213. package/src/processing-evaluator-variable.ts +81 -0
  214. package/src/processing-evaluator.ts +173 -0
  215. package/src/processing-expression-children.ts +69 -0
  216. package/src/processing-mapping.ts +153 -0
  217. package/src/processing-operators.ts +184 -0
  218. package/src/processing-random.ts +16 -0
  219. package/src/processing-state.ts +69 -0
  220. package/src/processing-templates.ts +12 -0
  221. package/src/processing-values.ts +162 -0
  222. package/src/processing-variables.ts +83 -0
  223. package/src/serializer-processing-expressions.ts +2 -12
  224. package/src/serializer-processing-xml.ts +2 -2
  225. package/src/serializer-processing.ts +3 -0
  226. package/src/server-scoring.ts +30 -226
  227. package/src/session.ts +128 -2006
  228. package/src/shared-vocabulary-coverage-policy.ts +1 -1
  229. package/src/shared-vocabulary-generated-families.ts +1 -1
  230. package/src/shared-vocabulary-support.ts +3 -3
  231. package/src/shared-vocabulary.ts +3 -2
  232. package/src/support-evidence.ts +107 -0
  233. package/src/support.ts +68 -54
  234. package/src/trusted-item-session.ts +429 -0
  235. package/src/tts.ts +29 -15
  236. package/src/validation-geometry.ts +204 -0
  237. package/src/validation-interactions.ts +831 -0
  238. package/src/validation-primitives.ts +63 -0
  239. package/src/validation-processing.ts +785 -0
  240. package/src/validation-shared-vocabulary-layout.ts +117 -0
  241. package/src/validation.ts +44 -2077
  242. package/src/xml.ts +1 -1
  243. package/src/interaction-test-fixtures.ts +0 -44
  244. package/src/serializer-processing.fixtures.ts +0 -469
@@ -1,13 +1,25 @@
1
- import type { QtiDiagnostic, QtiSourceLocation } from "./types.js";
2
- import { descendants, parseXmlTree, type XmlNode } from "./xml.js";
1
+ import {
2
+ FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES,
3
+ analyzeDeliveryXml,
4
+ isDeclarationDefaultValue,
5
+ normalizedQtiElementName,
6
+ parseDeliveryXml,
7
+ parseXmlBoolean,
8
+ redactDeliveryXml,
9
+ type DeliveryRedactionPolicy,
10
+ type QtiDeliverySecurityAnalysis,
11
+ type QtiDeliverySecurityFindingKind,
12
+ } from "./delivery-redaction.js";
13
+ import type { XmlNode } from "./xml.js";
14
+
15
+ export type {
16
+ QtiDeliverySecurityAnalysis,
17
+ QtiDeliverySecurityFinding,
18
+ QtiDeliverySecurityFindingKind,
19
+ } from "./delivery-redaction.js";
3
20
 
4
21
  const FORBIDDEN_DELIVERY_ELEMENT_NAMES = new Set([
5
- "correct-response",
6
- "mapping",
7
- "area-mapping",
8
- "match-table",
9
- "interpolation-table",
10
- "response-processing",
22
+ ...FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES,
11
23
  "feedback-inline",
12
24
  "feedback-block",
13
25
  "modal-feedback",
@@ -18,222 +30,63 @@ const UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES = new Set([
18
30
  "set-correct-response",
19
31
  ]);
20
32
 
21
- const DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES = new Set([
22
- "response-declaration",
23
- "outcome-declaration",
24
- "template-declaration",
25
- ]);
26
-
27
- export type QtiDeliverySecurityFindingKind =
28
- | "forbidden-delivery-element"
29
- | "unsupported-secure-delivery-element"
30
- | "unsupported-adaptive-response-processing";
31
-
32
- export interface QtiDeliverySecurityFinding {
33
- kind: QtiDeliverySecurityFindingKind;
34
- qtiName: string;
35
- localName: string;
36
- message: string;
37
- source?: QtiSourceLocation | undefined;
38
- }
39
-
40
- export interface QtiDeliverySecurityAnalysis {
41
- diagnostics: QtiDiagnostic[];
42
- findings: QtiDeliverySecurityFinding[];
43
- /** True when this exact XML contains no known answer/scoring/feedback delivery leaks. */
44
- deliverySafe: boolean;
45
- /** True when secure-delivery redaction can be attempted for this XML. */
46
- secureDeliverySupported: boolean;
47
- }
48
-
49
33
  export interface QtiDeliverySafeXmlResult {
50
34
  ok: boolean;
51
- diagnostics: QtiDiagnostic[];
35
+ diagnostics: QtiDeliverySecurityAnalysis["diagnostics"];
52
36
  analysis: QtiDeliverySecurityAnalysis;
53
37
  xml?: string | undefined;
54
38
  }
55
39
 
56
40
  export function analyzeQtiDeliverySecurity(xml: string): QtiDeliverySecurityAnalysis {
57
- const parsed = parseDeliveryXml(xml);
58
- return analyzeParsedDeliveryXml(parsed);
41
+ return analyzeDeliveryXml(parseDeliveryXml(xml), staticDeliveryPolicy);
59
42
  }
60
43
 
61
- function analyzeParsedDeliveryXml(parsed: {
62
- root: XmlNode | undefined;
63
- diagnostics: QtiDiagnostic[];
64
- }): QtiDeliverySecurityAnalysis {
65
- const diagnostics = [...parsed.diagnostics];
66
- const findings: QtiDeliverySecurityFinding[] = [];
67
-
68
- if (parsed.root) {
69
- const nodes = [parsed.root, ...descendants(parsed.root, () => true)];
70
- for (const node of nodes) {
71
- const normalizedName = normalizedQtiElementName(node.localName);
72
- if (isForbiddenDeliveryElement(node, normalizedName)) {
73
- findings.push({
74
- kind: "forbidden-delivery-element",
75
- qtiName: node.name,
76
- localName: node.localName,
77
- message: `${node.name} exposes answer keys, scoring, mapping, feedback, or solution information during delivery.`,
78
- source: node.source,
79
- });
80
- }
81
- if (UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES.has(normalizedName)) {
82
- findings.push({
83
- kind: "unsupported-secure-delivery-element",
84
- qtiName: node.name,
85
- localName: node.localName,
86
- message: `${node.name} is not supported by secure delivery redaction v1.`,
87
- source: node.source,
88
- });
89
- }
44
+ export function buildQtiDeliverySafeXml(xml: string): QtiDeliverySafeXmlResult {
45
+ return redactDeliveryXml(xml, staticDeliveryPolicy);
46
+ }
47
+
48
+ const staticDeliveryPolicy: DeliveryRedactionPolicy = {
49
+ isForbiddenElement(node, normalizedName) {
50
+ return (
51
+ FORBIDDEN_DELIVERY_ELEMENT_NAMES.has(normalizedName) ||
52
+ isDeclarationDefaultValue(node, normalizedName)
53
+ );
54
+ },
55
+ unsupportedFinding(node, normalizedName, nodes) {
56
+ if (UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES.has(normalizedName)) {
57
+ return {
58
+ kind: "unsupported-secure-delivery-element",
59
+ qtiName: node.name,
60
+ localName: node.localName,
61
+ message: `${node.name} is not supported by secure delivery redaction v1.`,
62
+ source: node.source,
63
+ };
90
64
  }
91
65
 
92
66
  if (
93
- parseXmlBoolean(parsed.root.attributes.adaptive) === true &&
94
- nodes.some((node) => normalizedQtiElementName(node.localName) === "response-processing")
67
+ node.parent === undefined &&
68
+ parseXmlBoolean(node.attributes.adaptive) === true &&
69
+ nodes.some(
70
+ (candidate) => normalizedQtiElementName(candidate.localName) === "response-processing",
71
+ )
95
72
  ) {
96
- findings.push({
73
+ return {
97
74
  kind: "unsupported-adaptive-response-processing",
98
- qtiName: parsed.root.name,
99
- localName: parsed.root.localName,
75
+ qtiName: node.name,
76
+ localName: node.localName,
100
77
  message:
101
78
  "Adaptive response processing requires server-side item materialization before secure delivery.",
102
- source: parsed.root.source,
103
- });
79
+ source: node.source,
80
+ };
104
81
  }
105
- }
106
-
107
- diagnostics.push(...findings.map(findingToDiagnostic));
108
- const parseOk = parsed.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
109
82
 
110
- return {
111
- diagnostics,
112
- findings,
113
- deliverySafe:
114
- parseOk && !findings.some((finding) => finding.kind === "forbidden-delivery-element"),
115
- secureDeliverySupported:
116
- parseOk &&
117
- !findings.some(
118
- (finding) =>
119
- finding.kind === "unsupported-secure-delivery-element" ||
120
- finding.kind === "unsupported-adaptive-response-processing",
121
- ),
122
- };
123
- }
124
-
125
- export function buildQtiDeliverySafeXml(xml: string): QtiDeliverySafeXmlResult {
126
- const parsed = parseDeliveryXml(xml);
127
- const analysis = analyzeParsedDeliveryXml(parsed);
128
- if (!analysis.secureDeliverySupported) {
129
- return {
130
- ok: false,
131
- diagnostics: analysis.diagnostics,
132
- analysis,
133
- };
134
- }
135
-
136
- if (!parsed.root) {
137
- return {
138
- ok: false,
139
- diagnostics: parsed.diagnostics,
140
- analysis,
141
- };
142
- }
143
-
144
- const redactionRanges = readRedactionRanges([
145
- parsed.root,
146
- ...descendants(parsed.root, () => true),
147
- ]);
148
- const redactedXml = removeSourceRanges(xml, redactionRanges);
149
- const redactedAnalysis = analyzeQtiDeliverySecurity(redactedXml);
150
- if (!redactedAnalysis.deliverySafe) {
151
- return {
152
- ok: false,
153
- diagnostics: [...analysis.diagnostics, ...redactedAnalysis.diagnostics],
154
- analysis: redactedAnalysis,
155
- };
156
- }
157
-
158
- return {
159
- ok: true,
160
- xml: redactedXml,
161
- diagnostics: redactedAnalysis.diagnostics,
162
- analysis: redactedAnalysis,
163
- };
164
- }
165
-
166
- function parseDeliveryXml(xml: string): {
167
- root: XmlNode | undefined;
168
- diagnostics: QtiDiagnostic[];
169
- } {
170
- let parsed: ReturnType<typeof parseXmlTree>;
171
- try {
172
- parsed = parseXmlTree(xml);
173
- } catch (error) {
174
- return {
175
- root: undefined,
176
- diagnostics: [
177
- {
178
- code: "xml.parse",
179
- severity: "error",
180
- message: error instanceof Error ? error.message : String(error),
181
- },
182
- ],
183
- };
184
- }
185
-
186
- const diagnostics: QtiDiagnostic[] = parsed.errors.map((error) => ({
187
- code: "xml.parse",
188
- severity: "error",
189
- message: error.message,
190
- }));
191
- if (!parsed.root) {
192
- diagnostics.push({
193
- code: "xml.empty",
194
- severity: "error",
195
- message: "No XML root element was found.",
196
- });
197
- }
198
- return { root: parsed.root, diagnostics };
199
- }
200
-
201
- function normalizedQtiElementName(localName: string): string {
202
- const lower = localName.toLowerCase();
203
- return lower.startsWith("qti-") ? lower.slice("qti-".length) : lower;
204
- }
205
-
206
- function parseXmlBoolean(value: string | undefined): boolean | undefined {
207
- if (value === undefined) return undefined;
208
- const normalized = value.trim().toLowerCase();
209
- if (normalized === "true" || normalized === "1") return true;
210
- if (normalized === "false" || normalized === "0") return false;
211
- return undefined;
212
- }
213
-
214
- function isForbiddenDeliveryElement(
215
- node: XmlNode,
216
- normalizedName = normalizedQtiElementName(node.localName),
217
- ): boolean {
218
- if (FORBIDDEN_DELIVERY_ELEMENT_NAMES.has(normalizedName)) return true;
219
- return (
220
- normalizedName === "default-value" &&
221
- DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES.has(
222
- normalizedQtiElementName(node.parent?.localName ?? ""),
223
- )
224
- );
225
- }
226
-
227
- function findingToDiagnostic(finding: QtiDeliverySecurityFinding): QtiDiagnostic {
228
- const code = diagnosticCodeForFinding(finding.kind);
229
- return {
230
- code,
231
- severity: "error",
232
- message: finding.message,
233
- path: finding.source?.path,
234
- source: finding.source,
235
- };
236
- }
83
+ return null;
84
+ },
85
+ diagnosticCodeForFinding,
86
+ forbiddenElementMessage(node: XmlNode) {
87
+ return `${node.name} exposes answer keys, scoring, mapping, feedback, or solution information during delivery.`;
88
+ },
89
+ };
237
90
 
238
91
  function diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string {
239
92
  if (kind === "forbidden-delivery-element") return "delivery.forbiddenElement";
@@ -242,42 +95,3 @@ function diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string
242
95
  }
243
96
  return "delivery.unsupportedSecureDelivery";
244
97
  }
245
-
246
- interface RedactionRange {
247
- startOffset: number;
248
- endOffset: number;
249
- }
250
-
251
- function readRedactionRanges(nodes: XmlNode[]): RedactionRange[] {
252
- const ranges = nodes.flatMap((node) => {
253
- if (!isForbiddenDeliveryElement(node)) return [];
254
- const endOffset = node.sourceRange.endOffset;
255
- if (node.sourceRange.startOffset < 0 || endOffset === undefined) return [];
256
- return [{ startOffset: node.sourceRange.startOffset, endOffset }];
257
- });
258
- return mergeSourceRanges(ranges);
259
- }
260
-
261
- function mergeSourceRanges(ranges: RedactionRange[]): RedactionRange[] {
262
- const sorted = [...ranges].sort((left, right) => left.startOffset - right.startOffset);
263
- const merged: RedactionRange[] = [];
264
- for (const range of sorted) {
265
- const last = merged.at(-1);
266
- if (last && range.startOffset <= last.endOffset) {
267
- last.endOffset = Math.max(last.endOffset, range.endOffset);
268
- continue;
269
- }
270
- merged.push({ ...range });
271
- }
272
- return merged;
273
- }
274
-
275
- function removeSourceRanges(xml: string, ranges: RedactionRange[]): string {
276
- let output = "";
277
- let cursor = 0;
278
- for (const range of ranges) {
279
- output += xml.slice(cursor, range.startOffset);
280
- cursor = range.endOffset;
281
- }
282
- return output + xml.slice(cursor);
283
- }
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
+ export {
2
+ processQtiAdaptiveItemTurn,
3
+ type QtiAdaptiveTurnInput,
4
+ type QtiAdaptiveTurnResponseInput,
5
+ type QtiAdaptiveTurnResult,
6
+ } from "./adaptive-turn.js";
7
+ export { assertNever } from "./assert-never.js";
1
8
  export {
2
9
  createCatalogSupportResolution,
3
10
  type QtiCatalogSupportResolution,
@@ -29,6 +36,7 @@ export {
29
36
  type QtiPackageXmlTree,
30
37
  } from "./package-xml.js";
31
38
  export { parseQtiXml } from "./parser.js";
39
+ export { numericTuple3, numericTuple4 } from "./parser-values.js";
32
40
  export { isConformanceParseDiagnostic } from "./parse-diagnostics.js";
33
41
  export {
34
42
  scoreQtiItemServerSide,
@@ -227,6 +235,7 @@ export type {
227
235
  QtiResponseProcessing,
228
236
  QtiResponseRule,
229
237
  QtiScoreResult,
238
+ QtiStylesheet,
230
239
  QtiLookupOutcomeValue,
231
240
  QtiSetOutcomeValue,
232
241
  QtiSupportStatus,
@@ -212,7 +212,7 @@ function parseCustomMarkupNode(
212
212
  code: options.nestedInteractionCode,
213
213
  severity: "error",
214
214
  message: `${options.contextName} must not contain nested QTI interaction ${node.localName}.`,
215
- path: node.source?.path,
215
+ path: node.source.path,
216
216
  source: node.source,
217
217
  });
218
218
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  coerceValue,
3
3
  normalizeValueForCardinality,
4
+ parseBaseType,
4
5
  parseCardinality,
5
6
  parseCoords,
6
7
  parseShape,
@@ -18,7 +19,7 @@ import { childElements, textContent, type XmlNode } from "./xml.js";
18
19
 
19
20
  export function parseResponseDeclaration(node: XmlNode): QtiResponseDeclaration {
20
21
  const cardinality = parseCardinality(node.attributes.cardinality);
21
- const baseType = node.attributes["base-type"] as QtiResponseDeclaration["baseType"];
22
+ const baseType = parseBaseType(node.attributes["base-type"]);
22
23
  return {
23
24
  kind: "response",
24
25
  identifier: node.attributes.identifier ?? "",
@@ -37,7 +38,7 @@ export function parseResponseDeclaration(node: XmlNode): QtiResponseDeclaration
37
38
  }
38
39
 
39
40
  export function parseOutcomeDeclaration(node: XmlNode): QtiOutcomeDeclaration {
40
- const baseType = node.attributes["base-type"] as QtiOutcomeDeclaration["baseType"];
41
+ const baseType = parseBaseType(node.attributes["base-type"]);
41
42
  return {
42
43
  kind: "outcome",
43
44
  identifier: node.attributes.identifier ?? "",
@@ -51,7 +52,7 @@ export function parseOutcomeDeclaration(node: XmlNode): QtiOutcomeDeclaration {
51
52
  }
52
53
 
53
54
  export function parseTemplateDeclaration(node: XmlNode): QtiTemplateDeclaration {
54
- const baseType = node.attributes["base-type"] as QtiTemplateDeclaration["baseType"];
55
+ const baseType = parseBaseType(node.attributes["base-type"]);
55
56
  return {
56
57
  kind: "template",
57
58
  identifier: node.attributes.identifier ?? "",
@@ -28,13 +28,14 @@ export function firstChildElement(
28
28
  duplicateCode: string,
29
29
  ): XmlNode | undefined {
30
30
  const matches = childElements(parent, childName);
31
- if (matches.length > 1) {
31
+ const duplicate = matches[1];
32
+ if (duplicate) {
32
33
  diagnostics.push({
33
34
  code: duplicateCode,
34
35
  severity: "error",
35
36
  message: `${parent.localName} allows at most one ${childName} child.`,
36
- path: matches[1]?.source?.path ?? parent.source?.path,
37
- source: matches[1]?.source ?? parent.source,
37
+ path: duplicate.source.path,
38
+ source: duplicate.source,
38
39
  });
39
40
  }
40
41
  return matches[0];
@@ -177,7 +178,7 @@ export function parseCompanionMaterialsInfo(
177
178
  code: "companionMaterials.child.unsupported",
178
179
  severity: "warning",
179
180
  message: `${child.localName} is not currently modeled in companion materials parsing.`,
180
- path: child.source?.path,
181
+ path: child.source.path,
181
182
  source: child.source,
182
183
  });
183
184
  }
@@ -200,7 +201,7 @@ function parsePhysicalMaterial(
200
201
  diagnostics,
201
202
  "companionMaterials.physicalMaterial.empty",
202
203
  "qti-physical-material requires non-empty text content.",
203
- node.source?.path,
204
+ node.source.path,
204
205
  node.source,
205
206
  );
206
207
  }
@@ -225,7 +226,7 @@ function parseDigitalMaterial(
225
226
  diagnostics,
226
227
  "companionMaterials.digitalMaterial.fileHref.missing",
227
228
  "qti-digital-material requires a qti-file-href child.",
228
- node.source?.path,
229
+ node.source.path,
229
230
  node.source,
230
231
  );
231
232
  }
@@ -236,7 +237,7 @@ function parseDigitalMaterial(
236
237
  diagnostics,
237
238
  "companionMaterials.digitalMaterial.fileHref.empty",
238
239
  "qti-digital-material qti-file-href requires non-empty text content.",
239
- fileHrefNode.source?.path,
240
+ fileHrefNode.source.path,
240
241
  fileHrefNode.source,
241
242
  );
242
243
  }
@@ -672,8 +672,8 @@ function parseExpression(node: XmlNode): QtiProcessingExpression | undefined {
672
672
  if (variable && correct) {
673
673
  return {
674
674
  type: "matchCorrect",
675
- identifier: variable?.attributes.identifier ?? "",
676
- correctIdentifier: correct?.attributes.identifier ?? "",
675
+ identifier: variable.attributes.identifier ?? "",
676
+ correctIdentifier: correct.attributes.identifier ?? "",
677
677
  source: node.source,
678
678
  };
679
679
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ QtiBaseType,
2
3
  QtiCardinality,
3
4
  QtiRecordValue,
4
5
  QtiResponseDeclaration,
@@ -6,6 +7,41 @@ import type {
6
7
  QtiValue,
7
8
  } from "./types.js";
8
9
 
10
+ const QTI_BASE_TYPES = new Set<string>([
11
+ "identifier",
12
+ "boolean",
13
+ "integer",
14
+ "float",
15
+ "string",
16
+ "point",
17
+ "pair",
18
+ "directedPair",
19
+ "duration",
20
+ "file",
21
+ "uri",
22
+ ]);
23
+
24
+ export function isQtiBaseType(value: string): value is QtiBaseType {
25
+ return QTI_BASE_TYPES.has(value);
26
+ }
27
+
28
+ export function parseBaseType(value: string | undefined): QtiBaseType | undefined {
29
+ if (value === undefined) return undefined;
30
+ return isQtiBaseType(value) ? value : undefined;
31
+ }
32
+
33
+ export function numericTuple3(values: number[]): [number, number, number] | undefined {
34
+ const [a, b, c] = values;
35
+ if (a === undefined || b === undefined || c === undefined) return undefined;
36
+ return [a, b, c];
37
+ }
38
+
39
+ export function numericTuple4(values: number[]): [number, number, number, number] | undefined {
40
+ const [a, b, c, d] = values;
41
+ if (a === undefined || b === undefined || c === undefined || d === undefined) return undefined;
42
+ return [a, b, c, d];
43
+ }
44
+
9
45
  export function coerceValue(value: string, baseType: string | undefined): QtiScalarValue {
10
46
  if (baseType === "integer") return Number.parseInt(value, 10);
11
47
  if (baseType === "float") return Number.parseFloat(value);
@@ -0,0 +1,48 @@
1
+ import type { QtiProcessingExpression, QtiValue } from "./types.js";
2
+ import { assertNever } from "./assert-never.js";
3
+ import type { EvaluationContext } from "./processing-evaluator.js";
4
+ import { booleanValueOrNull } from "./processing-values.js";
5
+
6
+ type BooleanExpression = Extract<QtiProcessingExpression, { type: "and" | "anyN" | "or" | "not" }>;
7
+
8
+ export function evaluateBooleanExpression(
9
+ expression: BooleanExpression,
10
+ context: EvaluationContext,
11
+ ): QtiValue {
12
+ switch (expression.type) {
13
+ case "and": {
14
+ let sawNull = false;
15
+ for (const item of expression.expressions) {
16
+ const value = booleanValueOrNull(context.evaluate(item));
17
+ if (value === false) return false;
18
+ if (value === null) sawNull = true;
19
+ }
20
+ return sawNull ? null : true;
21
+ }
22
+ case "anyN": {
23
+ const min = context.indexValue(expression.min) ?? 0;
24
+ const max = context.indexValue(expression.max) ?? 0;
25
+ const values = expression.expressions.map((item) => context.evaluate(item));
26
+ const trueCount = values.filter((value) => value === true).length;
27
+ const nullCount = values.filter((value) => value === null).length;
28
+ if (min > max || trueCount > max || trueCount + nullCount < min) return false;
29
+ if (trueCount >= min && trueCount <= max) return true;
30
+ return null;
31
+ }
32
+ case "or": {
33
+ let sawNull = false;
34
+ for (const item of expression.expressions) {
35
+ const value = booleanValueOrNull(context.evaluate(item));
36
+ if (value === true) return true;
37
+ if (value === null) sawNull = true;
38
+ }
39
+ return sawNull ? null : false;
40
+ }
41
+ case "not": {
42
+ const value = booleanValueOrNull(context.evaluate(expression.expression));
43
+ return value === null ? null : !value;
44
+ }
45
+ default:
46
+ return assertNever(expression);
47
+ }
48
+ }
@@ -0,0 +1,50 @@
1
+ import type { QtiProcessingExpression, QtiScalarValue, QtiValue } from "./types.js";
2
+ import { assertNever } from "./assert-never.js";
3
+ import type { EvaluationContext } from "./processing-evaluator.js";
4
+ import { valueContainer } from "./processing-values.js";
5
+
6
+ type CollectionExpression = Extract<
7
+ QtiProcessingExpression,
8
+ { type: "multiple" | "ordered" | "index" | "containerSize" }
9
+ >;
10
+ type RepeatExpression = Extract<QtiProcessingExpression, { type: "repeat" }>;
11
+
12
+ export function evaluateCollectionExpression(
13
+ expression: CollectionExpression,
14
+ context: EvaluationContext,
15
+ ): QtiValue {
16
+ switch (expression.type) {
17
+ case "multiple":
18
+ case "ordered": {
19
+ const values = expression.expressions.flatMap((item) =>
20
+ valueContainer(context.evaluate(item)),
21
+ );
22
+ return values.length > 0 ? values : null;
23
+ }
24
+ case "index": {
25
+ const values = valueContainer(context.evaluate(expression.expression));
26
+ const n = context.indexValue(expression.n);
27
+ if (n === undefined || n < 1 || n > values.length) return null;
28
+ return values[n - 1] ?? null;
29
+ }
30
+ case "containerSize":
31
+ return valueContainer(context.evaluate(expression.expression)).length;
32
+ default:
33
+ return assertNever(expression);
34
+ }
35
+ }
36
+
37
+ export function evaluateRepeatExpression(
38
+ expression: RepeatExpression,
39
+ context: EvaluationContext,
40
+ ): QtiValue {
41
+ const repeats = context.indexValue(expression.numberRepeats);
42
+ if (repeats === undefined || repeats < 1) return null;
43
+ const container: QtiScalarValue[] = [];
44
+ for (let repeat = 0; repeat < repeats; repeat += 1) {
45
+ for (const item of expression.expressions) {
46
+ container.push(...valueContainer(context.evaluate(item)));
47
+ }
48
+ }
49
+ return container.length > 0 ? container : null;
50
+ }