@novedu/cli 0.13.0 → 0.15.0

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 (2) hide show
  1. package/dist/main.js +414 -38
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -442,6 +442,41 @@ function formatZodIssues(zodIssues) {
442
442
  return out;
443
443
  }
444
444
  //#endregion
445
+ //#region ../lib/prompt-fragments/text-files.ts
446
+ /**
447
+ * Split a body into logical lines for range slicing. `body.split("\n")` yields a
448
+ * trailing empty element for a body that ends in a newline (`"a\n"` → `["a", ""]`); we
449
+ * drop exactly ONE such trailing element so a conventional newline-terminated file
450
+ * reports its real line count (`"a\n"` is one line, not two). An empty body is zero
451
+ * lines. NB: this is ONLY for line-range math — a no-range `{{file}}` renders the body
452
+ * byte-for-byte (see `load.ts`), trailing newline and all.
453
+ */
454
+ function toLines(body) {
455
+ if (body === "") return [];
456
+ const lines = body.split("\n");
457
+ if (lines[lines.length - 1] === "") lines.pop();
458
+ return lines;
459
+ }
460
+ /** The number of logical lines in `body` (a newline-terminated final line is not double-counted). */
461
+ function countLines(body) {
462
+ return toLines(body).length;
463
+ }
464
+ /**
465
+ * Slice `body` to the 1-based inclusive line range `[from, to]` and re-join with `"\n"`,
466
+ * appending nothing. `from` alone means "line `from` to end of file"; `to` alone means
467
+ * "line 1 to `to`". `to` beyond EOF CLAMPS to end-of-file (native `Array.slice`
468
+ * behaviour) — the runtime's graceful-degradation contract when a source file was
469
+ * shortened after validation. The `from` lower bound is enforced by the caller
470
+ * (`checkPlacements` errors on `from` beyond EOF), because an empty splice would
471
+ * silently drop material.
472
+ */
473
+ function sliceLines(body, from, to) {
474
+ const lines = toLines(body);
475
+ const start = from !== void 0 ? from - 1 : 0;
476
+ const end = to !== void 0 ? to : lines.length;
477
+ return lines.slice(start, end).join("\n");
478
+ }
479
+ //#endregion
445
480
  //#region ../lib/prompt-fragments/consistency.ts
446
481
  /** Compare a supplied value against its declared property type. Returns null when it matches. */
447
482
  function typeMismatch(prop, value) {
@@ -570,18 +605,33 @@ function mergeVariables(alias, fragment, args, errors, warnings) {
570
605
  return merged;
571
606
  }
572
607
  /**
573
- * Cross-check every inline placement against the declared libraries: duplicate
574
- * aliases, duplicate fragment ids within a file, each placement's `alias.id`
575
- * resolution + variable validation (via `resolveAndMerge`), and a warning for any
576
- * declared library no placement ever uses. Errors block the build; the rest are
577
- * warnings. Order-independent placements carry their own textual position.
608
+ * Cross-check every inline placement against the declared libraries AND text files:
609
+ * duplicate aliases (fragment libraries, text files, and collisions ACROSS the two
610
+ * one shared alias namespace), duplicate fragment ids within a file, each `{{fragment}}`
611
+ * placement's `alias.id` resolution + variable validation (via `resolveAndMerge`), each
612
+ * `{{file}}` placement's alias resolution + line-range bounds, and a warning for any
613
+ * declared library / text file no placement ever uses. Errors block the build; the rest
614
+ * are warnings. Order-independent — placements carry their own textual position.
615
+ *
616
+ * `textFilesByAlias` maps a declared text-file alias to its FETCHED body (needed for the
617
+ * range bounds check); `strict` is the authoring-strictness flag — when set (authoring
618
+ * validators / CLI), a `to` beyond EOF is an error too, whereas at runtime (`strict:
619
+ * false`) the render layer clamps `to` to EOF instead. A `from` beyond EOF is ALWAYS an
620
+ * error (an empty splice would silently drop material). The new params default so the
621
+ * legacy fragment-only call sites (and their tests) need no change; the one real caller
622
+ * (`load.ts`) always passes the text side explicitly.
578
623
  */
579
- function checkPlacements(placements, filesByAlias, fileRefs) {
624
+ function checkPlacements(placements, filesByAlias, fileRefs, textFilesByAlias = /* @__PURE__ */ new Map(), textFileRefs = [], strict = false) {
580
625
  const errors = [];
581
626
  const warnings = [];
582
627
  const aliasCounts = /* @__PURE__ */ new Map();
583
628
  for (const ref of fileRefs) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
584
629
  for (const [alias, count] of aliasCounts) if (count > 1) errors.push(error("DUPLICATE_FRAGMENT_FILE_ALIAS", `Fragment-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
630
+ const fragmentAliases = new Set(fileRefs.map((r) => r.id));
631
+ const textAliasCounts = /* @__PURE__ */ new Map();
632
+ for (const ref of textFileRefs) textAliasCounts.set(ref.id, (textAliasCounts.get(ref.id) ?? 0) + 1);
633
+ for (const [alias, count] of textAliasCounts) if (count > 1) errors.push(error("DUPLICATE_TEXT_FILE_ALIAS", `Text-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
634
+ else if (fragmentAliases.has(alias)) errors.push(error("DUPLICATE_TEXT_FILE_ALIAS", `Alias "${alias}" is declared as both a fragment library and a text file`, { fileAlias: alias }));
585
635
  for (const [alias, file] of filesByAlias) {
586
636
  const seen = /* @__PURE__ */ new Set();
587
637
  for (const frag of file.fragments) {
@@ -596,18 +646,47 @@ function checkPlacements(placements, filesByAlias, fileRefs) {
596
646
  }
597
647
  }
598
648
  const usedAliases = /* @__PURE__ */ new Set();
649
+ const usedTextAliases = /* @__PURE__ */ new Set();
599
650
  for (const placement of placements) {
651
+ if (placement.kind === "file") {
652
+ checkFilePlacement(placement, textFilesByAlias, strict, errors);
653
+ usedTextAliases.add(placement.ref);
654
+ continue;
655
+ }
600
656
  usedAliases.add(splitFragmentRef(placement.ref).alias);
601
657
  const resolved = resolveAndMerge(placement.ref, placement.args, filesByAlias);
602
658
  errors.push(...resolved.errors);
603
659
  warnings.push(...resolved.warnings);
604
660
  }
605
661
  for (const ref of fileRefs) if (!usedAliases.has(ref.id)) warnings.push(warning("UNUSED_FRAGMENT_FILE", `Fragment library "${ref.id}" is declared but no {{fragment}} marker uses it`, { fileAlias: ref.id }));
662
+ for (const ref of textFileRefs) if (!usedTextAliases.has(ref.id)) warnings.push(warning("UNUSED_TEXT_FILE", `Text file "${ref.id}" is declared but no {{file}} marker embeds it`, { fileAlias: ref.id }));
606
663
  return {
607
664
  errors,
608
665
  warnings
609
666
  };
610
667
  }
668
+ /**
669
+ * Validate one `{{file}}` placement: the alias must be a declared text file, and its
670
+ * line range must fit. `from` beyond EOF is ALWAYS an error (an empty splice would
671
+ * silently drop material); `to` beyond EOF is an error ONLY under `strict` (authoring) —
672
+ * at runtime the render layer clamps `to` to EOF so a shortened source degrades gracefully.
673
+ */
674
+ function checkFilePlacement(placement, textFilesByAlias, strict, errors) {
675
+ const body = textFilesByAlias.get(placement.ref);
676
+ if (body === void 0) {
677
+ errors.push(error("UNKNOWN_TEXT_FILE_ALIAS", `File marker "${placement.ref}" uses unknown text-file alias`, { fileAlias: placement.ref }));
678
+ return;
679
+ }
680
+ const lineCount = countLines(body);
681
+ if (placement.from !== void 0 && placement.from > lineCount) errors.push(error("TEXT_FILE_RANGE_OUT_OF_BOUNDS", `File "${placement.ref}" has ${lineCount} line(s), but from=${placement.from} is past the end`, {
682
+ fileAlias: placement.ref,
683
+ line: placement.line
684
+ }));
685
+ if (strict && placement.to !== void 0 && placement.to > lineCount) errors.push(error("TEXT_FILE_RANGE_OUT_OF_BOUNDS", `File "${placement.ref}" has ${lineCount} line(s), but to=${placement.to} is past the end`, {
686
+ fileAlias: placement.ref,
687
+ line: placement.line
688
+ }));
689
+ }
611
690
  //#endregion
612
691
  //#region ../lib/prompt-fragments/fetcher.ts
613
692
  const DEFAULT_TIMEOUT_MS = 1e4;
@@ -755,6 +834,28 @@ const FragmentFileRefSchema = z.strictObject({
755
834
  id: "fragmentFileRef",
756
835
  description: "A reference to a fragment library by alias + URL."
757
836
  });
837
+ /**
838
+ * A plain-text file reference: an `id` alias + a `url`, mirroring `FragmentFileRefSchema`
839
+ * exactly (same no-dot alias regex, same http(s)-or-relative `FragmentUrlRef`). Unlike a
840
+ * fragment library, a text file is arbitrary content (markdown course material, a
841
+ * sample-solution source file) fetched over HTTP(S) and spliced VERBATIM into the host
842
+ * text by an inline `{{file "alias"}}` marker — there is nothing to select inside it, so
843
+ * the alias carries no dot. Text-file and fragment-file aliases share ONE namespace (an
844
+ * `id` may not collide across the two lists), so every marker alias resolves unambiguously.
845
+ */
846
+ const TextFileRefSchema = z.strictObject({
847
+ id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
848
+ pattern: "^[^.]+$",
849
+ description: "Local alias for this text file, used in `{{file \"alias\"}}`. Must not contain a dot."
850
+ }),
851
+ url: FragmentUrlRef.meta({
852
+ pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
853
+ description: "HTTP(S) URL or relative path to the plain-text file."
854
+ })
855
+ }).meta({
856
+ id: "textFileRef",
857
+ description: "A reference to a plain-text file (markdown / source) by alias + URL."
858
+ });
758
859
  //#endregion
759
860
  //#region ../lib/prompt-fragments/fragment.ts
760
861
  /**
@@ -850,8 +951,10 @@ function checkFragmentFileValue(parsed, url) {
850
951
  //#region ../lib/prompt-fragments/host-template.ts
851
952
  const FRAGMENT_HELPER = "fragment";
852
953
  const ARRAY_HELPER = "array";
954
+ const FILE_HELPER = "file";
853
955
  const isFragmentNode = (node) => node.path?.original === FRAGMENT_HELPER;
854
- /** A structural error stamped with the node's 1-based position. */
956
+ const isFileNode = (node) => node.path?.original === FILE_HELPER;
957
+ /** A structural `{{fragment}}` error stamped with the node's 1-based position. */
855
958
  function markerInvalid(loc, detail) {
856
959
  return error("FRAGMENT_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
857
960
  line: loc.start.line,
@@ -859,6 +962,18 @@ function markerInvalid(loc, detail) {
859
962
  });
860
963
  }
861
964
  /**
965
+ * A structural `{{file}}` error stamped with the node's 1-based position. Every
966
+ * `{{file}}` violation collapses to this ONE code (there is deliberately no separate
967
+ * not-literal variant, unlike `{{fragment}}`): a file marker either parses into a valid
968
+ * `alias`/`from`/`to` shape or fails closed here.
969
+ */
970
+ function fileMarkerInvalid(loc, detail) {
971
+ return error("TEXT_FILE_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
972
+ line: loc.start.line,
973
+ column: loc.start.column + 1
974
+ });
975
+ }
976
+ /**
862
977
  * Read a hash-pair value into a supported literal. The supported set IS the contract:
863
978
  * a string, a boolean, or an `(array "…" …)` of string literals. Anything else — a
864
979
  * number, a path reference, a nested `(fragment …)`, an array with a non-string
@@ -903,25 +1018,75 @@ function extractInlineMarker(node, out) {
903
1018
  args[pair.key] = read.value;
904
1019
  }
905
1020
  out.placements.push({
1021
+ kind: "fragment",
906
1022
  ref: first.value,
907
1023
  args,
908
1024
  line: node.loc.start.line,
909
1025
  column: node.loc.start.column + 1
910
1026
  });
911
1027
  }
912
- /** Reject a `fragment` helper used anywhere but a simple inline mustache. */
1028
+ /**
1029
+ * Extract one inline `{{file "alias" from=N to=M}}` mustache's alias + optional range.
1030
+ * Structural contract (all TEXT_FILE_MARKER_INVALID, recording no placement on failure):
1031
+ * the first positional must be a quoted string literal, there is exactly one positional,
1032
+ * hash keys are restricted to `from` / `to`, each value must be an integer NumberLiteral
1033
+ * >= 1, and when both are present `from <= to`. A `from`/`to` beyond the file's actual
1034
+ * line count is NOT checked here (that needs the fetched body — see `checkPlacements`).
1035
+ */
1036
+ function extractFileMarker(node, out) {
1037
+ const first = node.params?.[0];
1038
+ if (first?.type !== "StringLiteral") {
1039
+ out.errors.push(fileMarkerInvalid(node.loc, "A {{file}} marker needs a quoted \"alias\" reference"));
1040
+ return;
1041
+ }
1042
+ if ((node.params?.length ?? 0) > 1) {
1043
+ out.errors.push(fileMarkerInvalid(node.loc, "A {{file}} marker takes exactly one \"alias\" reference"));
1044
+ return;
1045
+ }
1046
+ let from;
1047
+ let to;
1048
+ for (const pair of node.hash?.pairs ?? []) {
1049
+ if (pair.key !== "from" && pair.key !== "to") {
1050
+ out.errors.push(fileMarkerInvalid(node.loc, `A {{file}} marker accepts only from= / to=, not "${pair.key}"`));
1051
+ return;
1052
+ }
1053
+ if (pair.value.type !== "NumberLiteral" || !Number.isInteger(pair.value.value) || pair.value.value < 1) {
1054
+ out.errors.push(fileMarkerInvalid(node.loc, `"${pair.key}" must be an integer line number >= 1`));
1055
+ return;
1056
+ }
1057
+ if (pair.key === "from") from = pair.value.value;
1058
+ else to = pair.value.value;
1059
+ }
1060
+ if (from !== void 0 && to !== void 0 && from > to) {
1061
+ out.errors.push(fileMarkerInvalid(node.loc, `"from" (${from}) must not be greater than "to" (${to})`));
1062
+ return;
1063
+ }
1064
+ out.placements.push({
1065
+ kind: "file",
1066
+ ref: first.value,
1067
+ args: {},
1068
+ ...from !== void 0 ? { from } : {},
1069
+ ...to !== void 0 ? { to } : {},
1070
+ line: node.loc.start.line,
1071
+ column: node.loc.start.column + 1
1072
+ });
1073
+ }
1074
+ /** Reject a `fragment` / `file` helper used anywhere but a simple inline mustache. */
913
1075
  function visitExpression(node, out) {
914
1076
  if (node.type !== "SubExpression") return;
915
1077
  const sub = node;
916
1078
  if (isFragmentNode(sub)) out.errors.push(markerInvalid(sub.loc, "{{fragment}} must be a standalone inline marker, not a subexpression"));
1079
+ else if (isFileNode(sub)) out.errors.push(fileMarkerInvalid(sub.loc, "{{file}} must be a standalone inline marker, not a subexpression"));
917
1080
  for (const p of sub.params ?? []) visitExpression(p, out);
918
1081
  for (const pair of sub.hash?.pairs ?? []) visitExpression(pair.value, out);
919
1082
  }
920
- /** Collect every `{{fragment}}` marker in a program body (recursing into blocks). */
1083
+ /** Collect every `{{fragment}}` / `{{file}}` marker in a program body (recursing into blocks). */
921
1084
  function collectPlacements(program, out) {
922
1085
  for (const node of program.body) {
923
1086
  if (isFragmentNode(node)) if (node.type === "MustacheStatement") extractInlineMarker(node, out);
924
1087
  else out.errors.push(markerInvalid(node.loc, "{{fragment}} must be a simple inline marker, not a block {{#fragment}}…{{/fragment}}"));
1088
+ else if (isFileNode(node)) if (node.type === "MustacheStatement") extractFileMarker(node, out);
1089
+ else out.errors.push(fileMarkerInvalid(node.loc, "{{file}} must be a simple inline marker, not a block {{#file}}…{{/file}}"));
925
1090
  else {
926
1091
  for (const p of node.params ?? []) visitExpression(p, out);
927
1092
  for (const pair of node.hash?.pairs ?? []) visitExpression(pair.value, out);
@@ -935,8 +1100,9 @@ function collectPlacements(program, out) {
935
1100
  * order, WITHOUT rendering. A whole-template syntax error (a malformed marker, an
936
1101
  * unescaped literal `{{`) becomes a single `HOST_TEMPLATE_PARSE_ERROR`; its position
937
1102
  * is regexed out of Handlebars' message (`Parse error on line N:` — the parser
938
- * carries no structured position fields). A well-formed marker whose reference is not
939
- * a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`.
1103
+ * carries no structured position fields). A well-formed `{{fragment}}` marker whose
1104
+ * reference is not a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`;
1105
+ * a structurally wrong `{{file}}` marker becomes a per-marker `TEXT_FILE_MARKER_INVALID`.
940
1106
  */
941
1107
  function parseHostPlacements(text) {
942
1108
  const result = {
@@ -955,8 +1121,8 @@ function parseHostPlacements(text) {
955
1121
  collectPlacements(program, result);
956
1122
  return result;
957
1123
  }
958
- /** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` helpers. */
959
- function createHostInstance(resolver) {
1124
+ /** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` + `file` helpers. */
1125
+ function createHostInstance(resolver, fileResolver) {
960
1126
  const hb = Handlebars.create();
961
1127
  hb.registerHelper(FRAGMENT_HELPER, (...args) => {
962
1128
  const ref = args.length >= 2 ? args[0] : void 0;
@@ -967,17 +1133,28 @@ function createHostInstance(resolver) {
967
1133
  hb.registerHelper(ARRAY_HELPER, (...args) => {
968
1134
  return args.slice(0, -1);
969
1135
  });
1136
+ hb.registerHelper(FILE_HELPER, (...args) => {
1137
+ const alias = args.length >= 2 ? args[0] : void 0;
1138
+ if (typeof alias !== "string") throw new Error("a {{file}} marker needs a quoted \"alias\" reference");
1139
+ const options = args[args.length - 1];
1140
+ return fileResolver(alias, options?.hash?.from, options?.hash?.to);
1141
+ });
970
1142
  return hb;
971
1143
  }
972
1144
  /**
973
1145
  * Compile + render the host text with the isolated instance under
974
1146
  * `{ strict: true, noEscape: true }` — `strict` so any stray `{{…}}` that is not a
975
- * `fragment` / `array` marker fails closed instead of rendering empty; `noEscape` so
976
- * the prompt text (ASCII diagrams, quotes) passes through verbatim. Each placement is
977
- * replaced by its `resolver` result. May throw the caller wraps it as ASSEMBLY_ERROR.
1147
+ * `fragment` / `array` / `file` marker fails closed instead of rendering empty; `noEscape`
1148
+ * so the prompt text (ASCII diagrams, quotes) passes through verbatim. Each `{{fragment}}`
1149
+ * placement is replaced by its `resolver` result, each `{{file}}` by its `fileResolver`
1150
+ * result. May throw — the caller wraps it as ASSEMBLY_ERROR. `fileResolver` defaults to a
1151
+ * thrower: a template with no `{{file}}` markers never invokes it, but one that does
1152
+ * without a resolver fails closed rather than rendering wrong.
978
1153
  */
979
- function renderHostTemplate(text, resolver) {
980
- return createHostInstance(resolver).compile(text, {
1154
+ function renderHostTemplate(text, resolver, fileResolver = () => {
1155
+ throw new Error("a {{file}} marker was rendered without a file resolver");
1156
+ }) {
1157
+ return createHostInstance(resolver, fileResolver).compile(text, {
981
1158
  strict: true,
982
1159
  noEscape: true
983
1160
  })({});
@@ -996,6 +1173,8 @@ function resolveRelativeUrl(ref, baseUrl) {
996
1173
  }
997
1174
  //#endregion
998
1175
  //#region ../lib/prompt-fragments/load.ts
1176
+ /** The 200 KB (UTF-8 bytes) per-file cap on an embedded `text_files:` body — fail closed above it. */
1177
+ const MAX_TEXT_FILE_BYTES = 200 * 1024;
999
1178
  /**
1000
1179
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
1001
1180
  * as-is; anything else is treated as relative to the activity URL — standard URL resolution
@@ -1085,19 +1264,22 @@ async function loadYaml(url, fetchImpl, opts = {}) {
1085
1264
  * → check → render pipeline. `hostText` IS the template: fragments appear only where
1086
1265
  * the author placed a marker; there is no ordering concept and no prepend fallback.
1087
1266
  *
1088
- * TEMPLATE-SEMANTICS OPT-IN: an activity that declares no `fragment_files:` is NEVER
1089
- * compiled — its host text returns byte-verbatim (protecting plain activities and the
1090
- * authoring tutors whose prose contains sample markers as teaching content).
1267
+ * TEMPLATE-SEMANTICS OPT-IN: an activity that declares NEITHER `fragment_files:` NOR
1268
+ * `text_files:` is NEVER compiled — its host text returns byte-verbatim (protecting plain
1269
+ * activities and the authoring tutors whose prose contains sample markers as teaching
1270
+ * content). Text files are fetched in parallel with the fragment libraries; each is kept
1271
+ * as a RAW string (no YAML parse) and spliced verbatim at render — never re-compiled as
1272
+ * Handlebars, so a literal `{{` in course material can never execute.
1091
1273
  */
1092
1274
  async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
1093
1275
  const warnings = [];
1094
1276
  const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
1095
- if (block.fragment_files.length === 0) return {
1277
+ if (block.fragment_files.length === 0 && block.text_files.length === 0) return {
1096
1278
  ok: true,
1097
1279
  prompt: hostText,
1098
1280
  warnings
1099
1281
  };
1100
- const settled = await Promise.all(block.fragment_files.map(async (ref) => {
1282
+ const fragmentSettledPromise = Promise.all(block.fragment_files.map(async (ref) => {
1101
1283
  let fragmentUrl;
1102
1284
  try {
1103
1285
  fragmentUrl = resolveFragmentUrl(ref.url, baseUrl);
@@ -1142,14 +1324,57 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
1142
1324
  url: fragmentUrl
1143
1325
  };
1144
1326
  }));
1327
+ const textSettledPromise = Promise.all(block.text_files.map(async (ref) => {
1328
+ let fileUrl;
1329
+ try {
1330
+ fileUrl = resolveFragmentUrl(ref.url, baseUrl);
1331
+ } catch {
1332
+ return {
1333
+ alias: ref.id,
1334
+ error: error("INVALID_URL", `Invalid text-file URL: ${ref.url}`, {
1335
+ url: ref.url,
1336
+ fileAlias: ref.id
1337
+ })
1338
+ };
1339
+ }
1340
+ const schemeError = schemeGate(fileUrl, allowedSchemes);
1341
+ if (schemeError) return {
1342
+ alias: ref.id,
1343
+ error: {
1344
+ ...schemeError,
1345
+ fileAlias: ref.id
1346
+ }
1347
+ };
1348
+ const fetched = await fetchText(fileUrl, fetchImpl);
1349
+ if (!fetched.ok) return {
1350
+ alias: ref.id,
1351
+ error: fetched.error
1352
+ };
1353
+ const byteLength = new TextEncoder().encode(fetched.text).length;
1354
+ if (byteLength > MAX_TEXT_FILE_BYTES) return {
1355
+ alias: ref.id,
1356
+ error: error("TEXT_FILE_TOO_LARGE", `Text file "${ref.id}" is ${byteLength} bytes, over the ${MAX_TEXT_FILE_BYTES}-byte limit`, {
1357
+ url: fileUrl,
1358
+ fileAlias: ref.id
1359
+ })
1360
+ };
1361
+ return {
1362
+ alias: ref.id,
1363
+ body: fetched.text
1364
+ };
1365
+ }));
1366
+ const [fragmentSettled, textSettled] = await Promise.all([fragmentSettledPromise, textSettledPromise]);
1145
1367
  const fragmentFilesByAlias = /* @__PURE__ */ new Map();
1146
1368
  const fragmentUrlByAlias = /* @__PURE__ */ new Map();
1369
+ const textFilesByAlias = /* @__PURE__ */ new Map();
1147
1370
  const fileErrors = [];
1148
- for (const result of settled) if ("error" in result) fileErrors.push(result.error);
1371
+ for (const result of fragmentSettled) if ("error" in result) fileErrors.push(result.error);
1149
1372
  else {
1150
1373
  fragmentFilesByAlias.set(result.alias, result.file);
1151
1374
  fragmentUrlByAlias.set(result.alias, result.url);
1152
1375
  }
1376
+ for (const result of textSettled) if ("error" in result) fileErrors.push(result.error);
1377
+ else textFilesByAlias.set(result.alias, result.body);
1153
1378
  if (fileErrors.length > 0) return {
1154
1379
  ok: false,
1155
1380
  errors: fileErrors,
@@ -1170,7 +1395,7 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
1170
1395
  errors: [...libraryErrors, ...parsed.errors],
1171
1396
  warnings
1172
1397
  };
1173
- const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files);
1398
+ const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files, textFilesByAlias, block.text_files, opts.validateLibraries ?? false);
1174
1399
  warnings.push(...placementCheck.warnings);
1175
1400
  const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
1176
1401
  if (preRenderErrors.length > 0) return {
@@ -1185,6 +1410,11 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
1185
1410
  const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
1186
1411
  if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
1187
1412
  return renderFragmentContent(resolved.content, resolved.variables);
1413
+ }, (alias, from, to) => {
1414
+ const body = textFilesByAlias.get(alias);
1415
+ if (body === void 0) throw new Error(`Text file "${alias}" was not prefetched`);
1416
+ if (from === void 0 && to === void 0) return body;
1417
+ return sliceLines(body, from, to);
1188
1418
  }),
1189
1419
  warnings
1190
1420
  };
@@ -1227,7 +1457,8 @@ const CodingYamlSchema = z.strictObject({
1227
1457
  description: "The pinned model and provider that answer coding requests."
1228
1458
  }),
1229
1459
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1230
- instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned. When any fragment_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\"}} markers (escape a literal {{ as \\{{)." })
1460
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source, e.g. a sample solution) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
1461
+ instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." })
1231
1462
  });
1232
1463
  //#endregion
1233
1464
  //#region ../lib/coding-validate.ts
@@ -1266,7 +1497,10 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
1266
1497
  };
1267
1498
  const checked = checkCodingParsed(valid.data);
1268
1499
  if (!checked.ok) return checked;
1269
- const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1500
+ const assembled = await assembleFragmentPrompt({
1501
+ fragment_files: valid.data.fragment_files,
1502
+ text_files: valid.data.text_files
1503
+ }, url, fetchImpl, {
1270
1504
  allowedSchemes: opts.allowedSchemes,
1271
1505
  validateLibraries: opts.validateLibraries ?? true
1272
1506
  }, valid.data.instructions);
@@ -1283,6 +1517,25 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
1283
1517
  }
1284
1518
  //#endregion
1285
1519
  //#region ../lib/quiz-schema.ts
1520
+ /**
1521
+ * A live quiz include: alias + URL, mirroring `FragmentFileRefSchema` (same URL
1522
+ * contract). The alias prefixes every imported question id as `"<alias>/<id>"`, so
1523
+ * on top of the no-dot rule it may not contain a `/` either. Aliases live in their
1524
+ * OWN namespace (they never appear in `{{…}}` markers — only in question ids).
1525
+ */
1526
+ const QuizFileRefSchema = z.strictObject({
1527
+ id: z.string().regex(/^[^./]+$/, { message: "Alias must not contain a dot or a slash" }).meta({
1528
+ pattern: "^[^./]+$",
1529
+ description: "Local alias for this included quiz. Prefixes every imported question id as \"<alias>/<id>\", so it may not contain a dot or a slash."
1530
+ }),
1531
+ url: FragmentFileRefSchema.shape.url.meta({
1532
+ pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
1533
+ description: "HTTP(S) URL or relative path to the included quiz file."
1534
+ })
1535
+ }).meta({
1536
+ id: "quizFileRef",
1537
+ description: "A reference to another quiz file whose questions are included live."
1538
+ });
1286
1539
  /** An optional content image attached to a question (carries no secret). */
1287
1540
  const ImageRefSchema = z.strictObject({
1288
1541
  hosted: z.boolean().optional().meta({
@@ -1325,6 +1578,8 @@ const QuizYamlSchema = z.strictObject({
1325
1578
  default: true,
1326
1579
  description: "Present questions in a random order per attempt. Set to false to keep the authored order."
1327
1580
  }),
1581
+ question_count: z.number().int().min(1).optional().meta({ description: "How many questions one attempt asks (default: every question exactly once). May exceed the pool size — then questions repeat (drill mode). With shuffle off, fewer than the pool means the first N in authored order." }),
1582
+ quiz_files: z.array(QuizFileRefSchema).default([]).meta({ description: "Other quiz files whose questions are ALL included live into this quiz (a compound/final quiz). One level deep — an included quiz may not itself declare quiz_files." }),
1328
1583
  llm: z.strictObject({
1329
1584
  model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
1330
1585
  provider: providerSchema,
@@ -1341,8 +1596,9 @@ const QuizYamlSchema = z.strictObject({
1341
1596
  description: "Optional guidance for the per-question follow-up discussion chat."
1342
1597
  }),
1343
1598
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
1344
- instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files are declared, place fragments inline here with {{fragment \"alias.id\"}} markers (escape a literal {{ as \\{{)." }),
1345
- questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
1599
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
1600
+ instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files or text_files are declared, place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
1601
+ questions: z.array(QuizQuestionSchema).default([]).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt. May be omitted when quiz_files supplies the questions." })
1346
1602
  });
1347
1603
  //#endregion
1348
1604
  //#region ../lib/quiz-validate.ts
@@ -1362,12 +1618,39 @@ function findDuplicateQuestionIds(quiz) {
1362
1618
  return errors;
1363
1619
  }
1364
1620
  /**
1365
- * Check an already-schema-validated quiz: unique question ids metadata. Split from
1621
+ * Own question ids containing `/` reserved as the namespace delimiter for
1622
+ * questions imported via `quiz_files` (`"<alias>/<id>"`), so an own id can never
1623
+ * collide with (or masquerade as) an imported one.
1624
+ */
1625
+ function findReservedSlashIds(quiz) {
1626
+ return quiz.questions.filter((question) => question.id.includes("/")).map((question) => error("QUIZ_QUESTION_ID_RESERVED_SLASH", `Question id "${question.id}" contains "/" — reserved for questions imported via quiz_files`, { questionId: question.id }));
1627
+ }
1628
+ /** Include aliases declared on more than one `quiz_files` entry. */
1629
+ function findDuplicateIncludeAliases(quiz) {
1630
+ const errors = [];
1631
+ const seen = /* @__PURE__ */ new Set();
1632
+ for (const ref of quiz.quiz_files) {
1633
+ if (seen.has(ref.id)) {
1634
+ errors.push(error("DUPLICATE_QUIZ_INCLUDE_ALIAS", `Included-quiz alias "${ref.id}" is declared more than once`, { fileAlias: ref.id }));
1635
+ continue;
1636
+ }
1637
+ seen.add(ref.id);
1638
+ }
1639
+ return errors;
1640
+ }
1641
+ /**
1642
+ * Check an already-schema-validated quiz: unique question ids, no reserved `/` ids,
1643
+ * unique include aliases, and a non-empty (potential) pool → metadata. Split from
1366
1644
  * `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
1367
1645
  * (no second parse of the same document against the same schema).
1368
1646
  */
1369
1647
  function checkQuizParsed(quiz) {
1370
- const errors = findDuplicateQuestionIds(quiz);
1648
+ const errors = [
1649
+ ...findDuplicateQuestionIds(quiz),
1650
+ ...findReservedSlashIds(quiz),
1651
+ ...findDuplicateIncludeAliases(quiz)
1652
+ ];
1653
+ if (quiz.questions.length === 0 && quiz.quiz_files.length === 0) errors.push(error("QUIZ_NO_QUESTIONS", "This quiz has no questions and no quiz_files includes"));
1371
1654
  if (errors.length > 0) return {
1372
1655
  ok: false,
1373
1656
  errors,
@@ -1384,12 +1667,83 @@ function checkQuizParsed(quiz) {
1384
1667
  warnings: []
1385
1668
  };
1386
1669
  }
1670
+ /** Wrap an include's nested failures into ONE error carrying the alias + URL. */
1671
+ function includeUnreadable(alias, url, nested) {
1672
+ return error("QUIZ_INCLUDE_UNREADABLE", `Included quiz "${alias}" is not usable: ${nested.map((e) => e.message).join("; ")}`, url === void 0 ? { fileAlias: alias } : {
1673
+ fileAlias: alias,
1674
+ url
1675
+ });
1676
+ }
1677
+ /**
1678
+ * Deep-check ONE `quiz_files` include: resolve + fetch + parse + the FULL strict
1679
+ * quiz check (schema, consistency passes, its own fragment-block authoring gate),
1680
+ * plus the one-level rule (`QUIZ_INCLUDE_NESTED`). Every other failure is wrapped
1681
+ * as `QUIZ_INCLUDE_UNREADABLE` with the alias + resolved URL.
1682
+ */
1683
+ async function checkInclude(ref, baseUrl, fetchImpl, opts) {
1684
+ let includeUrl;
1685
+ try {
1686
+ includeUrl = resolveFragmentUrl(ref.url, baseUrl);
1687
+ } catch {
1688
+ return {
1689
+ ok: false,
1690
+ errors: [includeUnreadable(ref.id, void 0, [error("INVALID_URL", `Invalid include URL: ${ref.url}`)])],
1691
+ warnings: []
1692
+ };
1693
+ }
1694
+ const yaml = await loadYaml(includeUrl, fetchImpl, opts);
1695
+ if (!yaml.ok) return {
1696
+ ok: false,
1697
+ errors: [includeUnreadable(ref.id, includeUrl, [yaml.error])],
1698
+ warnings: []
1699
+ };
1700
+ const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", includeUrl);
1701
+ if (!valid.ok) return {
1702
+ ok: false,
1703
+ errors: [includeUnreadable(ref.id, includeUrl, [valid.error])],
1704
+ warnings: []
1705
+ };
1706
+ if (valid.data.quiz_files.length > 0) return {
1707
+ ok: false,
1708
+ errors: [error("QUIZ_INCLUDE_NESTED", `Included quiz "${ref.id}" itself declares quiz_files — includes are one level deep`, {
1709
+ fileAlias: ref.id,
1710
+ url: includeUrl
1711
+ })],
1712
+ warnings: []
1713
+ };
1714
+ const checked = checkQuizParsed(valid.data);
1715
+ if (!checked.ok) return {
1716
+ ok: false,
1717
+ errors: [includeUnreadable(ref.id, includeUrl, checked.errors)],
1718
+ warnings: checked.warnings
1719
+ };
1720
+ const assembled = await assembleFragmentPrompt({
1721
+ fragment_files: valid.data.fragment_files,
1722
+ text_files: valid.data.text_files
1723
+ }, includeUrl, fetchImpl, {
1724
+ allowedSchemes: opts.allowedSchemes,
1725
+ validateLibraries: opts.validateLibraries ?? true
1726
+ }, valid.data.instructions ?? "");
1727
+ if (!assembled.ok) return {
1728
+ ok: false,
1729
+ errors: [includeUnreadable(ref.id, includeUrl, assembled.errors)],
1730
+ warnings: assembled.warnings
1731
+ };
1732
+ return {
1733
+ ok: true,
1734
+ questionCount: valid.data.questions.length,
1735
+ warnings: assembled.warnings
1736
+ };
1737
+ }
1387
1738
  /**
1388
1739
  * Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
1389
- * `checkQuizValue`, then the document-level fragment block's authoring gate — fetch
1740
+ * `checkQuizValue`, the document-level fragment block's authoring gate — fetch
1390
1741
  * every referenced library, run the THOROUGH whole-library check, consistency, and an
1391
- * assembly dry-run (the strict-Handlebars backstop). The web app passes the default
1392
- * http(s)-only schemes; the CLI adds `file:` so a local quiz YAML on disk validates too.
1742
+ * assembly dry-run (the strict-Handlebars backstop) and a DEEP check of every
1743
+ * `quiz_files` include. On success `questionCount` is the RESOLVED pool size
1744
+ * (own + imported), so the `/files` save UI and code-create metadata reflect the
1745
+ * real exam size. The web app passes the default http(s)-only schemes; the CLI adds
1746
+ * `file:` so a local quiz YAML on disk validates too.
1393
1747
  */
1394
1748
  async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1395
1749
  const yaml = await loadYaml(url, fetchImpl, opts);
@@ -1406,7 +1760,10 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1406
1760
  };
1407
1761
  const checked = checkQuizParsed(valid.data);
1408
1762
  if (!checked.ok) return checked;
1409
- const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1763
+ const assembled = await assembleFragmentPrompt({
1764
+ fragment_files: valid.data.fragment_files,
1765
+ text_files: valid.data.text_files
1766
+ }, url, fetchImpl, {
1410
1767
  allowedSchemes: opts.allowedSchemes,
1411
1768
  validateLibraries: opts.validateLibraries ?? true
1412
1769
  }, valid.data.instructions ?? "");
@@ -1416,8 +1773,22 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1416
1773
  errors: assembled.errors,
1417
1774
  warnings
1418
1775
  };
1776
+ const includes = await Promise.all(valid.data.quiz_files.map((ref) => checkInclude(ref, url, fetchImpl, opts)));
1777
+ const includeErrors = [];
1778
+ let importedCount = 0;
1779
+ for (const include of includes) {
1780
+ warnings.push(...include.warnings);
1781
+ if (include.ok) importedCount += include.questionCount;
1782
+ else includeErrors.push(...include.errors);
1783
+ }
1784
+ if (includeErrors.length > 0) return {
1785
+ ok: false,
1786
+ errors: includeErrors,
1787
+ warnings
1788
+ };
1419
1789
  return {
1420
1790
  ...checked,
1791
+ questionCount: checked.questionCount + importedCount,
1421
1792
  warnings
1422
1793
  };
1423
1794
  }
@@ -1458,7 +1829,8 @@ const TutorSchema = z.strictObject({
1458
1829
  }),
1459
1830
  prompt: z.strictObject({
1460
1831
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
1461
- tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers (escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
1832
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim via {{file \"alias\"}} markers." }),
1833
+ tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files or text_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
1462
1834
  }).meta({
1463
1835
  id: "prompt",
1464
1836
  description: "The tutor system prompt: a host template with inline fragment markers."
@@ -1520,7 +1892,8 @@ const WritingYamlSchema = z.strictObject({
1520
1892
  description: "The model and provider that back the writing coach."
1521
1893
  }),
1522
1894
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1523
- instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy. When any fragment_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\"}} markers (escape a literal {{ as \\{{)." }),
1895
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
1896
+ instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
1524
1897
  placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
1525
1898
  });
1526
1899
  //#endregion
@@ -1563,7 +1936,10 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
1563
1936
  };
1564
1937
  const checked = checkWritingParsed(valid.data);
1565
1938
  if (!checked.ok) return checked;
1566
- const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1939
+ const assembled = await assembleFragmentPrompt({
1940
+ fragment_files: valid.data.fragment_files,
1941
+ text_files: valid.data.text_files
1942
+ }, url, fetchImpl, {
1567
1943
  allowedSchemes: opts.allowedSchemes,
1568
1944
  validateLibraries: opts.validateLibraries ?? true
1569
1945
  }, valid.data.instructions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions; signs in with Entra ID and manages codes and app-hosted files over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {