@pagefront/lint-commerce 0.6.0 → 0.8.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 (39) hide show
  1. package/README.md +42 -2
  2. package/dist/catalogue.js +15 -2
  3. package/dist/organization-catalogue.js +1 -1
  4. package/dist/product-schema.js +143 -19
  5. package/dist/rules/configurator-pricing.d.ts +114 -0
  6. package/dist/rules/configurator-pricing.js +349 -0
  7. package/dist/rules/pdp-e-001.js +12 -3
  8. package/dist/rules/pdp-e-005.d.ts +12 -0
  9. package/dist/rules/pdp-e-005.js +40 -0
  10. package/dist/rules/pdp-e-006.d.ts +11 -0
  11. package/dist/rules/pdp-e-006.js +36 -0
  12. package/dist/rules/pdp-e-007.d.ts +12 -0
  13. package/dist/rules/pdp-e-007.js +53 -0
  14. package/dist/rules/pdp-e-008.d.ts +10 -0
  15. package/dist/rules/pdp-e-008.js +41 -0
  16. package/dist/rules/pdp-e-009.d.ts +12 -0
  17. package/dist/rules/pdp-e-009.js +62 -0
  18. package/dist/rules/pdp-e-010.d.ts +10 -0
  19. package/dist/rules/pdp-e-010.js +37 -0
  20. package/dist/rules/pdp-e-011.d.ts +11 -0
  21. package/dist/rules/pdp-e-011.js +53 -0
  22. package/dist/rules/pdp-e-012.d.ts +2 -0
  23. package/dist/rules/pdp-e-012.js +32 -0
  24. package/dist/rules/pdp-w-031.d.ts +12 -0
  25. package/dist/rules/pdp-w-031.js +35 -0
  26. package/dist/rules/pdp-w-032.d.ts +10 -0
  27. package/dist/rules/pdp-w-032.js +42 -0
  28. package/dist/rules/pdp-w-033.d.ts +10 -0
  29. package/dist/rules/pdp-w-033.js +30 -0
  30. package/dist/rules/pdp-w-034.d.ts +10 -0
  31. package/dist/rules/pdp-w-034.js +28 -0
  32. package/dist/rules/pdp-w-035.d.ts +11 -0
  33. package/dist/rules/pdp-w-035.js +41 -0
  34. package/dist/rules/sheet-url-host.d.ts +11 -0
  35. package/dist/rules/sheet-url-host.js +22 -0
  36. package/dist/schema-validators.js +8 -2
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,349 @@
1
+ export function asObject(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value)
3
+ ? value
4
+ : null;
5
+ }
6
+ /** Own-property lookup: option keys are publisher strings (`constructor`, …). */
7
+ export function own(obj, key) {
8
+ return obj !== null && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
9
+ }
10
+ const CONTROL_ESCAPES = {
11
+ "\b": "\\b",
12
+ "\f": "\\f",
13
+ "\n": "\\n",
14
+ "\r": "\\r",
15
+ "\t": "\\t",
16
+ };
17
+ /**
18
+ * A name selector in the engine's path style (RFC 9535 normalized
19
+ * paths). Option keys are publisher strings, so the quote, the
20
+ * backslash and control characters are escaped as the RFC prescribes.
21
+ */
22
+ export function seg(key) {
23
+ const escaped = key.replace(/[\\'\u0000-\u001f]/g, (ch) => {
24
+ if (ch === "\\" || ch === "'")
25
+ return `\\${ch}`;
26
+ return CONTROL_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
27
+ });
28
+ return `['${escaped}']`;
29
+ }
30
+ const PLAIN_DECIMAL = /^(-?)(\d+)(?:\.(\d+))?$/;
31
+ /**
32
+ * A decimal reading of an amount: a plain decimal string, or a JSON
33
+ * number whose shortest serialization is a plain decimal. Anything else
34
+ * is not comparable and yields undefined.
35
+ */
36
+ export function parseDecimal(value) {
37
+ let text;
38
+ if (typeof value === "string")
39
+ text = value;
40
+ else if (typeof value === "number" && Number.isFinite(value))
41
+ text = String(value);
42
+ else
43
+ return undefined;
44
+ const m = PLAIN_DECIMAL.exec(text);
45
+ if (m === null)
46
+ return undefined;
47
+ const fraction = m[3] ?? "";
48
+ const units = BigInt(m[2] + fraction);
49
+ return { units: m[1] === "-" ? -units : units, scale: fraction.length };
50
+ }
51
+ function rescale(d, scale) {
52
+ return d.units * 10n ** BigInt(scale - d.scale);
53
+ }
54
+ export function addDecimal(a, b) {
55
+ const scale = Math.max(a.scale, b.scale);
56
+ return { units: rescale(a, scale) + rescale(b, scale), scale };
57
+ }
58
+ /** Negative, zero or positive as `a` is below, equal to or above `b`. */
59
+ export function compareDecimal(a, b) {
60
+ const scale = Math.max(a.scale, b.scale);
61
+ const diff = rescale(a, scale) - rescale(b, scale);
62
+ return diff < 0n ? -1 : diff > 0n ? 1 : 0;
63
+ }
64
+ export function isZero(d) {
65
+ return d.units === 0n;
66
+ }
67
+ /** Renders with the decimal's own number of places (`859.00` at scale 2). */
68
+ export function formatDecimal(d) {
69
+ const negative = d.units < 0n;
70
+ const digits = (negative ? -d.units : d.units).toString().padStart(d.scale + 1, "0");
71
+ const whole = digits.slice(0, digits.length - d.scale);
72
+ const fraction = d.scale > 0 ? `.${digits.slice(digits.length - d.scale)}` : "";
73
+ return `${negative ? "-" : ""}${whole}${fraction}`;
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // The configurator and the pricing block
77
+ // ---------------------------------------------------------------------------
78
+ /** The string form an option value is matched by (Definitions: *option key*). */
79
+ export function optionKey(value) {
80
+ if (typeof value === "string")
81
+ return value;
82
+ if (typeof value === "number" || typeof value === "boolean")
83
+ return JSON.stringify(value);
84
+ return undefined;
85
+ }
86
+ function selection(value) {
87
+ const obj = asObject(value);
88
+ if (obj === null)
89
+ return null;
90
+ const out = new Map();
91
+ for (const [name, raw] of Object.entries(obj)) {
92
+ const key = optionKey(raw);
93
+ if (key === undefined)
94
+ return null;
95
+ out.set(name, key);
96
+ }
97
+ return out;
98
+ }
99
+ /** The Sheet's `pagefront:configurator`, or null when absent or shapeless. */
100
+ export function readConfigurator(sheet) {
101
+ const raw = asObject(own(asObject(sheet.data), "pagefront:configurator"));
102
+ if (raw === null || !Array.isArray(raw["dimensions"]))
103
+ return null;
104
+ const dimensions = [];
105
+ const byName = new Map();
106
+ for (const entry of raw["dimensions"]) {
107
+ const d = asObject(entry);
108
+ if (d === null || typeof d["name"] !== "string")
109
+ continue;
110
+ let optionKeys = null;
111
+ if (Array.isArray(d["options"]) && d["options"].length > 0) {
112
+ optionKeys = [];
113
+ for (const option of d["options"]) {
114
+ const key = optionKey(asObject(option)?.["value"]);
115
+ if (key !== undefined && !optionKeys.includes(key))
116
+ optionKeys.push(key);
117
+ }
118
+ }
119
+ const dimension = { name: d["name"], priced: d["affectsPrice"] !== false, optionKeys };
120
+ dimensions.push(dimension);
121
+ if (!byName.has(dimension.name))
122
+ byName.set(dimension.name, dimension);
123
+ }
124
+ const exclusions = [];
125
+ if (Array.isArray(raw["exclusions"])) {
126
+ for (const entry of raw["exclusions"]) {
127
+ const pairs = selection(entry);
128
+ if (pairs !== null && pairs.size > 0)
129
+ exclusions.push(pairs);
130
+ }
131
+ }
132
+ const id = typeof raw["@id"] === "string" ? raw["@id"] : undefined;
133
+ return { id, dimensions, byName, exclusions };
134
+ }
135
+ export function hasPricedDimension(configurator) {
136
+ return configurator.dimensions.some((d) => d.priced);
137
+ }
138
+ /** The pricing block of an `offers` entry, or null. */
139
+ export function pricingBlock(offer) {
140
+ return asObject(own(asObject(offer), "pagefront:configuratorPricing"));
141
+ }
142
+ /** The `configurator.@id` a pricing block names, when it is a string. */
143
+ export function pricedConfiguratorId(block) {
144
+ const id = own(asObject(block["configurator"]), "@id");
145
+ return typeof id === "string" ? id : undefined;
146
+ }
147
+ /**
148
+ * The configurator a pricing block prices: the Sheet's configurator when
149
+ * the block's reference equals its `@id` by exact string match. Null is
150
+ * the unresolved reference PDP-E-001 reports; the pricing rules are
151
+ * silent on it.
152
+ */
153
+ export function resolveConfigurator(block, sheet) {
154
+ const configurator = readConfigurator(sheet);
155
+ const id = pricedConfiguratorId(block);
156
+ if (configurator === null || id === undefined || configurator.id !== id)
157
+ return null;
158
+ return configurator;
159
+ }
160
+ export function offerEntries(sheet) {
161
+ const offers = own(asObject(sheet.data), "offers");
162
+ return Array.isArray(offers) ? offers : [];
163
+ }
164
+ /**
165
+ * True when an `offers` entry states a price, in the sense PDP-W-033 and
166
+ * PDP-W-034 share: an `AggregateOffer`, or an `Offer` with `price` or a
167
+ * non-empty `priceSpecification`. Price-on-request offers state none.
168
+ */
169
+ export function entryStatesPrice(entry) {
170
+ const o = asObject(entry);
171
+ if (o === null || o["pagefront:priceOnRequest"] !== undefined)
172
+ return false;
173
+ if (o["@type"] === "AggregateOffer")
174
+ return true;
175
+ if (o["price"] !== undefined)
176
+ return true;
177
+ const spec = o["priceSpecification"];
178
+ if (Array.isArray(spec))
179
+ return spec.length > 0;
180
+ return asObject(spec) !== null && Object.keys(spec).length > 0;
181
+ }
182
+ export function isComplete(block) {
183
+ return block["pricing"] !== "partial";
184
+ }
185
+ /** PDP-E-007's findings: omitted dimensions first, then pairs in document order. */
186
+ export function baseConfigurationProblems(configurator, block) {
187
+ const base = asObject(block["baseConfiguration"]) ?? {};
188
+ const problems = [];
189
+ for (const d of configurator.dimensions) {
190
+ if (d.priced && d.optionKeys !== null && own(base, d.name) === undefined) {
191
+ problems.push({ kind: "omitted", dimension: d.name });
192
+ }
193
+ }
194
+ for (const [name, raw] of Object.entries(base)) {
195
+ const d = configurator.byName.get(name);
196
+ if (d === undefined) {
197
+ problems.push({ kind: "unknown-dimension", dimension: name });
198
+ continue;
199
+ }
200
+ if (!d.priced)
201
+ continue; // redundant pair, changes no total
202
+ const key = optionKey(raw);
203
+ if (key === undefined || d.optionKeys === null || !d.optionKeys.includes(key)) {
204
+ problems.push({ kind: "unknown-option", dimension: name, value: key ?? JSON.stringify(raw) });
205
+ }
206
+ }
207
+ return problems;
208
+ }
209
+ /** PDP-W-032's findings, whatever the block's `pricing` value. */
210
+ export function missingModifierProblems(configurator, block) {
211
+ const modifiers = asObject(block["modifiers"]);
212
+ const problems = [];
213
+ for (const d of configurator.dimensions) {
214
+ if (!d.priced)
215
+ continue;
216
+ if (d.optionKeys === null) {
217
+ problems.push({ kind: "no-options", dimension: d.name });
218
+ continue;
219
+ }
220
+ const entry = asObject(own(modifiers, d.name));
221
+ for (const option of d.optionKeys) {
222
+ if (own(entry, option) === undefined) {
223
+ problems.push({ kind: "missing", dimension: d.name, option });
224
+ }
225
+ }
226
+ }
227
+ return problems;
228
+ }
229
+ function matches(pairs, configuration) {
230
+ for (const [name, key] of pairs) {
231
+ if (configuration.get(name) !== key)
232
+ return false;
233
+ }
234
+ return true;
235
+ }
236
+ /**
237
+ * The minimum and maximum computed total over all non-excluded
238
+ * configurations, or null when it cannot be computed (an amount is not
239
+ * a decimal, a modifier is missing, or every configuration is
240
+ * excluded). The caller has established that the block resolves to
241
+ * `configurator`.
242
+ *
243
+ * Only the dimensions named in an exclusion or an adjustment are
244
+ * enumerated together. Every other priced dimension contributes its
245
+ * smallest modifier to the minimum and its largest to the maximum,
246
+ * independently of the rest, which yields the same band as the full
247
+ * enumeration.
248
+ */
249
+ export function computeBand(configurator, block) {
250
+ const base = parseDecimal(block["basePrice"]);
251
+ if (base === undefined)
252
+ return null;
253
+ const modifiers = asObject(block["modifiers"]);
254
+ const adjustments = [];
255
+ if (Array.isArray(block["adjustments"])) {
256
+ for (const entry of block["adjustments"]) {
257
+ const a = asObject(entry);
258
+ const when = selection(a?.["when"]);
259
+ const amount = parseDecimal(a?.["priceModifier"]);
260
+ if (when === null || when.size === 0 || amount === undefined)
261
+ return null;
262
+ adjustments.push({ when, amount });
263
+ }
264
+ }
265
+ const coupledNames = new Set();
266
+ for (const pairs of configurator.exclusions)
267
+ for (const name of pairs.keys())
268
+ coupledNames.add(name);
269
+ for (const { when } of adjustments)
270
+ for (const name of when.keys())
271
+ coupledNames.add(name);
272
+ const modifierOf = (d, option) => parseDecimal(own(asObject(own(modifiers, d.name)), option));
273
+ let min = base;
274
+ let max = base;
275
+ const coupled = [];
276
+ const seen = new Set();
277
+ for (const d of configurator.dimensions) {
278
+ if (seen.has(d.name))
279
+ continue;
280
+ seen.add(d.name);
281
+ if (d.optionKeys === null) {
282
+ if (d.priced)
283
+ return null;
284
+ continue;
285
+ }
286
+ if (coupledNames.has(d.name)) {
287
+ coupled.push(d);
288
+ continue;
289
+ }
290
+ if (!d.priced)
291
+ continue;
292
+ let lowest;
293
+ let highest;
294
+ for (const option of d.optionKeys) {
295
+ const amount = modifierOf(d, option);
296
+ if (amount === undefined)
297
+ return null;
298
+ if (lowest === undefined || compareDecimal(amount, lowest) < 0)
299
+ lowest = amount;
300
+ if (highest === undefined || compareDecimal(amount, highest) > 0)
301
+ highest = amount;
302
+ }
303
+ if (lowest === undefined || highest === undefined)
304
+ return null;
305
+ min = addDecimal(min, lowest);
306
+ max = addDecimal(max, highest);
307
+ }
308
+ let coupledMin;
309
+ let coupledMax;
310
+ let incomputable = false;
311
+ const configuration = new Map();
312
+ const visit = (index, subtotal) => {
313
+ if (incomputable)
314
+ return;
315
+ if (index === coupled.length) {
316
+ if (configurator.exclusions.some((pairs) => matches(pairs, configuration)))
317
+ return;
318
+ let total = subtotal;
319
+ for (const { when, amount } of adjustments) {
320
+ if (matches(when, configuration))
321
+ total = addDecimal(total, amount);
322
+ }
323
+ if (coupledMin === undefined || compareDecimal(total, coupledMin) < 0)
324
+ coupledMin = total;
325
+ if (coupledMax === undefined || compareDecimal(total, coupledMax) > 0)
326
+ coupledMax = total;
327
+ return;
328
+ }
329
+ const d = coupled[index];
330
+ for (const option of d.optionKeys ?? []) {
331
+ let next = subtotal;
332
+ if (d.priced) {
333
+ const amount = modifierOf(d, option);
334
+ if (amount === undefined) {
335
+ incomputable = true;
336
+ return;
337
+ }
338
+ next = addDecimal(subtotal, amount);
339
+ }
340
+ configuration.set(d.name, option);
341
+ visit(index + 1, next);
342
+ }
343
+ configuration.delete(d.name);
344
+ };
345
+ visit(0, { units: 0n, scale: 0 });
346
+ if (incomputable || coupledMin === undefined || coupledMax === undefined)
347
+ return null;
348
+ return { min: addDecimal(min, coupledMin), max: addDecimal(max, coupledMax) };
349
+ }
@@ -1,4 +1,5 @@
1
1
  import { collectDeclaredIds, isInlineDeclaration, isSameDocumentId, valueAtPath, } from "@pagefront/lint-core";
2
+ import { readConfigurator } from "./configurator-pricing.js";
2
3
  /**
3
4
  * PDP-E-001 — Broken @id reference.
4
5
  * Transcribed from commerce/spec/rules.md (normative).
@@ -14,7 +15,12 @@ import { collectDeclaredIds, isInlineDeclaration, isSameDocumentId, valueAtPath,
14
15
  * a same-document `@id` (Definitions) must resolve by exact string
15
16
  * match; only a non-same-document full URL is a cross-Sheet reference,
16
17
  * exempt from internal resolution.
18
+ *
19
+ * A pricing block's `configurator` reference has no cross-Sheet
20
+ * carve-out: whatever its form, it resolves only against the `@id` of
21
+ * the data block's `pagefront:configurator`, by exact string match.
17
22
  */
23
+ const CONFIGURATOR_REFERENCE = /\['pagefront:configuratorPricing'\]\['configurator'\]\['@id'\]$/;
18
24
  const REFERENCE_SEGMENT = /\['itemOffered'\]|\['isVariantOf'\]/;
19
25
  export const pdpE001 = {
20
26
  id: "PDP-E-001",
@@ -22,11 +28,14 @@ export const pdpE001 = {
22
28
  severity: "error",
23
29
  target: "$..['@id']",
24
30
  check: (match, ctx) => {
25
- if (!REFERENCE_SEGMENT.test(match.path))
26
- return null;
27
31
  const value = match.value;
28
32
  if (typeof value !== "string")
29
33
  return null;
34
+ if (CONFIGURATOR_REFERENCE.test(match.path)) {
35
+ return readConfigurator(ctx.sheet)?.id === value ? null : { values: { value } };
36
+ }
37
+ if (!REFERENCE_SEGMENT.test(match.path))
38
+ return null;
30
39
  const carrier = valueAtPath(ctx.sheet, match.path.replace(/\['@id'\]$/, ""));
31
40
  if (isInlineDeclaration(carrier))
32
41
  return null; // declares, not references
@@ -36,7 +45,7 @@ export const pdpE001 = {
36
45
  return resolves ? null : { values: { value } };
37
46
  },
38
47
  messageTemplate: "Reference `{value}` at `{path}` does not resolve to any entity in this Sheet.",
39
- remediation: "Either correct the reference to match an existing `@id`, or add the referenced entity to the Sheet. Same-document references (fragments, bare tokens, or full URLs on this Sheet's own base) must resolve within the Sheet, by exact string match. A full-URL `@id` on another document's base in `isVariantOf` or a `hasVariant` entry is a legitimate cross-Sheet reference (multi-Sheet variant family) and is validated for URL shape only — see PDP-W-021 for the traversal-hint recommendation that accompanies it.",
48
+ remediation: "Either correct the reference to match an existing `@id`, or add the referenced entity to the Sheet. Same-document references (fragments, bare tokens, or full URLs on this Sheet's own base) must resolve within the Sheet, by exact string match. A pricing block's `configurator.@id` must equal the `@id` declared on the Sheet's `pagefront:configurator`. A full-URL `@id` on another document's base in `isVariantOf` or a `hasVariant` entry is a legitimate cross-Sheet reference (multi-Sheet variant family) and is validated for URL shape only — see PDP-W-021 for the traversal-hint recommendation that accompanies it.",
40
49
  introduced: "v0.6",
41
50
  specReference: "product.md#itemoffered-references",
42
51
  };
@@ -0,0 +1,12 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-005 — Seller sheet URL off the product sheet's host.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Fires when an offer's `seller` carries `pagefront:sheetUrl` whose host
7
+ * differs from the host of the sheet's own `sheet_url`, or has no host.
8
+ * The seller's `@id` plays no part: the linking contract lets it be
9
+ * minted on another domain the organization controls. The finding sits
10
+ * at the `pagefront:sheetUrl` path.
11
+ */
12
+ export declare const pdpE005: Rule;
@@ -0,0 +1,40 @@
1
+ import { hostOf, renderHost } from "./sheet-url-host.js";
2
+ /**
3
+ * PDP-E-005 — Seller sheet URL off the product sheet's host.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Fires when an offer's `seller` carries `pagefront:sheetUrl` whose host
7
+ * differs from the host of the sheet's own `sheet_url`, or has no host.
8
+ * The seller's `@id` plays no part: the linking contract lets it be
9
+ * minted on another domain the organization controls. The finding sits
10
+ * at the `pagefront:sheetUrl` path.
11
+ */
12
+ export const pdpE005 = {
13
+ id: "PDP-E-005",
14
+ title: "Seller sheet URL off the product sheet's host",
15
+ severity: "error",
16
+ target: "$.data.offers[*]",
17
+ check: (match, ctx) => {
18
+ const offer = match.value;
19
+ if (typeof offer !== "object" || offer === null)
20
+ return null;
21
+ const seller = offer["seller"];
22
+ if (typeof seller !== "object" || seller === null || Array.isArray(seller))
23
+ return null;
24
+ const s = seller;
25
+ if (s["pagefront:sheetUrl"] === undefined)
26
+ return null;
27
+ const urlHost = hostOf(s["pagefront:sheetUrl"]);
28
+ const sheetHost = hostOf(ctx.sheet.sheet_url);
29
+ if (urlHost !== null && urlHost === sheetHost)
30
+ return null;
31
+ return {
32
+ path: `${match.path}['seller']['pagefront:sheetUrl']`,
33
+ values: { sheetUrlHost: renderHost(urlHost), sheetHost: renderHost(sheetHost) },
34
+ };
35
+ },
36
+ messageTemplate: "Seller sheet URL at `{path}` is on host `{sheetUrlHost}`; the product sheet is on `{sheetHost}`.",
37
+ remediation: "Publish the seller's organization sheet on the storefront host.",
38
+ introduced: "v0.9",
39
+ specReference: "organization.md#linking-contract-identity-only",
40
+ };
@@ -0,0 +1,11 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-006 — Brand sheet URL off the brand's host.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Fires when the data block's `brand` carries `pagefront:sheetUrl` whose
7
+ * host differs from the host of the brand's `@id`. A brand with no `@id`,
8
+ * or an `@id` without a host, fires too: there is no host for the URL to
9
+ * match. The finding sits at the `pagefront:sheetUrl` path.
10
+ */
11
+ export declare const pdpE006: Rule;
@@ -0,0 +1,36 @@
1
+ import { hostOf, renderHost } from "./sheet-url-host.js";
2
+ /**
3
+ * PDP-E-006 — Brand sheet URL off the brand's host.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Fires when the data block's `brand` carries `pagefront:sheetUrl` whose
7
+ * host differs from the host of the brand's `@id`. A brand with no `@id`,
8
+ * or an `@id` without a host, fires too: there is no host for the URL to
9
+ * match. The finding sits at the `pagefront:sheetUrl` path.
10
+ */
11
+ export const pdpE006 = {
12
+ id: "PDP-E-006",
13
+ title: "Brand sheet URL off the brand's host",
14
+ severity: "error",
15
+ target: "$.data.brand",
16
+ check: (match) => {
17
+ const brand = match.value;
18
+ if (typeof brand !== "object" || brand === null || Array.isArray(brand))
19
+ return null;
20
+ const b = brand;
21
+ if (b["pagefront:sheetUrl"] === undefined)
22
+ return null;
23
+ const urlHost = hostOf(b["pagefront:sheetUrl"]);
24
+ const idHost = hostOf(b["@id"]);
25
+ if (urlHost !== null && urlHost === idHost)
26
+ return null;
27
+ return {
28
+ path: `${match.path}['pagefront:sheetUrl']`,
29
+ values: { sheetUrlHost: renderHost(urlHost), idHost: renderHost(idHost) },
30
+ };
31
+ },
32
+ messageTemplate: "Brand sheet URL at `{path}` is on host `{sheetUrlHost}`; the brand's `@id` is on `{idHost}`.",
33
+ remediation: "Point `pagefront:sheetUrl` at the sheet the brand publishes on its own host, or remove it.",
34
+ introduced: "v0.9",
35
+ specReference: "organization.md#linking-contract-identity-only",
36
+ };
@@ -0,0 +1,12 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-007 — Base configuration incomplete.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Applies to a pricing block whose `configurator` reference resolves.
7
+ * One finding per omitted priced dimension (in dimension order), then
8
+ * one per `baseConfiguration` pair naming no dimension or a value that
9
+ * is not one of the dimension's options (in document order), all at the
10
+ * `baseConfiguration` path.
11
+ */
12
+ export declare const pdpE007: Rule;
@@ -0,0 +1,53 @@
1
+ import { asObject, baseConfigurationProblems, resolveConfigurator } from "./configurator-pricing.js";
2
+ /**
3
+ * PDP-E-007 — Base configuration incomplete.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Applies to a pricing block whose `configurator` reference resolves.
7
+ * One finding per omitted priced dimension (in dimension order), then
8
+ * one per `baseConfiguration` pair naming no dimension or a value that
9
+ * is not one of the dimension's options (in document order), all at the
10
+ * `baseConfiguration` path.
11
+ */
12
+ export const pdpE007 = {
13
+ id: "PDP-E-007",
14
+ title: "Base configuration incomplete",
15
+ severity: "error",
16
+ target: "$.data.offers[*]['pagefront:configuratorPricing']",
17
+ check: (match, ctx) => {
18
+ const block = asObject(match.value);
19
+ if (block === null)
20
+ return null;
21
+ const configurator = resolveConfigurator(block, ctx.sheet);
22
+ if (configurator === null)
23
+ return null;
24
+ const path = `${match.path}['baseConfiguration']`;
25
+ const hits = baseConfigurationProblems(configurator, block).map((p) => {
26
+ if (p.kind === "omitted") {
27
+ return { path, values: { dimension: p.dimension, problem: `omits priced dimension \`${p.dimension}\`` } };
28
+ }
29
+ if (p.kind === "unknown-dimension") {
30
+ return {
31
+ path,
32
+ values: {
33
+ dimension: p.dimension,
34
+ problem: `names \`${p.dimension}\`, which is not a dimension of the configurator`,
35
+ },
36
+ };
37
+ }
38
+ return {
39
+ path,
40
+ values: {
41
+ dimension: p.dimension,
42
+ value: p.value,
43
+ problem: `names \`${p.value}\` for \`${p.dimension}\`, which is not one of its options`,
44
+ },
45
+ };
46
+ });
47
+ return hits.length > 0 ? hits : null;
48
+ },
49
+ messageTemplate: "Base configuration at `{path}` {problem}.",
50
+ remediation: "Name exactly one option for every priced dimension, spelled as the configurator's `options` spell it. If a dimension never changes the price, declare `affectsPrice: false` on it and leave it out.",
51
+ introduced: "v0.9 (2026-10-01 revision)",
52
+ specReference: "product.md#configurator-pricing-and-price-bands",
53
+ };
@@ -0,0 +1,10 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-008 — Base option modifier is not zero.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * One finding per `baseConfiguration` pair whose modifier is present and
7
+ * differs from zero as a number, at the modifier's own path. A base
8
+ * option with no modifier is PDP-W-032's concern.
9
+ */
10
+ export declare const pdpE008: Rule;
@@ -0,0 +1,41 @@
1
+ import { asObject, isZero, optionKey, own, parseDecimal, seg } from "./configurator-pricing.js";
2
+ /**
3
+ * PDP-E-008 — Base option modifier is not zero.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * One finding per `baseConfiguration` pair whose modifier is present and
7
+ * differs from zero as a number, at the modifier's own path. A base
8
+ * option with no modifier is PDP-W-032's concern.
9
+ */
10
+ export const pdpE008 = {
11
+ id: "PDP-E-008",
12
+ title: "Base option modifier is not zero",
13
+ severity: "error",
14
+ target: "$.data.offers[*]['pagefront:configuratorPricing']",
15
+ check: (match) => {
16
+ const block = asObject(match.value);
17
+ const base = asObject(block?.["baseConfiguration"]);
18
+ const modifiers = asObject(block?.["modifiers"]);
19
+ if (base === null || modifiers === null)
20
+ return null;
21
+ const hits = [];
22
+ for (const [dimension, raw] of Object.entries(base)) {
23
+ const option = optionKey(raw);
24
+ if (option === undefined)
25
+ continue;
26
+ const stated = own(asObject(own(modifiers, dimension)), option);
27
+ const modifier = parseDecimal(stated);
28
+ if (modifier === undefined || isZero(modifier))
29
+ continue;
30
+ hits.push({
31
+ path: `${match.path}['modifiers']${seg(dimension)}${seg(option)}`,
32
+ values: { dimension, option, modifier: String(stated) },
33
+ });
34
+ }
35
+ return hits.length > 0 ? hits : null;
36
+ },
37
+ messageTemplate: "Modifier for base option `{option}` of `{dimension}` at `{path}` is `{modifier}`; the base option's modifier is `0`.",
38
+ remediation: "Set the base option's modifier to `\"0\"` and express the other options of the dimension as differences from it. If the modifier is right, `basePrice` is the total of another configuration: correct `baseConfiguration` or `basePrice`.",
39
+ introduced: "v0.9 (2026-10-01 revision)",
40
+ specReference: "product.md#configurator-pricing-and-price-bands",
41
+ };
@@ -0,0 +1,12 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-009 — Modifier for an unknown dimension or option.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Applies to a pricing block whose `configurator` reference resolves.
7
+ * A `modifiers` key naming no dimension, or a dimension declared
8
+ * `affectsPrice: false`, fires once at that key; under a priced
9
+ * dimension, each option key that is not one of its options fires at
10
+ * its own path.
11
+ */
12
+ export declare const pdpE009: Rule;