@pdtf/schemas 3.6.0-dev.16 → 3.6.0-dev.18

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/index.js CHANGED
@@ -225,6 +225,183 @@ const generateOverlayKey = (overlays) => {
225
225
  return overlays.join(".");
226
226
  };
227
227
 
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Overlay scoping
231
+ //
232
+ // A composed subschema is the CORE schema merged with the requested overlays,
233
+ // so it carries every field the core schema knows about — including questions
234
+ // the requested overlays never ask. Validating that full shape makes a form's
235
+ // completeness depend on questions it does not render: e.g. the NTS Material
236
+ // Information "Ownership" section validated the TA7 leasehold
237
+ // `ownershipAndManagement` node, so a seller who part-answered TA7 could never
238
+ // complete Material Information, with no field named in the error (the core
239
+ // schema keeps the `oneOf` but `extractOverlay` strips the `discriminator` that
240
+ // disambiguated it, so an absent tag matches EVERY branch: "must match exactly
241
+ // one schema in oneOf", passingSchemas [0,1]).
242
+ //
243
+ // Scoping prunes the composed subschema to the fields the requested overlays
244
+ // actually reference, so a form is validated against exactly what it asks.
245
+ //
246
+ // Discriminator tags are preserved even when unreferenced: they are structural,
247
+ // and removing one leaves AJV compiling a `discriminator` whose tag is gone
248
+ // ("oneOf subschemas must have properties/<tag>"). Tags are inherited into
249
+ // `oneOf` branches and `items`, because that is where the tag property lives.
250
+ // ---------------------------------------------------------------------------
251
+
252
+ // Every form overlay id -> the marker `extractOverlay` stamps for it. This must
253
+ // cover EVERY id in `overlaysMap`: an id missing here contributes no ref key, so
254
+ // its fields would look unreferenced and be pruned away. `getOverlayRefKeys`
255
+ // returns null if it sees an unknown id, which disables scoping altogether —
256
+ // under-scoping merely preserves today's behaviour, over-scoping would mark
257
+ // sections complete that are not.
258
+ const overlayToRefMapping = {
259
+ baspiV4: "baspi4Ref",
260
+ baspiV5: "baspi5Ref",
261
+ nts2023: "ntsRef",
262
+ nts2025: "nts2Ref",
263
+ ntsl2023: "ntslRef",
264
+ ntsl2025: "ntsl2Ref",
265
+ ta6ed4: "ta6Ref",
266
+ ta6ed6: "ta6ed6Ref",
267
+ ta6ed6v2: "ta6ed6v2Ref",
268
+ ta7ed3: "ta7Ref",
269
+ ta7ed5: "ta7ed5Ref",
270
+ ta10ed3: "ta10Ref",
271
+ lpe1ed4: "lpe1Ref",
272
+ fme1ed2: "fme1Ref",
273
+ llc1v2: "llc1Ref",
274
+ con29R2019: "con29RRef",
275
+ con29DW: "con29DWRef",
276
+ rdsV333: "rdsRef",
277
+ oc1v21: "oc1Ref",
278
+ piqV3: "piqRef",
279
+ sr24: "sr24Ref",
280
+ };
281
+
282
+ // Extension overlays contribute their host form's ref key(s). `ta` is the TA6
283
+ // compatibility overlay: it exists so TA6 answers can never invalidate the NTS
284
+ // sections, and its marker must therefore count as visible here too — otherwise
285
+ // scoping would prune away the very fields that overlay was added to protect.
286
+ const extensionOverlayToRefs = {
287
+ as: ["ntsRef"],
288
+ dr: ["ntsRef"],
289
+ er: ["ntsRef"],
290
+ fd: ["ntsRef"],
291
+ hi: ["ntsRef"],
292
+ hs: ["ntsRef"],
293
+ jk: ["ntsRef"],
294
+ la: ["ntsRef"],
295
+ ma: ["ntsRef"],
296
+ mc: ["ntsRef"],
297
+ oa: ["ntsRef"],
298
+ oc: ["ntsRef"],
299
+ sb: ["ntsRef"],
300
+ sf: ["ntsRef"],
301
+ sl: ["ntsRef"],
302
+ tf: ["ntsRef"],
303
+ dk: ["ntsRef", "sef25Ref"],
304
+ ic: ["ntsRef", "sef25Ref"],
305
+ lc: ["ntsRef", "sef25Ref"],
306
+ mi: ["ntsRef", "sef25Ref"],
307
+ nd: ["ntsRef", "sef25Ref"],
308
+ pc: ["ntsRef", "sef25Ref"],
309
+ ph: ["ntsRef", "sef25Ref"],
310
+ rw: ["ntsRef", "sef25Ref"],
311
+ sc: ["ntsRef", "sef25Ref"],
312
+ sd: ["ntsRef", "sef25Ref"],
313
+ tr: ["ntsRef", "sef25Ref"],
314
+ wg: ["ntsRef", "sef25Ref"],
315
+ ac: ["ntsRef", "sef25Ref"],
316
+ ta: ["ta6ed6CompatRef"],
317
+ };
318
+
319
+ // Returns the ref keys these overlays reference, or null when scoping must be
320
+ // skipped: an unrecognised overlay id (whose markers we therefore cannot
321
+ // identify) would otherwise have all of its fields pruned as unreferenced.
322
+ const getOverlayRefKeys = (overlays = []) => {
323
+ const overlayIds = (Array.isArray(overlays) ? overlays : [overlays]).filter(
324
+ (id) => id !== null && id !== undefined,
325
+ );
326
+ const refKeys = new Set();
327
+ for (const overlay of overlayIds) {
328
+ const refKey = overlayToRefMapping[overlay];
329
+ const extensionRefs = extensionOverlayToRefs[overlay];
330
+ if (!refKey && !extensionRefs) return null; // unknown id -> do not scope
331
+ if (refKey) refKeys.add(refKey);
332
+ (extensionRefs || []).forEach((k) => refKeys.add(k));
333
+ }
334
+ return [...refKeys];
335
+ };
336
+
337
+ // True when this node, or anything beneath it, is referenced by the overlays.
338
+ const schemaHasVisibleOverlayRef = (schema, refKeys = []) => {
339
+ if (!schema || typeof schema !== "object") return false;
340
+ if (refKeys.some((refKey) => !!schema[refKey])) return true;
341
+ if (schema.items && schemaHasVisibleOverlayRef(schema.items, refKeys))
342
+ return true;
343
+ if (schema.properties) {
344
+ if (
345
+ Object.values(schema.properties).some((p) =>
346
+ schemaHasVisibleOverlayRef(p, refKeys),
347
+ )
348
+ )
349
+ return true;
350
+ }
351
+ if (Array.isArray(schema.oneOf)) {
352
+ return schema.oneOf.some((b) => schemaHasVisibleOverlayRef(b, refKeys));
353
+ }
354
+ return false;
355
+ };
356
+
357
+ // The property names that discriminate this node's `oneOf`: the declared
358
+ // `discriminator`, plus — for a `oneOf` whose discriminator was stripped when
359
+ // the core schema was built — the enum key every branch constrains.
360
+ const discriminatingTags = (node) => {
361
+ const tags = new Set();
362
+ if (node.discriminator?.propertyName) tags.add(node.discriminator.propertyName);
363
+ if (Array.isArray(node.oneOf)) {
364
+ const branchKeys = node.oneOf.map(
365
+ (b) => new Set(Object.keys(b?.properties || {})),
366
+ );
367
+ const common = [...(branchKeys[0] || [])].filter((k) =>
368
+ branchKeys.every((s) => s.has(k)),
369
+ );
370
+ const inferred = common.find((k) =>
371
+ node.oneOf.every((b) => Array.isArray(b.properties[k]?.enum)),
372
+ );
373
+ if (inferred) tags.add(inferred);
374
+ }
375
+ return tags;
376
+ };
377
+
378
+ const pruneToOverlayScope = (node, refKeys, inheritedTags = new Set()) => {
379
+ if (!node || typeof node !== "object") return node;
380
+ const ownTags = discriminatingTags(node);
381
+ const keep = new Set([...ownTags, ...inheritedTags]);
382
+
383
+ if (node.properties) {
384
+ for (const [key, propertySchema] of Object.entries(node.properties)) {
385
+ if (!keep.has(key) && !schemaHasVisibleOverlayRef(propertySchema, refKeys)) {
386
+ delete node.properties[key];
387
+ if (Array.isArray(node.required)) {
388
+ node.required = node.required.filter((r) => r !== key);
389
+ }
390
+ } else {
391
+ pruneToOverlayScope(propertySchema, refKeys, new Set());
392
+ }
393
+ }
394
+ }
395
+ // Tags live inside branches and array items, so they must be inherited there.
396
+ if (node.items) pruneToOverlayScope(node.items, refKeys, ownTags);
397
+ if (Array.isArray(node.oneOf)) {
398
+ for (const branch of node.oneOf) {
399
+ pruneToOverlayScope(branch, refKeys, ownTags);
400
+ }
401
+ }
402
+ return node;
403
+ };
404
+
228
405
  // Enhanced caching function that stores both subschemas and validators
229
406
  const getCachedSchemaData = (path, schemaId, overlays) => {
230
407
  const overlayKey = generateOverlayKey(overlays);
@@ -313,6 +490,17 @@ const getCachedSchemaData = (path, schemaId, overlays) => {
313
490
  }, sourceSchema);
314
491
  }
315
492
 
493
+ // Scope the composed subschema to what these overlays actually ask, so a
494
+ // form's completeness never depends on questions it does not render.
495
+ // No overlays (or none carrying ref keys) => no scoping, shape unchanged.
496
+ const refKeys = getOverlayRefKeys(overlays);
497
+ if (refKeys && refKeys.length > 0 && subSchema && typeof subSchema === "object") {
498
+ subSchema = pruneToOverlayScope(
499
+ JSON.parse(JSON.stringify(subSchema)),
500
+ refKeys,
501
+ );
502
+ }
503
+
316
504
  // Add schema to AJV and get validator
317
505
  // Only add valid schemas to AJV
318
506
  let validator;
@@ -641,6 +829,9 @@ module.exports = {
641
829
  isPathValid,
642
830
  getSubschemaValidator,
643
831
  getTitleAtPath,
832
+ getOverlayRefKeys,
833
+ schemaHasVisibleOverlayRef,
834
+ pruneToOverlayScope,
644
835
  verifiedClaimsSchema,
645
836
  validateVerifiedClaims,
646
837
  overlaysMap,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdtf/schemas",
3
- "version": "3.6.0-dev.16",
3
+ "version": "3.6.0-dev.18",
4
4
  "description": "Property Data Trust Framework Schemas and Utilities",
5
5
  "main": "index.js",
6
6
  "files": [