soustack 0.2.3 → 0.4.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.
package/dist/cli/index.js CHANGED
@@ -5,7 +5,7 @@ var fs = require('fs');
5
5
  var path = require('path');
6
6
  var glob = require('glob');
7
7
  var cheerio = require('cheerio');
8
- var Ajv = require('ajv');
8
+ var Ajv2020 = require('ajv/dist/2020');
9
9
  var addFormats = require('ajv-formats');
10
10
 
11
11
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -30,7 +30,7 @@ function _interopNamespace(e) {
30
30
 
31
31
  var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
32
32
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
33
- var Ajv__default = /*#__PURE__*/_interopDefault(Ajv);
33
+ var Ajv2020__default = /*#__PURE__*/_interopDefault(Ajv2020);
34
34
  var addFormats__default = /*#__PURE__*/_interopDefault(addFormats);
35
35
 
36
36
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
@@ -351,6 +351,92 @@ function extractUrl(value) {
351
351
  return trimmed || void 0;
352
352
  }
353
353
 
354
+ // src/normalize.ts
355
+ function normalizeRecipe(input) {
356
+ if (!input || typeof input !== "object") {
357
+ throw new Error("Recipe input must be an object");
358
+ }
359
+ const recipe = JSON.parse(JSON.stringify(input));
360
+ const warnings = [];
361
+ const legacyField = ["mod", "ules"].join("");
362
+ if (legacyField in recipe) {
363
+ throw new Error("The legacy field is no longer supported. Use `stacks` instead.");
364
+ }
365
+ normalizeStacks(recipe, warnings);
366
+ if (!recipe.stacks) {
367
+ recipe.stacks = {};
368
+ }
369
+ if (recipe && typeof recipe === "object" && "version" in recipe && !recipe.recipeVersion && typeof recipe.version === "string") {
370
+ recipe.recipeVersion = recipe.version;
371
+ warnings.push("'version' is deprecated; mapped to 'recipeVersion'.");
372
+ }
373
+ normalizeTime(recipe);
374
+ return {
375
+ recipe,
376
+ warnings
377
+ };
378
+ }
379
+ function normalizeStacks(recipe, warnings) {
380
+ let stacks = {};
381
+ if (recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks)) {
382
+ for (const [key, value] of Object.entries(recipe.stacks)) {
383
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) {
384
+ stacks[key] = value;
385
+ } else {
386
+ warnings.push(`Invalid stack version for '${key}': expected positive integer, got ${value}`);
387
+ }
388
+ }
389
+ }
390
+ if (Array.isArray(recipe.stacks)) {
391
+ const stackIdentifiers = recipe.stacks.filter((s) => typeof s === "string");
392
+ for (const identifier of stackIdentifiers) {
393
+ const parsed = parseStackIdentifier(identifier);
394
+ if (parsed) {
395
+ const { name, version } = parsed;
396
+ if (!stacks[name] || stacks[name] < version) {
397
+ stacks[name] = version;
398
+ }
399
+ } else {
400
+ warnings.push(`Invalid stack identifier '${identifier}': expected format 'name@version' (e.g., 'scaling@1')`);
401
+ }
402
+ }
403
+ }
404
+ recipe.stacks = stacks;
405
+ }
406
+ function parseStackIdentifier(identifier) {
407
+ if (typeof identifier !== "string" || !identifier.trim()) {
408
+ return null;
409
+ }
410
+ const match = identifier.trim().match(/^([a-z0-9_-]+)@(\d+)$/i);
411
+ if (!match) {
412
+ return null;
413
+ }
414
+ const [, name, versionStr] = match;
415
+ const version = parseInt(versionStr, 10);
416
+ if (isNaN(version) || version < 1) {
417
+ return null;
418
+ }
419
+ return { name, version };
420
+ }
421
+ function normalizeTime(recipe) {
422
+ const time = recipe?.time;
423
+ if (!time || typeof time !== "object" || Array.isArray(time)) return;
424
+ const structuredKeys = [
425
+ "prep",
426
+ "active",
427
+ "passive",
428
+ "total"
429
+ ];
430
+ structuredKeys.forEach((key) => {
431
+ const value = time[key];
432
+ if (typeof value === "number") return;
433
+ const parsed = parseDuration(value);
434
+ if (parsed !== null) {
435
+ time[key] = parsed;
436
+ }
437
+ });
438
+ }
439
+
354
440
  // src/fromSchemaOrg.ts
355
441
  function fromSchemaOrg(input) {
356
442
  const recipeNode = extractRecipeNode(input);
@@ -364,8 +450,22 @@ function fromSchemaOrg(input) {
364
450
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
365
451
  const category = extractFirst(recipeNode.recipeCategory);
366
452
  const source = convertSource(recipeNode);
367
- const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
368
- return {
453
+ const dateModified = recipeNode.dateModified || void 0;
454
+ const nutrition = convertNutrition(recipeNode.nutrition);
455
+ const attribution = convertAttribution(recipeNode);
456
+ const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
457
+ const media = convertMedia(recipeNode.image, recipeNode.video);
458
+ const times = convertTimes(time);
459
+ const stacks = {};
460
+ if (attribution) stacks.attribution = 1;
461
+ if (taxonomy) stacks.taxonomy = 1;
462
+ if (media) stacks.media = 1;
463
+ if (nutrition) stacks.nutrition = 1;
464
+ if (times) stacks.times = 1;
465
+ const rawRecipe = {
466
+ "@type": "Recipe",
467
+ profile: "minimal",
468
+ stacks,
369
469
  name: recipeNode.name.trim(),
370
470
  description: recipeNode.description?.trim() || void 0,
371
471
  image: normalizeImage(recipeNode.image),
@@ -373,13 +473,19 @@ function fromSchemaOrg(input) {
373
473
  tags: tags.length ? tags : void 0,
374
474
  source,
375
475
  dateAdded: recipeNode.datePublished || void 0,
376
- dateModified: recipeNode.dateModified || void 0,
377
476
  yield: recipeYield,
378
477
  time,
379
478
  ingredients,
380
479
  instructions,
381
- nutrition
480
+ ...dateModified ? { dateModified } : {},
481
+ ...nutrition ? { nutrition } : {},
482
+ ...attribution ? { attribution } : {},
483
+ ...taxonomy ? { taxonomy } : {},
484
+ ...media ? { media } : {},
485
+ ...times ? { times } : {}
382
486
  };
487
+ const { recipe } = normalizeRecipe(rawRecipe);
488
+ return recipe;
383
489
  }
384
490
  function extractRecipeNode(input) {
385
491
  if (!input) return null;
@@ -591,6 +697,162 @@ function extractEntityName(value) {
591
697
  }
592
698
  return void 0;
593
699
  }
700
+ function convertAttribution(recipe) {
701
+ const attribution = {};
702
+ const url = (recipe.url || recipe.mainEntityOfPage)?.trim();
703
+ const author = extractEntityName(recipe.author);
704
+ const datePublished = recipe.datePublished?.trim();
705
+ if (url) attribution.url = url;
706
+ if (author) attribution.author = author;
707
+ if (datePublished) attribution.datePublished = datePublished;
708
+ return Object.keys(attribution).length ? attribution : void 0;
709
+ }
710
+ function convertTaxonomy(keywords, category, cuisine) {
711
+ const taxonomy = {};
712
+ if (keywords.length) taxonomy.keywords = keywords;
713
+ if (category) taxonomy.category = category;
714
+ if (cuisine) taxonomy.cuisine = cuisine;
715
+ return Object.keys(taxonomy).length ? taxonomy : void 0;
716
+ }
717
+ function normalizeMediaList(value) {
718
+ if (!value) return [];
719
+ if (typeof value === "string") return [value.trim()].filter(Boolean);
720
+ if (Array.isArray(value)) {
721
+ return value.map((item) => typeof item === "string" ? item.trim() : extractMediaUrl(item)).filter((entry) => Boolean(entry?.length));
722
+ }
723
+ const url = extractMediaUrl(value);
724
+ return url ? [url] : [];
725
+ }
726
+ function extractMediaUrl(value) {
727
+ if (value && typeof value === "object" && "url" in value && typeof value.url === "string") {
728
+ const trimmed = value.url.trim();
729
+ return trimmed || void 0;
730
+ }
731
+ return void 0;
732
+ }
733
+ function convertMedia(image, video) {
734
+ const normalizedImage = normalizeImage(image);
735
+ const images = normalizedImage ? Array.isArray(normalizedImage) ? normalizedImage : [normalizedImage] : [];
736
+ const videos = normalizeMediaList(video);
737
+ const media = {};
738
+ if (images.length) media.images = images;
739
+ if (videos.length) media.videos = videos;
740
+ return Object.keys(media).length ? media : void 0;
741
+ }
742
+ function convertTimes(time) {
743
+ if (!time) return void 0;
744
+ const times = {};
745
+ if (typeof time.prep === "number") times.prepMinutes = time.prep;
746
+ if (typeof time.active === "number") times.cookMinutes = time.active;
747
+ if (typeof time.total === "number") times.totalMinutes = time.total;
748
+ return Object.keys(times).length ? times : void 0;
749
+ }
750
+ function convertNutrition(nutrition) {
751
+ if (!nutrition || typeof nutrition !== "object") {
752
+ return void 0;
753
+ }
754
+ const result = {};
755
+ let hasData = false;
756
+ if ("calories" in nutrition) {
757
+ const calories = nutrition.calories;
758
+ if (typeof calories === "number") {
759
+ result.calories = calories;
760
+ hasData = true;
761
+ } else if (typeof calories === "string") {
762
+ const parsed = parseFloat(calories.replace(/[^\d.-]/g, ""));
763
+ if (!isNaN(parsed)) {
764
+ result.calories = parsed;
765
+ hasData = true;
766
+ }
767
+ }
768
+ }
769
+ if ("proteinContent" in nutrition || "protein_g" in nutrition) {
770
+ const protein = nutrition.proteinContent || nutrition.protein_g;
771
+ if (typeof protein === "number") {
772
+ result.protein_g = protein;
773
+ hasData = true;
774
+ } else if (typeof protein === "string") {
775
+ const parsed = parseFloat(protein.replace(/[^\d.-]/g, ""));
776
+ if (!isNaN(parsed)) {
777
+ result.protein_g = parsed;
778
+ hasData = true;
779
+ }
780
+ }
781
+ }
782
+ return hasData ? result : void 0;
783
+ }
784
+
785
+ // src/schemas/registry/stacks.json
786
+ var stacks_default = {
787
+ stacks: [
788
+ {
789
+ id: "attribution",
790
+ versions: [1],
791
+ latest: 1,
792
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/attribution",
793
+ schema: "http://soustack.org/schema/v0.3.0/stacks/attribution",
794
+ schemaOrgMappable: true,
795
+ schemaOrgConfidence: "medium",
796
+ minProfile: "minimal",
797
+ allowedOnMinimal: true
798
+ },
799
+ {
800
+ id: "taxonomy",
801
+ versions: [1],
802
+ latest: 1,
803
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
804
+ schema: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
805
+ schemaOrgMappable: true,
806
+ schemaOrgConfidence: "high",
807
+ minProfile: "minimal",
808
+ allowedOnMinimal: true
809
+ },
810
+ {
811
+ id: "media",
812
+ versions: [1],
813
+ latest: 1,
814
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/media",
815
+ schema: "http://soustack.org/schema/v0.3.0/stacks/media",
816
+ schemaOrgMappable: true,
817
+ schemaOrgConfidence: "medium",
818
+ minProfile: "minimal",
819
+ allowedOnMinimal: true
820
+ },
821
+ {
822
+ id: "nutrition",
823
+ versions: [1],
824
+ latest: 1,
825
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
826
+ schema: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
827
+ schemaOrgMappable: false,
828
+ schemaOrgConfidence: "low",
829
+ minProfile: "minimal",
830
+ allowedOnMinimal: true
831
+ },
832
+ {
833
+ id: "times",
834
+ versions: [1],
835
+ latest: 1,
836
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/times",
837
+ schema: "http://soustack.org/schema/v0.3.0/stacks/times",
838
+ schemaOrgMappable: true,
839
+ schemaOrgConfidence: "medium",
840
+ minProfile: "minimal",
841
+ allowedOnMinimal: true
842
+ },
843
+ {
844
+ id: "schedule",
845
+ versions: [1],
846
+ latest: 1,
847
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/schedule",
848
+ schema: "http://soustack.org/schema/v0.3.0/stacks/schedule",
849
+ schemaOrgMappable: false,
850
+ schemaOrgConfidence: "low",
851
+ minProfile: "core",
852
+ allowedOnMinimal: false
853
+ }
854
+ ]
855
+ };
594
856
 
595
857
  // src/converters/toSchemaOrg.ts
596
858
  function convertBasicMetadata(recipe) {
@@ -729,6 +991,22 @@ function convertTime2(time) {
729
991
  }
730
992
  return result;
731
993
  }
994
+ function convertTimesModule(times) {
995
+ if (!times) {
996
+ return {};
997
+ }
998
+ const result = {};
999
+ if (times.prepMinutes !== void 0) {
1000
+ result.prepTime = formatDuration(times.prepMinutes);
1001
+ }
1002
+ if (times.cookMinutes !== void 0) {
1003
+ result.cookTime = formatDuration(times.cookMinutes);
1004
+ }
1005
+ if (times.totalMinutes !== void 0) {
1006
+ result.totalTime = formatDuration(times.totalMinutes);
1007
+ }
1008
+ return result;
1009
+ }
732
1010
  function convertYield(yld) {
733
1011
  if (!yld) {
734
1012
  return void 0;
@@ -767,33 +1045,65 @@ function convertCategoryTags(category, tags) {
767
1045
  }
768
1046
  return result;
769
1047
  }
770
- function convertNutrition(nutrition) {
1048
+ function convertNutrition2(nutrition) {
771
1049
  if (!nutrition) {
772
1050
  return void 0;
773
1051
  }
774
- return {
775
- ...nutrition,
1052
+ const result = {
776
1053
  "@type": "NutritionInformation"
777
1054
  };
1055
+ if (nutrition.calories !== void 0) {
1056
+ if (typeof nutrition.calories === "number") {
1057
+ result.calories = `${nutrition.calories} calories`;
1058
+ } else {
1059
+ result.calories = nutrition.calories;
1060
+ }
1061
+ }
1062
+ Object.keys(nutrition).forEach((key) => {
1063
+ if (key !== "calories" && key !== "@type") {
1064
+ result[key] = nutrition[key];
1065
+ }
1066
+ });
1067
+ return result;
778
1068
  }
779
1069
  function cleanOutput(obj) {
780
1070
  return Object.fromEntries(
781
1071
  Object.entries(obj).filter(([, value]) => value !== void 0)
782
1072
  );
783
1073
  }
1074
+ function getSchemaOrgMappableStacks(stacks = {}) {
1075
+ const mappableStackIds = /* @__PURE__ */ new Set();
1076
+ const mappableFromRegistry = stacks_default.stacks.filter((stack) => stack.schemaOrgMappable).map((stack) => `${stack.id}@${stack.latest}`);
1077
+ for (const [name, version] of Object.entries(stacks)) {
1078
+ const stackId = `${name}@${version}`;
1079
+ if (mappableFromRegistry.includes(stackId)) {
1080
+ mappableStackIds.add(stackId);
1081
+ }
1082
+ }
1083
+ return mappableStackIds;
1084
+ }
784
1085
  function toSchemaOrg(recipe) {
785
1086
  const base = convertBasicMetadata(recipe);
786
1087
  const ingredients = convertIngredients2(recipe.ingredients);
787
1088
  const instructions = convertInstructions2(recipe.instructions);
788
- const nutrition = convertNutrition(recipe.nutrition);
1089
+ const recipeStacks = recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks) ? recipe.stacks : {};
1090
+ const mappableStacks = getSchemaOrgMappableStacks(recipeStacks);
1091
+ const hasMappableNutrition = mappableStacks.has("nutrition@1");
1092
+ const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
1093
+ const hasMappableTimes = mappableStacks.has("times@1");
1094
+ const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
1095
+ const hasMappableAttribution = mappableStacks.has("attribution@1");
1096
+ const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
1097
+ const hasMappableTaxonomy = mappableStacks.has("taxonomy@1");
1098
+ const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
789
1099
  return cleanOutput({
790
1100
  ...base,
791
1101
  recipeIngredient: ingredients.length ? ingredients : void 0,
792
1102
  recipeInstructions: instructions.length ? instructions : void 0,
793
1103
  recipeYield: convertYield(recipe.yield),
794
- ...convertTime2(recipe.time),
795
- ...convertAuthor(recipe.source),
796
- ...convertCategoryTags(recipe.category, recipe.tags),
1104
+ ...timeData,
1105
+ ...attributionData,
1106
+ ...taxonomyData,
797
1107
  nutrition
798
1108
  });
799
1109
  }
@@ -865,13 +1175,16 @@ async function fetchPage(url, options = {}) {
865
1175
  const response = await resolvedFetch(url, requestInit);
866
1176
  clearTimeout(timeoutId);
867
1177
  if (response && typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
868
- try {
869
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
870
- if (globalFetch) {
871
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:63", message: "fetch response", data: { url, status: response.status, statusText: response.statusText, ok: response.ok, isNYTimes: url.includes("nytimes.com") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
872
- });
1178
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1179
+ if (ingestUrl) {
1180
+ try {
1181
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1182
+ if (globalFetch) {
1183
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:63", message: "fetch response", data: { url, status: response.status, statusText: response.statusText, ok: response.ok, isNYTimes: url.includes("nytimes.com") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
1184
+ });
1185
+ }
1186
+ } catch {
873
1187
  }
874
- } catch {
875
1188
  }
876
1189
  }
877
1190
  if (!response.ok) {
@@ -883,13 +1196,16 @@ async function fetchPage(url, options = {}) {
883
1196
  }
884
1197
  const html = await response.text();
885
1198
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
886
- try {
887
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
888
- if (globalFetch) {
889
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:75", message: "HTML received", data: { htmlLength: html.length, hasLoginPage: html.toLowerCase().includes("login") || html.toLowerCase().includes("sign in"), hasRecipeData: html.includes("application/ld+json") || html.includes("schema.org/Recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B,D" }) }).catch(() => {
890
- });
1199
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1200
+ if (ingestUrl) {
1201
+ try {
1202
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1203
+ if (globalFetch) {
1204
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:75", message: "HTML received", data: { htmlLength: html.length, hasLoginPage: html.toLowerCase().includes("login") || html.toLowerCase().includes("sign in"), hasRecipeData: html.includes("application/ld+json") || html.includes("schema.org/Recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B,D" }) }).catch(() => {
1205
+ });
1206
+ }
1207
+ } catch {
891
1208
  }
892
- } catch {
893
1209
  }
894
1210
  }
895
1211
  return html;
@@ -919,8 +1235,6 @@ function isRecipeNode(value) {
919
1235
  return false;
920
1236
  }
921
1237
  const type = value["@type"];
922
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/utils.ts:14", message: "isRecipeNode check", data: { type, typeLower: typeof type === "string" ? type.toLowerCase() : Array.isArray(type) ? type.map((t) => typeof t === "string" ? t.toLowerCase() : t) : void 0, isMatch: typeof type === "string" ? RECIPE_TYPES.has(type.toLowerCase()) : Array.isArray(type) ? type.some((e) => typeof e === "string" && RECIPE_TYPES.has(e.toLowerCase())) : false }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
923
- });
924
1238
  if (typeof type === "string") {
925
1239
  return RECIPE_TYPES.has(type.toLowerCase());
926
1240
  }
@@ -948,20 +1262,14 @@ function normalizeText(value) {
948
1262
  function extractJsonLd(html) {
949
1263
  const $ = cheerio.load(html);
950
1264
  const scripts = $('script[type="application/ld+json"]');
951
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:8", message: "JSON-LD scripts found", data: { scriptCount: scripts.length }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
952
- });
953
1265
  const candidates = [];
954
1266
  scripts.each((_, element) => {
955
1267
  const content = $(element).html();
956
1268
  if (!content) return;
957
1269
  const parsed = safeJsonParse(content);
958
1270
  if (!parsed) return;
959
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:18", message: "JSON-LD parsed", data: { hasGraph: !!(parsed && typeof parsed === "object" && "@graph" in parsed), type: parsed && typeof parsed === "object" && "@type" in parsed ? parsed["@type"] : void 0 }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
960
- });
961
1271
  collectCandidates(parsed, candidates);
962
1272
  });
963
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:22", message: "JSON-LD candidates", data: { candidateCount: candidates.length, candidateTypes: candidates.map((c) => c["@type"]) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
964
- });
965
1273
  return candidates[0] ?? null;
966
1274
  }
967
1275
  function collectCandidates(payload, bucket) {
@@ -1143,13 +1451,16 @@ function extractRecipe(html) {
1143
1451
  }
1144
1452
  const jsonLdRecipe = extractJsonLd(html);
1145
1453
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1146
- try {
1147
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1148
- if (globalFetch) {
1149
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:6", message: "JSON-LD extraction result", data: { hasJsonLd: !!jsonLdRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
1150
- });
1454
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1455
+ if (ingestUrl) {
1456
+ try {
1457
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1458
+ if (globalFetch) {
1459
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:6", message: "JSON-LD extraction result", data: { hasJsonLd: !!jsonLdRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
1460
+ });
1461
+ }
1462
+ } catch {
1151
1463
  }
1152
- } catch {
1153
1464
  }
1154
1465
  }
1155
1466
  if (jsonLdRecipe) {
@@ -1157,13 +1468,16 @@ function extractRecipe(html) {
1157
1468
  }
1158
1469
  const microdataRecipe = extractMicrodata(html);
1159
1470
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1160
- try {
1161
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1162
- if (globalFetch) {
1163
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:12", message: "Microdata extraction result", data: { hasMicrodata: !!microdataRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "D" }) }).catch(() => {
1164
- });
1471
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1472
+ if (ingestUrl) {
1473
+ try {
1474
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1475
+ if (globalFetch) {
1476
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:12", message: "Microdata extraction result", data: { hasMicrodata: !!microdataRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "D" }) }).catch(() => {
1477
+ });
1478
+ }
1479
+ } catch {
1165
1480
  }
1166
- } catch {
1167
1481
  }
1168
1482
  }
1169
1483
  if (microdataRecipe) {
@@ -1175,35 +1489,44 @@ function extractRecipe(html) {
1175
1489
  // src/scraper/index.ts
1176
1490
  async function scrapeRecipe(url, options = {}) {
1177
1491
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1178
- try {
1179
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1180
- if (globalFetch) {
1181
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:7", message: "scrapeRecipe entry", data: { url, hasOptions: !!options }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,B,C,D,E" }) }).catch(() => {
1182
- });
1492
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1493
+ if (ingestUrl) {
1494
+ try {
1495
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1496
+ if (globalFetch) {
1497
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:7", message: "scrapeRecipe entry", data: { url, hasOptions: !!options }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,B,C,D,E" }) }).catch(() => {
1498
+ });
1499
+ }
1500
+ } catch {
1183
1501
  }
1184
- } catch {
1185
1502
  }
1186
1503
  }
1187
1504
  const html = await fetchPage(url, options);
1188
1505
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1189
- try {
1190
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1191
- if (globalFetch) {
1192
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:9", message: "HTML fetched", data: { htmlLength: html?.length, htmlPreview: html?.substring(0, 200) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
1193
- });
1506
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1507
+ if (ingestUrl) {
1508
+ try {
1509
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1510
+ if (globalFetch) {
1511
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:9", message: "HTML fetched", data: { htmlLength: html?.length, htmlPreview: html?.substring(0, 200) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
1512
+ });
1513
+ }
1514
+ } catch {
1194
1515
  }
1195
- } catch {
1196
1516
  }
1197
1517
  }
1198
1518
  const { recipe } = extractRecipe(html);
1199
1519
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1200
- try {
1201
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1202
- if (globalFetch) {
1203
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:11", message: "extractRecipe result", data: { hasRecipe: !!recipe, recipeType: recipe?.["@type"], recipeName: recipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C,D" }) }).catch(() => {
1204
- });
1520
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1521
+ if (ingestUrl) {
1522
+ try {
1523
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1524
+ if (globalFetch) {
1525
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:11", message: "extractRecipe result", data: { hasRecipe: !!recipe, recipeType: recipe?.["@type"], recipeName: recipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C,D" }) }).catch(() => {
1526
+ });
1527
+ }
1528
+ } catch {
1205
1529
  }
1206
- } catch {
1207
1530
  }
1208
1531
  }
1209
1532
  if (!recipe) {
@@ -1211,13 +1534,16 @@ async function scrapeRecipe(url, options = {}) {
1211
1534
  }
1212
1535
  const soustackRecipe = fromSchemaOrg(recipe);
1213
1536
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1214
- try {
1215
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1216
- if (globalFetch) {
1217
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:17", message: "fromSchemaOrg result", data: { hasSoustackRecipe: !!soustackRecipe, soustackRecipeName: soustackRecipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
1218
- });
1537
+ const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
1538
+ if (ingestUrl) {
1539
+ try {
1540
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1541
+ if (globalFetch) {
1542
+ globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:17", message: "fromSchemaOrg result", data: { hasSoustackRecipe: !!soustackRecipe, soustackRecipeName: soustackRecipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
1543
+ });
1544
+ }
1545
+ } catch {
1219
1546
  }
1220
- } catch {
1221
1547
  }
1222
1548
  }
1223
1549
  if (!soustackRecipe) {
@@ -1226,11 +1552,233 @@ async function scrapeRecipe(url, options = {}) {
1226
1552
  return soustackRecipe;
1227
1553
  }
1228
1554
 
1229
- // src/schema.json
1230
- var schema_default = {
1555
+ // src/conformance/index.ts
1556
+ function validateConformance(recipe) {
1557
+ const issues = [];
1558
+ issues.push(...checkDAGValidity(recipe));
1559
+ if (hasSchedulableProfile(recipe)) {
1560
+ issues.push(...checkTimingSchedulability(recipe));
1561
+ }
1562
+ issues.push(...checkScalingSanity(recipe));
1563
+ const ok = issues.filter((i) => i.severity === "error").length === 0;
1564
+ return { ok, issues };
1565
+ }
1566
+ function hasSchedulableProfile(recipe) {
1567
+ const schema = recipe.$schema;
1568
+ if (typeof schema === "string") {
1569
+ return schema.includes("schedulable") || schema === "http://soustack.org/schema/v0.3.0/profiles/schedulable";
1570
+ }
1571
+ return false;
1572
+ }
1573
+ function checkDAGValidity(recipe) {
1574
+ const issues = [];
1575
+ const instructions = recipe.instructions;
1576
+ if (!Array.isArray(instructions)) {
1577
+ return issues;
1578
+ }
1579
+ const instructionIds = /* @__PURE__ */ new Set();
1580
+ const dependencyRefs = [];
1581
+ const collect = (items, basePath) => {
1582
+ items.forEach((item, index) => {
1583
+ const currentPath = `${basePath}/${index}`;
1584
+ if (isInstructionSubsection(item)) {
1585
+ if (Array.isArray(item.items)) {
1586
+ collect(item.items, `${currentPath}/items`);
1587
+ }
1588
+ return;
1589
+ }
1590
+ if (isInstruction(item)) {
1591
+ const id = typeof item.id === "string" ? item.id : void 0;
1592
+ if (id) {
1593
+ instructionIds.add(id);
1594
+ }
1595
+ if (Array.isArray(item.dependsOn)) {
1596
+ item.dependsOn.forEach((depId, depIndex) => {
1597
+ if (typeof depId === "string") {
1598
+ dependencyRefs.push({
1599
+ fromId: id,
1600
+ toId: depId,
1601
+ path: `${currentPath}/dependsOn/${depIndex}`
1602
+ });
1603
+ }
1604
+ });
1605
+ }
1606
+ }
1607
+ });
1608
+ };
1609
+ collect(instructions, "/instructions");
1610
+ dependencyRefs.forEach((ref) => {
1611
+ if (!instructionIds.has(ref.toId)) {
1612
+ issues.push({
1613
+ code: "DAG_MISSING_NODE",
1614
+ path: ref.path,
1615
+ message: `Instruction dependency references missing step id '${ref.toId}'.`,
1616
+ severity: "error"
1617
+ });
1618
+ }
1619
+ });
1620
+ const adjacency = /* @__PURE__ */ new Map();
1621
+ dependencyRefs.forEach((ref) => {
1622
+ if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
1623
+ const list = adjacency.get(ref.fromId) ?? [];
1624
+ list.push({ toId: ref.toId, path: ref.path });
1625
+ adjacency.set(ref.fromId, list);
1626
+ }
1627
+ });
1628
+ const visiting = /* @__PURE__ */ new Set();
1629
+ const visited = /* @__PURE__ */ new Set();
1630
+ const detectCycles = (nodeId) => {
1631
+ if (visiting.has(nodeId)) {
1632
+ return;
1633
+ }
1634
+ if (visited.has(nodeId)) {
1635
+ return;
1636
+ }
1637
+ visiting.add(nodeId);
1638
+ const neighbors = adjacency.get(nodeId) ?? [];
1639
+ neighbors.forEach((edge) => {
1640
+ if (visiting.has(edge.toId)) {
1641
+ issues.push({
1642
+ code: "DAG_CYCLE",
1643
+ path: edge.path,
1644
+ message: `Circular dependency detected involving step id '${edge.toId}'.`,
1645
+ severity: "error"
1646
+ });
1647
+ return;
1648
+ }
1649
+ detectCycles(edge.toId);
1650
+ });
1651
+ visiting.delete(nodeId);
1652
+ visited.add(nodeId);
1653
+ };
1654
+ instructionIds.forEach((id) => detectCycles(id));
1655
+ return issues;
1656
+ }
1657
+ function checkTimingSchedulability(recipe) {
1658
+ const issues = [];
1659
+ const instructions = recipe.instructions;
1660
+ if (!Array.isArray(instructions)) {
1661
+ return issues;
1662
+ }
1663
+ const checkInstruction = (item, path2) => {
1664
+ if (isInstructionSubsection(item)) {
1665
+ if (Array.isArray(item.items)) {
1666
+ item.items.forEach((subItem, index) => {
1667
+ checkInstruction(subItem, `${path2}/items/${index}`);
1668
+ });
1669
+ }
1670
+ return;
1671
+ }
1672
+ if (isInstruction(item)) {
1673
+ if (!item.id) {
1674
+ issues.push({
1675
+ code: "SCHEDULABLE_MISSING_ID",
1676
+ path: path2,
1677
+ message: "Schedulable profile requires all instructions to have an id.",
1678
+ severity: "error"
1679
+ });
1680
+ }
1681
+ if (!item.timing) {
1682
+ issues.push({
1683
+ code: "SCHEDULABLE_MISSING_TIMING",
1684
+ path: path2,
1685
+ message: "Schedulable profile requires all instructions to have timing information.",
1686
+ severity: "error"
1687
+ });
1688
+ } else if (!item.timing.duration) {
1689
+ issues.push({
1690
+ code: "SCHEDULABLE_MISSING_DURATION",
1691
+ path: `${path2}/timing`,
1692
+ message: "Schedulable profile requires timing.duration for all instructions.",
1693
+ severity: "error"
1694
+ });
1695
+ }
1696
+ }
1697
+ };
1698
+ instructions.forEach((item, index) => {
1699
+ checkInstruction(item, `/instructions/${index}`);
1700
+ });
1701
+ return issues;
1702
+ }
1703
+ function checkScalingSanity(recipe) {
1704
+ const issues = [];
1705
+ const ingredients = recipe.ingredients;
1706
+ if (!Array.isArray(ingredients)) {
1707
+ return issues;
1708
+ }
1709
+ const ingredientIds = /* @__PURE__ */ new Set();
1710
+ const collectIngredientIds = (items, basePath) => {
1711
+ items.forEach((item, index) => {
1712
+ if (isIngredientSubsection(item)) {
1713
+ if (Array.isArray(item.items)) {
1714
+ collectIngredientIds(item.items);
1715
+ }
1716
+ return;
1717
+ }
1718
+ if (isIngredient(item)) {
1719
+ if (typeof item.id === "string") {
1720
+ ingredientIds.add(item.id);
1721
+ }
1722
+ }
1723
+ });
1724
+ };
1725
+ collectIngredientIds(ingredients);
1726
+ const checkIngredient = (item, path2) => {
1727
+ if (isIngredientSubsection(item)) {
1728
+ if (Array.isArray(item.items)) {
1729
+ item.items.forEach((subItem, index) => {
1730
+ checkIngredient(subItem, `${path2}/items/${index}`);
1731
+ });
1732
+ }
1733
+ return;
1734
+ }
1735
+ if (isIngredient(item)) {
1736
+ const scaling = item.scaling;
1737
+ if (scaling && typeof scaling === "object" && "type" in scaling && scaling.type === "bakers_percentage") {
1738
+ const bakersScaling = scaling;
1739
+ if (bakersScaling.referenceId) {
1740
+ if (!ingredientIds.has(bakersScaling.referenceId)) {
1741
+ issues.push({
1742
+ code: "SCALING_INVALID_REFERENCE",
1743
+ path: `${path2}/scaling/referenceId`,
1744
+ message: `Baker's percentage references missing ingredient id '${bakersScaling.referenceId}'.`,
1745
+ severity: "error"
1746
+ });
1747
+ }
1748
+ } else {
1749
+ issues.push({
1750
+ code: "SCALING_MISSING_REFERENCE",
1751
+ path: `${path2}/scaling`,
1752
+ message: "Baker's percentage scaling requires a referenceId.",
1753
+ severity: "error"
1754
+ });
1755
+ }
1756
+ }
1757
+ }
1758
+ };
1759
+ ingredients.forEach((item, index) => {
1760
+ checkIngredient(item, `/ingredients/${index}`);
1761
+ });
1762
+ return issues;
1763
+ }
1764
+ function isInstruction(item) {
1765
+ return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
1766
+ }
1767
+ function isInstructionSubsection(item) {
1768
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
1769
+ }
1770
+ function isIngredient(item) {
1771
+ return item && typeof item === "object" && !Array.isArray(item) && "item" in item;
1772
+ }
1773
+ function isIngredientSubsection(item) {
1774
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
1775
+ }
1776
+
1777
+ // src/soustack.schema.json
1778
+ var soustack_schema_default = {
1231
1779
  $schema: "http://json-schema.org/draft-07/schema#",
1232
- $id: "http://soustack.org/schema/v0.2.1",
1233
- title: "Soustack Recipe Schema v0.2.1",
1780
+ $id: "http://soustack.org/schema/v0.3.0",
1781
+ title: "Soustack Recipe Schema v0.3.0",
1234
1782
  description: "A portable, scalable, interoperable recipe format.",
1235
1783
  type: "object",
1236
1784
  required: ["name", "ingredients", "instructions"],
@@ -1378,7 +1926,10 @@ var schema_default = {
1378
1926
  required: ["amount"],
1379
1927
  properties: {
1380
1928
  amount: { type: "number" },
1381
- unit: { type: ["string", "null"] }
1929
+ unit: {
1930
+ type: ["string", "null"],
1931
+ description: "Display-friendly unit text; implementations may normalize or canonicalize units separately."
1932
+ }
1382
1933
  }
1383
1934
  },
1384
1935
  scaling: {
@@ -1413,7 +1964,16 @@ var schema_default = {
1413
1964
  aisle: { type: "string" },
1414
1965
  prep: { type: "string" },
1415
1966
  prepAction: { type: "string" },
1967
+ prepActions: {
1968
+ type: "array",
1969
+ items: { type: "string" },
1970
+ description: "Structured prep verbs (e.g., peel, dice) for mise en place workflows."
1971
+ },
1416
1972
  prepTime: { type: "number" },
1973
+ form: {
1974
+ type: "string",
1975
+ description: "State of the ingredient as used (packed, sifted, melted, room_temperature, etc.)."
1976
+ },
1417
1977
  destination: { type: "string" },
1418
1978
  scaling: { $ref: "#/definitions/scaling" },
1419
1979
  critical: { type: "boolean" },
@@ -1572,399 +2132,280 @@ var schema_default = {
1572
2132
  }
1573
2133
  };
1574
2134
 
1575
- // src/soustack.schema.json
1576
- var soustack_schema_default = {
2135
+ // src/schemas/recipe/base.schema.json
2136
+ var base_schema_default = {
1577
2137
  $schema: "http://json-schema.org/draft-07/schema#",
1578
- $id: "http://soustack.org/schema/v0.2.1",
1579
- title: "Soustack Recipe Schema v0.2.1",
1580
- description: "A portable, scalable, interoperable recipe format.",
2138
+ $id: "http://soustack.org/schema/recipe/base.schema.json",
2139
+ title: "Soustack Recipe Base Schema",
2140
+ description: "Base document shape for Soustack recipe documents. Profiles and stacks build on this baseline.",
1581
2141
  type: "object",
1582
- required: ["name", "ingredients", "instructions"],
1583
- additionalProperties: false,
1584
- patternProperties: {
1585
- "^x-": {}
1586
- },
2142
+ additionalProperties: true,
1587
2143
  properties: {
1588
- $schema: {
1589
- type: "string",
1590
- format: "uri",
1591
- description: "Optional schema hint for tooling compatibility"
1592
- },
1593
- id: {
1594
- type: "string",
1595
- description: "Unique identifier (slug or UUID)"
1596
- },
1597
- name: {
1598
- type: "string",
1599
- description: "The title of the recipe"
1600
- },
1601
- title: {
1602
- type: "string",
1603
- description: "Optional display title; alias for name"
1604
- },
1605
- version: {
1606
- type: "string",
1607
- pattern: "^\\d+\\.\\d+\\.\\d+$",
1608
- description: "DEPRECATED: use recipeVersion for authoring revisions"
1609
- },
1610
- recipeVersion: {
1611
- type: "string",
1612
- pattern: "^\\d+\\.\\d+\\.\\d+$",
1613
- description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
1614
- },
1615
- description: {
1616
- type: "string"
1617
- },
1618
- category: {
1619
- type: "string",
1620
- examples: ["Main Course", "Dessert"]
1621
- },
1622
- tags: {
1623
- type: "array",
1624
- items: { type: "string" }
1625
- },
1626
- image: {
1627
- description: "Recipe-level hero image(s)",
1628
- anyOf: [
1629
- {
1630
- type: "string",
1631
- format: "uri"
1632
- },
1633
- {
1634
- type: "array",
1635
- minItems: 1,
1636
- items: {
1637
- type: "string",
1638
- format: "uri"
1639
- }
1640
- }
1641
- ]
2144
+ "@type": {
2145
+ const: "Recipe",
2146
+ description: "Document marker for Soustack recipes"
1642
2147
  },
1643
- dateAdded: {
2148
+ profile: {
1644
2149
  type: "string",
1645
- format: "date-time"
1646
- },
1647
- metadata: {
1648
- type: "object",
1649
- additionalProperties: true,
1650
- description: "Free-form vendor metadata"
2150
+ description: "Profile identifier applied to this recipe"
1651
2151
  },
1652
- source: {
2152
+ stacks: {
1653
2153
  type: "object",
1654
- properties: {
1655
- author: { type: "string" },
1656
- url: { type: "string", format: "uri" },
1657
- name: { type: "string" },
1658
- adapted: { type: "boolean" }
2154
+ description: "Stack declarations as a map: Record<stackName, versionNumber>",
2155
+ additionalProperties: {
2156
+ type: "integer",
2157
+ minimum: 1
1659
2158
  }
1660
2159
  },
1661
- yield: {
1662
- $ref: "#/definitions/yield"
1663
- },
1664
- time: {
1665
- $ref: "#/definitions/time"
1666
- },
1667
- equipment: {
1668
- type: "array",
1669
- items: { $ref: "#/definitions/equipment" }
2160
+ name: {
2161
+ type: "string",
2162
+ description: "Human-readable recipe name"
1670
2163
  },
1671
2164
  ingredients: {
1672
2165
  type: "array",
1673
- items: {
1674
- anyOf: [
1675
- { type: "string" },
1676
- { $ref: "#/definitions/ingredient" },
1677
- { $ref: "#/definitions/ingredientSubsection" }
1678
- ]
1679
- }
2166
+ description: "Ingredients payload; content is validated by profiles/stacks"
1680
2167
  },
1681
2168
  instructions: {
1682
2169
  type: "array",
1683
- items: {
1684
- anyOf: [
1685
- { type: "string" },
1686
- { $ref: "#/definitions/instruction" },
1687
- { $ref: "#/definitions/instructionSubsection" }
1688
- ]
1689
- }
1690
- },
1691
- storage: {
1692
- $ref: "#/definitions/storage"
1693
- },
1694
- substitutions: {
1695
- type: "array",
1696
- items: { $ref: "#/definitions/substitution" }
2170
+ description: "Instruction payload; content is validated by profiles/stacks"
1697
2171
  }
1698
2172
  },
1699
- definitions: {
1700
- yield: {
1701
- type: "object",
1702
- required: ["amount", "unit"],
1703
- properties: {
1704
- amount: { type: "number" },
1705
- unit: { type: "string" },
1706
- servings: { type: "number" },
1707
- description: { type: "string" }
1708
- }
1709
- },
1710
- time: {
1711
- type: "object",
1712
- properties: {
1713
- prep: { type: "number" },
1714
- active: { type: "number" },
1715
- passive: { type: "number" },
1716
- total: { type: "number" },
1717
- prepTime: { type: "string", format: "duration" },
1718
- cookTime: { type: "string", format: "duration" }
1719
- },
1720
- minProperties: 1
1721
- },
1722
- quantity: {
1723
- type: "object",
1724
- required: ["amount"],
1725
- properties: {
1726
- amount: { type: "number" },
1727
- unit: { type: ["string", "null"] }
1728
- }
1729
- },
1730
- scaling: {
1731
- type: "object",
1732
- required: ["type"],
1733
- properties: {
1734
- type: {
1735
- type: "string",
1736
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
1737
- },
1738
- factor: { type: "number" },
1739
- referenceId: { type: "string" },
1740
- roundTo: { type: "number" },
1741
- min: { type: "number" },
1742
- max: { type: "number" }
1743
- },
1744
- if: {
1745
- properties: { type: { const: "bakers_percentage" } }
1746
- },
1747
- then: {
1748
- required: ["referenceId"]
1749
- }
1750
- },
1751
- ingredient: {
1752
- type: "object",
1753
- required: ["item"],
1754
- properties: {
1755
- id: { type: "string" },
1756
- item: { type: "string" },
1757
- quantity: { $ref: "#/definitions/quantity" },
1758
- name: { type: "string" },
1759
- aisle: { type: "string" },
1760
- prep: { type: "string" },
1761
- prepAction: { type: "string" },
1762
- prepTime: { type: "number" },
1763
- destination: { type: "string" },
1764
- scaling: { $ref: "#/definitions/scaling" },
1765
- critical: { type: "boolean" },
1766
- optional: { type: "boolean" },
1767
- notes: { type: "string" }
1768
- }
1769
- },
1770
- ingredientSubsection: {
1771
- type: "object",
1772
- required: ["subsection", "items"],
1773
- properties: {
1774
- subsection: { type: "string" },
1775
- items: {
1776
- type: "array",
1777
- items: { $ref: "#/definitions/ingredient" }
1778
- }
1779
- }
1780
- },
1781
- equipment: {
1782
- type: "object",
1783
- required: ["name"],
1784
- properties: {
1785
- id: { type: "string" },
1786
- name: { type: "string" },
1787
- required: { type: "boolean" },
1788
- label: { type: "string" },
1789
- capacity: { $ref: "#/definitions/quantity" },
1790
- scalingLimit: { type: "number" },
1791
- alternatives: {
1792
- type: "array",
1793
- items: { type: "string" }
1794
- }
1795
- }
2173
+ required: ["@type"]
2174
+ };
2175
+
2176
+ // src/schemas/recipe/profiles/minimal.schema.json
2177
+ var minimal_schema_default = {
2178
+ $schema: "http://json-schema.org/draft-07/schema#",
2179
+ $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
2180
+ title: "Soustack Recipe Minimal Profile",
2181
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing stacks to extend it.",
2182
+ allOf: [
2183
+ {
2184
+ $ref: "http://soustack.org/schema/recipe/base.schema.json"
1796
2185
  },
1797
- instruction: {
2186
+ {
1798
2187
  type: "object",
1799
- required: ["text"],
1800
2188
  properties: {
1801
- id: { type: "string" },
1802
- text: { type: "string" },
1803
- image: {
1804
- type: "string",
1805
- format: "uri",
1806
- description: "Optional image that illustrates this instruction"
1807
- },
1808
- destination: { type: "string" },
1809
- dependsOn: {
1810
- type: "array",
1811
- items: { type: "string" }
2189
+ profile: {
2190
+ const: "minimal"
1812
2191
  },
1813
- inputs: {
1814
- type: "array",
1815
- items: { type: "string" }
1816
- },
1817
- timing: {
2192
+ stacks: {
1818
2193
  type: "object",
1819
- required: ["duration", "type"],
2194
+ additionalProperties: {
2195
+ type: "integer",
2196
+ minimum: 1
2197
+ },
1820
2198
  properties: {
1821
- duration: {
1822
- anyOf: [
1823
- { type: "number" },
1824
- { type: "string", pattern: "^P" }
1825
- ],
1826
- description: "Minutes as a number or ISO8601 duration string"
1827
- },
1828
- type: { type: "string", enum: ["active", "passive"] },
1829
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
2199
+ attribution: { type: "integer", minimum: 1 },
2200
+ taxonomy: { type: "integer", minimum: 1 },
2201
+ media: { type: "integer", minimum: 1 },
2202
+ nutrition: { type: "integer", minimum: 1 },
2203
+ times: { type: "integer", minimum: 1 }
1830
2204
  }
1831
- }
1832
- }
1833
- },
1834
- instructionSubsection: {
1835
- type: "object",
1836
- required: ["subsection", "items"],
1837
- properties: {
1838
- subsection: { type: "string" },
1839
- items: {
2205
+ },
2206
+ name: {
2207
+ type: "string",
2208
+ minLength: 1
2209
+ },
2210
+ ingredients: {
1840
2211
  type: "array",
1841
- items: {
1842
- anyOf: [
1843
- { type: "string" },
1844
- { $ref: "#/definitions/instruction" }
1845
- ]
1846
- }
1847
- }
1848
- }
1849
- },
1850
- storage: {
1851
- type: "object",
1852
- properties: {
1853
- roomTemp: { $ref: "#/definitions/storageMethod" },
1854
- refrigerated: { $ref: "#/definitions/storageMethod" },
1855
- frozen: {
1856
- allOf: [
1857
- { $ref: "#/definitions/storageMethod" },
1858
- {
1859
- type: "object",
1860
- properties: { thawing: { type: "string" } }
1861
- }
1862
- ]
2212
+ minItems: 1
1863
2213
  },
1864
- reheating: { type: "string" },
1865
- makeAhead: {
2214
+ instructions: {
1866
2215
  type: "array",
1867
- items: {
1868
- allOf: [
1869
- { $ref: "#/definitions/storageMethod" },
1870
- {
1871
- type: "object",
1872
- required: ["component", "storage"],
1873
- properties: {
1874
- component: { type: "string" },
1875
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
1876
- }
1877
- }
1878
- ]
1879
- }
2216
+ minItems: 1
1880
2217
  }
1881
- }
1882
- },
1883
- storageMethod: {
1884
- type: "object",
1885
- required: ["duration"],
1886
- properties: {
1887
- duration: { type: "string", pattern: "^P" },
1888
- method: { type: "string" },
1889
- notes: { type: "string" }
1890
- }
1891
- },
1892
- substitution: {
2218
+ },
2219
+ required: [
2220
+ "profile",
2221
+ "name",
2222
+ "ingredients",
2223
+ "instructions"
2224
+ ],
2225
+ additionalProperties: true
2226
+ }
2227
+ ]
2228
+ };
2229
+
2230
+ // src/schemas/recipe/profiles/core.schema.json
2231
+ var core_schema_default = {
2232
+ $schema: "http://json-schema.org/draft-07/schema#",
2233
+ $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
2234
+ title: "Soustack Recipe Core Profile",
2235
+ description: "Core profile that builds on the minimal profile and is intended to be combined with recipe stacks.",
2236
+ allOf: [
2237
+ { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
2238
+ {
1893
2239
  type: "object",
1894
- required: ["ingredient"],
1895
2240
  properties: {
1896
- ingredient: { type: "string" },
1897
- critical: { type: "boolean" },
1898
- notes: { type: "string" },
1899
- alternatives: {
1900
- type: "array",
1901
- items: {
1902
- type: "object",
1903
- required: ["name", "ratio"],
1904
- properties: {
1905
- name: { type: "string" },
1906
- ratio: { type: "string" },
1907
- notes: { type: "string" },
1908
- impact: { type: "string" },
1909
- dietary: {
1910
- type: "array",
1911
- items: { type: "string" }
1912
- }
1913
- }
2241
+ profile: { const: "core" },
2242
+ stacks: {
2243
+ type: "object",
2244
+ additionalProperties: {
2245
+ type: "integer",
2246
+ minimum: 1
1914
2247
  }
1915
- }
1916
- }
2248
+ },
2249
+ name: { type: "string", minLength: 1 },
2250
+ ingredients: { type: "array", minItems: 1 },
2251
+ instructions: { type: "array", minItems: 1 }
2252
+ },
2253
+ required: ["profile", "name", "ingredients", "instructions"],
2254
+ additionalProperties: true
1917
2255
  }
1918
- }
2256
+ ]
1919
2257
  };
1920
2258
 
1921
- // src/profiles/base.schema.json
1922
- var base_schema_default = {
2259
+ // spec/profiles/base.schema.json
2260
+ var base_schema_default2 = {
1923
2261
  $schema: "http://json-schema.org/draft-07/schema#",
1924
- $id: "http://soustack.org/schema/v0.2.1/profiles/base",
2262
+ $id: "http://soustack.org/schema/v0.3.0/profiles/base",
1925
2263
  title: "Soustack Base Profile Schema",
1926
2264
  description: "Wrapper schema that exposes the unmodified Soustack base schema.",
1927
2265
  allOf: [
1928
- { $ref: "http://soustack.org/schema/v0.2.1" }
2266
+ { $ref: "http://soustack.org/schema/v0.3.0" }
1929
2267
  ]
1930
2268
  };
1931
2269
 
1932
- // src/profiles/cookable.schema.json
2270
+ // spec/profiles/cookable.schema.json
1933
2271
  var cookable_schema_default = {
1934
2272
  $schema: "http://json-schema.org/draft-07/schema#",
1935
- $id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
2273
+ $id: "http://soustack.org/schema/v0.3.0/profiles/cookable",
1936
2274
  title: "Soustack Cookable Profile Schema",
1937
2275
  description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
1938
2276
  allOf: [
1939
- { $ref: "http://soustack.org/schema/v0.2.1" },
2277
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1940
2278
  {
1941
2279
  required: ["yield", "time", "ingredients", "instructions"],
1942
2280
  properties: {
1943
- yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
1944
- time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
2281
+ yield: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
2282
+ time: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/time" },
1945
2283
  ingredients: { type: "array", minItems: 1 },
1946
2284
  instructions: { type: "array", minItems: 1 }
1947
2285
  }
1948
2286
  }
1949
- ]
2287
+ ]
2288
+ };
2289
+
2290
+ // spec/profiles/illustrated.schema.json
2291
+ var illustrated_schema_default = {
2292
+ $schema: "http://json-schema.org/draft-07/schema#",
2293
+ $id: "http://soustack.org/schema/v0.3.0/profiles/illustrated",
2294
+ title: "Soustack Illustrated Profile Schema",
2295
+ description: "Extends the base schema to guarantee at least one illustrative image.",
2296
+ allOf: [
2297
+ { $ref: "http://soustack.org/schema/v0.3.0" },
2298
+ {
2299
+ anyOf: [
2300
+ { required: ["image"] },
2301
+ {
2302
+ properties: {
2303
+ instructions: {
2304
+ type: "array",
2305
+ contains: {
2306
+ anyOf: [
2307
+ { $ref: "#/definitions/imageInstruction" },
2308
+ { $ref: "#/definitions/instructionSubsectionWithImage" }
2309
+ ]
2310
+ }
2311
+ }
2312
+ }
2313
+ }
2314
+ ]
2315
+ }
2316
+ ],
2317
+ definitions: {
2318
+ imageInstruction: {
2319
+ allOf: [
2320
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
2321
+ { required: ["image"] }
2322
+ ]
2323
+ },
2324
+ instructionSubsectionWithImage: {
2325
+ allOf: [
2326
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
2327
+ {
2328
+ properties: {
2329
+ items: {
2330
+ type: "array",
2331
+ contains: { $ref: "#/definitions/imageInstruction" }
2332
+ }
2333
+ }
2334
+ }
2335
+ ]
2336
+ }
2337
+ }
2338
+ };
2339
+
2340
+ // spec/profiles/quantified.schema.json
2341
+ var quantified_schema_default = {
2342
+ $schema: "http://json-schema.org/draft-07/schema#",
2343
+ $id: "http://soustack.org/schema/v0.3.0/profiles/quantified",
2344
+ title: "Soustack Quantified Profile Schema",
2345
+ description: "Extends the base schema to require quantified ingredient entries.",
2346
+ allOf: [
2347
+ { $ref: "http://soustack.org/schema/v0.3.0" },
2348
+ {
2349
+ properties: {
2350
+ ingredients: {
2351
+ type: "array",
2352
+ items: {
2353
+ anyOf: [
2354
+ { $ref: "#/definitions/quantifiedIngredient" },
2355
+ { $ref: "#/definitions/quantifiedIngredientSubsection" }
2356
+ ]
2357
+ }
2358
+ }
2359
+ }
2360
+ }
2361
+ ],
2362
+ definitions: {
2363
+ quantifiedIngredient: {
2364
+ allOf: [
2365
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
2366
+ { required: ["item", "quantity"] }
2367
+ ]
2368
+ },
2369
+ quantifiedIngredientSubsection: {
2370
+ allOf: [
2371
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
2372
+ {
2373
+ properties: {
2374
+ items: {
2375
+ type: "array",
2376
+ items: { $ref: "#/definitions/quantifiedIngredient" }
2377
+ }
2378
+ }
2379
+ }
2380
+ ]
2381
+ }
2382
+ }
1950
2383
  };
1951
2384
 
1952
- // src/profiles/quantified.schema.json
1953
- var quantified_schema_default = {
2385
+ // spec/profiles/scalable.schema.json
2386
+ var scalable_schema_default = {
1954
2387
  $schema: "http://json-schema.org/draft-07/schema#",
1955
- $id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
1956
- title: "Soustack Quantified Profile Schema",
1957
- description: "Extends the base schema to require quantified ingredient entries.",
2388
+ $id: "http://soustack.org/schema/v0.3.0/profiles/scalable",
2389
+ title: "Soustack Scalable Profile Schema",
2390
+ description: "Extends the base schema to guarantee quantified ingredients plus a structured yield for deterministic scaling.",
1958
2391
  allOf: [
1959
- { $ref: "http://soustack.org/schema/v0.2.1" },
2392
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1960
2393
  {
2394
+ required: ["yield", "ingredients"],
1961
2395
  properties: {
2396
+ yield: {
2397
+ allOf: [
2398
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
2399
+ { properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
2400
+ ]
2401
+ },
1962
2402
  ingredients: {
1963
2403
  type: "array",
2404
+ minItems: 1,
1964
2405
  items: {
1965
2406
  anyOf: [
1966
- { $ref: "#/definitions/quantifiedIngredient" },
1967
- { $ref: "#/definitions/quantifiedIngredientSubsection" }
2407
+ { $ref: "#/definitions/scalableIngredient" },
2408
+ { $ref: "#/definitions/scalableIngredientSubsection" }
1968
2409
  ]
1969
2410
  }
1970
2411
  }
@@ -1972,20 +2413,44 @@ var quantified_schema_default = {
1972
2413
  }
1973
2414
  ],
1974
2415
  definitions: {
1975
- quantifiedIngredient: {
2416
+ scalableIngredient: {
1976
2417
  allOf: [
1977
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
1978
- { required: ["item", "quantity"] }
2418
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
2419
+ { required: ["item", "quantity"] },
2420
+ {
2421
+ properties: {
2422
+ quantity: {
2423
+ allOf: [
2424
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/quantity" },
2425
+ { properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
2426
+ ]
2427
+ }
2428
+ }
2429
+ },
2430
+ {
2431
+ if: {
2432
+ properties: {
2433
+ scaling: {
2434
+ type: "object",
2435
+ properties: { type: { const: "bakers_percentage" } },
2436
+ required: ["type"]
2437
+ }
2438
+ },
2439
+ required: ["scaling"]
2440
+ },
2441
+ then: { required: ["id"] }
2442
+ }
1979
2443
  ]
1980
2444
  },
1981
- quantifiedIngredientSubsection: {
2445
+ scalableIngredientSubsection: {
1982
2446
  allOf: [
1983
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
2447
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
1984
2448
  {
1985
2449
  properties: {
1986
2450
  items: {
1987
2451
  type: "array",
1988
- items: { $ref: "#/definitions/quantifiedIngredient" }
2452
+ minItems: 1,
2453
+ items: { $ref: "#/definitions/scalableIngredient" }
1989
2454
  }
1990
2455
  }
1991
2456
  }
@@ -1994,48 +2459,43 @@ var quantified_schema_default = {
1994
2459
  }
1995
2460
  };
1996
2461
 
1997
- // src/profiles/illustrated.schema.json
1998
- var illustrated_schema_default = {
2462
+ // spec/profiles/schedulable.schema.json
2463
+ var schedulable_schema_default = {
1999
2464
  $schema: "http://json-schema.org/draft-07/schema#",
2000
- $id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
2001
- title: "Soustack Illustrated Profile Schema",
2002
- description: "Extends the base schema to guarantee at least one illustrative image.",
2465
+ $id: "http://soustack.org/schema/v0.3.0/profiles/schedulable",
2466
+ title: "Soustack Schedulable Profile Schema",
2467
+ description: "Extends the base schema to ensure every instruction is fully scheduled.",
2003
2468
  allOf: [
2004
- { $ref: "http://soustack.org/schema/v0.2.1" },
2469
+ { $ref: "http://soustack.org/schema/v0.3.0" },
2005
2470
  {
2006
- anyOf: [
2007
- { required: ["image"] },
2008
- {
2009
- properties: {
2010
- instructions: {
2011
- type: "array",
2012
- contains: {
2013
- anyOf: [
2014
- { $ref: "#/definitions/imageInstruction" },
2015
- { $ref: "#/definitions/instructionSubsectionWithImage" }
2016
- ]
2017
- }
2018
- }
2471
+ properties: {
2472
+ instructions: {
2473
+ type: "array",
2474
+ items: {
2475
+ anyOf: [
2476
+ { $ref: "#/definitions/schedulableInstruction" },
2477
+ { $ref: "#/definitions/schedulableInstructionSubsection" }
2478
+ ]
2019
2479
  }
2020
2480
  }
2021
- ]
2481
+ }
2022
2482
  }
2023
2483
  ],
2024
2484
  definitions: {
2025
- imageInstruction: {
2485
+ schedulableInstruction: {
2026
2486
  allOf: [
2027
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
2028
- { required: ["image"] }
2487
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
2488
+ { required: ["id", "timing"] }
2029
2489
  ]
2030
2490
  },
2031
- instructionSubsectionWithImage: {
2491
+ schedulableInstructionSubsection: {
2032
2492
  allOf: [
2033
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
2493
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
2034
2494
  {
2035
2495
  properties: {
2036
2496
  items: {
2037
2497
  type: "array",
2038
- contains: { $ref: "#/definitions/imageInstruction" }
2498
+ items: { $ref: "#/definitions/schedulableInstruction" }
2039
2499
  }
2040
2500
  }
2041
2501
  }
@@ -2044,76 +2504,451 @@ var illustrated_schema_default = {
2044
2504
  }
2045
2505
  };
2046
2506
 
2047
- // src/profiles/schedulable.schema.json
2048
- var schedulable_schema_default = {
2507
+ // src/schemas/recipe/stacks/attribution/1.schema.json
2508
+ var schema_default = {
2509
+ $schema: "http://json-schema.org/draft-07/schema#",
2510
+ $id: "https://soustack.org/schemas/recipe/stacks/attribution/1.schema.json",
2511
+ title: "Soustack Recipe Stack: attribution v1",
2512
+ description: "Schema for the attribution stack. Ensures namespace data is present when the stack is enabled and vice versa.",
2513
+ type: "object",
2514
+ properties: {
2515
+ stacks: {
2516
+ type: "object",
2517
+ additionalProperties: {
2518
+ type: "integer",
2519
+ minimum: 1
2520
+ }
2521
+ },
2522
+ attribution: {
2523
+ type: "object",
2524
+ properties: {
2525
+ url: { type: "string" },
2526
+ author: { type: "string" },
2527
+ datePublished: { type: "string" }
2528
+ },
2529
+ additionalProperties: false
2530
+ }
2531
+ },
2532
+ allOf: [
2533
+ {
2534
+ if: {
2535
+ properties: {
2536
+ stacks: {
2537
+ type: "object",
2538
+ properties: {
2539
+ attribution: { const: 1 }
2540
+ },
2541
+ required: ["attribution"]
2542
+ }
2543
+ }
2544
+ },
2545
+ then: {
2546
+ required: ["attribution"]
2547
+ }
2548
+ },
2549
+ {
2550
+ if: {
2551
+ required: ["attribution"]
2552
+ },
2553
+ then: {
2554
+ required: ["stacks"],
2555
+ properties: {
2556
+ stacks: {
2557
+ type: "object",
2558
+ properties: {
2559
+ attribution: { const: 1 }
2560
+ },
2561
+ required: ["attribution"]
2562
+ }
2563
+ }
2564
+ }
2565
+ }
2566
+ ],
2567
+ additionalProperties: true
2568
+ };
2569
+
2570
+ // src/schemas/recipe/stacks/media/1.schema.json
2571
+ var schema_default2 = {
2572
+ $schema: "http://json-schema.org/draft-07/schema#",
2573
+ $id: "https://soustack.org/schemas/recipe/stacks/media/1.schema.json",
2574
+ title: "Soustack Recipe Stack: media v1",
2575
+ description: "Schema for the media stack. Guards media blocks based on stack activation and ensures declarations accompany payloads.",
2576
+ type: "object",
2577
+ properties: {
2578
+ stacks: {
2579
+ type: "object",
2580
+ additionalProperties: {
2581
+ type: "integer",
2582
+ minimum: 1
2583
+ }
2584
+ },
2585
+ media: {
2586
+ type: "object",
2587
+ properties: {
2588
+ images: { type: "array", items: { type: "string" } },
2589
+ videos: { type: "array", items: { type: "string" } }
2590
+ },
2591
+ additionalProperties: false
2592
+ }
2593
+ },
2594
+ allOf: [
2595
+ {
2596
+ if: {
2597
+ properties: {
2598
+ stacks: {
2599
+ type: "object",
2600
+ properties: {
2601
+ media: { const: 1 }
2602
+ },
2603
+ required: ["media"]
2604
+ }
2605
+ }
2606
+ },
2607
+ then: {
2608
+ required: ["media"]
2609
+ }
2610
+ },
2611
+ {
2612
+ if: {
2613
+ required: ["media"]
2614
+ },
2615
+ then: {
2616
+ required: ["stacks"],
2617
+ properties: {
2618
+ stacks: {
2619
+ type: "object",
2620
+ properties: {
2621
+ media: { const: 1 }
2622
+ },
2623
+ required: ["media"]
2624
+ }
2625
+ }
2626
+ }
2627
+ }
2628
+ ],
2629
+ additionalProperties: true
2630
+ };
2631
+
2632
+ // src/schemas/recipe/stacks/nutrition/1.schema.json
2633
+ var schema_default3 = {
2634
+ $schema: "http://json-schema.org/draft-07/schema#",
2635
+ $id: "https://soustack.org/schemas/recipe/stacks/nutrition/1.schema.json",
2636
+ title: "Soustack Recipe Stack: nutrition v1",
2637
+ description: "Schema for the nutrition stack. Keeps nutrition data aligned with stack declarations and vice versa.",
2638
+ type: "object",
2639
+ properties: {
2640
+ stacks: {
2641
+ type: "object",
2642
+ additionalProperties: {
2643
+ type: "integer",
2644
+ minimum: 1
2645
+ }
2646
+ },
2647
+ nutrition: {
2648
+ type: "object",
2649
+ properties: {
2650
+ calories: { type: "number" },
2651
+ protein_g: { type: "number" }
2652
+ },
2653
+ additionalProperties: false
2654
+ }
2655
+ },
2656
+ allOf: [
2657
+ {
2658
+ if: {
2659
+ properties: {
2660
+ stacks: {
2661
+ type: "object",
2662
+ properties: {
2663
+ nutrition: { const: 1 }
2664
+ },
2665
+ required: ["nutrition"]
2666
+ }
2667
+ }
2668
+ },
2669
+ then: {
2670
+ required: ["nutrition"]
2671
+ }
2672
+ },
2673
+ {
2674
+ if: {
2675
+ required: ["nutrition"]
2676
+ },
2677
+ then: {
2678
+ required: ["stacks"],
2679
+ properties: {
2680
+ stacks: {
2681
+ type: "object",
2682
+ properties: {
2683
+ nutrition: { const: 1 }
2684
+ },
2685
+ required: ["nutrition"]
2686
+ }
2687
+ }
2688
+ }
2689
+ }
2690
+ ],
2691
+ additionalProperties: true
2692
+ };
2693
+
2694
+ // src/schemas/recipe/stacks/schedule/1.schema.json
2695
+ var schema_default4 = {
2696
+ $schema: "http://json-schema.org/draft-07/schema#",
2697
+ $id: "https://soustack.org/schemas/recipe/stacks/schedule/1.schema.json",
2698
+ title: "Soustack Recipe Stack: schedule v1",
2699
+ description: "Schema for the schedule stack. Enforces bidirectional stack gating and restricts usage to the core profile.",
2700
+ type: "object",
2701
+ properties: {
2702
+ profile: { type: "string" },
2703
+ stacks: {
2704
+ type: "object",
2705
+ additionalProperties: {
2706
+ type: "integer",
2707
+ minimum: 1
2708
+ }
2709
+ },
2710
+ schedule: {
2711
+ type: "object",
2712
+ properties: {
2713
+ tasks: { type: "array" }
2714
+ },
2715
+ additionalProperties: false
2716
+ }
2717
+ },
2718
+ allOf: [
2719
+ {
2720
+ if: {
2721
+ properties: {
2722
+ stacks: {
2723
+ type: "object",
2724
+ properties: {
2725
+ schedule: { const: 1 }
2726
+ },
2727
+ required: ["schedule"]
2728
+ }
2729
+ }
2730
+ },
2731
+ then: {
2732
+ required: ["schedule", "profile"],
2733
+ properties: {
2734
+ profile: { const: "core" }
2735
+ }
2736
+ }
2737
+ },
2738
+ {
2739
+ if: {
2740
+ required: ["schedule"]
2741
+ },
2742
+ then: {
2743
+ required: ["stacks", "profile"],
2744
+ properties: {
2745
+ stacks: {
2746
+ type: "object",
2747
+ properties: {
2748
+ schedule: { const: 1 }
2749
+ },
2750
+ required: ["schedule"]
2751
+ },
2752
+ profile: { const: "core" }
2753
+ }
2754
+ }
2755
+ }
2756
+ ],
2757
+ additionalProperties: true
2758
+ };
2759
+
2760
+ // src/schemas/recipe/stacks/taxonomy/1.schema.json
2761
+ var schema_default5 = {
2762
+ $schema: "http://json-schema.org/draft-07/schema#",
2763
+ $id: "https://soustack.org/schemas/recipe/stacks/taxonomy/1.schema.json",
2764
+ title: "Soustack Recipe Stack: taxonomy v1",
2765
+ description: "Schema for the taxonomy stack. Enforces keyword and categorization data when enabled and ensures stack declaration accompanies the namespace block.",
2766
+ type: "object",
2767
+ properties: {
2768
+ stacks: {
2769
+ type: "object",
2770
+ additionalProperties: {
2771
+ type: "integer",
2772
+ minimum: 1
2773
+ }
2774
+ },
2775
+ taxonomy: {
2776
+ type: "object",
2777
+ properties: {
2778
+ keywords: { type: "array", items: { type: "string" } },
2779
+ category: { type: "string" },
2780
+ cuisine: { type: "string" }
2781
+ },
2782
+ additionalProperties: false
2783
+ }
2784
+ },
2785
+ allOf: [
2786
+ {
2787
+ if: {
2788
+ properties: {
2789
+ stacks: {
2790
+ type: "object",
2791
+ properties: {
2792
+ taxonomy: { const: 1 }
2793
+ },
2794
+ required: ["taxonomy"]
2795
+ }
2796
+ }
2797
+ },
2798
+ then: {
2799
+ required: ["taxonomy"]
2800
+ }
2801
+ },
2802
+ {
2803
+ if: {
2804
+ required: ["taxonomy"]
2805
+ },
2806
+ then: {
2807
+ required: ["stacks"],
2808
+ properties: {
2809
+ stacks: {
2810
+ type: "object",
2811
+ properties: {
2812
+ taxonomy: { const: 1 }
2813
+ },
2814
+ required: ["taxonomy"]
2815
+ }
2816
+ }
2817
+ }
2818
+ }
2819
+ ],
2820
+ additionalProperties: true
2821
+ };
2822
+
2823
+ // src/schemas/recipe/stacks/times/1.schema.json
2824
+ var schema_default6 = {
2049
2825
  $schema: "http://json-schema.org/draft-07/schema#",
2050
- $id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
2051
- title: "Soustack Schedulable Profile Schema",
2052
- description: "Extends the base schema to ensure every instruction is fully scheduled.",
2826
+ $id: "https://soustack.org/schemas/recipe/stacks/times/1.schema.json",
2827
+ title: "Soustack Recipe Stack: times v1",
2828
+ description: "Schema for the times stack. Maintains alignment between stack declarations and timing payloads.",
2829
+ type: "object",
2830
+ properties: {
2831
+ stacks: {
2832
+ type: "object",
2833
+ additionalProperties: {
2834
+ type: "integer",
2835
+ minimum: 1
2836
+ }
2837
+ },
2838
+ times: {
2839
+ type: "object",
2840
+ properties: {
2841
+ prepMinutes: { type: "number" },
2842
+ cookMinutes: { type: "number" },
2843
+ totalMinutes: { type: "number" }
2844
+ },
2845
+ additionalProperties: false
2846
+ }
2847
+ },
2053
2848
  allOf: [
2054
- { $ref: "http://soustack.org/schema/v0.2.1" },
2055
2849
  {
2056
- properties: {
2057
- instructions: {
2058
- type: "array",
2059
- items: {
2060
- anyOf: [
2061
- { $ref: "#/definitions/schedulableInstruction" },
2062
- { $ref: "#/definitions/schedulableInstructionSubsection" }
2063
- ]
2850
+ if: {
2851
+ properties: {
2852
+ stacks: {
2853
+ type: "object",
2854
+ properties: {
2855
+ times: { const: 1 }
2856
+ },
2857
+ required: ["times"]
2064
2858
  }
2065
2859
  }
2860
+ },
2861
+ then: {
2862
+ required: ["times"]
2066
2863
  }
2067
- }
2068
- ],
2069
- definitions: {
2070
- schedulableInstruction: {
2071
- allOf: [
2072
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
2073
- { required: ["id", "timing"] }
2074
- ]
2075
2864
  },
2076
- schedulableInstructionSubsection: {
2077
- allOf: [
2078
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
2079
- {
2080
- properties: {
2081
- items: {
2082
- type: "array",
2083
- items: { $ref: "#/definitions/schedulableInstruction" }
2084
- }
2865
+ {
2866
+ if: {
2867
+ required: ["times"]
2868
+ },
2869
+ then: {
2870
+ required: ["stacks"],
2871
+ properties: {
2872
+ stacks: {
2873
+ type: "object",
2874
+ properties: {
2875
+ times: { const: 1 }
2876
+ },
2877
+ required: ["times"]
2085
2878
  }
2086
2879
  }
2087
- ]
2880
+ }
2088
2881
  }
2089
- }
2882
+ ],
2883
+ additionalProperties: true
2090
2884
  };
2091
2885
 
2092
2886
  // src/validator.ts
2093
- var profileSchemas = {
2094
- base: base_schema_default,
2095
- cookable: cookable_schema_default,
2096
- scalable: base_schema_default,
2097
- quantified: quantified_schema_default,
2098
- illustrated: illustrated_schema_default,
2099
- schedulable: schedulable_schema_default
2100
- };
2887
+ var LEGACY_ROOT_SCHEMA_ID = "http://soustack.org/schema/v0.3.0";
2888
+ var DEFAULT_ROOT_SCHEMA_ID = "https://soustack.spec/soustack.schema.json";
2889
+ var BASE_SCHEMA_ID = "http://soustack.org/schema/recipe/base.schema.json";
2890
+ var PROFILE_SCHEMA_PREFIX = "http://soustack.org/schema/recipe/profiles/";
2101
2891
  var validationContexts = /* @__PURE__ */ new Map();
2892
+ function loadAllSchemas(ajv) {
2893
+ const schemas = [
2894
+ soustack_schema_default,
2895
+ base_schema_default,
2896
+ minimal_schema_default,
2897
+ core_schema_default,
2898
+ base_schema_default2,
2899
+ cookable_schema_default,
2900
+ illustrated_schema_default,
2901
+ quantified_schema_default,
2902
+ scalable_schema_default,
2903
+ schedulable_schema_default,
2904
+ schema_default,
2905
+ schema_default2,
2906
+ schema_default3,
2907
+ schema_default4,
2908
+ schema_default5,
2909
+ schema_default6
2910
+ ];
2911
+ for (const schema of schemas) {
2912
+ if (schema && typeof schema === "object" && "$id" in schema) {
2913
+ const schemaWithId = schema;
2914
+ if (schemaWithId.$id) {
2915
+ ajv.addSchema(schemaWithId, schemaWithId.$id);
2916
+ }
2917
+ }
2918
+ }
2919
+ ajv.addSchema(
2920
+ {
2921
+ $id: DEFAULT_ROOT_SCHEMA_ID,
2922
+ allOf: [
2923
+ { $ref: LEGACY_ROOT_SCHEMA_ID },
2924
+ {
2925
+ type: "object",
2926
+ properties: {
2927
+ $schema: { const: DEFAULT_ROOT_SCHEMA_ID }
2928
+ }
2929
+ }
2930
+ ]
2931
+ },
2932
+ DEFAULT_ROOT_SCHEMA_ID
2933
+ );
2934
+ }
2102
2935
  function createContext(collectAllErrors) {
2103
- const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
2936
+ const ajv = new Ajv2020__default.default({
2937
+ strict: false,
2938
+ allErrors: collectAllErrors,
2939
+ validateSchema: false
2940
+ // Don't validate schemas themselves
2941
+ });
2104
2942
  addFormats__default.default(ajv);
2105
- const loadedIds = /* @__PURE__ */ new Set();
2106
- const addSchemaIfNew = (schema) => {
2107
- if (!schema) return;
2108
- const schemaId = schema?.$id;
2109
- if (schemaId && loadedIds.has(schemaId)) return;
2110
- ajv.addSchema(schema);
2111
- if (schemaId) loadedIds.add(schemaId);
2943
+ loadAllSchemas(ajv);
2944
+ const rootValidator = ajv.getSchema(DEFAULT_ROOT_SCHEMA_ID) || ajv.getSchema(LEGACY_ROOT_SCHEMA_ID);
2945
+ const baseValidator = ajv.getSchema(BASE_SCHEMA_ID);
2946
+ return {
2947
+ ajv,
2948
+ rootValidator: rootValidator || void 0,
2949
+ baseValidator: baseValidator || void 0,
2950
+ validators: /* @__PURE__ */ new Map()
2112
2951
  };
2113
- addSchemaIfNew(schema_default);
2114
- addSchemaIfNew(soustack_schema_default);
2115
- Object.values(profileSchemas).forEach(addSchemaIfNew);
2116
- return { ajv, validators: {} };
2117
2952
  }
2118
2953
  function getContext(collectAllErrors) {
2119
2954
  if (!validationContexts.has(collectAllErrors)) {
@@ -2127,68 +2962,6 @@ function cloneRecipe(recipe) {
2127
2962
  }
2128
2963
  return JSON.parse(JSON.stringify(recipe));
2129
2964
  }
2130
- function detectProfileFromSchema(schemaRef) {
2131
- if (!schemaRef) return void 0;
2132
- const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
2133
- if (match) {
2134
- const profile = match[1].toLowerCase();
2135
- if (profile in profileSchemas) return profile;
2136
- }
2137
- return void 0;
2138
- }
2139
- function getValidator(profile, context) {
2140
- if (!profileSchemas[profile]) {
2141
- throw new Error(`Unknown Soustack profile: ${profile}`);
2142
- }
2143
- if (!context.validators[profile]) {
2144
- context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
2145
- }
2146
- return context.validators[profile];
2147
- }
2148
- function normalizeRecipe(recipe) {
2149
- const normalized = cloneRecipe(recipe);
2150
- const warnings = [];
2151
- normalizeTime(normalized);
2152
- if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
2153
- normalized.recipeVersion = normalized.version;
2154
- warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
2155
- }
2156
- return { normalized, warnings };
2157
- }
2158
- function normalizeTime(recipe) {
2159
- const time = recipe?.time;
2160
- if (!time || typeof time !== "object" || Array.isArray(time)) return;
2161
- const structuredKeys = [
2162
- "prep",
2163
- "active",
2164
- "passive",
2165
- "total"
2166
- ];
2167
- structuredKeys.forEach((key) => {
2168
- const value = time[key];
2169
- if (typeof value === "number") return;
2170
- const parsed = parseDuration(value);
2171
- if (parsed !== null) {
2172
- time[key] = parsed;
2173
- }
2174
- });
2175
- }
2176
- var allowedTopLevelProps = /* @__PURE__ */ new Set([
2177
- ...Object.keys(soustack_schema_default?.properties ?? {}),
2178
- "metadata",
2179
- "$schema"
2180
- ]);
2181
- function detectUnknownTopLevelKeys(recipe) {
2182
- if (!recipe || typeof recipe !== "object") return [];
2183
- const disallowedKeys = Object.keys(recipe).filter(
2184
- (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
2185
- );
2186
- return disallowedKeys.map((key) => ({
2187
- path: `/${key}`,
2188
- keyword: "additionalProperties",
2189
- message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
2190
- }));
2191
- }
2192
2965
  function formatAjvError(error) {
2193
2966
  let path2 = error.instancePath || "/";
2194
2967
  if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
@@ -2201,112 +2974,227 @@ function formatAjvError(error) {
2201
2974
  message: error.message || "Validation error"
2202
2975
  };
2203
2976
  }
2204
- function runAjvValidation(data, profile, context, schemaRef) {
2205
- const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
2206
- const validateFn = validator ?? getValidator(profile, context);
2207
- const isValid = validateFn(data);
2208
- return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
2209
- }
2210
- function isInstruction(item) {
2211
- return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
2212
- }
2213
- function isInstructionSubsection(item) {
2214
- return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
2977
+ function isSoustackSchemaId(schemaId) {
2978
+ return schemaId.startsWith("http://soustack.org/schema") || schemaId.startsWith("https://soustack.org/schema") || schemaId.startsWith("https://soustack.spec/") || schemaId.startsWith("https://soustack.org/schemas/");
2979
+ }
2980
+ function inferStacksFromPayload(recipe) {
2981
+ const inferred = {};
2982
+ const payloadToStack = {
2983
+ attribution: "attribution",
2984
+ taxonomy: "taxonomy",
2985
+ media: "media",
2986
+ times: "times",
2987
+ nutrition: "nutrition",
2988
+ schedule: "schedule"
2989
+ };
2990
+ for (const [field, stackName] of Object.entries(payloadToStack)) {
2991
+ if (recipe && typeof recipe === "object" && field in recipe && recipe[field] !== void 0) {
2992
+ inferred[stackName] = 1;
2993
+ }
2994
+ }
2995
+ return inferred;
2996
+ }
2997
+ function getComposedValidator(profile, stacks, context) {
2998
+ const stackIdentifiers = Object.entries(stacks).map(([name, version]) => `${name}@${version}`).sort();
2999
+ const cacheKey = `${profile}::${stackIdentifiers.join(",")}`;
3000
+ const cached = context.validators.get(cacheKey);
3001
+ if (cached) return cached;
3002
+ const allOf = [{ $ref: BASE_SCHEMA_ID }];
3003
+ if (!context.ajv.getSchema(BASE_SCHEMA_ID)) {
3004
+ throw new Error(`Base schema not loaded: ${BASE_SCHEMA_ID}. Ensure schemas are loaded before creating validators.`);
3005
+ }
3006
+ const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
3007
+ if (!context.ajv.getSchema(profileSchemaId)) {
3008
+ throw new Error(`Profile schema not loaded: ${profileSchemaId}`);
3009
+ }
3010
+ allOf.push({ $ref: profileSchemaId });
3011
+ for (const [name, version] of Object.entries(stacks)) {
3012
+ if (typeof version === "number" && version >= 1) {
3013
+ const stackSchemaId = `https://soustack.org/schemas/recipe/stacks/${name}/${version}.schema.json`;
3014
+ if (!context.ajv.getSchema(stackSchemaId)) {
3015
+ throw new Error(`Stack schema not loaded: ${stackSchemaId}`);
3016
+ }
3017
+ allOf.push({ $ref: stackSchemaId });
3018
+ }
3019
+ }
3020
+ const composedSchema = {
3021
+ $id: `urn:soustack:composed:${cacheKey}`,
3022
+ allOf
3023
+ };
3024
+ const validateFn = context.ajv.compile(composedSchema);
3025
+ context.validators.set(cacheKey, validateFn);
3026
+ return validateFn;
2215
3027
  }
2216
- function checkInstructionGraph(recipe) {
2217
- const instructions = recipe?.instructions;
2218
- if (!Array.isArray(instructions)) return [];
2219
- const instructionIds = /* @__PURE__ */ new Set();
2220
- const dependencyRefs = [];
2221
- const collect = (items, basePath) => {
2222
- items.forEach((item, index) => {
2223
- const currentPath = `${basePath}/${index}`;
2224
- if (isInstructionSubsection(item) && Array.isArray(item.items)) {
2225
- collect(item.items, `${currentPath}/items`);
2226
- return;
3028
+ function validateRecipeSchemaNormalized(normalizedInput, inputHasStacks, collectAllErrors, schemaOverride) {
3029
+ const normalized = cloneRecipe(normalizedInput);
3030
+ const context = getContext(collectAllErrors);
3031
+ const schemaId = typeof schemaOverride === "string" ? schemaOverride : typeof normalized.$schema === "string" ? normalized.$schema : void 0;
3032
+ const hasSchemaOverride = typeof schemaOverride === "string";
3033
+ const isSoustackSchema = schemaId ? isSoustackSchemaId(schemaId) : false;
3034
+ if (schemaId && isSoustackSchema) {
3035
+ const schemaValidator = context.ajv.getSchema(schemaId);
3036
+ if (!schemaValidator) {
3037
+ return {
3038
+ ok: false,
3039
+ errors: [
3040
+ {
3041
+ path: "/$schema",
3042
+ message: `Unknown schema: ${schemaId}`
3043
+ }
3044
+ ]
3045
+ };
3046
+ }
3047
+ const schemaInput = cloneRecipe(normalized);
3048
+ if (hasSchemaOverride && "$schema" in schemaInput && schemaInput.$schema !== schemaId) {
3049
+ delete schemaInput.$schema;
3050
+ }
3051
+ const isLegacySchema = schemaId.startsWith(LEGACY_ROOT_SCHEMA_ID);
3052
+ const shouldRemoveStacks = (isLegacySchema || schemaId === DEFAULT_ROOT_SCHEMA_ID) && !inputHasStacks;
3053
+ if (isLegacySchema && "@type" in schemaInput) {
3054
+ delete schemaInput["@type"];
3055
+ }
3056
+ if (shouldRemoveStacks && "stacks" in schemaInput) {
3057
+ delete schemaInput.stacks;
3058
+ }
3059
+ const schemaValid = schemaValidator(schemaInput);
3060
+ const schemaErrors = schemaValidator.errors || [];
3061
+ return {
3062
+ ok: !!schemaValid,
3063
+ errors: schemaErrors.map(formatAjvError)
3064
+ };
3065
+ }
3066
+ const hasProfile = normalized.profile && typeof normalized.profile === "string";
3067
+ let declaredStacks = {};
3068
+ if (normalized.stacks && typeof normalized.stacks === "object" && !Array.isArray(normalized.stacks)) {
3069
+ for (const [name, version] of Object.entries(normalized.stacks)) {
3070
+ if (typeof version === "number" && version >= 1) {
3071
+ declaredStacks[name] = version;
2227
3072
  }
2228
- if (isInstruction(item)) {
2229
- const id = typeof item.id === "string" ? item.id : void 0;
2230
- if (id) instructionIds.add(id);
2231
- if (Array.isArray(item.dependsOn)) {
2232
- item.dependsOn.forEach((depId, depIndex) => {
2233
- if (typeof depId === "string") {
2234
- dependencyRefs.push({
2235
- fromId: id,
2236
- toId: depId,
2237
- path: `${currentPath}/dependsOn/${depIndex}`
2238
- });
2239
- }
2240
- });
3073
+ }
3074
+ }
3075
+ const inferredStacks = inferStacksFromPayload(normalized);
3076
+ const allStacks = { ...declaredStacks };
3077
+ for (const [name, version] of Object.entries(inferredStacks)) {
3078
+ if (!allStacks[name] || allStacks[name] < version) {
3079
+ allStacks[name] = version;
3080
+ }
3081
+ }
3082
+ let isValid;
3083
+ let errors = [];
3084
+ const profile = hasProfile ? normalized.profile.toLowerCase() : "core";
3085
+ const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
3086
+ if (!context.ajv.getSchema(profileSchemaId)) {
3087
+ return {
3088
+ ok: false,
3089
+ errors: [
3090
+ {
3091
+ path: "/profile",
3092
+ message: `Profile schema not loaded: ${profileSchemaId}`
2241
3093
  }
2242
- }
2243
- });
2244
- };
2245
- collect(instructions, "/instructions");
2246
- const errors = [];
2247
- dependencyRefs.forEach((ref) => {
2248
- if (!instructionIds.has(ref.toId)) {
2249
- errors.push({
2250
- path: ref.path,
2251
- message: `Instruction dependency references missing id '${ref.toId}'.`
2252
- });
3094
+ ]
3095
+ };
3096
+ }
3097
+ {
3098
+ const validationCopy = cloneRecipe(normalized);
3099
+ if (!validationCopy.stacks || typeof validationCopy.stacks !== "object" || Array.isArray(validationCopy.stacks)) {
3100
+ validationCopy.stacks = declaredStacks;
2253
3101
  }
2254
- });
2255
- const adjacency = /* @__PURE__ */ new Map();
2256
- dependencyRefs.forEach((ref) => {
2257
- if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
2258
- const list = adjacency.get(ref.fromId) ?? [];
2259
- list.push({ toId: ref.toId, path: ref.path });
2260
- adjacency.set(ref.fromId, list);
3102
+ if (!validationCopy.profile) {
3103
+ validationCopy.profile = profile;
2261
3104
  }
2262
- });
2263
- const visiting = /* @__PURE__ */ new Set();
2264
- const visited = /* @__PURE__ */ new Set();
2265
- const detectCycles = (nodeId) => {
2266
- if (visiting.has(nodeId)) return;
2267
- if (visited.has(nodeId)) return;
2268
- visiting.add(nodeId);
2269
- const neighbors = adjacency.get(nodeId) ?? [];
2270
- neighbors.forEach((edge) => {
2271
- if (visiting.has(edge.toId)) {
2272
- errors.push({
2273
- path: edge.path,
2274
- message: `Circular dependency detected involving instruction id '${edge.toId}'.`
2275
- });
2276
- return;
3105
+ const validator = getComposedValidator(profile, allStacks, context);
3106
+ isValid = validator(validationCopy);
3107
+ errors = validator.errors || [];
3108
+ if (isValid && context.rootValidator) {
3109
+ const rootCheckCopy = cloneRecipe(normalized);
3110
+ if ("@type" in rootCheckCopy) {
3111
+ delete rootCheckCopy["@type"];
2277
3112
  }
2278
- detectCycles(edge.toId);
2279
- });
2280
- visiting.delete(nodeId);
2281
- visited.add(nodeId);
3113
+ if ("stacks" in rootCheckCopy) {
3114
+ delete rootCheckCopy.stacks;
3115
+ }
3116
+ if ("profile" in rootCheckCopy) {
3117
+ delete rootCheckCopy.profile;
3118
+ }
3119
+ const stackPayloadFields = ["attribution", "taxonomy", "media", "times", "nutrition", "schedule"];
3120
+ for (const field of stackPayloadFields) {
3121
+ if (field in rootCheckCopy) {
3122
+ delete rootCheckCopy[field];
3123
+ }
3124
+ }
3125
+ const rootValid = context.rootValidator(rootCheckCopy);
3126
+ if (!rootValid && context.rootValidator.errors) {
3127
+ const unknownKeyErrors = context.rootValidator.errors.filter(
3128
+ (e) => e.keyword === "additionalProperties" && (e.instancePath === "" || e.instancePath === "/")
3129
+ );
3130
+ const schemaConstErrors = context.rootValidator.errors.filter(
3131
+ (e) => e.keyword === "const" && e.instancePath === "/$schema"
3132
+ );
3133
+ const relevantErrors = [...unknownKeyErrors, ...schemaConstErrors];
3134
+ if (relevantErrors.length > 0) {
3135
+ errors.push(...relevantErrors);
3136
+ isValid = false;
3137
+ }
3138
+ }
3139
+ }
3140
+ }
3141
+ return {
3142
+ ok: isValid,
3143
+ errors: errors.map(formatAjvError)
2282
3144
  };
2283
- instructionIds.forEach((id) => detectCycles(id));
2284
- return errors;
2285
3145
  }
2286
3146
  function validateRecipe(input, options = {}) {
2287
- const collectAllErrors = options.collectAllErrors ?? true;
2288
- const context = getContext(collectAllErrors);
2289
- const schemaRef = options.schema ?? (typeof input?.$schema === "string" ? input.$schema : void 0);
2290
- const profile = options.profile ?? detectProfileFromSchema(schemaRef) ?? "base";
2291
- const { normalized, warnings } = normalizeRecipe(input);
2292
- const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
2293
- const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
2294
- const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
2295
- const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
3147
+ const { recipe: normalized, warnings } = normalizeRecipe(input);
3148
+ if (options.profile) {
3149
+ normalized.profile = options.profile;
3150
+ }
3151
+ const inputHasStacks = !!input && typeof input === "object" && !Array.isArray(input) && "stacks" in input;
3152
+ const { ok: schemaOk, errors: schemaErrors } = validateRecipeSchemaNormalized(
3153
+ normalized,
3154
+ inputHasStacks,
3155
+ options.collectAllErrors ?? true,
3156
+ options.schema
3157
+ );
3158
+ const mode = options.mode ?? "full";
3159
+ let conformanceIssues = [];
3160
+ let conformanceOk = true;
3161
+ if (mode === "full") {
3162
+ if (schemaOk) {
3163
+ const conformanceResult = validateConformance(normalized);
3164
+ conformanceIssues = conformanceResult.issues;
3165
+ conformanceOk = conformanceResult.ok;
3166
+ } else {
3167
+ conformanceOk = false;
3168
+ }
3169
+ }
3170
+ const ok = schemaOk && (mode === "schema" ? true : conformanceOk);
3171
+ const normalizedRecipe = ok || options.includeNormalized ? normalized : void 0;
2296
3172
  return {
2297
- valid: errors.length === 0,
2298
- errors,
3173
+ ok,
3174
+ schemaErrors,
3175
+ conformanceIssues,
2299
3176
  warnings,
2300
- normalized: errors.length === 0 ? normalized : void 0
3177
+ normalizedRecipe
2301
3178
  };
2302
3179
  }
2303
3180
 
2304
3181
  // bin/cli.ts
2305
- var supportedProfiles = ["base", "cookable", "scalable", "quantified", "illustrated", "schedulable"];
3182
+ var supportedProfiles = [
3183
+ "base",
3184
+ "equipped",
3185
+ "illustrated",
3186
+ "lite",
3187
+ "prepped",
3188
+ "scalable",
3189
+ "timed"
3190
+ ];
2306
3191
  async function runCli(argv) {
2307
3192
  const [command, ...args] = argv;
2308
3193
  try {
2309
3194
  switch (command) {
3195
+ case "check":
3196
+ await handleCheck(args);
3197
+ return;
2310
3198
  case "validate":
2311
3199
  await handleValidate(args);
2312
3200
  return;
@@ -2336,23 +3224,50 @@ async function runCli(argv) {
2336
3224
  }
2337
3225
  function printUsage() {
2338
3226
  console.log("Usage:");
2339
- console.log(" soustack validate <fileOrGlob> [--profile <name>] [--strict] [--json]");
3227
+ console.log(" soustack check <file> --json");
3228
+ console.log(
3229
+ " soustack validate <fileOrGlob> [--profile <name>] [--force-profile] [--schema-only] [--strict] [--json]"
3230
+ );
2340
3231
  console.log(" soustack convert --from <schemaorg|soustack> --to <schemaorg|soustack> <input> [-o <output>]");
2341
3232
  console.log(" soustack import --url <url> [-o <soustack.json>]");
2342
- console.log(" soustack test [--profile <name>] [--strict] [--json]");
3233
+ console.log(" soustack test [--profile <name>] [--force-profile] [--schema-only] [--strict] [--json]");
2343
3234
  console.log(" soustack scale <soustack.json> <multiplier>");
2344
3235
  console.log(" soustack scrape <url> -o <soustack.json>");
3236
+ console.log(`
3237
+ Profiles: ${supportedProfiles.join(", ")}`);
3238
+ }
3239
+ async function handleCheck(args) {
3240
+ const { target, json } = parseCheckArgs(args);
3241
+ if (!target) throw new Error("Path to Soustack recipe JSON is required");
3242
+ if (!json) throw new Error("Check usage: check <file> --json");
3243
+ try {
3244
+ const input = readJsonFile(target);
3245
+ const result = validateRecipe(input, { mode: "full", includeNormalized: true });
3246
+ const report = buildConformanceReport(result);
3247
+ console.log(JSON.stringify(report, null, 2));
3248
+ if (!report.ok) process.exitCode = 1;
3249
+ } catch (error) {
3250
+ const report = buildConformanceReport({
3251
+ ok: false,
3252
+ warnings: [],
3253
+ schemaErrors: [{ path: "/", message: error?.message || "Validation failed" }],
3254
+ conformanceIssues: [],
3255
+ normalizedRecipe: void 0
3256
+ });
3257
+ console.log(JSON.stringify(report, null, 2));
3258
+ process.exitCode = 1;
3259
+ }
2345
3260
  }
2346
3261
  async function handleValidate(args) {
2347
- const { target, profile, strict, json } = parseValidateArgs(args);
3262
+ const { target, profile, forceProfile, strict, json, mode } = parseValidateArgs(args);
2348
3263
  if (!target) throw new Error("Path or glob to Soustack recipe JSON is required");
2349
3264
  const files = expandTargets(target);
2350
3265
  if (files.length === 0) throw new Error(`No files matched pattern: ${target}`);
2351
- const results = files.map((file) => validateFile(file, profile));
2352
- reportValidation(results, { strict, json });
3266
+ const results = files.map((file) => validateFile(file, profile, mode, forceProfile));
3267
+ reportValidation(results, { strict, json});
2353
3268
  }
2354
3269
  async function handleTest(args) {
2355
- const { profile, strict, json } = parseValidationFlags(args);
3270
+ const { profile, forceProfile, strict, json, mode } = parseValidationFlags(args);
2356
3271
  const cwd = process.cwd();
2357
3272
  const files = glob.globSync("**/*.soustack.json", {
2358
3273
  cwd,
@@ -2364,7 +3279,7 @@ async function handleTest(args) {
2364
3279
  console.log("No *.soustack.json files found in the current repository.");
2365
3280
  return;
2366
3281
  }
2367
- const results = files.map((file) => validateFile(file, profile));
3282
+ const results = files.map((file) => validateFile(file, profile, mode, forceProfile));
2368
3283
  reportValidation(results, { strict, json, context: "test" });
2369
3284
  }
2370
3285
  async function handleConvert(args) {
@@ -2416,8 +3331,10 @@ async function handleScrape(args) {
2416
3331
  }
2417
3332
  function parseValidateArgs(args) {
2418
3333
  let profile;
3334
+ let forceProfile = false;
2419
3335
  let strict = false;
2420
3336
  let json = false;
3337
+ let mode = "full";
2421
3338
  let target;
2422
3339
  for (let i = 0; i < args.length; i++) {
2423
3340
  const arg = args[i];
@@ -2426,6 +3343,12 @@ function parseValidateArgs(args) {
2426
3343
  profile = normalizeProfile(args[i + 1]);
2427
3344
  i++;
2428
3345
  break;
3346
+ case "--force-profile":
3347
+ forceProfile = true;
3348
+ break;
3349
+ case "--schema-only":
3350
+ mode = "schema";
3351
+ break;
2429
3352
  case "--strict":
2430
3353
  strict = true;
2431
3354
  break;
@@ -2439,11 +3362,26 @@ function parseValidateArgs(args) {
2439
3362
  break;
2440
3363
  }
2441
3364
  }
2442
- return { profile, strict, json, target };
3365
+ return { profile, forceProfile, strict, json, mode, target };
3366
+ }
3367
+ function parseCheckArgs(args) {
3368
+ let json = false;
3369
+ let target;
3370
+ for (let i = 0; i < args.length; i++) {
3371
+ const arg = args[i];
3372
+ if (arg === "--json") {
3373
+ json = true;
3374
+ continue;
3375
+ }
3376
+ if (!arg.startsWith("--") && !target) {
3377
+ target = arg;
3378
+ }
3379
+ }
3380
+ return { target, json };
2443
3381
  }
2444
3382
  function parseValidationFlags(args) {
2445
- const { profile, strict, json } = parseValidateArgs(args);
2446
- return { profile, strict, json };
3383
+ const { profile, forceProfile, strict, json, mode } = parseValidateArgs(args);
3384
+ return { profile, forceProfile, strict, json, mode };
2447
3385
  }
2448
3386
  function normalizeProfile(value) {
2449
3387
  if (!value) return void 0;
@@ -2451,7 +3389,7 @@ function normalizeProfile(value) {
2451
3389
  if (supportedProfiles.includes(normalized)) {
2452
3390
  return normalized;
2453
3391
  }
2454
- throw new Error(`Unknown Soustack profile: ${value}`);
3392
+ throw new Error(`Unknown Soustack profile: ${value}. Supported profiles: ${supportedProfiles.join(", ")}`);
2455
3393
  }
2456
3394
  function parseConvertArgs(args) {
2457
3395
  let from;
@@ -2517,16 +3455,64 @@ function expandTargets(target) {
2517
3455
  const unique = Array.from(new Set(matches.map((match) => path__namespace.resolve(match))));
2518
3456
  return unique;
2519
3457
  }
2520
- function validateFile(file, profile) {
2521
- const recipe = readJsonFile(file);
2522
- const result = validateRecipe(recipe, profile ? { profile } : {});
2523
- return {
2524
- file,
2525
- profile,
2526
- valid: result.valid,
2527
- warnings: result.warnings,
2528
- errors: result.errors
2529
- };
3458
+ function validateFile(file, profile, mode = "full", forceProfile = false) {
3459
+ try {
3460
+ const recipe = readJsonFile(file);
3461
+ const { recipe: validationRecipe, mismatchError } = resolveProfileForValidation(recipe, profile, forceProfile);
3462
+ if (mismatchError) {
3463
+ return {
3464
+ file,
3465
+ profile,
3466
+ ok: false,
3467
+ warnings: [],
3468
+ schemaErrors: [mismatchError],
3469
+ conformanceIssues: []
3470
+ };
3471
+ }
3472
+ const result = validateRecipe(validationRecipe, profile ? { profile, mode } : { mode });
3473
+ return {
3474
+ file,
3475
+ profile,
3476
+ ok: result.ok,
3477
+ warnings: result.warnings,
3478
+ schemaErrors: result.schemaErrors,
3479
+ conformanceIssues: result.conformanceIssues
3480
+ };
3481
+ } catch (error) {
3482
+ return {
3483
+ file,
3484
+ profile,
3485
+ ok: false,
3486
+ warnings: [],
3487
+ schemaErrors: [{ path: "/", message: error?.message || "Validation failed", keyword: "error" }],
3488
+ conformanceIssues: []
3489
+ };
3490
+ }
3491
+ }
3492
+ function resolveProfileForValidation(recipe, profile, forceProfile = false) {
3493
+ if (!profile) return { recipe };
3494
+ if (!recipe || typeof recipe !== "object" || Array.isArray(recipe)) {
3495
+ return { recipe };
3496
+ }
3497
+ const recipeProfileRaw = recipe.profile;
3498
+ const recipeProfile = typeof recipeProfileRaw === "string" ? recipeProfileRaw.toLowerCase() : void 0;
3499
+ if (!recipeProfile) {
3500
+ return { recipe: { ...recipe, profile } };
3501
+ }
3502
+ if (recipeProfile !== profile) {
3503
+ if (!forceProfile) {
3504
+ return {
3505
+ recipe,
3506
+ mismatchError: {
3507
+ path: "/profile",
3508
+ keyword: "profile",
3509
+ message: `Recipe profile "${recipeProfile}" does not match --profile "${profile}". Use --force-profile to override.`
3510
+ }
3511
+ };
3512
+ }
3513
+ return { recipe: { ...recipe, profile } };
3514
+ }
3515
+ return { recipe };
2530
3516
  }
2531
3517
  function reportValidation(results, options) {
2532
3518
  const summary = {
@@ -2542,9 +3528,10 @@ function reportValidation(results, options) {
2542
3528
  return {
2543
3529
  file: path__namespace.relative(process.cwd(), result.file),
2544
3530
  profile: result.profile,
2545
- valid: result.valid,
3531
+ ok: result.ok,
2546
3532
  warnings: result.warnings,
2547
- errors: result.errors,
3533
+ schemaErrors: result.schemaErrors,
3534
+ conformanceIssues: result.conformanceIssues,
2548
3535
  passed
2549
3536
  };
2550
3537
  });
@@ -2554,14 +3541,22 @@ function reportValidation(results, options) {
2554
3541
  serializable.forEach((entry) => {
2555
3542
  const prefix = entry.passed ? "\u2705" : "\u274C";
2556
3543
  console.log(`${prefix} ${entry.file}`);
2557
- if (!entry.passed && entry.errors.length) {
2558
- entry.errors.forEach((error) => {
3544
+ if (!entry.passed && entry.schemaErrors.length) {
3545
+ console.log(" Schema errors:");
3546
+ entry.schemaErrors.forEach((error) => {
2559
3547
  console.log(` \u2022 [${error.path}] ${error.message}`);
2560
3548
  });
2561
3549
  }
3550
+ if (!entry.passed && entry.conformanceIssues.length) {
3551
+ console.log(" Conformance issues:");
3552
+ entry.conformanceIssues.forEach((issue) => {
3553
+ console.log(` \u2022 [${issue.path}] ${issue.message} (${issue.code})`);
3554
+ });
3555
+ }
2562
3556
  if (!entry.passed && options.strict && entry.warnings.length) {
3557
+ console.log(" Warnings:");
2563
3558
  entry.warnings.forEach((warning) => {
2564
- console.log(` \u2022 [${warning.path}] ${warning.message} (warning)`);
3559
+ console.log(` \u2022 ${warning} (warning)`);
2565
3560
  });
2566
3561
  }
2567
3562
  });
@@ -2573,7 +3568,7 @@ function reportValidation(results, options) {
2573
3568
  }
2574
3569
  }
2575
3570
  function isEffectivelyValid(result, strict) {
2576
- return result.valid && (!strict || result.warnings.length === 0);
3571
+ return result.ok && (!strict || result.warnings.length === 0);
2577
3572
  }
2578
3573
  function readJsonFile(relativePath) {
2579
3574
  const absolutePath = path__namespace.resolve(relativePath);
@@ -2605,6 +3600,52 @@ function writeOutput(data, outputPath) {
2605
3600
  const absolutePath = path__namespace.resolve(outputPath);
2606
3601
  fs__namespace.writeFileSync(absolutePath, serialized, "utf-8");
2607
3602
  }
3603
+ function buildConformanceReport(result) {
3604
+ const recipe = result.normalizedRecipe;
3605
+ const level = typeof recipe?.level === "string" ? recipe.level : null;
3606
+ const stacks = normalizeStacksForReport(recipe?.stacks);
3607
+ const schemaErrors = sortSchemaErrors(result.schemaErrors).map((error) => ({
3608
+ path: error.path,
3609
+ keyword: error.keyword ?? null,
3610
+ message: error.message
3611
+ }));
3612
+ const conformanceIssues = sortConformanceIssues(result.conformanceIssues).map((issue) => ({
3613
+ code: issue.code,
3614
+ path: issue.path,
3615
+ severity: issue.severity === "warning" ? "warn" : "error",
3616
+ message: issue.message
3617
+ }));
3618
+ return {
3619
+ ok: result.ok,
3620
+ level,
3621
+ stacks,
3622
+ warnings: result.warnings,
3623
+ schemaErrors,
3624
+ conformanceIssues
3625
+ };
3626
+ }
3627
+ function normalizeStacksForReport(stacks) {
3628
+ if (!stacks || typeof stacks !== "object" || Array.isArray(stacks)) return {};
3629
+ const entries = Object.entries(stacks).filter(([, value]) => typeof value === "number");
3630
+ entries.sort(([a], [b]) => a.localeCompare(b));
3631
+ return Object.fromEntries(entries);
3632
+ }
3633
+ function sortSchemaErrors(errors) {
3634
+ return [...errors].sort((left, right) => {
3635
+ const pathCompare = left.path.localeCompare(right.path);
3636
+ if (pathCompare !== 0) return pathCompare;
3637
+ const leftKeyword = left.keyword ?? "";
3638
+ const rightKeyword = right.keyword ?? "";
3639
+ return leftKeyword.localeCompare(rightKeyword);
3640
+ });
3641
+ }
3642
+ function sortConformanceIssues(issues) {
3643
+ return [...issues].sort((left, right) => {
3644
+ const pathCompare = left.path.localeCompare(right.path);
3645
+ if (pathCompare !== 0) return pathCompare;
3646
+ return left.code.localeCompare(right.code);
3647
+ });
3648
+ }
2608
3649
  if (__require.main === module) {
2609
3650
  runCli(process.argv.slice(2)).catch((error) => {
2610
3651
  console.error(`\u274C ${error?.message ?? error}`);