@pagefront/lint-commerce 0.7.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.
@@ -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-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;
@@ -0,0 +1,62 @@
1
+ import { asObject, resolveConfigurator, seg } from "./configurator-pricing.js";
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 const pdpE009 = {
13
+ id: "PDP-E-009",
14
+ title: "Modifier for an unknown dimension or option",
15
+ severity: "error",
16
+ target: "$.data.offers[*]['pagefront:configuratorPricing']",
17
+ check: (match, ctx) => {
18
+ const block = asObject(match.value);
19
+ const modifiers = asObject(block?.["modifiers"]);
20
+ if (block === null || modifiers === null)
21
+ return null;
22
+ const configurator = resolveConfigurator(block, ctx.sheet);
23
+ if (configurator === null)
24
+ return null;
25
+ const hits = [];
26
+ for (const [dimension, entry] of Object.entries(modifiers)) {
27
+ const path = `${match.path}['modifiers']${seg(dimension)}`;
28
+ const d = configurator.byName.get(dimension);
29
+ if (d === undefined) {
30
+ hits.push({
31
+ path,
32
+ values: { dimension, problem: `names \`${dimension}\`, which is not a dimension of the configurator` },
33
+ });
34
+ continue;
35
+ }
36
+ if (!d.priced) {
37
+ hits.push({
38
+ path,
39
+ values: { dimension, problem: `names \`${dimension}\`, which is declared \`affectsPrice: false\`` },
40
+ });
41
+ continue;
42
+ }
43
+ for (const option of Object.keys(asObject(entry) ?? {})) {
44
+ if (d.optionKeys !== null && d.optionKeys.includes(option))
45
+ continue;
46
+ hits.push({
47
+ path: `${path}${seg(option)}`,
48
+ values: {
49
+ dimension,
50
+ option,
51
+ problem: `names \`${option}\`, which is not an option of \`${dimension}\``,
52
+ },
53
+ });
54
+ }
55
+ }
56
+ return hits.length > 0 ? hits : null;
57
+ },
58
+ messageTemplate: "Modifier at `{path}` {problem}.",
59
+ remediation: "Spell dimension names and option values as the configurator spells them. Remove modifiers for dimensions that do not change the price, or remove `affectsPrice: false` from a dimension that does.",
60
+ introduced: "v0.9 (2026-10-01 revision)",
61
+ specReference: "product.md#configurator-pricing-and-price-bands",
62
+ };
@@ -0,0 +1,10 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-010 — Offer price disagrees with base price.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * An `Offer` carrying a pricing block has `price` equal to the block's
7
+ * `basePrice` as a number, whatever the block's `pricing` value. The
8
+ * finding sits at the Offer's `price`.
9
+ */
10
+ export declare const pdpE010: Rule;
@@ -0,0 +1,37 @@
1
+ import { asObject, compareDecimal, parseDecimal, pricingBlock } from "./configurator-pricing.js";
2
+ /**
3
+ * PDP-E-010 — Offer price disagrees with base price.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * An `Offer` carrying a pricing block has `price` equal to the block's
7
+ * `basePrice` as a number, whatever the block's `pricing` value. The
8
+ * finding sits at the Offer's `price`.
9
+ */
10
+ export const pdpE010 = {
11
+ id: "PDP-E-010",
12
+ title: "Offer price disagrees with base price",
13
+ severity: "error",
14
+ target: "$.data.offers[*]",
15
+ check: (match) => {
16
+ const offer = asObject(match.value);
17
+ if (offer === null || offer["@type"] !== "Offer")
18
+ return null;
19
+ const block = pricingBlock(offer);
20
+ if (block === null)
21
+ return null;
22
+ const price = parseDecimal(offer["price"]);
23
+ const basePrice = parseDecimal(block["basePrice"]);
24
+ if (price === undefined || basePrice === undefined)
25
+ return null;
26
+ if (compareDecimal(price, basePrice) === 0)
27
+ return null;
28
+ return {
29
+ path: `${match.path}['price']`,
30
+ values: { price: String(offer["price"]), basePrice: String(block["basePrice"]) },
31
+ };
32
+ },
33
+ messageTemplate: "Offer `price` at `{path}` is `{price}` but its pricing block's `basePrice` is `{basePrice}`.",
34
+ remediation: "Make the two amounts equal. If `price` is the amount the page shows on load, set `baseConfiguration` to the configuration the page preselects and `basePrice` to its total.",
35
+ introduced: "v0.9 (2026-10-01 revision)",
36
+ specReference: "product.md#configurator-pricing-and-price-bands",
37
+ };
@@ -0,0 +1,11 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-011 — AggregateOffer band disagrees with computed band.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * Applies to an `AggregateOffer` whose pricing block is complete. Silent
7
+ * when the band cannot be computed: the reference does not resolve, the
8
+ * block fires PDP-E-007 or PDP-W-032, or every configuration is
9
+ * excluded. One finding per disagreeing bound, at the bound.
10
+ */
11
+ export declare const pdpE011: Rule;