@lxpack/validators 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
7
 
8
- Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, and component lessons (v0.2.0).
8
+ Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, and component lessons (v0.2.2).
9
9
 
10
10
  Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
11
 
@@ -94,7 +94,7 @@ isPathContained(courseDir, abs); // true if inside course root
94
94
  | `buildRuntimeAssessmentBundle(dir, manifest)` | Load assessments; split learner view, keys, configs, feedback |
95
95
  | `toLearnerAssessment(assessment)` | Strip `correct` from choices; extract config and feedback maps |
96
96
  | `validateFlow(manifest)` | Flow rule and target validation |
97
- | `detectFlowCycles(flow)` | Cycle detection for branching graphs |
97
+ | `detectFlowCycles(manifest)` | Flow-jump cycle detection for branching graphs |
98
98
  | `collectActivityIds(manifest)` | Lesson and assessment IDs for flow targets |
99
99
  | `conditionSchema`, `flowRuleSchema` | Zod schemas for flow conditions and rules |
100
100
  | `BUILTIN_COMPONENT_IDS`, `isBuiltinComponentId` | Allowed built-in component lesson IDs |
package/dist/index.d.ts CHANGED
@@ -30,6 +30,8 @@ declare const flowRuleSchema: z.ZodObject<{
30
30
  }>;
31
31
  type FlowRule = z.infer<typeof flowRuleSchema>;
32
32
 
33
+ /** Safe for SCORM paths and manifest identifiers. */
34
+ declare const activityIdSchema: z.ZodString;
33
35
  declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
34
36
  id: z.ZodString;
35
37
  prompt: z.ZodString;
@@ -185,7 +187,7 @@ declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
185
187
  props?: Record<string, unknown> | undefined;
186
188
  }>]>;
187
189
  declare const showFeedbackSchema: z.ZodDefault<z.ZodEnum<["immediate", "end", "never"]>>;
188
- declare const assessmentSchema: z.ZodObject<{
190
+ declare const assessmentSchema: z.ZodEffects<z.ZodObject<{
189
191
  id: z.ZodString;
190
192
  title: z.ZodOptional<z.ZodString>;
191
193
  passingScore: z.ZodDefault<z.ZodNumber>;
@@ -280,6 +282,40 @@ declare const assessmentSchema: z.ZodObject<{
280
282
  maxAttempts?: number | undefined;
281
283
  shuffleChoices?: boolean | undefined;
282
284
  showFeedback?: "never" | "immediate" | "end" | undefined;
285
+ }>, {
286
+ id: string;
287
+ passingScore: number;
288
+ questions: {
289
+ id: string;
290
+ prompt: string;
291
+ choices: {
292
+ id: string;
293
+ text: string;
294
+ correct?: boolean | undefined;
295
+ }[];
296
+ explanation?: string | undefined;
297
+ }[];
298
+ title?: string | undefined;
299
+ maxAttempts?: number | undefined;
300
+ shuffleChoices?: boolean | undefined;
301
+ showFeedback?: "never" | "immediate" | "end" | undefined;
302
+ }, {
303
+ id: string;
304
+ questions: {
305
+ id: string;
306
+ prompt: string;
307
+ choices: {
308
+ id: string;
309
+ text: string;
310
+ correct?: boolean | undefined;
311
+ }[];
312
+ explanation?: string | undefined;
313
+ }[];
314
+ title?: string | undefined;
315
+ passingScore?: number | undefined;
316
+ maxAttempts?: number | undefined;
317
+ shuffleChoices?: boolean | undefined;
318
+ showFeedback?: "never" | "immediate" | "end" | undefined;
283
319
  }>;
284
320
  declare const assessmentRefSchema: z.ZodObject<{
285
321
  id: z.ZodString;
@@ -578,9 +614,13 @@ declare function validateCourse(courseDir: string): Promise<ValidationResult>;
578
614
 
579
615
  declare function collectActivityIds(manifest: CourseManifest): Set<string>;
580
616
  declare function collectAssessmentIds(manifest: CourseManifest): Set<string>;
617
+ declare function collectInteractionIds(manifest: CourseManifest): Set<string>;
618
+ declare function buildActivityOrder(manifest: CourseManifest): string[];
581
619
  declare function validateFlow(manifest: CourseManifest): ValidationIssue[];
582
- /** Detect simple goto cycles (same-target chains). */
583
- declare function detectFlowCycles(flow: FlowRule[]): string[];
620
+ /**
621
+ * Detect cycles reachable via flow jumps (first matching applicable rule).
622
+ */
623
+ declare function detectFlowCycles(manifest: CourseManifest): string[];
584
624
 
585
625
  interface LearnerChoice {
586
626
  id: string;
@@ -635,4 +675,4 @@ declare function enumerateActivities(manifest: CourseManifest): CourseActivity[]
635
675
 
636
676
  declare function escapeHtml(text: string): string;
637
677
 
638
- export { type Assessment, type AssessmentRef, type AssessmentRuntimeConfig, BUILTIN_COMPONENT_IDS, type BuiltinComponentId, type ComponentLesson, type Condition, type CourseActivity, type CourseManifest, type FlowRule, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type QuestionFeedback, type RuntimeAssessmentBundle, type ShowFeedback, type ValidationIssue, type ValidationResult, type VariableDef, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildRuntimeAssessmentBundle, buildRuntimeAssessmentBundleFromParsed, collectActivityIds, collectAssessmentIds, componentLessonSchema, conditionSchema, courseManifestSchema, detectFlowCycles, enumerateActivities, escapeHtml, flowRuleSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isBuiltinComponentId, isPathContained, lessonSchema, loadManifest, loadParsedAssessments, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, showFeedbackSchema, toLearnerAssessment, trackingSchema, validateCourse, validateFlow, variableDefSchema };
678
+ export { type Assessment, type AssessmentRef, type AssessmentRuntimeConfig, BUILTIN_COMPONENT_IDS, type BuiltinComponentId, type ComponentLesson, type Condition, type CourseActivity, type CourseManifest, type FlowRule, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type QuestionFeedback, type RuntimeAssessmentBundle, type ShowFeedback, type ValidationIssue, type ValidationResult, type VariableDef, activityIdSchema, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildActivityOrder, buildRuntimeAssessmentBundle, buildRuntimeAssessmentBundleFromParsed, collectActivityIds, collectAssessmentIds, collectInteractionIds, componentLessonSchema, conditionSchema, courseManifestSchema, detectFlowCycles, enumerateActivities, escapeHtml, flowRuleSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isBuiltinComponentId, isPathContained, lessonSchema, loadManifest, loadParsedAssessments, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, showFeedbackSchema, toLearnerAssessment, trackingSchema, validateCourse, validateFlow, variableDefSchema };
package/dist/index.js CHANGED
@@ -36,6 +36,10 @@ var flowRuleSchema = z.object({
36
36
  }).strict();
37
37
 
38
38
  // src/schemas.ts
39
+ var activityIdSchema = z2.string().min(1).regex(
40
+ /^[a-zA-Z][a-zA-Z0-9_-]*$/,
41
+ "ID must start with a letter and contain only letters, numbers, underscores, and hyphens"
42
+ );
39
43
  var choiceSchema = z2.object({
40
44
  id: z2.string().min(1),
41
45
  text: z2.string().min(1),
@@ -55,21 +59,33 @@ var assessmentQuestionSchema = z2.object({
55
59
  path: ["choices"]
56
60
  });
57
61
  }
62
+ const choiceIds = /* @__PURE__ */ new Set();
63
+ for (let i = 0; i < question.choices.length; i++) {
64
+ const choice = question.choices[i];
65
+ if (choiceIds.has(choice.id)) {
66
+ ctx.addIssue({
67
+ code: z2.ZodIssueCode.custom,
68
+ message: `Duplicate choice id: ${choice.id}`,
69
+ path: ["choices", i, "id"]
70
+ });
71
+ }
72
+ choiceIds.add(choice.id);
73
+ }
58
74
  });
59
75
  var markdownLessonSchema = z2.object({
60
- id: z2.string().min(1),
76
+ id: activityIdSchema,
61
77
  type: z2.literal("markdown"),
62
78
  file: z2.string().min(1),
63
79
  title: z2.string().optional()
64
80
  }).strict();
65
81
  var htmlLessonSchema = z2.object({
66
- id: z2.string().min(1),
82
+ id: activityIdSchema,
67
83
  type: z2.literal("html"),
68
84
  path: z2.string().min(1),
69
85
  title: z2.string().optional()
70
86
  }).strict();
71
87
  var componentLessonSchema = z2.object({
72
- id: z2.string().min(1),
88
+ id: activityIdSchema,
73
89
  type: z2.literal("component"),
74
90
  component: z2.string().min(1),
75
91
  props: z2.record(z2.unknown()).optional(),
@@ -82,16 +98,29 @@ var lessonSchema = z2.discriminatedUnion("type", [
82
98
  ]);
83
99
  var showFeedbackSchema = z2.enum(["immediate", "end", "never"]).default("never");
84
100
  var assessmentSchema = z2.object({
85
- id: z2.string().min(1),
101
+ id: activityIdSchema,
86
102
  title: z2.string().optional(),
87
103
  passingScore: z2.number().min(0).max(1).default(0.7),
88
104
  maxAttempts: z2.number().int().min(1).optional(),
89
105
  shuffleChoices: z2.boolean().optional(),
90
106
  showFeedback: showFeedbackSchema.optional(),
91
107
  questions: z2.array(assessmentQuestionSchema).min(1)
92
- }).strict();
108
+ }).strict().superRefine((assessment, ctx) => {
109
+ const questionIds = /* @__PURE__ */ new Set();
110
+ for (let i = 0; i < assessment.questions.length; i++) {
111
+ const q = assessment.questions[i];
112
+ if (questionIds.has(q.id)) {
113
+ ctx.addIssue({
114
+ code: z2.ZodIssueCode.custom,
115
+ message: `Duplicate question id: ${q.id}`,
116
+ path: ["questions", i, "id"]
117
+ });
118
+ }
119
+ questionIds.add(q.id);
120
+ }
121
+ });
93
122
  var assessmentRefSchema = z2.object({
94
- id: z2.string().min(1),
123
+ id: activityIdSchema,
95
124
  file: z2.string().min(1)
96
125
  }).strict();
97
126
  var variableDefSchema = z2.object({
@@ -169,6 +198,54 @@ function collectAssessmentIds(manifest) {
169
198
  }
170
199
  return ids;
171
200
  }
201
+ function collectInteractionIds(manifest) {
202
+ const ids = /* @__PURE__ */ new Set();
203
+ for (const lesson of manifest.lessons) {
204
+ if (lesson.type === "html") {
205
+ ids.add(lesson.id);
206
+ }
207
+ }
208
+ return ids;
209
+ }
210
+ function buildActivityOrder(manifest) {
211
+ const ids = manifest.lessons.map((l) => l.id);
212
+ for (const ref of manifest.assessments ?? []) {
213
+ ids.push(ref.id);
214
+ }
215
+ return ids;
216
+ }
217
+ function validateConditionShape(condition, path) {
218
+ const issues = [];
219
+ if ("all" in condition && condition.all) {
220
+ if (condition.all.length === 0) {
221
+ issues.push({
222
+ path,
223
+ message: "Condition all: [] is always true at runtime; use a non-empty list",
224
+ severity: "error"
225
+ });
226
+ }
227
+ for (let i = 0; i < condition.all.length; i++) {
228
+ issues.push(
229
+ ...validateConditionShape(condition.all[i], `${path}.all[${i}]`)
230
+ );
231
+ }
232
+ }
233
+ if ("any" in condition && condition.any) {
234
+ if (condition.any.length === 0) {
235
+ issues.push({
236
+ path,
237
+ message: "Condition any: [] is always false at runtime; use a non-empty list",
238
+ severity: "error"
239
+ });
240
+ }
241
+ for (let i = 0; i < condition.any.length; i++) {
242
+ issues.push(
243
+ ...validateConditionShape(condition.any[i], `${path}.any[${i}]`)
244
+ );
245
+ }
246
+ }
247
+ return issues;
248
+ }
172
249
  function collectConditionRefs(condition, refs) {
173
250
  if ("variable" in condition && condition.variable?.eq) {
174
251
  refs.variables.add(condition.variable.eq[0]);
@@ -192,9 +269,11 @@ function validateFlow(manifest) {
192
269
  if (!flow?.length) return issues;
193
270
  const activityIds = collectActivityIds(manifest);
194
271
  const assessmentIds = collectAssessmentIds(manifest);
272
+ const interactionIds = collectInteractionIds(manifest);
195
273
  const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
196
274
  flow.forEach((rule, index) => {
197
275
  const path = `flow[${index}]`;
276
+ issues.push(...validateConditionShape(rule.when, `${path}.when`));
198
277
  if (!activityIds.has(rule.goto)) {
199
278
  issues.push({
200
279
  path: `${path}.goto`,
@@ -227,10 +306,10 @@ function validateFlow(manifest) {
227
306
  }
228
307
  }
229
308
  for (const i of refs.interactions) {
230
- if (!activityIds.has(i)) {
309
+ if (!interactionIds.has(i)) {
231
310
  issues.push({
232
311
  path: `${path}.when`,
233
- message: `Unknown interaction/lesson id in condition: ${i}`,
312
+ message: `Unknown interaction id in condition (expected html lesson): ${i}`,
234
313
  severity: "error"
235
314
  });
236
315
  }
@@ -238,30 +317,63 @@ function validateFlow(manifest) {
238
317
  });
239
318
  return issues;
240
319
  }
241
- function detectFlowCycles(flow) {
242
- const gotoOf = /* @__PURE__ */ new Map();
243
- flow.forEach((rule, i) => gotoOf.set(i, rule.goto));
320
+ function conditionCouldApplyAt(condition, currentActivityId, interactionIds) {
321
+ if ("variable" in condition) return true;
322
+ if ("assessment" in condition && condition.assessment?.passed) {
323
+ return currentActivityId === condition.assessment.passed;
324
+ }
325
+ if ("interaction" in condition && condition.interaction?.done) {
326
+ return interactionIds.has(currentActivityId);
327
+ }
328
+ if ("all" in condition && condition.all) {
329
+ return condition.all.length > 0 && condition.all.every(
330
+ (c) => conditionCouldApplyAt(c, currentActivityId, interactionIds)
331
+ );
332
+ }
333
+ if ("any" in condition && condition.any) {
334
+ return condition.any.length > 0 && condition.any.some(
335
+ (c) => conditionCouldApplyAt(c, currentActivityId, interactionIds)
336
+ );
337
+ }
338
+ return false;
339
+ }
340
+ function detectFlowCycles(manifest) {
341
+ const flow = manifest.flow;
342
+ if (!flow?.length) return [];
343
+ const activityIds = collectActivityIds(manifest);
344
+ const interactionIds = collectInteractionIds(manifest);
345
+ const flowJumpFrom = (current) => {
346
+ for (const rule of flow) {
347
+ if (rule.goto !== current && activityIds.has(rule.goto) && conditionCouldApplyAt(rule.when, current, interactionIds)) {
348
+ return rule.goto;
349
+ }
350
+ }
351
+ return null;
352
+ };
244
353
  const errors = [];
245
- const visited = /* @__PURE__ */ new Set();
246
- for (let start = 0; start < flow.length; start++) {
247
- if (visited.has(start)) continue;
248
- const chain = /* @__PURE__ */ new Set();
249
- let i = start;
250
- while (i !== void 0 && i < flow.length) {
251
- const ruleIndex = i;
252
- if (chain.has(ruleIndex)) {
253
- errors.push(`Flow rule cycle detected involving flow[${ruleIndex}]`);
254
- break;
354
+ const reported = /* @__PURE__ */ new Set();
355
+ const dfs = (node, stack, onStack) => {
356
+ const jump = flowJumpFrom(node);
357
+ if (!jump) return;
358
+ if (onStack.has(jump)) {
359
+ const cycleStart = stack.indexOf(jump);
360
+ const cycle = [...stack.slice(cycleStart), jump];
361
+ const key = cycle.join("->");
362
+ if (!reported.has(key)) {
363
+ reported.add(key);
364
+ errors.push(`Flow cycle: ${cycle.join(" -> ")}`);
255
365
  }
256
- if (visited.has(ruleIndex)) break;
257
- chain.add(ruleIndex);
258
- visited.add(ruleIndex);
259
- const target = gotoOf.get(ruleIndex);
260
- const nextIdx = flow.findIndex(
261
- (_, idx) => idx > ruleIndex && flow[idx].goto === target
262
- );
263
- i = nextIdx >= 0 ? nextIdx : void 0;
366
+ return;
264
367
  }
368
+ if (stack.length >= activityIds.size) return;
369
+ onStack.add(jump);
370
+ stack.push(jump);
371
+ dfs(jump, stack, onStack);
372
+ stack.pop();
373
+ onStack.delete(jump);
374
+ };
375
+ for (const id of activityIds) {
376
+ dfs(id, [id], /* @__PURE__ */ new Set([id]));
265
377
  }
266
378
  return errors;
267
379
  }
@@ -359,6 +471,7 @@ async function loadParsedAssessments(courseDir, manifest) {
359
471
  message: `Duplicate assessment ID: ${ref.id}`,
360
472
  severity: "error"
361
473
  });
474
+ continue;
362
475
  }
363
476
  assessmentIds.add(ref.id);
364
477
  const resolved = resolveCoursePath(resolvedDir, ref.file);
@@ -710,7 +823,7 @@ async function validateCourse(courseDir) {
710
823
  issues.push(...validateActivityIds(manifest));
711
824
  issues.push(...validateFlow(manifest));
712
825
  if (manifest.flow?.length) {
713
- for (const message of detectFlowCycles(manifest.flow)) {
826
+ for (const message of detectFlowCycles(manifest)) {
714
827
  issues.push({
715
828
  path: "flow",
716
829
  message,
@@ -749,14 +862,17 @@ function escapeHtml(text) {
749
862
  }
750
863
  export {
751
864
  BUILTIN_COMPONENT_IDS,
865
+ activityIdSchema,
752
866
  assertResolvedPathContained,
753
867
  assessmentQuestionSchema,
754
868
  assessmentRefSchema,
755
869
  assessmentSchema,
870
+ buildActivityOrder,
756
871
  buildRuntimeAssessmentBundle,
757
872
  buildRuntimeAssessmentBundleFromParsed,
758
873
  collectActivityIds,
759
874
  collectAssessmentIds,
875
+ collectInteractionIds,
760
876
  componentLessonSchema,
761
877
  conditionSchema,
762
878
  courseManifestSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/validators",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Course manifest validation for LXPack",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {