@object-ui/cli 17.7.0 → 18.0.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.js CHANGED
@@ -528,7 +528,9 @@ import { globSync } from "glob";
528
528
  import { readFileSync as readFileSync2 } from "fs";
529
529
  import { join as join6 } from "path";
530
530
  import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
531
- import { safeValidateSchema } from "@object-ui/types/zod";
531
+
532
+ // src/utils/authoring-face.ts
533
+ import { StrictAnyComponentSchema } from "@object-ui/types/zod";
532
534
 
533
535
  // src/utils/issue-path.ts
534
536
  function formatIssuePath(path) {
@@ -536,6 +538,188 @@ function formatIssuePath(path) {
536
538
  return segments.length > 0 ? segments.join(" \u2192 ") : "(root)";
537
539
  }
538
540
 
541
+ // src/utils/union-arm-diagnostics.ts
542
+ var MAX_UNION_ARMS_REPORTED = 5;
543
+ function editDistance(a, b) {
544
+ if (a === b) return 0;
545
+ if (a.length === 0) return b.length;
546
+ if (b.length === 0) return a.length;
547
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
548
+ for (let i = 1; i <= a.length; i += 1) {
549
+ const current = [i];
550
+ for (let j = 1; j <= b.length; j += 1) {
551
+ current[j] = Math.min(
552
+ previous[j] + 1,
553
+ current[j - 1] + 1,
554
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
555
+ );
556
+ }
557
+ previous = current;
558
+ }
559
+ return previous[b.length];
560
+ }
561
+ function nearestArmNames(authoredType, armNames) {
562
+ const unique = [...new Set(armNames)].sort();
563
+ if (typeof authoredType !== "string" || authoredType === "") {
564
+ return { candidates: [], total: unique.length };
565
+ }
566
+ const ordered = [...unique].sort((x, y) => {
567
+ const d = editDistance(authoredType, x) - editDistance(authoredType, y);
568
+ return d !== 0 ? d : x.localeCompare(y);
569
+ });
570
+ return { candidates: ordered.slice(0, MAX_UNION_ARMS_REPORTED), total: unique.length };
571
+ }
572
+ function authoredTypeAt(document, path) {
573
+ let node = document;
574
+ for (const key of path) {
575
+ if (node === null || typeof node !== "object") return void 0;
576
+ node = node[key];
577
+ }
578
+ if (node === null || typeof node !== "object") return void 0;
579
+ const type = node.type;
580
+ return typeof type === "string" ? type : void 0;
581
+ }
582
+ function isUnion(issue) {
583
+ return issue.code === "invalid_union" && Array.isArray(issue.errors);
584
+ }
585
+ var NO_MATCHING_DISCRIMINATOR = "No matching discriminator";
586
+ function noArmAccepts(node, literals, document) {
587
+ const authoredType = authoredTypeAt(document, node);
588
+ const { candidates, total } = nearestArmNames(authoredType, literals);
589
+ return {
590
+ kind: "note",
591
+ path: [...node],
592
+ ...authoredType === void 0 ? {} : { authoredType },
593
+ candidates,
594
+ totalArmNames: total
595
+ };
596
+ }
597
+ function noMatchingDiscriminator(issue, document) {
598
+ if (issue.code !== "invalid_union") return void 0;
599
+ if (issue.note !== NO_MATCHING_DISCRIMINATOR) return void 0;
600
+ if (!Array.isArray(issue.options)) return void 0;
601
+ if ((issue.errors ?? []).length > 0) return void 0;
602
+ const path = issue.path ?? [];
603
+ if (path[path.length - 1] !== "type") return void 0;
604
+ if (issue.discriminator !== void 0 && issue.discriminator !== "type") return void 0;
605
+ const literals = issue.options.filter((v) => typeof v === "string");
606
+ if (literals.length === 0) return void 0;
607
+ return noArmAccepts(path.slice(0, -1), literals, document);
608
+ }
609
+ function isAtTypeKey(issue) {
610
+ const path = issue.path ?? [];
611
+ return path.length === 1 && path[0] === "type";
612
+ }
613
+ function declaredTypeLiterals(armIssues) {
614
+ const literals = [];
615
+ let declares = false;
616
+ for (const issue of armIssues) {
617
+ if (isAtTypeKey(issue) && issue.code === "invalid_value" && Array.isArray(issue.values)) {
618
+ declares = true;
619
+ literals.push(...issue.values.filter((v) => typeof v === "string"));
620
+ continue;
621
+ }
622
+ if (isAtTypeKey(issue) && isUnion(issue) && Array.isArray(issue.options)) {
623
+ declares = true;
624
+ literals.push(...issue.options.filter((v) => typeof v === "string"));
625
+ continue;
626
+ }
627
+ if ((issue.path ?? []).length === 0 && isUnion(issue)) {
628
+ const nested = (issue.errors ?? []).map((sub) => declaredTypeLiterals(sub));
629
+ if (nested.length > 0 && nested.every((n) => n !== void 0)) {
630
+ declares = true;
631
+ for (const n of nested) literals.push(...n);
632
+ }
633
+ }
634
+ }
635
+ return declares ? literals : void 0;
636
+ }
637
+ function explain(unionIssue, prefix, document) {
638
+ const arms = unionIssue.errors ?? [];
639
+ const declared = arms.map((arm) => declaredTypeLiterals(arm));
640
+ const declaringArms = declared.filter((d) => d !== void 0);
641
+ const acceptingIndexes = declared.map((d, i) => d === void 0 ? i : -1).filter((i) => i >= 0);
642
+ const expand = (armIssues, arm) => armIssues.flatMap((issue) => {
643
+ const absolute = [...prefix, ...issue.path ?? []];
644
+ const discriminated = noMatchingDiscriminator({ ...issue, path: absolute }, document);
645
+ if (discriminated) return [discriminated];
646
+ if (isUnion(issue)) return explain(issue, absolute, document);
647
+ return [
648
+ {
649
+ kind: "issue",
650
+ path: absolute,
651
+ message: issue.message ?? "",
652
+ code: issue.code,
653
+ ...arm === void 0 ? {} : { arm }
654
+ }
655
+ ];
656
+ });
657
+ if (declaringArms.length > 0 && acceptingIndexes.length === 1) {
658
+ return expand(arms[acceptingIndexes[0]]);
659
+ }
660
+ if (declaringArms.length > 0 && acceptingIndexes.length === 0) {
661
+ return [noArmAccepts(prefix, declaringArms.flat(), document)];
662
+ }
663
+ return arms.slice(0, MAX_UNION_ARMS_REPORTED).flatMap((arm, i) => expand(arm, `${i + 1}/${arms.length}`));
664
+ }
665
+ function explainUnionIssue(issue, document) {
666
+ const discriminated = noMatchingDiscriminator(issue, document);
667
+ if (discriminated) return [discriminated];
668
+ if (!isUnion(issue)) return [];
669
+ return explain(issue, issue.path ?? [], document);
670
+ }
671
+ function isTypeMismatch(issue) {
672
+ return isAtTypeKey(issue) && (issue.code === "invalid_value" || isUnion(issue));
673
+ }
674
+ function isShapeMismatch(issue) {
675
+ if ((issue.path ?? []).length !== 0) return false;
676
+ if (issue.code === "invalid_type") return true;
677
+ return isUnion(issue) && (issue.errors ?? []).every((arm) => !isViableArm(arm));
678
+ }
679
+ function isViableArm(armIssues) {
680
+ if (armIssues.some(isTypeMismatch)) return false;
681
+ return !armIssues.every(isShapeMismatch);
682
+ }
683
+ function findUndeclaredKeys(issues) {
684
+ const idOf = (finding) => JSON.stringify([...finding.path.map(String), finding.key]);
685
+ const collect = (list, prefix) => {
686
+ const found = [];
687
+ for (const issue of list) {
688
+ const path = [...prefix, ...issue.path ?? []];
689
+ if (issue.code === "unrecognized_keys") {
690
+ for (const key of issue.keys ?? []) found.push({ path, key });
691
+ }
692
+ if (isUnion(issue)) {
693
+ const perArm = (issue.errors ?? []).filter(isViableArm).map((arm) => collect(arm, path));
694
+ const [first, ...others] = perArm;
695
+ if (first === void 0) continue;
696
+ const refusedByAll = first.filter(
697
+ (finding) => others.every((arm) => arm.some((other) => idOf(other) === idOf(finding)))
698
+ );
699
+ found.push(...refusedByAll);
700
+ }
701
+ }
702
+ return found;
703
+ };
704
+ const seen = /* @__PURE__ */ new Set();
705
+ return collect(issues, []).filter((finding) => {
706
+ const id = idOf(finding);
707
+ if (seen.has(id)) return false;
708
+ seen.add(id);
709
+ return true;
710
+ });
711
+ }
712
+
713
+ // src/utils/authoring-face.ts
714
+ function validateAuthoredDocument(document) {
715
+ return StrictAnyComponentSchema.safeParse(document);
716
+ }
717
+ function describeUndeclaredKey(finding, document) {
718
+ const type = authoredTypeAt(document, finding.path);
719
+ const owner = type === void 0 ? "" : ` (type "${type}")`;
720
+ return `Undeclared key "${finding.key}" at ${formatIssuePath(finding.path)}${owner}: no schema declares it there. Remove it, or check its spelling against the keys declared at that position.`;
721
+ }
722
+
539
723
  // src/utils/known-schema-types.ts
540
724
  var KNOWN_SCHEMA_TYPES = [
541
725
  "a",
@@ -558,8 +742,6 @@ var KNOWN_SCHEMA_TYPES = [
558
742
  "app-schema-renderer",
559
743
  "app-shell:cloud-connection:panel",
560
744
  "app-shell:cloud:ai-model-status",
561
- "app-shell:cloud:onboarding-next",
562
- "app-shell:marketplace:installed-list",
563
745
  "app-shell:mcp:connect-agent",
564
746
  "app:launcher",
565
747
  "article",
@@ -585,11 +767,11 @@ var KNOWN_SCHEMA_TYPES = [
585
767
  "chatbot-enhanced",
586
768
  "chatbot-floating",
587
769
  "checkbox",
770
+ "checklist",
588
771
  "cite",
589
772
  "cloud-connection:panel",
590
773
  "cloud:ai-model-status",
591
- "cloud:onboarding-next",
592
- "cloud:plan-status",
774
+ "cloud:workspace-timezone-notice",
593
775
  "code",
594
776
  "code-editor",
595
777
  "collapsible",
@@ -658,8 +840,9 @@ var KNOWN_SCHEMA_TYPES = [
658
840
  "field:object-ref",
659
841
  "field:password",
660
842
  "field:percent",
661
- "field:permission-facet-link",
843
+ "field:permission-ref",
662
844
  "field:phone",
845
+ "field:position-ref",
663
846
  "field:progress",
664
847
  "field:qrcode",
665
848
  "field:radio",
@@ -698,6 +881,7 @@ var KNOWN_SCHEMA_TYPES = [
698
881
  "h6",
699
882
  "header",
700
883
  "header-bar",
884
+ "hero",
701
885
  "home",
702
886
  "hover-card",
703
887
  "hr",
@@ -720,7 +904,6 @@ var KNOWN_SCHEMA_TYPES = [
720
904
  "main",
721
905
  "mark",
722
906
  "markdown",
723
- "marketplace:installed-list",
724
907
  "mcp:connect-agent",
725
908
  "menubar",
726
909
  "nav",
@@ -892,6 +1075,7 @@ var KNOWN_SCHEMA_TYPES = [
892
1075
  "record",
893
1076
  "record:activity",
894
1077
  "record:alert",
1078
+ "record:approval_decision",
895
1079
  "record:approvals",
896
1080
  "record:attachments",
897
1081
  "record:chatter",
@@ -922,6 +1106,7 @@ var KNOWN_SCHEMA_TYPES = [
922
1106
  "span",
923
1107
  "spinner",
924
1108
  "stack",
1109
+ "stat",
925
1110
  "statistic",
926
1111
  "strong",
927
1112
  "sub",
@@ -931,6 +1116,7 @@ var KNOWN_SCHEMA_TYPES = [
931
1116
  "tabs",
932
1117
  "text",
933
1118
  "textarea",
1119
+ "tile-grid",
934
1120
  "time",
935
1121
  "timeline",
936
1122
  "toast",
@@ -1064,6 +1250,7 @@ var KNOWN_SCHEMA_TYPES = [
1064
1250
  "ui:ul",
1065
1251
  "ui:utility",
1066
1252
  "ul",
1253
+ "usage-bar",
1067
1254
  "utility",
1068
1255
  "view-switcher",
1069
1256
  "view:calendar",
@@ -1123,6 +1310,7 @@ import {
1123
1310
  EXPRESSION_BINDABLE_TEXT_KEYS,
1124
1311
  expressionBindableTextKeysFor
1125
1312
  } from "@objectstack/spec/ui";
1313
+ import { nodeSlotValues, nodeSlotsFor } from "@object-ui/types";
1126
1314
  var EXPRESSION_PATTERN = /\$\{[^}]+\}/;
1127
1315
  var COMPOSITION_KEY = "children";
1128
1316
  var PAGE_DOCUMENT_ROOT_TYPE = "page";
@@ -1156,6 +1344,11 @@ function visit(node, path, isDocumentRoot, into) {
1156
1344
  } else if (isComponentNode(children)) {
1157
1345
  visit(children, [...path, COMPOSITION_KEY], false, into);
1158
1346
  }
1347
+ for (const slot of nodeSlotsFor(node.type)) {
1348
+ for (const { segments, value } of nodeSlotValues(node, slot.path)) {
1349
+ if (isComponentNode(value)) visit(value, [...path, ...segments], false, into);
1350
+ }
1351
+ }
1159
1352
  }
1160
1353
  function findUnbindableTextExpressions(document) {
1161
1354
  const found = [];
@@ -1207,7 +1400,7 @@ function recogniseObjectUiSchemaFile(content) {
1207
1400
  if (OBJECTUI_STRUCTURAL_KEYS.some((key) => key in content)) {
1208
1401
  return { recognised: true, validated: false };
1209
1402
  }
1210
- const result = safeValidateSchema(content);
1403
+ const result = validateAuthoredDocument(content);
1211
1404
  return result.success ? { recognised: true, validated: true } : { recognised: false, issues: result.error.issues };
1212
1405
  }
1213
1406
  function describeFirstIssue(issues) {
@@ -1248,6 +1441,18 @@ async function check(cwd = process.cwd()) {
1248
1441
  const unvalidatedCandidates = [];
1249
1442
  let validated = 0;
1250
1443
  let notValidated = 0;
1444
+ const judgeTextExpressions = (file, document) => {
1445
+ for (const finding of findUnbindableTextExpressions(document)) {
1446
+ const line = `${file} ${describeUnbindableTextExpression(finding)}`;
1447
+ if (finding.severity === "refusal") {
1448
+ console.log(chalk6.red(`x Unevaluated expression in ${line}`));
1449
+ console.log(chalk6.dim(` ${workingChannels(finding)}`));
1450
+ errors++;
1451
+ } else {
1452
+ console.log(chalk6.yellow(`\u26A0\uFE0F Expression not judged in ${line}`));
1453
+ }
1454
+ }
1455
+ };
1251
1456
  for (const file of files) {
1252
1457
  try {
1253
1458
  if (file.endsWith(".json")) {
@@ -1272,8 +1477,10 @@ async function check(cwd = process.cwd()) {
1272
1477
  unvalidatedCandidates.push({
1273
1478
  file,
1274
1479
  type: content.type,
1275
- issues: recognition.issues
1480
+ issues: recognition.issues,
1481
+ document: content
1276
1482
  });
1483
+ judgeTextExpressions(file, content);
1277
1484
  } else {
1278
1485
  skipped++;
1279
1486
  }
@@ -1291,16 +1498,7 @@ async function check(cwd = process.cwd()) {
1291
1498
  )
1292
1499
  );
1293
1500
  }
1294
- for (const finding of findUnbindableTextExpressions(content)) {
1295
- const line = `${file} ${describeUnbindableTextExpression(finding)}`;
1296
- if (finding.severity === "refusal") {
1297
- console.log(chalk6.red(`x Unevaluated expression in ${line}`));
1298
- console.log(chalk6.dim(` ${workingChannels(finding)}`));
1299
- errors++;
1300
- } else {
1301
- console.log(chalk6.yellow(`\u26A0\uFE0F Expression not judged in ${line}`));
1302
- }
1303
- }
1501
+ judgeTextExpressions(file, content);
1304
1502
  }
1305
1503
  }
1306
1504
  }
@@ -1316,12 +1514,15 @@ async function check(cwd = process.cwd()) {
1316
1514
  `\u26A0\uFE0F ${n} file${n === 1 ? "" : "s"} carr${n === 1 ? "ies" : "y"} a registered ObjectUI component type but did not validate as an ObjectUI schema:`
1317
1515
  )
1318
1516
  );
1319
- for (const { file, type, issues } of unvalidatedCandidates) {
1517
+ for (const { file, type, issues, document } of unvalidatedCandidates) {
1320
1518
  console.log(chalk6.yellow(` ${file} (type "${type}")`));
1321
1519
  const firstIssue = describeFirstIssue(issues);
1322
1520
  if (firstIssue !== void 0) {
1323
1521
  console.log(chalk6.yellow(` ${firstIssue}`));
1324
1522
  }
1523
+ for (const finding of findUndeclaredKeys(issues)) {
1524
+ console.log(chalk6.yellow(` ${describeUndeclaredKey(finding, document)}`));
1525
+ }
1325
1526
  }
1326
1527
  console.log(
1327
1528
  chalk6.dim(" Run `objectui validate <file>` on any of them for the full diagnosis.")
@@ -1373,7 +1574,6 @@ import chalk7 from "chalk";
1373
1574
  import { readFileSync as readFileSync3, existsSync as existsSync5 } from "fs";
1374
1575
  import { resolve as resolve3 } from "path";
1375
1576
  import { load as loadYaml } from "js-yaml";
1376
- import { safeValidateSchema as safeValidateSchema2 } from "@object-ui/types/zod";
1377
1577
 
1378
1578
  // src/utils/spec-vocabulary-hint.ts
1379
1579
  function findSpecVocabularyFormFields(node, path = "") {
@@ -1404,135 +1604,41 @@ function findSpecVocabularyFormFields(node, path = "") {
1404
1604
  return findings;
1405
1605
  }
1406
1606
 
1407
- // src/utils/union-arm-diagnostics.ts
1408
- var MAX_UNION_ARMS_REPORTED = 5;
1409
- function editDistance(a, b) {
1410
- if (a === b) return 0;
1411
- if (a.length === 0) return b.length;
1412
- if (b.length === 0) return a.length;
1413
- let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
1414
- for (let i = 1; i <= a.length; i += 1) {
1415
- const current = [i];
1416
- for (let j = 1; j <= b.length; j += 1) {
1417
- current[j] = Math.min(
1418
- previous[j] + 1,
1419
- current[j - 1] + 1,
1420
- previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
1421
- );
1422
- }
1423
- previous = current;
1424
- }
1425
- return previous[b.length];
1426
- }
1427
- function nearestArmNames(authoredType, armNames) {
1428
- const unique = [...new Set(armNames)].sort();
1429
- if (typeof authoredType !== "string" || authoredType === "") {
1430
- return { candidates: [], total: unique.length };
1607
+ // src/utils/record-block-binding-hint.ts
1608
+ var ONE_RECORD_BLOCK_TYPES = /* @__PURE__ */ new Set([
1609
+ "record:details",
1610
+ "record:highlights",
1611
+ "record:quick_actions"
1612
+ ]);
1613
+ var PARENT_BINDING_BLOCK_TYPE = "record:related_list";
1614
+ var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
1615
+ var unboundedObjectOf = (binding) => isRecord(binding) && typeof binding.object === "string" && binding.object.length > 0 && binding.limit !== 1 ? binding.object : void 0;
1616
+ function findRecordBindingsWithoutLimitOne(document, path = []) {
1617
+ if (Array.isArray(document)) {
1618
+ return document.flatMap((child, i) => findRecordBindingsWithoutLimitOne(child, [...path, i]));
1431
1619
  }
1432
- const ordered = [...unique].sort((x, y) => {
1433
- const d = editDistance(authoredType, x) - editDistance(authoredType, y);
1434
- return d !== 0 ? d : x.localeCompare(y);
1435
- });
1436
- return { candidates: ordered.slice(0, MAX_UNION_ARMS_REPORTED), total: unique.length };
1437
- }
1438
- function authoredTypeAt(document, path) {
1439
- let node = document;
1440
- for (const key of path) {
1441
- if (node === null || typeof node !== "object") return void 0;
1442
- node = node[key];
1620
+ if (!isRecord(document)) return [];
1621
+ const findings = [];
1622
+ if (typeof document.type === "string" && ONE_RECORD_BLOCK_TYPES.has(document.type)) {
1623
+ const object = unboundedObjectOf(document.dataSource);
1624
+ if (object) findings.push({ path: formatIssuePath(path), type: document.type, object, key: "dataSource" });
1443
1625
  }
1444
- if (node === null || typeof node !== "object") return void 0;
1445
- const type = node.type;
1446
- return typeof type === "string" ? type : void 0;
1447
- }
1448
- function isUnion(issue) {
1449
- return issue.code === "invalid_union" && Array.isArray(issue.errors);
1450
- }
1451
- var NO_MATCHING_DISCRIMINATOR = "No matching discriminator";
1452
- function noArmAccepts(node, literals, document) {
1453
- const authoredType = authoredTypeAt(document, node);
1454
- const { candidates, total } = nearestArmNames(authoredType, literals);
1455
- return {
1456
- kind: "note",
1457
- path: [...node],
1458
- ...authoredType === void 0 ? {} : { authoredType },
1459
- candidates,
1460
- totalArmNames: total
1461
- };
1462
- }
1463
- function noMatchingDiscriminator(issue, document) {
1464
- if (issue.code !== "invalid_union") return void 0;
1465
- if (issue.note !== NO_MATCHING_DISCRIMINATOR) return void 0;
1466
- if (!Array.isArray(issue.options)) return void 0;
1467
- if ((issue.errors ?? []).length > 0) return void 0;
1468
- const path = issue.path ?? [];
1469
- if (path[path.length - 1] !== "type") return void 0;
1470
- if (issue.discriminator !== void 0 && issue.discriminator !== "type") return void 0;
1471
- const literals = issue.options.filter((v) => typeof v === "string");
1472
- if (literals.length === 0) return void 0;
1473
- return noArmAccepts(path.slice(0, -1), literals, document);
1474
- }
1475
- function isAtTypeKey(issue) {
1476
- const path = issue.path ?? [];
1477
- return path.length === 1 && path[0] === "type";
1478
- }
1479
- function declaredTypeLiterals(armIssues) {
1480
- const literals = [];
1481
- let declares = false;
1482
- for (const issue of armIssues) {
1483
- if (isAtTypeKey(issue) && issue.code === "invalid_value" && Array.isArray(issue.values)) {
1484
- declares = true;
1485
- literals.push(...issue.values.filter((v) => typeof v === "string"));
1486
- continue;
1487
- }
1488
- if (isAtTypeKey(issue) && isUnion(issue) && Array.isArray(issue.options)) {
1489
- declares = true;
1490
- literals.push(...issue.options.filter((v) => typeof v === "string"));
1491
- continue;
1492
- }
1493
- if ((issue.path ?? []).length === 0 && isUnion(issue)) {
1494
- const nested = (issue.errors ?? []).map((sub) => declaredTypeLiterals(sub));
1495
- if (nested.length > 0 && nested.every((n) => n !== void 0)) {
1496
- declares = true;
1497
- for (const n of nested) literals.push(...n);
1498
- }
1626
+ if (document.type === PARENT_BINDING_BLOCK_TYPE && isRecord(document.properties)) {
1627
+ const object = unboundedObjectOf(document.properties.parentDataSource);
1628
+ if (object) {
1629
+ findings.push({ path: formatIssuePath(path), type: document.type, object, key: "properties.parentDataSource" });
1499
1630
  }
1500
1631
  }
1501
- return declares ? literals : void 0;
1502
- }
1503
- function explain(unionIssue, prefix, document) {
1504
- const arms = unionIssue.errors ?? [];
1505
- const declared = arms.map((arm) => declaredTypeLiterals(arm));
1506
- const declaringArms = declared.filter((d) => d !== void 0);
1507
- const acceptingIndexes = declared.map((d, i) => d === void 0 ? i : -1).filter((i) => i >= 0);
1508
- const expand = (armIssues, arm) => armIssues.flatMap((issue) => {
1509
- const absolute = [...prefix, ...issue.path ?? []];
1510
- const discriminated = noMatchingDiscriminator({ ...issue, path: absolute }, document);
1511
- if (discriminated) return [discriminated];
1512
- if (isUnion(issue)) return explain(issue, absolute, document);
1513
- return [
1514
- {
1515
- kind: "issue",
1516
- path: absolute,
1517
- message: issue.message ?? "",
1518
- code: issue.code,
1519
- ...arm === void 0 ? {} : { arm }
1520
- }
1521
- ];
1522
- });
1523
- if (declaringArms.length > 0 && acceptingIndexes.length === 1) {
1524
- return expand(arms[acceptingIndexes[0]]);
1525
- }
1526
- if (declaringArms.length > 0 && acceptingIndexes.length === 0) {
1527
- return [noArmAccepts(prefix, declaringArms.flat(), document)];
1632
+ for (const [key, value] of Object.entries(document)) {
1633
+ findings.push(...findRecordBindingsWithoutLimitOne(value, [...path, key]));
1528
1634
  }
1529
- return arms.slice(0, MAX_UNION_ARMS_REPORTED).flatMap((arm, i) => expand(arm, `${i + 1}/${arms.length}`));
1635
+ return findings;
1530
1636
  }
1531
- function explainUnionIssue(issue, document) {
1532
- const discriminated = noMatchingDiscriminator(issue, document);
1533
- if (discriminated) return [discriminated];
1534
- if (!isUnion(issue)) return [];
1535
- return explain(issue, issue.path ?? [], document);
1637
+ function describeRecordBindingWithoutLimitOne(finding) {
1638
+ if (finding.key === "properties.parentDataSource") {
1639
+ return `${finding.path}: ${finding.type}'s parent record is bound to "${finding.object}" by a \`properties.parentDataSource\` without \`limit: 1\`. A related list hangs off ONE parent record \u2014 the first row of the binding by its \`sort\` \u2014 so declare \`limit: 1\` (and a \`sort\` when several rows can match) to say which record that is.`;
1640
+ }
1641
+ return `${finding.path}: ${finding.type} is bound to "${finding.object}" by a dataSource without \`limit: 1\`. A record block shows ONE record \u2014 the first row of the binding by its \`sort\` \u2014 so declare \`limit: 1\` (and a \`sort\` when several rows can match) to say which record that is.`;
1536
1642
  }
1537
1643
 
1538
1644
  // src/commands/validate.ts
@@ -1562,7 +1668,7 @@ async function validate(schemaPath) {
1562
1668
  }
1563
1669
  }
1564
1670
  console.log(chalk7.gray("Validating schema...\n"));
1565
- const result = safeValidateSchema2(schema);
1671
+ const result = validateAuthoredDocument(schema);
1566
1672
  if (result.success) {
1567
1673
  console.log(chalk7.green("\u2713 Schema is valid!\n"));
1568
1674
  const data = result.data;
@@ -1579,13 +1685,17 @@ async function validate(schemaPath) {
1579
1685
  console.log(chalk7.gray(" Children:"), data.children.length);
1580
1686
  }
1581
1687
  const mixed = findSpecVocabularyFormFields(schema).filter((f) => f.mixedName);
1582
- if (mixed.length > 0) {
1688
+ const unboundedRecordBindings = findRecordBindingsWithoutLimitOne(schema);
1689
+ if (mixed.length > 0 || unboundedRecordBindings.length > 0) {
1583
1690
  console.log(chalk7.yellow("\nWarnings:"));
1584
1691
  for (const m of mixed) {
1585
1692
  console.log(chalk7.yellow(
1586
1693
  ` ${m.path}: '${m.mixedName}' also carries { field: '${m.field}' } \u2014 mixed form-field vocabularies. The renderer ignores \`field\` here; drop one of the two.`
1587
1694
  ));
1588
1695
  }
1696
+ for (const finding of unboundedRecordBindings) {
1697
+ console.log(chalk7.yellow(` ${describeRecordBindingWithoutLimitOne(finding)}`));
1698
+ }
1589
1699
  }
1590
1700
  console.log("");
1591
1701
  process.exit(0);
@@ -1631,6 +1741,19 @@ ${index + 1}. ${issue.message}`));
1631
1741
  }
1632
1742
  });
1633
1743
  });
1744
+ const undeclared = findUndeclaredKeys(result.error.issues);
1745
+ if (undeclared.length > 0) {
1746
+ console.error(chalk7.bold("\nUndeclared keys \u2014 the strict authoring face refuses them:"));
1747
+ for (const finding of undeclared) {
1748
+ console.error(chalk7.yellow(` ${describeUndeclaredKey(finding, schema)}`));
1749
+ }
1750
+ }
1751
+ const mixedHere = findSpecVocabularyFormFields(schema).filter((f) => f.mixedName);
1752
+ for (const m of mixedHere) {
1753
+ console.error(chalk7.yellow(
1754
+ ` ${m.path}: '${m.mixedName}' also carries { field: '${m.field}' } \u2014 mixed form-field vocabularies. The renderer ignores \`field\` here; drop one of the two.`
1755
+ ));
1756
+ }
1634
1757
  const specShaped = findSpecVocabularyFormFields(schema).filter((f) => !f.mixedName);
1635
1758
  if (specShaped.length > 0) {
1636
1759
  console.error(chalk7.bold("\nLikely cause \u2014 spec form-view vocabulary in a standalone form:"));