soustack 0.3.0 → 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);
@@ -370,16 +456,16 @@ function fromSchemaOrg(input) {
370
456
  const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
371
457
  const media = convertMedia(recipeNode.image, recipeNode.video);
372
458
  const times = convertTimes(time);
373
- const modules = [];
374
- if (attribution) modules.push("attribution@1");
375
- if (taxonomy) modules.push("taxonomy@1");
376
- if (media) modules.push("media@1");
377
- if (nutrition) modules.push("nutrition@1");
378
- if (times) modules.push("times@1");
379
- return {
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 = {
380
466
  "@type": "Recipe",
381
467
  profile: "minimal",
382
- modules: modules.sort(),
468
+ stacks,
383
469
  name: recipeNode.name.trim(),
384
470
  description: recipeNode.description?.trim() || void 0,
385
471
  image: normalizeImage(recipeNode.image),
@@ -398,6 +484,8 @@ function fromSchemaOrg(input) {
398
484
  ...media ? { media } : {},
399
485
  ...times ? { times } : {}
400
486
  };
487
+ const { recipe } = normalizeRecipe(rawRecipe);
488
+ return recipe;
401
489
  }
402
490
  function extractRecipeNode(input) {
403
491
  if (!input) return null;
@@ -694,17 +782,15 @@ function convertNutrition(nutrition) {
694
782
  return hasData ? result : void 0;
695
783
  }
696
784
 
697
- // src/schemas/registry/modules.json
698
- var modules_default = {
699
- modules: [
785
+ // src/schemas/registry/stacks.json
786
+ var stacks_default = {
787
+ stacks: [
700
788
  {
701
789
  id: "attribution",
702
- versions: [
703
- 1
704
- ],
790
+ versions: [1],
705
791
  latest: 1,
706
- namespace: "https://soustack.org/schemas/recipe/modules/attribution",
707
- schema: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
792
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/attribution",
793
+ schema: "http://soustack.org/schema/v0.3.0/stacks/attribution",
708
794
  schemaOrgMappable: true,
709
795
  schemaOrgConfidence: "medium",
710
796
  minProfile: "minimal",
@@ -712,12 +798,10 @@ var modules_default = {
712
798
  },
713
799
  {
714
800
  id: "taxonomy",
715
- versions: [
716
- 1
717
- ],
801
+ versions: [1],
718
802
  latest: 1,
719
- namespace: "https://soustack.org/schemas/recipe/modules/taxonomy",
720
- schema: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
803
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
804
+ schema: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
721
805
  schemaOrgMappable: true,
722
806
  schemaOrgConfidence: "high",
723
807
  minProfile: "minimal",
@@ -725,12 +809,10 @@ var modules_default = {
725
809
  },
726
810
  {
727
811
  id: "media",
728
- versions: [
729
- 1
730
- ],
812
+ versions: [1],
731
813
  latest: 1,
732
- namespace: "https://soustack.org/schemas/recipe/modules/media",
733
- schema: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
814
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/media",
815
+ schema: "http://soustack.org/schema/v0.3.0/stacks/media",
734
816
  schemaOrgMappable: true,
735
817
  schemaOrgConfidence: "medium",
736
818
  minProfile: "minimal",
@@ -738,12 +820,10 @@ var modules_default = {
738
820
  },
739
821
  {
740
822
  id: "nutrition",
741
- versions: [
742
- 1
743
- ],
823
+ versions: [1],
744
824
  latest: 1,
745
- namespace: "https://soustack.org/schemas/recipe/modules/nutrition",
746
- schema: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
825
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
826
+ schema: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
747
827
  schemaOrgMappable: false,
748
828
  schemaOrgConfidence: "low",
749
829
  minProfile: "minimal",
@@ -751,12 +831,10 @@ var modules_default = {
751
831
  },
752
832
  {
753
833
  id: "times",
754
- versions: [
755
- 1
756
- ],
834
+ versions: [1],
757
835
  latest: 1,
758
- namespace: "https://soustack.org/schemas/recipe/modules/times",
759
- schema: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
836
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/times",
837
+ schema: "http://soustack.org/schema/v0.3.0/stacks/times",
760
838
  schemaOrgMappable: true,
761
839
  schemaOrgConfidence: "medium",
762
840
  minProfile: "minimal",
@@ -764,12 +842,10 @@ var modules_default = {
764
842
  },
765
843
  {
766
844
  id: "schedule",
767
- versions: [
768
- 1
769
- ],
845
+ versions: [1],
770
846
  latest: 1,
771
- namespace: "https://soustack.org/schemas/recipe/modules/schedule",
772
- schema: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
847
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/schedule",
848
+ schema: "http://soustack.org/schema/v0.3.0/stacks/schedule",
773
849
  schemaOrgMappable: false,
774
850
  schemaOrgConfidence: "low",
775
851
  minProfile: "core",
@@ -995,23 +1071,30 @@ function cleanOutput(obj) {
995
1071
  Object.entries(obj).filter(([, value]) => value !== void 0)
996
1072
  );
997
1073
  }
998
- function getSchemaOrgMappableModules(modules = []) {
999
- const mappableModules = modules_default.modules.filter((m) => m.schemaOrgMappable).map((m) => `${m.id}@${m.latest}`);
1000
- return modules.filter((moduleId) => mappableModules.includes(moduleId));
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;
1001
1084
  }
1002
1085
  function toSchemaOrg(recipe) {
1003
1086
  const base = convertBasicMetadata(recipe);
1004
1087
  const ingredients = convertIngredients2(recipe.ingredients);
1005
1088
  const instructions = convertInstructions2(recipe.instructions);
1006
- const recipeModules = Array.isArray(recipe.modules) ? recipe.modules : [];
1007
- const mappableModules = getSchemaOrgMappableModules(recipeModules);
1008
- const hasMappableNutrition = mappableModules.includes("nutrition@1");
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");
1009
1092
  const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
1010
- const hasMappableTimes = mappableModules.includes("times@1");
1093
+ const hasMappableTimes = mappableStacks.has("times@1");
1011
1094
  const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
1012
- const hasMappableAttribution = mappableModules.includes("attribution@1");
1095
+ const hasMappableAttribution = mappableStacks.has("attribution@1");
1013
1096
  const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
1014
- const hasMappableTaxonomy = mappableModules.includes("taxonomy@1");
1097
+ const hasMappableTaxonomy = mappableStacks.has("taxonomy@1");
1015
1098
  const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
1016
1099
  return cleanOutput({
1017
1100
  ...base,
@@ -1092,13 +1175,16 @@ async function fetchPage(url, options = {}) {
1092
1175
  const response = await resolvedFetch(url, requestInit);
1093
1176
  clearTimeout(timeoutId);
1094
1177
  if (response && typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1095
- try {
1096
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1097
- if (globalFetch) {
1098
- 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(() => {
1099
- });
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 {
1100
1187
  }
1101
- } catch {
1102
1188
  }
1103
1189
  }
1104
1190
  if (!response.ok) {
@@ -1110,13 +1196,16 @@ async function fetchPage(url, options = {}) {
1110
1196
  }
1111
1197
  const html = await response.text();
1112
1198
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1113
- try {
1114
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1115
- if (globalFetch) {
1116
- 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(() => {
1117
- });
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 {
1118
1208
  }
1119
- } catch {
1120
1209
  }
1121
1210
  }
1122
1211
  return html;
@@ -1146,8 +1235,6 @@ function isRecipeNode(value) {
1146
1235
  return false;
1147
1236
  }
1148
1237
  const type = value["@type"];
1149
- 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(() => {
1150
- });
1151
1238
  if (typeof type === "string") {
1152
1239
  return RECIPE_TYPES.has(type.toLowerCase());
1153
1240
  }
@@ -1175,20 +1262,14 @@ function normalizeText(value) {
1175
1262
  function extractJsonLd(html) {
1176
1263
  const $ = cheerio.load(html);
1177
1264
  const scripts = $('script[type="application/ld+json"]');
1178
- 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(() => {
1179
- });
1180
1265
  const candidates = [];
1181
1266
  scripts.each((_, element) => {
1182
1267
  const content = $(element).html();
1183
1268
  if (!content) return;
1184
1269
  const parsed = safeJsonParse(content);
1185
1270
  if (!parsed) return;
1186
- 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(() => {
1187
- });
1188
1271
  collectCandidates(parsed, candidates);
1189
1272
  });
1190
- 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(() => {
1191
- });
1192
1273
  return candidates[0] ?? null;
1193
1274
  }
1194
1275
  function collectCandidates(payload, bucket) {
@@ -1370,13 +1451,16 @@ function extractRecipe(html) {
1370
1451
  }
1371
1452
  const jsonLdRecipe = extractJsonLd(html);
1372
1453
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1373
- try {
1374
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1375
- if (globalFetch) {
1376
- 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(() => {
1377
- });
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 {
1378
1463
  }
1379
- } catch {
1380
1464
  }
1381
1465
  }
1382
1466
  if (jsonLdRecipe) {
@@ -1384,13 +1468,16 @@ function extractRecipe(html) {
1384
1468
  }
1385
1469
  const microdataRecipe = extractMicrodata(html);
1386
1470
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1387
- try {
1388
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1389
- if (globalFetch) {
1390
- 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(() => {
1391
- });
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 {
1392
1480
  }
1393
- } catch {
1394
1481
  }
1395
1482
  }
1396
1483
  if (microdataRecipe) {
@@ -1402,35 +1489,44 @@ function extractRecipe(html) {
1402
1489
  // src/scraper/index.ts
1403
1490
  async function scrapeRecipe(url, options = {}) {
1404
1491
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1405
- try {
1406
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1407
- if (globalFetch) {
1408
- 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(() => {
1409
- });
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 {
1410
1501
  }
1411
- } catch {
1412
1502
  }
1413
1503
  }
1414
1504
  const html = await fetchPage(url, options);
1415
1505
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1416
- try {
1417
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1418
- if (globalFetch) {
1419
- 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(() => {
1420
- });
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 {
1421
1515
  }
1422
- } catch {
1423
1516
  }
1424
1517
  }
1425
1518
  const { recipe } = extractRecipe(html);
1426
1519
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1427
- try {
1428
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1429
- if (globalFetch) {
1430
- 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(() => {
1431
- });
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 {
1432
1529
  }
1433
- } catch {
1434
1530
  }
1435
1531
  }
1436
1532
  if (!recipe) {
@@ -1438,13 +1534,16 @@ async function scrapeRecipe(url, options = {}) {
1438
1534
  }
1439
1535
  const soustackRecipe = fromSchemaOrg(recipe);
1440
1536
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1441
- try {
1442
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1443
- if (globalFetch) {
1444
- 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(() => {
1445
- });
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 {
1446
1546
  }
1447
- } catch {
1448
1547
  }
1449
1548
  }
1450
1549
  if (!soustackRecipe) {
@@ -1453,72 +1552,625 @@ async function scrapeRecipe(url, options = {}) {
1453
1552
  return soustackRecipe;
1454
1553
  }
1455
1554
 
1456
- // src/schemas/recipe/base.schema.json
1457
- var base_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 = {
1458
1779
  $schema: "http://json-schema.org/draft-07/schema#",
1459
- $id: "http://soustack.org/schema/recipe/base.schema.json",
1460
- title: "Soustack Recipe Base Schema",
1461
- description: "Base document shape for Soustack recipe documents. Profiles and modules build on this baseline.",
1780
+ $id: "http://soustack.org/schema/v0.3.0",
1781
+ title: "Soustack Recipe Schema v0.3.0",
1782
+ description: "A portable, scalable, interoperable recipe format.",
1462
1783
  type: "object",
1463
- additionalProperties: true,
1784
+ required: ["name", "ingredients", "instructions"],
1785
+ additionalProperties: false,
1786
+ patternProperties: {
1787
+ "^x-": {}
1788
+ },
1464
1789
  properties: {
1465
- "@type": {
1466
- const: "Recipe",
1467
- description: "Document marker for Soustack recipes"
1790
+ $schema: {
1791
+ type: "string",
1792
+ format: "uri",
1793
+ description: "Optional schema hint for tooling compatibility"
1468
1794
  },
1469
- profile: {
1795
+ id: {
1470
1796
  type: "string",
1471
- description: "Profile identifier applied to this recipe"
1797
+ description: "Unique identifier (slug or UUID)"
1798
+ },
1799
+ name: {
1800
+ type: "string",
1801
+ description: "The title of the recipe"
1802
+ },
1803
+ title: {
1804
+ type: "string",
1805
+ description: "Optional display title; alias for name"
1806
+ },
1807
+ version: {
1808
+ type: "string",
1809
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
1810
+ description: "DEPRECATED: use recipeVersion for authoring revisions"
1472
1811
  },
1473
- modules: {
1812
+ recipeVersion: {
1813
+ type: "string",
1814
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
1815
+ description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
1816
+ },
1817
+ description: {
1818
+ type: "string"
1819
+ },
1820
+ category: {
1821
+ type: "string",
1822
+ examples: ["Main Course", "Dessert"]
1823
+ },
1824
+ tags: {
1474
1825
  type: "array",
1475
- description: "List of module identifiers applied to this recipe",
1476
- items: {
1477
- type: "string"
1478
- }
1826
+ items: { type: "string" }
1479
1827
  },
1480
- name: {
1828
+ image: {
1829
+ description: "Recipe-level hero image(s)",
1830
+ anyOf: [
1831
+ {
1832
+ type: "string",
1833
+ format: "uri"
1834
+ },
1835
+ {
1836
+ type: "array",
1837
+ minItems: 1,
1838
+ items: {
1839
+ type: "string",
1840
+ format: "uri"
1841
+ }
1842
+ }
1843
+ ]
1844
+ },
1845
+ dateAdded: {
1481
1846
  type: "string",
1482
- description: "Human-readable recipe name"
1847
+ format: "date-time"
1848
+ },
1849
+ metadata: {
1850
+ type: "object",
1851
+ additionalProperties: true,
1852
+ description: "Free-form vendor metadata"
1853
+ },
1854
+ source: {
1855
+ type: "object",
1856
+ properties: {
1857
+ author: { type: "string" },
1858
+ url: { type: "string", format: "uri" },
1859
+ name: { type: "string" },
1860
+ adapted: { type: "boolean" }
1861
+ }
1862
+ },
1863
+ yield: {
1864
+ $ref: "#/definitions/yield"
1865
+ },
1866
+ time: {
1867
+ $ref: "#/definitions/time"
1868
+ },
1869
+ equipment: {
1870
+ type: "array",
1871
+ items: { $ref: "#/definitions/equipment" }
1483
1872
  },
1484
1873
  ingredients: {
1485
1874
  type: "array",
1486
- description: "Ingredients payload; content is validated by profiles/modules"
1875
+ items: {
1876
+ anyOf: [
1877
+ { type: "string" },
1878
+ { $ref: "#/definitions/ingredient" },
1879
+ { $ref: "#/definitions/ingredientSubsection" }
1880
+ ]
1881
+ }
1487
1882
  },
1488
1883
  instructions: {
1489
1884
  type: "array",
1490
- description: "Instruction payload; content is validated by profiles/modules"
1885
+ items: {
1886
+ anyOf: [
1887
+ { type: "string" },
1888
+ { $ref: "#/definitions/instruction" },
1889
+ { $ref: "#/definitions/instructionSubsection" }
1890
+ ]
1891
+ }
1892
+ },
1893
+ storage: {
1894
+ $ref: "#/definitions/storage"
1895
+ },
1896
+ substitutions: {
1897
+ type: "array",
1898
+ items: { $ref: "#/definitions/substitution" }
1491
1899
  }
1492
1900
  },
1493
- required: ["@type"]
1494
- };
1495
-
1496
- // src/schemas/recipe/profiles/core.schema.json
1497
- var core_schema_default = {
1498
- $schema: "http://json-schema.org/draft-07/schema#",
1499
- $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
1500
- title: "Soustack Recipe Core Profile",
1501
- description: "Core profile that builds on the minimal profile and is intended to be combined with recipe modules.",
1502
- allOf: [
1503
- { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
1504
- {
1901
+ definitions: {
1902
+ yield: {
1505
1903
  type: "object",
1904
+ required: ["amount", "unit"],
1506
1905
  properties: {
1507
- profile: { const: "core" },
1508
- modules: {
1906
+ amount: { type: "number" },
1907
+ unit: { type: "string" },
1908
+ servings: { type: "number" },
1909
+ description: { type: "string" }
1910
+ }
1911
+ },
1912
+ time: {
1913
+ type: "object",
1914
+ properties: {
1915
+ prep: { type: "number" },
1916
+ active: { type: "number" },
1917
+ passive: { type: "number" },
1918
+ total: { type: "number" },
1919
+ prepTime: { type: "string", format: "duration" },
1920
+ cookTime: { type: "string", format: "duration" }
1921
+ },
1922
+ minProperties: 1
1923
+ },
1924
+ quantity: {
1925
+ type: "object",
1926
+ required: ["amount"],
1927
+ properties: {
1928
+ amount: { type: "number" },
1929
+ unit: {
1930
+ type: ["string", "null"],
1931
+ description: "Display-friendly unit text; implementations may normalize or canonicalize units separately."
1932
+ }
1933
+ }
1934
+ },
1935
+ scaling: {
1936
+ type: "object",
1937
+ required: ["type"],
1938
+ properties: {
1939
+ type: {
1940
+ type: "string",
1941
+ enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
1942
+ },
1943
+ factor: { type: "number" },
1944
+ referenceId: { type: "string" },
1945
+ roundTo: { type: "number" },
1946
+ min: { type: "number" },
1947
+ max: { type: "number" }
1948
+ },
1949
+ if: {
1950
+ properties: { type: { const: "bakers_percentage" } }
1951
+ },
1952
+ then: {
1953
+ required: ["referenceId"]
1954
+ }
1955
+ },
1956
+ ingredient: {
1957
+ type: "object",
1958
+ required: ["item"],
1959
+ properties: {
1960
+ id: { type: "string" },
1961
+ item: { type: "string" },
1962
+ quantity: { $ref: "#/definitions/quantity" },
1963
+ name: { type: "string" },
1964
+ aisle: { type: "string" },
1965
+ prep: { type: "string" },
1966
+ prepAction: { type: "string" },
1967
+ prepActions: {
1509
1968
  type: "array",
1510
1969
  items: { type: "string" },
1511
- uniqueItems: true,
1512
- default: []
1970
+ description: "Structured prep verbs (e.g., peel, dice) for mise en place workflows."
1513
1971
  },
1514
- name: { type: "string", minLength: 1 },
1515
- ingredients: { type: "array", minItems: 1 },
1516
- instructions: { type: "array", minItems: 1 }
1517
- },
1518
- required: ["profile", "name", "ingredients", "instructions"],
1519
- additionalProperties: true
1972
+ prepTime: { type: "number" },
1973
+ form: {
1974
+ type: "string",
1975
+ description: "State of the ingredient as used (packed, sifted, melted, room_temperature, etc.)."
1976
+ },
1977
+ destination: { type: "string" },
1978
+ scaling: { $ref: "#/definitions/scaling" },
1979
+ critical: { type: "boolean" },
1980
+ optional: { type: "boolean" },
1981
+ notes: { type: "string" }
1982
+ }
1983
+ },
1984
+ ingredientSubsection: {
1985
+ type: "object",
1986
+ required: ["subsection", "items"],
1987
+ properties: {
1988
+ subsection: { type: "string" },
1989
+ items: {
1990
+ type: "array",
1991
+ items: { $ref: "#/definitions/ingredient" }
1992
+ }
1993
+ }
1994
+ },
1995
+ equipment: {
1996
+ type: "object",
1997
+ required: ["name"],
1998
+ properties: {
1999
+ id: { type: "string" },
2000
+ name: { type: "string" },
2001
+ required: { type: "boolean" },
2002
+ label: { type: "string" },
2003
+ capacity: { $ref: "#/definitions/quantity" },
2004
+ scalingLimit: { type: "number" },
2005
+ alternatives: {
2006
+ type: "array",
2007
+ items: { type: "string" }
2008
+ }
2009
+ }
2010
+ },
2011
+ instruction: {
2012
+ type: "object",
2013
+ required: ["text"],
2014
+ properties: {
2015
+ id: { type: "string" },
2016
+ text: { type: "string" },
2017
+ image: {
2018
+ type: "string",
2019
+ format: "uri",
2020
+ description: "Optional image that illustrates this instruction"
2021
+ },
2022
+ destination: { type: "string" },
2023
+ dependsOn: {
2024
+ type: "array",
2025
+ items: { type: "string" }
2026
+ },
2027
+ inputs: {
2028
+ type: "array",
2029
+ items: { type: "string" }
2030
+ },
2031
+ timing: {
2032
+ type: "object",
2033
+ required: ["duration", "type"],
2034
+ properties: {
2035
+ duration: {
2036
+ anyOf: [
2037
+ { type: "number" },
2038
+ { type: "string", pattern: "^P" }
2039
+ ],
2040
+ description: "Minutes as a number or ISO8601 duration string"
2041
+ },
2042
+ type: { type: "string", enum: ["active", "passive"] },
2043
+ scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
2044
+ }
2045
+ }
2046
+ }
2047
+ },
2048
+ instructionSubsection: {
2049
+ type: "object",
2050
+ required: ["subsection", "items"],
2051
+ properties: {
2052
+ subsection: { type: "string" },
2053
+ items: {
2054
+ type: "array",
2055
+ items: {
2056
+ anyOf: [
2057
+ { type: "string" },
2058
+ { $ref: "#/definitions/instruction" }
2059
+ ]
2060
+ }
2061
+ }
2062
+ }
2063
+ },
2064
+ storage: {
2065
+ type: "object",
2066
+ properties: {
2067
+ roomTemp: { $ref: "#/definitions/storageMethod" },
2068
+ refrigerated: { $ref: "#/definitions/storageMethod" },
2069
+ frozen: {
2070
+ allOf: [
2071
+ { $ref: "#/definitions/storageMethod" },
2072
+ {
2073
+ type: "object",
2074
+ properties: { thawing: { type: "string" } }
2075
+ }
2076
+ ]
2077
+ },
2078
+ reheating: { type: "string" },
2079
+ makeAhead: {
2080
+ type: "array",
2081
+ items: {
2082
+ allOf: [
2083
+ { $ref: "#/definitions/storageMethod" },
2084
+ {
2085
+ type: "object",
2086
+ required: ["component", "storage"],
2087
+ properties: {
2088
+ component: { type: "string" },
2089
+ storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
2090
+ }
2091
+ }
2092
+ ]
2093
+ }
2094
+ }
2095
+ }
2096
+ },
2097
+ storageMethod: {
2098
+ type: "object",
2099
+ required: ["duration"],
2100
+ properties: {
2101
+ duration: { type: "string", pattern: "^P" },
2102
+ method: { type: "string" },
2103
+ notes: { type: "string" }
2104
+ }
2105
+ },
2106
+ substitution: {
2107
+ type: "object",
2108
+ required: ["ingredient"],
2109
+ properties: {
2110
+ ingredient: { type: "string" },
2111
+ critical: { type: "boolean" },
2112
+ notes: { type: "string" },
2113
+ alternatives: {
2114
+ type: "array",
2115
+ items: {
2116
+ type: "object",
2117
+ required: ["name", "ratio"],
2118
+ properties: {
2119
+ name: { type: "string" },
2120
+ ratio: { type: "string" },
2121
+ notes: { type: "string" },
2122
+ impact: { type: "string" },
2123
+ dietary: {
2124
+ type: "array",
2125
+ items: { type: "string" }
2126
+ }
2127
+ }
2128
+ }
2129
+ }
2130
+ }
1520
2131
  }
1521
- ]
2132
+ }
2133
+ };
2134
+
2135
+ // src/schemas/recipe/base.schema.json
2136
+ var base_schema_default = {
2137
+ $schema: "http://json-schema.org/draft-07/schema#",
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.",
2141
+ type: "object",
2142
+ additionalProperties: true,
2143
+ properties: {
2144
+ "@type": {
2145
+ const: "Recipe",
2146
+ description: "Document marker for Soustack recipes"
2147
+ },
2148
+ profile: {
2149
+ type: "string",
2150
+ description: "Profile identifier applied to this recipe"
2151
+ },
2152
+ stacks: {
2153
+ type: "object",
2154
+ description: "Stack declarations as a map: Record<stackName, versionNumber>",
2155
+ additionalProperties: {
2156
+ type: "integer",
2157
+ minimum: 1
2158
+ }
2159
+ },
2160
+ name: {
2161
+ type: "string",
2162
+ description: "Human-readable recipe name"
2163
+ },
2164
+ ingredients: {
2165
+ type: "array",
2166
+ description: "Ingredients payload; content is validated by profiles/stacks"
2167
+ },
2168
+ instructions: {
2169
+ type: "array",
2170
+ description: "Instruction payload; content is validated by profiles/stacks"
2171
+ }
2172
+ },
2173
+ required: ["@type"]
1522
2174
  };
1523
2175
 
1524
2176
  // src/schemas/recipe/profiles/minimal.schema.json
@@ -1526,7 +2178,7 @@ var minimal_schema_default = {
1526
2178
  $schema: "http://json-schema.org/draft-07/schema#",
1527
2179
  $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
1528
2180
  title: "Soustack Recipe Minimal Profile",
1529
- description: "Minimal profile that ensures the basic Recipe structure is present while allowing modules to extend it.",
2181
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing stacks to extend it.",
1530
2182
  allOf: [
1531
2183
  {
1532
2184
  $ref: "http://soustack.org/schema/recipe/base.schema.json"
@@ -1537,19 +2189,19 @@ var minimal_schema_default = {
1537
2189
  profile: {
1538
2190
  const: "minimal"
1539
2191
  },
1540
- modules: {
1541
- type: "array",
1542
- items: {
1543
- type: "string",
1544
- enum: [
1545
- "attribution@1",
1546
- "taxonomy@1",
1547
- "media@1",
1548
- "nutrition@1",
1549
- "times@1"
1550
- ]
2192
+ stacks: {
2193
+ type: "object",
2194
+ additionalProperties: {
2195
+ type: "integer",
2196
+ minimum: 1
1551
2197
  },
1552
- default: []
2198
+ properties: {
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 }
2204
+ }
1553
2205
  },
1554
2206
  name: {
1555
2207
  type: "string",
@@ -1575,23 +2227,304 @@ var minimal_schema_default = {
1575
2227
  ]
1576
2228
  };
1577
2229
 
1578
- // src/schemas/recipe/modules/schedule/1.schema.json
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
+ {
2239
+ type: "object",
2240
+ properties: {
2241
+ profile: { const: "core" },
2242
+ stacks: {
2243
+ type: "object",
2244
+ additionalProperties: {
2245
+ type: "integer",
2246
+ minimum: 1
2247
+ }
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
2255
+ }
2256
+ ]
2257
+ };
2258
+
2259
+ // spec/profiles/base.schema.json
2260
+ var base_schema_default2 = {
2261
+ $schema: "http://json-schema.org/draft-07/schema#",
2262
+ $id: "http://soustack.org/schema/v0.3.0/profiles/base",
2263
+ title: "Soustack Base Profile Schema",
2264
+ description: "Wrapper schema that exposes the unmodified Soustack base schema.",
2265
+ allOf: [
2266
+ { $ref: "http://soustack.org/schema/v0.3.0" }
2267
+ ]
2268
+ };
2269
+
2270
+ // spec/profiles/cookable.schema.json
2271
+ var cookable_schema_default = {
2272
+ $schema: "http://json-schema.org/draft-07/schema#",
2273
+ $id: "http://soustack.org/schema/v0.3.0/profiles/cookable",
2274
+ title: "Soustack Cookable Profile Schema",
2275
+ description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
2276
+ allOf: [
2277
+ { $ref: "http://soustack.org/schema/v0.3.0" },
2278
+ {
2279
+ required: ["yield", "time", "ingredients", "instructions"],
2280
+ properties: {
2281
+ yield: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
2282
+ time: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/time" },
2283
+ ingredients: { type: "array", minItems: 1 },
2284
+ instructions: { type: "array", minItems: 1 }
2285
+ }
2286
+ }
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
+ }
2383
+ };
2384
+
2385
+ // spec/profiles/scalable.schema.json
2386
+ var scalable_schema_default = {
2387
+ $schema: "http://json-schema.org/draft-07/schema#",
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.",
2391
+ allOf: [
2392
+ { $ref: "http://soustack.org/schema/v0.3.0" },
2393
+ {
2394
+ required: ["yield", "ingredients"],
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
+ },
2402
+ ingredients: {
2403
+ type: "array",
2404
+ minItems: 1,
2405
+ items: {
2406
+ anyOf: [
2407
+ { $ref: "#/definitions/scalableIngredient" },
2408
+ { $ref: "#/definitions/scalableIngredientSubsection" }
2409
+ ]
2410
+ }
2411
+ }
2412
+ }
2413
+ }
2414
+ ],
2415
+ definitions: {
2416
+ scalableIngredient: {
2417
+ allOf: [
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
+ }
2443
+ ]
2444
+ },
2445
+ scalableIngredientSubsection: {
2446
+ allOf: [
2447
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
2448
+ {
2449
+ properties: {
2450
+ items: {
2451
+ type: "array",
2452
+ minItems: 1,
2453
+ items: { $ref: "#/definitions/scalableIngredient" }
2454
+ }
2455
+ }
2456
+ }
2457
+ ]
2458
+ }
2459
+ }
2460
+ };
2461
+
2462
+ // spec/profiles/schedulable.schema.json
2463
+ var schedulable_schema_default = {
2464
+ $schema: "http://json-schema.org/draft-07/schema#",
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.",
2468
+ allOf: [
2469
+ { $ref: "http://soustack.org/schema/v0.3.0" },
2470
+ {
2471
+ properties: {
2472
+ instructions: {
2473
+ type: "array",
2474
+ items: {
2475
+ anyOf: [
2476
+ { $ref: "#/definitions/schedulableInstruction" },
2477
+ { $ref: "#/definitions/schedulableInstructionSubsection" }
2478
+ ]
2479
+ }
2480
+ }
2481
+ }
2482
+ }
2483
+ ],
2484
+ definitions: {
2485
+ schedulableInstruction: {
2486
+ allOf: [
2487
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
2488
+ { required: ["id", "timing"] }
2489
+ ]
2490
+ },
2491
+ schedulableInstructionSubsection: {
2492
+ allOf: [
2493
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
2494
+ {
2495
+ properties: {
2496
+ items: {
2497
+ type: "array",
2498
+ items: { $ref: "#/definitions/schedulableInstruction" }
2499
+ }
2500
+ }
2501
+ }
2502
+ ]
2503
+ }
2504
+ }
2505
+ };
2506
+
2507
+ // src/schemas/recipe/stacks/attribution/1.schema.json
1579
2508
  var schema_default = {
1580
2509
  $schema: "http://json-schema.org/draft-07/schema#",
1581
- $id: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
1582
- title: "Soustack Recipe Module: schedule v1",
1583
- description: "Schema for the schedule module. Enforces bidirectional module gating and restricts usage to the core profile.",
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.",
1584
2513
  type: "object",
1585
2514
  properties: {
1586
- profile: { type: "string" },
1587
- modules: {
1588
- type: "array",
1589
- items: { type: "string" }
2515
+ stacks: {
2516
+ type: "object",
2517
+ additionalProperties: {
2518
+ type: "integer",
2519
+ minimum: 1
2520
+ }
1590
2521
  },
1591
- schedule: {
2522
+ attribution: {
1592
2523
  type: "object",
1593
2524
  properties: {
1594
- tasks: { type: "array" }
2525
+ url: { type: "string" },
2526
+ author: { type: "string" },
2527
+ datePublished: { type: "string" }
1595
2528
  },
1596
2529
  additionalProperties: false
1597
2530
  }
@@ -1600,32 +2533,33 @@ var schema_default = {
1600
2533
  {
1601
2534
  if: {
1602
2535
  properties: {
1603
- modules: {
1604
- type: "array",
1605
- contains: { const: "schedule@1" }
2536
+ stacks: {
2537
+ type: "object",
2538
+ properties: {
2539
+ attribution: { const: 1 }
2540
+ },
2541
+ required: ["attribution"]
1606
2542
  }
1607
2543
  }
1608
2544
  },
1609
2545
  then: {
1610
- required: ["schedule", "profile"],
1611
- properties: {
1612
- profile: { const: "core" }
1613
- }
2546
+ required: ["attribution"]
1614
2547
  }
1615
2548
  },
1616
2549
  {
1617
2550
  if: {
1618
- required: ["schedule"]
2551
+ required: ["attribution"]
1619
2552
  },
1620
2553
  then: {
1621
- required: ["modules", "profile"],
2554
+ required: ["stacks"],
1622
2555
  properties: {
1623
- modules: {
1624
- type: "array",
1625
- items: { type: "string" },
1626
- contains: { const: "schedule@1" }
1627
- },
1628
- profile: { const: "core" }
2556
+ stacks: {
2557
+ type: "object",
2558
+ properties: {
2559
+ attribution: { const: 1 }
2560
+ },
2561
+ required: ["attribution"]
2562
+ }
1629
2563
  }
1630
2564
  }
1631
2565
  }
@@ -1633,23 +2567,26 @@ var schema_default = {
1633
2567
  additionalProperties: true
1634
2568
  };
1635
2569
 
1636
- // src/schemas/recipe/modules/nutrition/1.schema.json
2570
+ // src/schemas/recipe/stacks/media/1.schema.json
1637
2571
  var schema_default2 = {
1638
2572
  $schema: "http://json-schema.org/draft-07/schema#",
1639
- $id: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
1640
- title: "Soustack Recipe Module: nutrition v1",
1641
- description: "Schema for the nutrition module. Keeps nutrition data aligned with module declarations and vice versa.",
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.",
1642
2576
  type: "object",
1643
2577
  properties: {
1644
- modules: {
1645
- type: "array",
1646
- items: { type: "string" }
2578
+ stacks: {
2579
+ type: "object",
2580
+ additionalProperties: {
2581
+ type: "integer",
2582
+ minimum: 1
2583
+ }
1647
2584
  },
1648
- nutrition: {
2585
+ media: {
1649
2586
  type: "object",
1650
2587
  properties: {
1651
- calories: { type: "number" },
1652
- protein_g: { type: "number" }
2588
+ images: { type: "array", items: { type: "string" } },
2589
+ videos: { type: "array", items: { type: "string" } }
1653
2590
  },
1654
2591
  additionalProperties: false
1655
2592
  }
@@ -1658,27 +2595,32 @@ var schema_default2 = {
1658
2595
  {
1659
2596
  if: {
1660
2597
  properties: {
1661
- modules: {
1662
- type: "array",
1663
- contains: { const: "nutrition@1" }
2598
+ stacks: {
2599
+ type: "object",
2600
+ properties: {
2601
+ media: { const: 1 }
2602
+ },
2603
+ required: ["media"]
1664
2604
  }
1665
2605
  }
1666
2606
  },
1667
2607
  then: {
1668
- required: ["nutrition"]
2608
+ required: ["media"]
1669
2609
  }
1670
2610
  },
1671
2611
  {
1672
2612
  if: {
1673
- required: ["nutrition"]
2613
+ required: ["media"]
1674
2614
  },
1675
2615
  then: {
1676
- required: ["modules"],
2616
+ required: ["stacks"],
1677
2617
  properties: {
1678
- modules: {
1679
- type: "array",
1680
- items: { type: "string" },
1681
- contains: { const: "nutrition@1" }
2618
+ stacks: {
2619
+ type: "object",
2620
+ properties: {
2621
+ media: { const: 1 }
2622
+ },
2623
+ required: ["media"]
1682
2624
  }
1683
2625
  }
1684
2626
  }
@@ -1687,24 +2629,26 @@ var schema_default2 = {
1687
2629
  additionalProperties: true
1688
2630
  };
1689
2631
 
1690
- // src/schemas/recipe/modules/attribution/1.schema.json
2632
+ // src/schemas/recipe/stacks/nutrition/1.schema.json
1691
2633
  var schema_default3 = {
1692
2634
  $schema: "http://json-schema.org/draft-07/schema#",
1693
- $id: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
1694
- title: "Soustack Recipe Module: attribution v1",
1695
- description: "Schema for the attribution module. Ensures namespace data is present when the module is enabled and vice versa.",
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.",
1696
2638
  type: "object",
1697
2639
  properties: {
1698
- modules: {
1699
- type: "array",
1700
- items: { type: "string" }
2640
+ stacks: {
2641
+ type: "object",
2642
+ additionalProperties: {
2643
+ type: "integer",
2644
+ minimum: 1
2645
+ }
1701
2646
  },
1702
- attribution: {
2647
+ nutrition: {
1703
2648
  type: "object",
1704
2649
  properties: {
1705
- url: { type: "string" },
1706
- author: { type: "string" },
1707
- datePublished: { type: "string" }
2650
+ calories: { type: "number" },
2651
+ protein_g: { type: "number" }
1708
2652
  },
1709
2653
  additionalProperties: false
1710
2654
  }
@@ -1713,27 +2657,32 @@ var schema_default3 = {
1713
2657
  {
1714
2658
  if: {
1715
2659
  properties: {
1716
- modules: {
1717
- type: "array",
1718
- contains: { const: "attribution@1" }
2660
+ stacks: {
2661
+ type: "object",
2662
+ properties: {
2663
+ nutrition: { const: 1 }
2664
+ },
2665
+ required: ["nutrition"]
1719
2666
  }
1720
2667
  }
1721
2668
  },
1722
2669
  then: {
1723
- required: ["attribution"]
2670
+ required: ["nutrition"]
1724
2671
  }
1725
2672
  },
1726
2673
  {
1727
2674
  if: {
1728
- required: ["attribution"]
2675
+ required: ["nutrition"]
1729
2676
  },
1730
2677
  then: {
1731
- required: ["modules"],
2678
+ required: ["stacks"],
1732
2679
  properties: {
1733
- modules: {
1734
- type: "array",
1735
- items: { type: "string" },
1736
- contains: { const: "attribution@1" }
2680
+ stacks: {
2681
+ type: "object",
2682
+ properties: {
2683
+ nutrition: { const: 1 }
2684
+ },
2685
+ required: ["nutrition"]
1737
2686
  }
1738
2687
  }
1739
2688
  }
@@ -1742,24 +2691,26 @@ var schema_default3 = {
1742
2691
  additionalProperties: true
1743
2692
  };
1744
2693
 
1745
- // src/schemas/recipe/modules/taxonomy/1.schema.json
2694
+ // src/schemas/recipe/stacks/schedule/1.schema.json
1746
2695
  var schema_default4 = {
1747
2696
  $schema: "http://json-schema.org/draft-07/schema#",
1748
- $id: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
1749
- title: "Soustack Recipe Module: taxonomy v1",
1750
- description: "Schema for the taxonomy module. Enforces keyword and categorization data when enabled and ensures module declaration accompanies the namespace block.",
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.",
1751
2700
  type: "object",
1752
2701
  properties: {
1753
- modules: {
1754
- type: "array",
1755
- items: { type: "string" }
2702
+ profile: { type: "string" },
2703
+ stacks: {
2704
+ type: "object",
2705
+ additionalProperties: {
2706
+ type: "integer",
2707
+ minimum: 1
2708
+ }
1756
2709
  },
1757
- taxonomy: {
2710
+ schedule: {
1758
2711
  type: "object",
1759
2712
  properties: {
1760
- keywords: { type: "array", items: { type: "string" } },
1761
- category: { type: "string" },
1762
- cuisine: { type: "string" }
2713
+ tasks: { type: "array" }
1763
2714
  },
1764
2715
  additionalProperties: false
1765
2716
  }
@@ -1768,28 +2719,37 @@ var schema_default4 = {
1768
2719
  {
1769
2720
  if: {
1770
2721
  properties: {
1771
- modules: {
1772
- type: "array",
1773
- contains: { const: "taxonomy@1" }
2722
+ stacks: {
2723
+ type: "object",
2724
+ properties: {
2725
+ schedule: { const: 1 }
2726
+ },
2727
+ required: ["schedule"]
1774
2728
  }
1775
2729
  }
1776
2730
  },
1777
2731
  then: {
1778
- required: ["taxonomy"]
2732
+ required: ["schedule", "profile"],
2733
+ properties: {
2734
+ profile: { const: "core" }
2735
+ }
1779
2736
  }
1780
2737
  },
1781
2738
  {
1782
2739
  if: {
1783
- required: ["taxonomy"]
2740
+ required: ["schedule"]
1784
2741
  },
1785
2742
  then: {
1786
- required: ["modules"],
2743
+ required: ["stacks", "profile"],
1787
2744
  properties: {
1788
- modules: {
1789
- type: "array",
1790
- items: { type: "string" },
1791
- contains: { const: "taxonomy@1" }
1792
- }
2745
+ stacks: {
2746
+ type: "object",
2747
+ properties: {
2748
+ schedule: { const: 1 }
2749
+ },
2750
+ required: ["schedule"]
2751
+ },
2752
+ profile: { const: "core" }
1793
2753
  }
1794
2754
  }
1795
2755
  }
@@ -1797,23 +2757,27 @@ var schema_default4 = {
1797
2757
  additionalProperties: true
1798
2758
  };
1799
2759
 
1800
- // src/schemas/recipe/modules/media/1.schema.json
2760
+ // src/schemas/recipe/stacks/taxonomy/1.schema.json
1801
2761
  var schema_default5 = {
1802
2762
  $schema: "http://json-schema.org/draft-07/schema#",
1803
- $id: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
1804
- title: "Soustack Recipe Module: media v1",
1805
- description: "Schema for the media module. Guards media blocks based on module activation and ensures declarations accompany payloads.",
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.",
1806
2766
  type: "object",
1807
2767
  properties: {
1808
- modules: {
1809
- type: "array",
1810
- items: { type: "string" }
2768
+ stacks: {
2769
+ type: "object",
2770
+ additionalProperties: {
2771
+ type: "integer",
2772
+ minimum: 1
2773
+ }
1811
2774
  },
1812
- media: {
2775
+ taxonomy: {
1813
2776
  type: "object",
1814
2777
  properties: {
1815
- images: { type: "array", items: { type: "string" } },
1816
- videos: { type: "array", items: { type: "string" } }
2778
+ keywords: { type: "array", items: { type: "string" } },
2779
+ category: { type: "string" },
2780
+ cuisine: { type: "string" }
1817
2781
  },
1818
2782
  additionalProperties: false
1819
2783
  }
@@ -1822,27 +2786,32 @@ var schema_default5 = {
1822
2786
  {
1823
2787
  if: {
1824
2788
  properties: {
1825
- modules: {
1826
- type: "array",
1827
- contains: { const: "media@1" }
2789
+ stacks: {
2790
+ type: "object",
2791
+ properties: {
2792
+ taxonomy: { const: 1 }
2793
+ },
2794
+ required: ["taxonomy"]
1828
2795
  }
1829
2796
  }
1830
2797
  },
1831
2798
  then: {
1832
- required: ["media"]
2799
+ required: ["taxonomy"]
1833
2800
  }
1834
2801
  },
1835
2802
  {
1836
2803
  if: {
1837
- required: ["media"]
2804
+ required: ["taxonomy"]
1838
2805
  },
1839
2806
  then: {
1840
- required: ["modules"],
2807
+ required: ["stacks"],
1841
2808
  properties: {
1842
- modules: {
1843
- type: "array",
1844
- items: { type: "string" },
1845
- contains: { const: "media@1" }
2809
+ stacks: {
2810
+ type: "object",
2811
+ properties: {
2812
+ taxonomy: { const: 1 }
2813
+ },
2814
+ required: ["taxonomy"]
1846
2815
  }
1847
2816
  }
1848
2817
  }
@@ -1851,17 +2820,20 @@ var schema_default5 = {
1851
2820
  additionalProperties: true
1852
2821
  };
1853
2822
 
1854
- // src/schemas/recipe/modules/times/1.schema.json
2823
+ // src/schemas/recipe/stacks/times/1.schema.json
1855
2824
  var schema_default6 = {
1856
2825
  $schema: "http://json-schema.org/draft-07/schema#",
1857
- $id: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
1858
- title: "Soustack Recipe Module: times v1",
1859
- description: "Schema for the times module. Maintains alignment between module declarations and timing payloads.",
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.",
1860
2829
  type: "object",
1861
2830
  properties: {
1862
- modules: {
1863
- type: "array",
1864
- items: { type: "string" }
2831
+ stacks: {
2832
+ type: "object",
2833
+ additionalProperties: {
2834
+ type: "integer",
2835
+ minimum: 1
2836
+ }
1865
2837
  },
1866
2838
  times: {
1867
2839
  type: "object",
@@ -1877,9 +2849,12 @@ var schema_default6 = {
1877
2849
  {
1878
2850
  if: {
1879
2851
  properties: {
1880
- modules: {
1881
- type: "array",
1882
- contains: { const: "times@1" }
2852
+ stacks: {
2853
+ type: "object",
2854
+ properties: {
2855
+ times: { const: 1 }
2856
+ },
2857
+ required: ["times"]
1883
2858
  }
1884
2859
  }
1885
2860
  },
@@ -1892,12 +2867,14 @@ var schema_default6 = {
1892
2867
  required: ["times"]
1893
2868
  },
1894
2869
  then: {
1895
- required: ["modules"],
2870
+ required: ["stacks"],
1896
2871
  properties: {
1897
- modules: {
1898
- type: "array",
1899
- items: { type: "string" },
1900
- contains: { const: "times@1" }
2872
+ stacks: {
2873
+ type: "object",
2874
+ properties: {
2875
+ times: { const: 1 }
2876
+ },
2877
+ required: ["times"]
1901
2878
  }
1902
2879
  }
1903
2880
  }
@@ -1907,69 +2884,71 @@ var schema_default6 = {
1907
2884
  };
1908
2885
 
1909
2886
  // src/validator.ts
1910
- var CANONICAL_BASE_SCHEMA_ID = base_schema_default.$id || "http://soustack.org/schema/recipe/base.schema.json";
1911
- var canonicalProfileId = (profile) => {
1912
- if (profile === "minimal") {
1913
- return minimal_schema_default.$id;
1914
- }
1915
- if (profile === "core") {
1916
- return core_schema_default.$id;
1917
- }
1918
- throw new Error(`Unknown profile: ${profile}`);
1919
- };
1920
- var moduleIdToSchemaRef = (moduleId) => {
1921
- const match = moduleId.match(/^([a-z0-9_-]+)@(\d+(?:\.\d+)*)$/i);
1922
- if (!match) {
1923
- throw new Error(`Invalid module identifier '${moduleId}'. Expected <name>@<version>.`);
1924
- }
1925
- const [, name, version] = match;
1926
- const moduleSchemas2 = {
1927
- "schedule@1": schema_default,
1928
- "nutrition@1": schema_default2,
1929
- "attribution@1": schema_default3,
1930
- "taxonomy@1": schema_default4,
1931
- "media@1": schema_default5,
1932
- "times@1": schema_default6
1933
- };
1934
- const schema = moduleSchemas2[moduleId];
1935
- if (schema && schema.$id) {
1936
- return schema.$id;
1937
- }
1938
- return `https://soustack.org/schemas/recipe/modules/${name}/${version}.schema.json`;
1939
- };
1940
- var profileSchemas = {
1941
- minimal: minimal_schema_default,
1942
- core: core_schema_default
1943
- };
1944
- var moduleSchemas = {
1945
- "schedule@1": schema_default,
1946
- "nutrition@1": schema_default2,
1947
- "attribution@1": schema_default3,
1948
- "taxonomy@1": schema_default4,
1949
- "media@1": schema_default5,
1950
- "times@1": schema_default6
1951
- };
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/";
1952
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
+ }
1953
2935
  function createContext(collectAllErrors) {
1954
- 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
+ });
1955
2942
  addFormats__default.default(ajv);
1956
- const addSchemaWithAlias = (schema, alias) => {
1957
- if (!schema) return;
1958
- const schemaId = schema.$id || alias;
1959
- if (schemaId) {
1960
- ajv.addSchema(schema, schemaId);
1961
- } else {
1962
- ajv.addSchema(schema);
1963
- }
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()
1964
2951
  };
1965
- addSchemaWithAlias(base_schema_default, CANONICAL_BASE_SCHEMA_ID);
1966
- Object.entries(profileSchemas).forEach(([name, schema]) => {
1967
- addSchemaWithAlias(schema, canonicalProfileId(name));
1968
- });
1969
- Object.entries(moduleSchemas).forEach(([moduleId, schema]) => {
1970
- addSchemaWithAlias(schema, moduleIdToSchemaRef(moduleId));
1971
- });
1972
- return { ajv, validators: /* @__PURE__ */ new Map() };
1973
2952
  }
1974
2953
  function getContext(collectAllErrors) {
1975
2954
  if (!validationContexts.has(collectAllErrors)) {
@@ -1983,275 +2962,239 @@ function cloneRecipe(recipe) {
1983
2962
  }
1984
2963
  return JSON.parse(JSON.stringify(recipe));
1985
2964
  }
1986
- function detectProfileFromSchema(schemaRef) {
1987
- if (!schemaRef) return void 0;
1988
- const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
1989
- if (match) {
1990
- const profile = match[1].toLowerCase();
1991
- if (profile in profileSchemas) return profile;
2965
+ function formatAjvError(error) {
2966
+ let path2 = error.instancePath || "/";
2967
+ if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
2968
+ const extra = error.params.additionalProperty;
2969
+ path2 = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
1992
2970
  }
1993
- return void 0;
2971
+ return {
2972
+ path: path2,
2973
+ keyword: error.keyword,
2974
+ message: error.message || "Validation error"
2975
+ };
1994
2976
  }
1995
- function resolveSchemaRef(inputSchema, requestedSchema) {
1996
- if (typeof requestedSchema === "string") return requestedSchema;
1997
- if (typeof inputSchema !== "string") return void 0;
1998
- return detectProfileFromSchema(inputSchema) ? inputSchema : void 0;
1999
- }
2000
- function inferModulesFromPayload(recipe) {
2001
- const inferred = [];
2002
- const payloadToModule = {
2003
- attribution: "attribution@1",
2004
- taxonomy: "taxonomy@1",
2005
- media: "media@1",
2006
- times: "times@1",
2007
- nutrition: "nutrition@1",
2008
- schedule: "schedule@1"
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"
2009
2989
  };
2010
- for (const [field, moduleId] of Object.entries(payloadToModule)) {
2011
- if (recipe && typeof recipe === "object" && field in recipe && recipe[field] != null) {
2012
- const payload = recipe[field];
2013
- if (typeof payload === "object" && !Array.isArray(payload)) {
2014
- if (Object.keys(payload).length > 0) {
2015
- inferred.push(moduleId);
2016
- }
2017
- } else if (Array.isArray(payload) && payload.length > 0) {
2018
- inferred.push(moduleId);
2019
- } else if (payload !== null && payload !== void 0) {
2020
- inferred.push(moduleId);
2021
- }
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;
2022
2993
  }
2023
2994
  }
2024
2995
  return inferred;
2025
2996
  }
2026
- function getCombinedValidator(profile, modules, recipe, context) {
2027
- const inferredModules = inferModulesFromPayload(recipe);
2028
- const allModules = /* @__PURE__ */ new Set([...modules, ...inferredModules]);
2029
- const sortedModules = Array.from(allModules).sort();
2030
- const cacheKey = `${profile}::${sortedModules.join(",")}`;
2997
+ function getComposedValidator(profile, stacks, context) {
2998
+ const stackIdentifiers = Object.entries(stacks).map(([name, version]) => `${name}@${version}`).sort();
2999
+ const cacheKey = `${profile}::${stackIdentifiers.join(",")}`;
2031
3000
  const cached = context.validators.get(cacheKey);
2032
3001
  if (cached) return cached;
2033
- if (!profileSchemas[profile]) {
2034
- throw new Error(`Unknown Soustack profile: ${profile}`);
2035
- }
2036
- const schema = {
2037
- $id: `urn:soustack:recipe:${cacheKey}`,
2038
- allOf: [
2039
- { $ref: CANONICAL_BASE_SCHEMA_ID },
2040
- { $ref: canonicalProfileId(profile) },
2041
- ...sortedModules.map((moduleId) => ({ $ref: moduleIdToSchemaRef(moduleId) }))
2042
- ]
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
2043
3023
  };
2044
- const validateFn = context.ajv.compile(schema);
3024
+ const validateFn = context.ajv.compile(composedSchema);
2045
3025
  context.validators.set(cacheKey, validateFn);
2046
3026
  return validateFn;
2047
3027
  }
2048
- function normalizeRecipe(recipe) {
2049
- const normalized = cloneRecipe(recipe);
2050
- const warnings = [];
2051
- normalizeTime(normalized);
2052
- if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
2053
- normalized.recipeVersion = normalized.version;
2054
- warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
2055
- }
2056
- return { normalized, warnings };
2057
- }
2058
- function normalizeTime(recipe) {
2059
- const time = recipe?.time;
2060
- if (!time || typeof time !== "object" || Array.isArray(time)) return;
2061
- const structuredKeys = [
2062
- "prep",
2063
- "active",
2064
- "passive",
2065
- "total"
2066
- ];
2067
- structuredKeys.forEach((key) => {
2068
- const value = time[key];
2069
- if (typeof value === "number") return;
2070
- const parsed = parseDuration(value);
2071
- if (parsed !== null) {
2072
- time[key] = parsed;
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
+ };
2073
3046
  }
2074
- });
2075
- }
2076
- var allowedTopLevelProps = /* @__PURE__ */ new Set([
2077
- ...Object.keys(base_schema_default?.properties ?? {}),
2078
- "$schema",
2079
- // Module fields (validated by module schemas)
2080
- "attribution",
2081
- "taxonomy",
2082
- "media",
2083
- "times",
2084
- "nutrition",
2085
- "schedule",
2086
- // Common recipe fields (allowed by base schema's additionalProperties: true)
2087
- "description",
2088
- "image",
2089
- "category",
2090
- "tags",
2091
- "source",
2092
- "dateAdded",
2093
- "dateModified",
2094
- "yield",
2095
- "time",
2096
- "id",
2097
- "title",
2098
- "recipeVersion",
2099
- "version",
2100
- // deprecated but allowed
2101
- "equipment",
2102
- "storage",
2103
- "substitutions"
2104
- ]);
2105
- function detectUnknownTopLevelKeys(recipe) {
2106
- if (!recipe || typeof recipe !== "object") return [];
2107
- const disallowedKeys = Object.keys(recipe).filter(
2108
- (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
2109
- );
2110
- return disallowedKeys.map((key) => ({
2111
- path: `/${key}`,
2112
- keyword: "additionalProperties",
2113
- message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
2114
- }));
2115
- }
2116
- function formatAjvError(error) {
2117
- let path2 = error.instancePath || "/";
2118
- if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
2119
- const extra = error.params.additionalProperty;
2120
- path2 = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
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
+ };
2121
3065
  }
2122
- return {
2123
- path: path2,
2124
- keyword: error.keyword,
2125
- message: error.message || "Validation error"
2126
- };
2127
- }
2128
- function runAjvValidation(data, profile, modules, context) {
2129
- try {
2130
- const validateFn = getCombinedValidator(profile, modules, data, context);
2131
- const isValid = validateFn(data);
2132
- return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
2133
- } catch (error) {
2134
- return [
2135
- {
2136
- path: "/",
2137
- message: error instanceof Error ? error.message : "Validation failed to initialize"
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;
2138
3072
  }
2139
- ];
3073
+ }
2140
3074
  }
2141
- }
2142
- function isInstruction(item) {
2143
- return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
2144
- }
2145
- function isInstructionSubsection(item) {
2146
- return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
2147
- }
2148
- function checkInstructionGraph(recipe) {
2149
- const instructions = recipe?.instructions;
2150
- if (!Array.isArray(instructions)) return [];
2151
- const instructionIds = /* @__PURE__ */ new Set();
2152
- const dependencyRefs = [];
2153
- const collect = (items, basePath) => {
2154
- items.forEach((item, index) => {
2155
- const currentPath = `${basePath}/${index}`;
2156
- if (isInstructionSubsection(item) && Array.isArray(item.items)) {
2157
- collect(item.items, `${currentPath}/items`);
2158
- return;
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}`
3093
+ }
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;
3101
+ }
3102
+ if (!validationCopy.profile) {
3103
+ validationCopy.profile = profile;
3104
+ }
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"];
2159
3112
  }
2160
- if (isInstruction(item)) {
2161
- const id = typeof item.id === "string" ? item.id : void 0;
2162
- if (id) instructionIds.add(id);
2163
- if (Array.isArray(item.dependsOn)) {
2164
- item.dependsOn.forEach((depId, depIndex) => {
2165
- if (typeof depId === "string") {
2166
- dependencyRefs.push({
2167
- fromId: id,
2168
- toId: depId,
2169
- path: `${currentPath}/dependsOn/${depIndex}`
2170
- });
2171
- }
2172
- });
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];
2173
3123
  }
2174
3124
  }
2175
- });
2176
- };
2177
- collect(instructions, "/instructions");
2178
- const errors = [];
2179
- dependencyRefs.forEach((ref) => {
2180
- if (!instructionIds.has(ref.toId)) {
2181
- errors.push({
2182
- path: ref.path,
2183
- message: `Instruction dependency references missing id '${ref.toId}'.`
2184
- });
2185
- }
2186
- });
2187
- const adjacency = /* @__PURE__ */ new Map();
2188
- dependencyRefs.forEach((ref) => {
2189
- if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
2190
- const list = adjacency.get(ref.fromId) ?? [];
2191
- list.push({ toId: ref.toId, path: ref.path });
2192
- adjacency.set(ref.fromId, list);
2193
- }
2194
- });
2195
- const visiting = /* @__PURE__ */ new Set();
2196
- const visited = /* @__PURE__ */ new Set();
2197
- const detectCycles = (nodeId) => {
2198
- if (visiting.has(nodeId)) return;
2199
- if (visited.has(nodeId)) return;
2200
- visiting.add(nodeId);
2201
- const neighbors = adjacency.get(nodeId) ?? [];
2202
- neighbors.forEach((edge) => {
2203
- if (visiting.has(edge.toId)) {
2204
- errors.push({
2205
- path: edge.path,
2206
- message: `Circular dependency detected involving instruction id '${edge.toId}'.`
2207
- });
2208
- return;
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
+ }
2209
3138
  }
2210
- detectCycles(edge.toId);
2211
- });
2212
- visiting.delete(nodeId);
2213
- visited.add(nodeId);
3139
+ }
3140
+ }
3141
+ return {
3142
+ ok: isValid,
3143
+ errors: errors.map(formatAjvError)
2214
3144
  };
2215
- instructionIds.forEach((id) => detectCycles(id));
2216
- return errors;
2217
3145
  }
2218
3146
  function validateRecipe(input, options = {}) {
2219
- const collectAllErrors = options.collectAllErrors ?? true;
2220
- const context = getContext(collectAllErrors);
2221
- const schemaRef = resolveSchemaRef(input?.$schema, options.schema);
2222
- const profileFromDocument = typeof input?.profile === "string" ? input.profile : void 0;
2223
- const profile = options.profile ?? profileFromDocument ?? detectProfileFromSchema(schemaRef) ?? "core";
2224
- const modulesFromDocument = Array.isArray(input?.modules) ? input.modules.filter((value) => typeof value === "string") : [];
2225
- const modules = modulesFromDocument.length > 0 ? [...modulesFromDocument].sort() : [];
2226
- const { normalized, warnings } = normalizeRecipe(input);
2227
- if (!profileFromDocument) {
2228
- normalized.profile = profile;
2229
- } else {
2230
- normalized.profile = profileFromDocument;
2231
- }
2232
- if (!("modules" in normalized) || normalized.modules === void 0 || normalized.modules === null) {
2233
- normalized.modules = [];
2234
- } else if (modulesFromDocument.length > 0) {
2235
- normalized.modules = modules;
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
+ }
2236
3169
  }
2237
- const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
2238
- const validationErrors = runAjvValidation(normalized, profile, modules, context);
2239
- const graphErrors = modules.includes("schedule@1") && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
2240
- const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
3170
+ const ok = schemaOk && (mode === "schema" ? true : conformanceOk);
3171
+ const normalizedRecipe = ok || options.includeNormalized ? normalized : void 0;
2241
3172
  return {
2242
- valid: errors.length === 0,
2243
- errors,
3173
+ ok,
3174
+ schemaErrors,
3175
+ conformanceIssues,
2244
3176
  warnings,
2245
- normalized: errors.length === 0 ? normalized : void 0
3177
+ normalizedRecipe
2246
3178
  };
2247
3179
  }
2248
3180
 
2249
3181
  // bin/cli.ts
2250
- 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
+ ];
2251
3191
  async function runCli(argv) {
2252
3192
  const [command, ...args] = argv;
2253
3193
  try {
2254
3194
  switch (command) {
3195
+ case "check":
3196
+ await handleCheck(args);
3197
+ return;
2255
3198
  case "validate":
2256
3199
  await handleValidate(args);
2257
3200
  return;
@@ -2281,23 +3224,50 @@ async function runCli(argv) {
2281
3224
  }
2282
3225
  function printUsage() {
2283
3226
  console.log("Usage:");
2284
- 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
+ );
2285
3231
  console.log(" soustack convert --from <schemaorg|soustack> --to <schemaorg|soustack> <input> [-o <output>]");
2286
3232
  console.log(" soustack import --url <url> [-o <soustack.json>]");
2287
- console.log(" soustack test [--profile <name>] [--strict] [--json]");
3233
+ console.log(" soustack test [--profile <name>] [--force-profile] [--schema-only] [--strict] [--json]");
2288
3234
  console.log(" soustack scale <soustack.json> <multiplier>");
2289
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
+ }
2290
3260
  }
2291
3261
  async function handleValidate(args) {
2292
- const { target, profile, strict, json } = parseValidateArgs(args);
3262
+ const { target, profile, forceProfile, strict, json, mode } = parseValidateArgs(args);
2293
3263
  if (!target) throw new Error("Path or glob to Soustack recipe JSON is required");
2294
3264
  const files = expandTargets(target);
2295
3265
  if (files.length === 0) throw new Error(`No files matched pattern: ${target}`);
2296
- const results = files.map((file) => validateFile(file, profile));
2297
- reportValidation(results, { strict, json });
3266
+ const results = files.map((file) => validateFile(file, profile, mode, forceProfile));
3267
+ reportValidation(results, { strict, json});
2298
3268
  }
2299
3269
  async function handleTest(args) {
2300
- const { profile, strict, json } = parseValidationFlags(args);
3270
+ const { profile, forceProfile, strict, json, mode } = parseValidationFlags(args);
2301
3271
  const cwd = process.cwd();
2302
3272
  const files = glob.globSync("**/*.soustack.json", {
2303
3273
  cwd,
@@ -2309,7 +3279,7 @@ async function handleTest(args) {
2309
3279
  console.log("No *.soustack.json files found in the current repository.");
2310
3280
  return;
2311
3281
  }
2312
- const results = files.map((file) => validateFile(file, profile));
3282
+ const results = files.map((file) => validateFile(file, profile, mode, forceProfile));
2313
3283
  reportValidation(results, { strict, json, context: "test" });
2314
3284
  }
2315
3285
  async function handleConvert(args) {
@@ -2361,8 +3331,10 @@ async function handleScrape(args) {
2361
3331
  }
2362
3332
  function parseValidateArgs(args) {
2363
3333
  let profile;
3334
+ let forceProfile = false;
2364
3335
  let strict = false;
2365
3336
  let json = false;
3337
+ let mode = "full";
2366
3338
  let target;
2367
3339
  for (let i = 0; i < args.length; i++) {
2368
3340
  const arg = args[i];
@@ -2371,6 +3343,12 @@ function parseValidateArgs(args) {
2371
3343
  profile = normalizeProfile(args[i + 1]);
2372
3344
  i++;
2373
3345
  break;
3346
+ case "--force-profile":
3347
+ forceProfile = true;
3348
+ break;
3349
+ case "--schema-only":
3350
+ mode = "schema";
3351
+ break;
2374
3352
  case "--strict":
2375
3353
  strict = true;
2376
3354
  break;
@@ -2384,11 +3362,26 @@ function parseValidateArgs(args) {
2384
3362
  break;
2385
3363
  }
2386
3364
  }
2387
- 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 };
2388
3381
  }
2389
3382
  function parseValidationFlags(args) {
2390
- const { profile, strict, json } = parseValidateArgs(args);
2391
- return { profile, strict, json };
3383
+ const { profile, forceProfile, strict, json, mode } = parseValidateArgs(args);
3384
+ return { profile, forceProfile, strict, json, mode };
2392
3385
  }
2393
3386
  function normalizeProfile(value) {
2394
3387
  if (!value) return void 0;
@@ -2396,7 +3389,7 @@ function normalizeProfile(value) {
2396
3389
  if (supportedProfiles.includes(normalized)) {
2397
3390
  return normalized;
2398
3391
  }
2399
- throw new Error(`Unknown Soustack profile: ${value}`);
3392
+ throw new Error(`Unknown Soustack profile: ${value}. Supported profiles: ${supportedProfiles.join(", ")}`);
2400
3393
  }
2401
3394
  function parseConvertArgs(args) {
2402
3395
  let from;
@@ -2462,16 +3455,64 @@ function expandTargets(target) {
2462
3455
  const unique = Array.from(new Set(matches.map((match) => path__namespace.resolve(match))));
2463
3456
  return unique;
2464
3457
  }
2465
- function validateFile(file, profile) {
2466
- const recipe = readJsonFile(file);
2467
- const result = validateRecipe(recipe, profile ? { profile } : {});
2468
- return {
2469
- file,
2470
- profile,
2471
- valid: result.valid,
2472
- warnings: result.warnings,
2473
- errors: result.errors
2474
- };
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 };
2475
3516
  }
2476
3517
  function reportValidation(results, options) {
2477
3518
  const summary = {
@@ -2487,9 +3528,10 @@ function reportValidation(results, options) {
2487
3528
  return {
2488
3529
  file: path__namespace.relative(process.cwd(), result.file),
2489
3530
  profile: result.profile,
2490
- valid: result.valid,
3531
+ ok: result.ok,
2491
3532
  warnings: result.warnings,
2492
- errors: result.errors,
3533
+ schemaErrors: result.schemaErrors,
3534
+ conformanceIssues: result.conformanceIssues,
2493
3535
  passed
2494
3536
  };
2495
3537
  });
@@ -2499,14 +3541,22 @@ function reportValidation(results, options) {
2499
3541
  serializable.forEach((entry) => {
2500
3542
  const prefix = entry.passed ? "\u2705" : "\u274C";
2501
3543
  console.log(`${prefix} ${entry.file}`);
2502
- if (!entry.passed && entry.errors.length) {
2503
- entry.errors.forEach((error) => {
3544
+ if (!entry.passed && entry.schemaErrors.length) {
3545
+ console.log(" Schema errors:");
3546
+ entry.schemaErrors.forEach((error) => {
2504
3547
  console.log(` \u2022 [${error.path}] ${error.message}`);
2505
3548
  });
2506
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
+ }
2507
3556
  if (!entry.passed && options.strict && entry.warnings.length) {
3557
+ console.log(" Warnings:");
2508
3558
  entry.warnings.forEach((warning) => {
2509
- console.log(` \u2022 [${warning.path}] ${warning.message} (warning)`);
3559
+ console.log(` \u2022 ${warning} (warning)`);
2510
3560
  });
2511
3561
  }
2512
3562
  });
@@ -2518,7 +3568,7 @@ function reportValidation(results, options) {
2518
3568
  }
2519
3569
  }
2520
3570
  function isEffectivelyValid(result, strict) {
2521
- return result.valid && (!strict || result.warnings.length === 0);
3571
+ return result.ok && (!strict || result.warnings.length === 0);
2522
3572
  }
2523
3573
  function readJsonFile(relativePath) {
2524
3574
  const absolutePath = path__namespace.resolve(relativePath);
@@ -2550,6 +3600,52 @@ function writeOutput(data, outputPath) {
2550
3600
  const absolutePath = path__namespace.resolve(outputPath);
2551
3601
  fs__namespace.writeFileSync(absolutePath, serialized, "utf-8");
2552
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
+ }
2553
3649
  if (__require.main === module) {
2554
3650
  runCli(process.argv.slice(2)).catch((error) => {
2555
3651
  console.error(`\u274C ${error?.message ?? error}`);