@pagefront/lint-commerce 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +33 -13
  2. package/dist/catalogue.js +4 -2
  3. package/dist/generated/release-manifest.d.ts +8 -0
  4. package/dist/generated/release-manifest.js +9 -0
  5. package/dist/generated/schema-ids.js +2 -0
  6. package/dist/generated/schemas.js +2450 -7
  7. package/dist/organization-catalogue.js +3 -2
  8. package/dist/rules/org-e-001.js +2 -0
  9. package/dist/rules/org-e-002.js +2 -0
  10. package/dist/rules/org-e-003.js +2 -0
  11. package/dist/rules/org-e-004.d.ts +11 -0
  12. package/dist/rules/org-e-004.js +18 -0
  13. package/dist/rules/org-w-001.js +2 -0
  14. package/dist/rules/org-w-002.js +2 -0
  15. package/dist/rules/org-w-003.js +2 -0
  16. package/dist/rules/org-w-004.js +2 -0
  17. package/dist/rules/org-w-005.js +2 -0
  18. package/dist/rules/org-w-006.js +2 -0
  19. package/dist/rules/pdp-e-001.js +2 -0
  20. package/dist/rules/pdp-e-002.js +2 -0
  21. package/dist/rules/pdp-e-003.js +2 -0
  22. package/dist/rules/pdp-e-004.js +2 -0
  23. package/dist/rules/pdp-e-005.js +2 -0
  24. package/dist/rules/pdp-e-006.js +2 -0
  25. package/dist/rules/pdp-e-007.js +2 -0
  26. package/dist/rules/pdp-e-008.js +2 -0
  27. package/dist/rules/pdp-e-009.js +2 -0
  28. package/dist/rules/pdp-e-010.js +2 -0
  29. package/dist/rules/pdp-e-011.js +2 -0
  30. package/dist/rules/pdp-e-012.js +2 -0
  31. package/dist/rules/pdp-e-013.js +2 -0
  32. package/dist/rules/pdp-e-014.js +2 -0
  33. package/dist/rules/pdp-e-015.js +2 -0
  34. package/dist/rules/pdp-e-016.js +2 -0
  35. package/dist/rules/pdp-e-017.d.ts +2 -0
  36. package/dist/rules/pdp-e-017.js +80 -0
  37. package/dist/rules/pdp-e-018.d.ts +11 -0
  38. package/dist/rules/pdp-e-018.js +18 -0
  39. package/dist/rules/pdp-i-001.js +2 -0
  40. package/dist/rules/pdp-w-001.js +2 -0
  41. package/dist/rules/pdp-w-002.js +2 -0
  42. package/dist/rules/pdp-w-003.js +2 -0
  43. package/dist/rules/pdp-w-004.js +2 -0
  44. package/dist/rules/pdp-w-005.js +2 -0
  45. package/dist/rules/pdp-w-006.js +2 -0
  46. package/dist/rules/pdp-w-007.js +2 -0
  47. package/dist/rules/pdp-w-008.js +2 -0
  48. package/dist/rules/pdp-w-009.js +2 -0
  49. package/dist/rules/pdp-w-010.js +2 -0
  50. package/dist/rules/pdp-w-011.js +2 -0
  51. package/dist/rules/pdp-w-012.js +2 -0
  52. package/dist/rules/pdp-w-013.js +2 -0
  53. package/dist/rules/pdp-w-014.js +2 -0
  54. package/dist/rules/pdp-w-015.js +2 -0
  55. package/dist/rules/pdp-w-016.js +2 -0
  56. package/dist/rules/pdp-w-017.js +2 -0
  57. package/dist/rules/pdp-w-018.js +2 -0
  58. package/dist/rules/pdp-w-019.js +2 -0
  59. package/dist/rules/pdp-w-020.js +2 -0
  60. package/dist/rules/pdp-w-021.js +2 -0
  61. package/dist/rules/pdp-w-022.js +2 -0
  62. package/dist/rules/pdp-w-023.js +2 -0
  63. package/dist/rules/pdp-w-024.js +2 -0
  64. package/dist/rules/pdp-w-025.js +2 -0
  65. package/dist/rules/pdp-w-026.js +2 -0
  66. package/dist/rules/pdp-w-027.js +2 -0
  67. package/dist/rules/pdp-w-028.js +2 -0
  68. package/dist/rules/pdp-w-029.js +2 -0
  69. package/dist/rules/pdp-w-030.js +2 -0
  70. package/dist/rules/pdp-w-031.js +2 -0
  71. package/dist/rules/pdp-w-032.js +2 -0
  72. package/dist/rules/pdp-w-033.js +2 -0
  73. package/dist/rules/pdp-w-034.js +2 -0
  74. package/dist/rules/pdp-w-035.js +2 -0
  75. package/dist/rules/pdp-w-036.js +2 -0
  76. package/dist/rules/pdp-w-037.js +2 -0
  77. package/dist/rules/pdp-w-038.js +2 -0
  78. package/dist/rules/pdp-w-039.js +2 -0
  79. package/dist/rules/release-schema.d.ts +18 -7
  80. package/dist/rules/release-schema.js +55 -15
  81. package/dist/schema-registry.d.ts +27 -14
  82. package/dist/schema-registry.js +29 -12
  83. package/dist/schema-validators.js +12 -4
  84. package/dist/validate-and-lint.d.ts +13 -9
  85. package/dist/validate-and-lint.js +23 -10
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. package/package.json +2 -2
@@ -35,5 +35,7 @@ export const pdpW023 = {
35
35
  messageTemplate: "Recall safety notice at `{path}` coexists with offer at `{offerPath}` declaring `InStock`; the recall overrides commercial signals and the offer data likely needs updating.",
36
36
  remediation: "Update the offer's `availability` to reflect the corrective measure (typically `Discontinued` or `OutOfStock`). If the recall has been resolved and the product legitimately relisted, remove the stale `pagefront:safetyNotice` entry instead.",
37
37
  introduced: "v0.8",
38
+ appliesFrom: "0.3.0",
39
+ appliesToLegacy: true,
38
40
  specReference: "product.md#safety-notices",
39
41
  };
@@ -54,5 +54,7 @@ export const pdpW024 = {
54
54
  messageTemplate: "`pagefront:legalEntity` entry with role `{role}` at `{path}` declares an organization without an `address`.",
55
55
  remediation: "Add the entity's postal address to the `organization`. If no address is available, the entry still identifies the organization but should not be relied on as a regulatory responsible-person declaration.",
56
56
  introduced: "v0.8",
57
+ appliesFrom: "0.3.0",
58
+ appliesToLegacy: true,
57
59
  specReference: "product.md#legal-entities",
58
60
  };
@@ -31,5 +31,7 @@ export const pdpW025 = {
31
31
  messageTemplate: "Feature `{name}` at `{path}` carries value `{value}`, which parses as a number plus a unit.",
32
32
  remediation: "Move the numeric part into `value`, express the unit as a `unitCode`, and if the merchant's display formatting matters, carry the original string in `pagefront:presentationValue`.",
33
33
  introduced: "v0.8",
34
+ appliesFrom: "0.3.0",
35
+ appliesToLegacy: true,
34
36
  specReference: "product.md#feature-groups",
35
37
  };
@@ -27,5 +27,7 @@ export const pdpW026 = {
27
27
  messageTemplate: "Safety data sheet at `{path}` declares no `inLanguage`.",
28
28
  remediation: "Add the document's BCP 47 language tag as `inLanguage`. If the document is multilingual, declare the primary language.",
29
29
  introduced: "v0.8",
30
+ appliesFrom: "0.3.0",
31
+ appliesToLegacy: true,
30
32
  specReference: "product.md#documents",
31
33
  };
@@ -43,5 +43,7 @@ export const pdpW027 = {
43
43
  messageTemplate: "Question `{name}` at `{path}` has an `acceptedAnswer` with no text.",
44
44
  remediation: "Populate `acceptedAnswer.text` with the answer prose, or remove the `acceptedAnswer` (and consider whether an unanswered question belongs in the sheet at all).",
45
45
  introduced: "v0.8",
46
+ appliesFrom: "0.3.0",
47
+ appliesToLegacy: true,
46
48
  specReference: "product.md#product-qa",
47
49
  };
@@ -25,5 +25,7 @@ export const pdpW028 = {
25
25
  messageTemplate: "Offer at `{path}` states a price but not whether value-added tax is included.",
26
26
  remediation: "Add a `UnitPriceSpecification` entry mirroring `price` with `valueAddedTaxIncluded` set to `true` or `false`; on an Offer that already uses `priceSpecification`, set the flag on each entry.",
27
27
  introduced: "v0.9",
28
+ appliesFrom: "0.3.0",
29
+ appliesToLegacy: true,
28
30
  specReference: "product.md#tax-inclusion",
29
31
  };
@@ -24,5 +24,7 @@ export const pdpW029 = {
24
24
  messageTemplate: "Offer at `{path}` states a price but no region it applies to.",
25
25
  remediation: "Declare `areaServed` (or `eligibleRegion`) for the market whose displayed price this is. Where the market cannot be determined from the storefront, stating the tax basis via `valueAddedTaxIncluded` is the higher priority; see \"Tax inclusion.\"",
26
26
  introduced: "v0.9",
27
+ appliesFrom: "0.3.0",
28
+ appliesToLegacy: true,
27
29
  specReference: "product.md#tax-inclusion",
28
30
  };
@@ -48,5 +48,7 @@ export const pdpW030 = {
48
48
  messageTemplate: "Warranty at `{path}` states neither a duration nor a description.",
49
49
  remediation: "State the warranty as written in `description`, and add `durationOfWarranty` where a finite length is stated. A lifetime warranty carries `description` (and optionally `name`) with no `durationOfWarranty`.",
50
50
  introduced: "v0.9",
51
+ appliesFrom: "0.3.0",
52
+ appliesToLegacy: true,
51
53
  specReference: "product.md#warranty",
52
54
  };
@@ -31,5 +31,7 @@ export const pdpW031 = {
31
31
  messageTemplate: "Seller at `{path}` has an `@id` but no `pagefront:sheetUrl`, and the Offer states no return or shipping terms of its own.",
32
32
  remediation: "Add `pagefront:sheetUrl` to the seller reference, or state the terms on the Offer.",
33
33
  introduced: "v0.9",
34
+ appliesFrom: "0.3.0",
35
+ appliesToLegacy: true,
34
36
  specReference: "organization.md#linking-contract-identity-only",
35
37
  };
@@ -46,5 +46,7 @@ export const pdpW032 = {
46
46
  messageTemplate: "Pricing block at `{path}` {problem}, and `pricing` is not `partial`.",
47
47
  remediation: "State the modifier, zero included; for a text dimension, state it under the key `\"*\"`. If the dimension never changes the price, declare `affectsPrice: false` on it. If the price is not known, declare `pricing: \"partial\"`.",
48
48
  introduced: "v0.9 (2026-10-01 revision)",
49
+ appliesFrom: "0.3.0",
50
+ appliesToLegacy: true,
49
51
  specReference: "product.md#configurator-pricing-and-price-bands",
50
52
  };
@@ -26,5 +26,7 @@ export const pdpW033 = {
26
26
  messageTemplate: "Configurator at `{path}` has priced dimensions but no offer prices it.",
27
27
  remediation: "Add an offer carrying `pagefront:configuratorPricing` for this configurator. If no option changes the price, declare `affectsPrice: false` on each dimension.",
28
28
  introduced: "v0.9 (2026-10-01 revision)",
29
+ appliesFrom: "0.3.0",
30
+ appliesToLegacy: true,
29
31
  specReference: "product.md#configurator-pricing-and-price-bands",
30
32
  };
@@ -24,5 +24,7 @@ export const pdpW034 = {
24
24
  messageTemplate: "Offer price is not linked to the configurator; state which configuration it prices.",
25
25
  remediation: "Add `pagefront:configuratorPricing` to the offer: give the configurator an `@id`, reference it, and name the configuration the price is the total of. When only that one total is known, declare `pricing: \"partial\"`.",
26
26
  introduced: "v0.9 (2026-10-01 revision)",
27
+ appliesFrom: "0.3.0",
28
+ appliesToLegacy: true,
27
29
  specReference: "product.md#configurator-pricing-and-price-bands",
28
30
  };
@@ -37,5 +37,7 @@ export const pdpW035 = {
37
37
  messageTemplate: "basePrice may be a component price, not a configured total.",
38
38
  remediation: "Confirm that `basePrice` is what a buyer pays for the whole of `baseConfiguration`. If it is the price of one part, replace it with the configured total and recompute the modifiers as differences from that total.",
39
39
  introduced: "v0.9 (2026-10-01 revision)",
40
+ appliesFrom: "0.3.0",
41
+ appliesToLegacy: true,
40
42
  specReference: "product.md#configurator-pricing-and-price-bands",
41
43
  };
@@ -27,5 +27,7 @@ export const pdpW036 = {
27
27
  messageTemplate: "Option value should be a stable identifier; put the label in description.",
28
28
  remediation: "Replace the value with a lowercase identifier without spaces (`yellow_gold`, `0.5`) and move the label to `description`. Put a unit in the dimension's `note`. Update the pricing block's keys, `baseConfiguration`, `when` and `exclusions` to the new value.",
29
29
  introduced: "v0.9 (2026-10-01 revision)",
30
+ appliesFrom: "0.3.0",
31
+ appliesToLegacy: true,
30
32
  specReference: "product.md#configurator-pricing-and-price-bands",
31
33
  };
@@ -33,5 +33,7 @@ export const pdpW037 = {
33
33
  messageTemplate: "Option does not look like a size in the declared system.",
34
34
  remediation: "Remove the entry if it is a service, and describe the service in the dimension's `note` or in `pagefront:madeToOrderTerms.aftermarketServices`. If it is a size, write it as the system writes it, without the system's name.",
35
35
  introduced: "v0.9 (2026-10-01 revision)",
36
+ appliesFrom: "0.3.0",
37
+ appliesToLegacy: true,
36
38
  specReference: "product.md#configurator-pricing-and-price-bands",
37
39
  };
@@ -26,5 +26,7 @@ export const pdpW038 = {
26
26
  messageTemplate: "Note reads as page copy; state the fact.",
27
27
  remediation: "Restate the note as a fact in the third person: what the merchant offers or recommends, with no \"you\" and no instruction.",
28
28
  introduced: "v0.9 (2026-10-01 revision)",
29
+ appliesFrom: "0.3.0",
30
+ appliesToLegacy: true,
29
31
  specReference: "product.md#configurator-pricing-and-price-bands",
30
32
  };
@@ -25,5 +25,7 @@ export const pdpW039 = {
25
25
  messageTemplate: "Size advice at `{path}` could not be stated in the Sheet's sizing system (`{system}`); it is carried as prose only.",
26
26
  remediation: "If the merchant states the advice in the sizing dimension's system, add a `sizeAdjustment` in that system. If it does not, no action: do not convert between systems.",
27
27
  introduced: "v0.9 (2026-10-01 revision)",
28
+ appliesFrom: "0.3.0",
29
+ appliesToLegacy: true,
28
30
  specReference: "product.md#size-guidance-pagefrontsizechart-and-pagefrontsizerecommendation",
29
31
  };
@@ -1,16 +1,27 @@
1
1
  import type { CheckFn, Rule } from "@pagefront/lint-core";
2
2
  import type { SheetType } from "../sheet-type.js";
3
3
  /**
4
- * Release / schema mismatch. On the release line the `$schema` version
5
- * segment must be the sheet-spec version the release manifest maps the
6
- * declared `release` and the sheet type to; a release the manifest does
7
- * not list has no mapping and the rule is silent. On the legacy line
8
- * the segment must equal `format_version`, as before. Quiet when
9
- * `$schema` is absent or names no version, and when the sheet carries
10
- * both fields (the both-fields rule reports that).
4
+ * Release / schema mismatch. The rule reads `$schema` and is quiet
5
+ * whenever it is absent, on both lines: whether `$schema` is required is
6
+ * the schema's own statement (Product Sheet spec 0.10.0 and Organization
7
+ * Sheet spec 0.4.0 onward), not this rule's. On the release line a
8
+ * present `$schema` must be the URL of the sheet-spec schema the release
9
+ * manifest maps the declared `release` and the sheet type to; a release
10
+ * the manifest does not list has no mapping (the unknown-release rule
11
+ * reports it). On the legacy line the `$schema` version segment must
12
+ * equal `format_version`, as before. Quiet when the sheet carries both
13
+ * fields (the both-fields rule reports that).
11
14
  */
12
15
  export declare function releaseSchemaMismatch(sheetType: SheetType): CheckFn;
13
16
  export declare const RELEASE_SCHEMA_MISMATCH: Pick<Rule, "title" | "severity" | "target" | "messageTemplate" | "remediation">;
14
17
  /** A sheet carrying both `format_version` and `release`. */
15
18
  export declare const bothVersionFields: CheckFn;
16
19
  export declare const BOTH_VERSION_FIELDS: Pick<Rule, "title" | "severity" | "target" | "messageTemplate" | "remediation">;
20
+ /**
21
+ * Unknown release: the sheet declares a `release` the release manifest
22
+ * does not list, so no sheet-spec version and no schema can be resolved
23
+ * for it. Only a string `release` is judged; another JSON type is
24
+ * Layer 1's concern.
25
+ */
26
+ export declare const unknownRelease: CheckFn;
27
+ export declare const UNKNOWN_RELEASE: Pick<Rule, "title" | "severity" | "target" | "messageTemplate" | "remediation">;
@@ -1,4 +1,4 @@
1
- import { sheetSpecVersionFor } from "../releases.js";
1
+ import { KNOWN_RELEASES, schemaUrlFor, sheetSpecVersionFor } from "../releases.js";
2
2
  /**
3
3
  * Shared checks for the envelope's version fields, used by the product
4
4
  * and organization catalogues (PDP-E-002 / ORG-E-003, PDP-E-016 /
@@ -11,39 +11,59 @@ const SHEET_LABEL = {
11
11
  organization: "Organization Sheet",
12
12
  };
13
13
  /**
14
- * Release / schema mismatch. On the release line the `$schema` version
15
- * segment must be the sheet-spec version the release manifest maps the
16
- * declared `release` and the sheet type to; a release the manifest does
17
- * not list has no mapping and the rule is silent. On the legacy line
18
- * the segment must equal `format_version`, as before. Quiet when
19
- * `$schema` is absent or names no version, and when the sheet carries
20
- * both fields (the both-fields rule reports that).
14
+ * Release / schema mismatch. The rule reads `$schema` and is quiet
15
+ * whenever it is absent, on both lines: whether `$schema` is required is
16
+ * the schema's own statement (Product Sheet spec 0.10.0 and Organization
17
+ * Sheet spec 0.4.0 onward), not this rule's. On the release line a
18
+ * present `$schema` must be the URL of the sheet-spec schema the release
19
+ * manifest maps the declared `release` and the sheet type to; a release
20
+ * the manifest does not list has no mapping (the unknown-release rule
21
+ * reports it). On the legacy line the `$schema` version segment must
22
+ * equal `format_version`, as before. Quiet when the sheet carries both
23
+ * fields (the both-fields rule reports that).
21
24
  */
22
25
  export function releaseSchemaMismatch(sheetType) {
23
26
  return (_match, ctx) => {
24
27
  const { release, format_version: formatVersion, $schema: schema } = ctx.sheet;
25
28
  if (typeof schema !== "string")
26
29
  return null;
27
- const schemaVersion = SCHEMA_VERSION.exec(schema)?.[1];
28
- if (schemaVersion === undefined)
29
- return null;
30
30
  if (release !== undefined && formatVersion !== undefined)
31
31
  return null;
32
+ const schemaVersion = SCHEMA_VERSION.exec(schema)?.[1];
32
33
  if (typeof release === "string") {
33
34
  const expected = sheetSpecVersionFor(release, sheetType);
34
- if (expected === undefined || expected === schemaVersion)
35
+ if (expected === undefined)
36
+ return null;
37
+ const expectedUrl = schemaUrlFor(sheetType, expected);
38
+ if (schema === expectedUrl)
35
39
  return null;
40
+ const declared = `\`release\` \`${release}\` bundles ${SHEET_LABEL[sheetType]} spec \`${expected}\``;
41
+ if (schemaVersion !== undefined && schemaVersion !== expected) {
42
+ return {
43
+ path: "$['release']",
44
+ values: {
45
+ declared,
46
+ found: `\`$schema\` URL declares version \`${schemaVersion}\``,
47
+ release,
48
+ expected,
49
+ schema_version: schemaVersion,
50
+ },
51
+ };
52
+ }
36
53
  return {
37
54
  path: "$['release']",
38
55
  values: {
39
- declared: `\`release\` \`${release}\` bundles ${SHEET_LABEL[sheetType]} spec \`${expected}\``,
56
+ declared,
57
+ found: `\`$schema\` is not that schema's URL, \`${expectedUrl}\``,
40
58
  release,
41
59
  expected,
42
- schema_version: schemaVersion,
60
+ expected_url: expectedUrl,
43
61
  },
44
62
  };
45
63
  }
46
64
  if (formatVersion !== undefined) {
65
+ if (schemaVersion === undefined)
66
+ return null;
47
67
  const declared = String(formatVersion);
48
68
  if (declared === schemaVersion)
49
69
  return null;
@@ -51,6 +71,7 @@ export function releaseSchemaMismatch(sheetType) {
51
71
  path: "$['format_version']",
52
72
  values: {
53
73
  declared: `\`format_version\` is \`${declared}\``,
74
+ found: `\`$schema\` URL declares version \`${schemaVersion}\``,
54
75
  format_version: declared,
55
76
  schema_version: schemaVersion,
56
77
  },
@@ -63,7 +84,7 @@ export const RELEASE_SCHEMA_MISMATCH = {
63
84
  title: "Release / schema mismatch",
64
85
  severity: "error",
65
86
  target: "$",
66
- messageTemplate: "{declared} but `$schema` URL declares version `{schema_version}`.",
87
+ messageTemplate: "{declared} but {found}.",
67
88
  remediation: "Point `$schema` at the sheet-spec version the declared release bundles (see releases.md), or declare the release that bundles the schema the sheet was written against. On a legacy sheet, align `format_version` and the `$schema` version.",
68
89
  };
69
90
  /** A sheet carrying both `format_version` and `release`. */
@@ -75,3 +96,22 @@ export const BOTH_VERSION_FIELDS = {
75
96
  messageTemplate: "Sheet carries both `format_version` and `release`; a sheet declares one.",
76
97
  remediation: "Keep `release` and remove `format_version`. A sheet on the legacy draft line keeps `format_version` alone and its legacy `$schema`.",
77
98
  };
99
+ /**
100
+ * Unknown release: the sheet declares a `release` the release manifest
101
+ * does not list, so no sheet-spec version and no schema can be resolved
102
+ * for it. Only a string `release` is judged; another JSON type is
103
+ * Layer 1's concern.
104
+ */
105
+ export const unknownRelease = (_match, ctx) => {
106
+ const { release } = ctx.sheet;
107
+ if (typeof release !== "string" || KNOWN_RELEASES.includes(release))
108
+ return null;
109
+ return { path: "$['release']", values: { release, known: KNOWN_RELEASES.join(", ") } };
110
+ };
111
+ export const UNKNOWN_RELEASE = {
112
+ title: "Unknown release",
113
+ severity: "error",
114
+ target: "$",
115
+ messageTemplate: "`release` at `{path}` is `{release}`, which is not a release this catalogue knows ({known}).",
116
+ remediation: "Declare a published commerce release (see releases.md). If the release is newer than this linter, update the linter.",
117
+ };
@@ -2,28 +2,41 @@ import type { Sheet } from "@pagefront/lint-core";
2
2
  import type { SheetType } from "./sheet-type.js";
3
3
  /**
4
4
  * Which frozen schema a sheet is validated against. The package bundles
5
- * every schema ever published (commerce/schemas/), keyed by `$id`, and a
6
- * sheet is validated against the one its `$schema` names, on either
7
- * line. Nothing is substituted: a `$schema` that names no bundled schema
8
- * is reported as such, and the sheet is not validated against a
9
- * neighbouring version.
5
+ * every schema ever published (commerce/schemas/), keyed by `$id`. A
6
+ * sheet is judged by the release it declares: on the release line the
7
+ * schema is the one the release manifest gives for the declared
8
+ * `release` and the sheet's type, whatever `$schema` says (a `$schema`
9
+ * that disagrees is PDP-E-002's finding, at Layer 2). Legacy sheets,
10
+ * which declare no release, keep resolving through their `$schema`.
10
11
  */
11
12
  export type SchemaResolution = {
12
13
  kind: "schema";
13
14
  id: string;
14
15
  }
15
- /** `$schema` is a string that names no schema this package bundles. */
16
+ /** `release` names no release in the manifest. */
16
17
  | {
17
- kind: "unknown";
18
+ kind: "unknown-release";
19
+ declared: string;
20
+ }
21
+ /** A legacy sheet's `$schema` names no schema this package bundles. */
22
+ | {
23
+ kind: "unknown-schema";
18
24
  declared: string;
19
25
  };
20
26
  /**
21
- * Resolve the schema for a document. A declared `$schema` is taken at
22
- * its word. Without one, the schema is the one the sheet's own
23
- * statements lead to: the legacy schema for a sheet on the legacy line,
24
- * and for a sheet on the release line the sheet-spec version its
25
- * `release` bundles (the current release's when the release is not in
26
- * the manifest). `sheetType` forces the type for callers that validate
27
- * one type only.
27
+ * Resolve the schema for a document.
28
+ *
29
+ * - Release line (`release` present): the sheet-spec version the
30
+ * manifest bundles in that release for the sheet's type. The type
31
+ * comes from the usual dispatch (`$schema` path, else data-block
32
+ * shape), so it does not depend on `$schema` being present.
33
+ * - Legacy line (`format_version`, no `release`): the schema `$schema`
34
+ * names when it is bundled, the legacy schema for the pre-2026-09-08
35
+ * identifier or when `$schema` is absent.
36
+ * - Neither field: the schema `$schema` names when it is bundled, else
37
+ * the current release's, whose `required` then reports the missing
38
+ * `release`.
39
+ *
40
+ * `sheetType` forces the type for callers that validate one type only.
28
41
  */
29
42
  export declare function resolveSchema(sheet: Sheet, sheetType?: SheetType): SchemaResolution;
@@ -8,28 +8,45 @@ import { detectSheetLine, detectSheetType } from "./sheet-type.js";
8
8
  */
9
9
  const FORMER_LEGACY_ID = /^https:\/\/themachineweb\.org\/pagefront\/commerce\/schemas\/(product|organization)\/v0\.9\.json$/;
10
10
  /**
11
- * Resolve the schema for a document. A declared `$schema` is taken at
12
- * its word. Without one, the schema is the one the sheet's own
13
- * statements lead to: the legacy schema for a sheet on the legacy line,
14
- * and for a sheet on the release line the sheet-spec version its
15
- * `release` bundles (the current release's when the release is not in
16
- * the manifest). `sheetType` forces the type for callers that validate
17
- * one type only.
11
+ * Resolve the schema for a document.
12
+ *
13
+ * - Release line (`release` present): the sheet-spec version the
14
+ * manifest bundles in that release for the sheet's type. The type
15
+ * comes from the usual dispatch (`$schema` path, else data-block
16
+ * shape), so it does not depend on `$schema` being present.
17
+ * - Legacy line (`format_version`, no `release`): the schema `$schema`
18
+ * names when it is bundled, the legacy schema for the pre-2026-09-08
19
+ * identifier or when `$schema` is absent.
20
+ * - Neither field: the schema `$schema` names when it is bundled, else
21
+ * the current release's, whose `required` then reports the missing
22
+ * `release`.
23
+ *
24
+ * `sheetType` forces the type for callers that validate one type only.
18
25
  */
19
26
  export function resolveSchema(sheet, sheetType) {
27
+ const type = sheetType ?? detectSheetType(sheet);
20
28
  const declared = sheet.$schema;
29
+ if (sheet.release !== undefined) {
30
+ // A non-string release is validated against the current schema,
31
+ // whose `release` pattern rejects it.
32
+ if (typeof sheet.release !== "string") {
33
+ return { kind: "schema", id: schemaUrlFor(type, sheetSpecVersionFor(CURRENT_RELEASE, type)) };
34
+ }
35
+ const version = sheetSpecVersionFor(sheet.release, type);
36
+ if (version === undefined)
37
+ return { kind: "unknown-release", declared: sheet.release };
38
+ return { kind: "schema", id: schemaUrlFor(type, version) };
39
+ }
21
40
  if (typeof declared === "string") {
22
41
  if (SCHEMA_IDS.includes(declared))
23
42
  return { kind: "schema", id: declared };
24
43
  const former = FORMER_LEGACY_ID.exec(declared);
25
44
  if (former !== null)
26
45
  return { kind: "schema", id: legacySchemaUrl(former[1]) };
27
- return { kind: "unknown", declared };
46
+ if (detectSheetLine(sheet) === "legacy")
47
+ return { kind: "unknown-schema", declared };
28
48
  }
29
- const type = sheetType ?? detectSheetType(sheet);
30
49
  if (detectSheetLine(sheet) === "legacy")
31
50
  return { kind: "schema", id: legacySchemaUrl(type) };
32
- const release = typeof sheet.release === "string" ? sheet.release : CURRENT_RELEASE;
33
- const version = sheetSpecVersionFor(release, type) ?? sheetSpecVersionFor(CURRENT_RELEASE, type);
34
- return { kind: "schema", id: schemaUrlFor(type, version) };
51
+ return { kind: "schema", id: schemaUrlFor(type, sheetSpecVersionFor(CURRENT_RELEASE, type)) };
35
52
  }