@uwmd/core 2.6.2 → 2.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 (87) hide show
  1. package/README.md +12 -1
  2. package/dist/block-roles.d.ts +20 -0
  3. package/dist/block-roles.d.ts.map +1 -0
  4. package/dist/block-roles.js +54 -0
  5. package/dist/block-roles.js.map +1 -0
  6. package/dist/browser.d.ts +11 -2
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +6 -1
  9. package/dist/browser.js.map +1 -1
  10. package/dist/calc/dependencies.d.ts +3 -0
  11. package/dist/calc/dependencies.d.ts.map +1 -1
  12. package/dist/calc/dependencies.js +14 -1
  13. package/dist/calc/dependencies.js.map +1 -1
  14. package/dist/calc/errors.d.ts +1 -1
  15. package/dist/calc/errors.d.ts.map +1 -1
  16. package/dist/calc/errors.js.map +1 -1
  17. package/dist/calc/evaluator.d.ts +1 -1
  18. package/dist/calc/evaluator.d.ts.map +1 -1
  19. package/dist/calc/evaluator.js +10 -0
  20. package/dist/calc/evaluator.js.map +1 -1
  21. package/dist/calc/parser.d.ts +10 -0
  22. package/dist/calc/parser.d.ts.map +1 -1
  23. package/dist/calc/parser.js +25 -1
  24. package/dist/calc/parser.js.map +1 -1
  25. package/dist/calc/prng.d.ts.map +1 -1
  26. package/dist/calc/prng.js +6 -14
  27. package/dist/calc/prng.js.map +1 -1
  28. package/dist/calculation-context.d.ts +6 -0
  29. package/dist/calculation-context.d.ts.map +1 -0
  30. package/dist/calculation-context.js +52 -0
  31. package/dist/calculation-context.js.map +1 -0
  32. package/dist/cli.js +65 -3
  33. package/dist/cli.js.map +1 -1
  34. package/dist/editor.d.ts.map +1 -1
  35. package/dist/editor.js +23 -1
  36. package/dist/editor.js.map +1 -1
  37. package/dist/index.d.ts +11 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +6 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/lease-up-cash-flows.d.ts +10 -0
  42. package/dist/lease-up-cash-flows.d.ts.map +1 -0
  43. package/dist/lease-up-cash-flows.js +143 -0
  44. package/dist/lease-up-cash-flows.js.map +1 -0
  45. package/dist/lease-up-structure.d.ts +3 -0
  46. package/dist/lease-up-structure.d.ts.map +1 -0
  47. package/dist/lease-up-structure.js +76 -0
  48. package/dist/lease-up-structure.js.map +1 -0
  49. package/dist/packs/excel-emit.d.ts +3 -0
  50. package/dist/packs/excel-emit.d.ts.map +1 -1
  51. package/dist/packs/excel-emit.js +32 -1
  52. package/dist/packs/excel-emit.js.map +1 -1
  53. package/dist/parser.d.ts.map +1 -1
  54. package/dist/parser.js +12 -3
  55. package/dist/parser.js.map +1 -1
  56. package/dist/period-path.d.ts +23 -0
  57. package/dist/period-path.d.ts.map +1 -0
  58. package/dist/period-path.js +100 -0
  59. package/dist/period-path.js.map +1 -0
  60. package/dist/period-validation.d.ts +4 -0
  61. package/dist/period-validation.d.ts.map +1 -0
  62. package/dist/period-validation.js +84 -0
  63. package/dist/period-validation.js.map +1 -0
  64. package/dist/periods.d.ts +17 -0
  65. package/dist/periods.d.ts.map +1 -0
  66. package/dist/periods.js +92 -0
  67. package/dist/periods.js.map +1 -0
  68. package/dist/protocol.d.ts +97 -3
  69. package/dist/protocol.d.ts.map +1 -1
  70. package/dist/protocol.js +45 -1
  71. package/dist/protocol.js.map +1 -1
  72. package/dist/refinement.d.ts +5 -1
  73. package/dist/refinement.d.ts.map +1 -1
  74. package/dist/refinement.js +98 -25
  75. package/dist/refinement.js.map +1 -1
  76. package/dist/runner.d.ts.map +1 -1
  77. package/dist/runner.js +5 -0
  78. package/dist/runner.js.map +1 -1
  79. package/dist/types.d.ts +9 -0
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/types.js.map +1 -1
  82. package/dist/validator.d.ts.map +1 -1
  83. package/dist/validator.js +98 -96
  84. package/dist/validator.js.map +1 -1
  85. package/dist/version.d.ts +1 -1
  86. package/dist/version.js +1 -1
  87. package/package.json +2 -2
package/dist/validator.js CHANGED
@@ -2,15 +2,18 @@
2
2
  // Spec: UW_FORMAT_SPEC_v1.md Part V
3
3
  import { DEFAULT_THRESHOLDS, SOURCE_TAGS } from './types.js';
4
4
  import { getSection, getSectionVariant, deepGet } from './parser.js';
5
- import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, STAGE_REQUIREMENTS, requiredSectionsFor, CROSS_CHECK_RULE_IDS, CROSS_CHECK_VARIANT_PREFERENCE, RETURN_TAX_BASES, DEFAULT_RETURN_TAX_BASIS, } from './protocol.js';
5
+ import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, STAGE_REQUIREMENTS, requiredSectionsFor, CROSS_CHECK_RULE_IDS, RETURN_TAX_BASES, DEFAULT_RETURN_TAX_BASIS, } from './protocol.js';
6
6
  import { EXTERNAL_ANNOTATION_KEY } from './composition.js';
7
7
  import { UW_LITE_SOURCE_EXTENSION } from './lite-bridge.js';
8
8
  import { readGapsContent } from './gaps.js';
9
- import { leaseUpPeriodOrdinal, LEASE_UP_STABILIZED_TOLERANCE } from './lease-up.js';
9
+ import { LEASE_UP_STABILIZED_TOLERANCE } from './lease-up.js';
10
+ import { checkLeaseUpContent } from './lease-up-structure.js';
10
11
  import { CASH_FLOW_KINDS } from './cash-flow-series.js';
11
12
  import { isDayCountConvention, parseISODate } from './calc/day-count.js';
12
13
  import { parseAssetClass, declaredModuleDependencies } from './asset-class.js';
13
14
  import { isV2File } from './meta-shape.js';
15
+ import { checkPeriodSeries } from './period-validation.js';
16
+ import { hasBlockRole, isBlockRole, resolveRoleBlock } from './block-roles.js';
14
17
  // ─── BUILTIN_REMEDIATIONS lookup (UW_PROTOCOL_v1.md §III.6) ──────────────────
15
18
  //
16
19
  // Validator constructs inline messages with deal-specific values, but the
@@ -106,6 +109,8 @@ export function validateUWFile(parsed, thresholdOverrides) {
106
109
  checkAssetClassIdentifier(parsed, issues);
107
110
  checkMetaIntegrity(parsed, issues);
108
111
  checkMetaShape(parsed, issues);
112
+ checkBlockRoles(parsed, issues);
113
+ checkPeriodSeries(parsed, issues);
109
114
  checkSourceVocabulary(parsed, issues);
110
115
  checkScopeReadiness(parsed, issues);
111
116
  checkDataQuality(parsed, issues);
@@ -116,7 +121,8 @@ export function validateUWFile(parsed, thresholdOverrides) {
116
121
  const codes = [...entry.codes].sort();
117
122
  issues.push({
118
123
  code: 'CC-16', severity: 'info', section: sectionId,
119
- message: `CC-16: ${sectionId} is present as ${entry.variants.length} variants (${entry.variants.join(', ')}) and none is named default or base; ${codes.join(', ')} ${codes.length === 1 ? 'was' : 'were'} skipped — name a default variant or state the section once (§5.3, RFC 0037)`,
124
+ ...(entry.detail ? { remediation: 'Resolve the reported role collision or declare an eligible property-level block; component blocks cannot serve as the property total.' } : {}),
125
+ message: entry.detail ? `CC-16: ${sectionId}: ${entry.detail}; could not resolve for ${codes.join(', ')} (§5.3, RFC 0040)` : `CC-16: ${sectionId} is present as ${entry.variants.length} variants (${entry.variants.join(', ')}) and none is named default or base; ${codes.join(', ')} ${codes.length === 1 ? 'was' : 'were'} skipped — name a default variant or state the section once (§5.3, RFC 0037)`,
120
126
  value: entry.variants,
121
127
  });
122
128
  }
@@ -266,7 +272,7 @@ function checkFinancialValidity(parsed, t, issues) {
266
272
  }
267
273
  }
268
274
  function newCoverageLedger() {
269
- return { coverage: {}, unresolvable: new Map() };
275
+ return { coverage: {}, unresolvable: new Map(), resolutions: {} };
270
276
  }
271
277
  function markEvaluated(ledger, code) {
272
278
  ledger.coverage[code] = { status: 'evaluated' };
@@ -276,8 +282,8 @@ function markSkipped(ledger, code, reason, detail) {
276
282
  return;
277
283
  ledger.coverage[code] = { status: 'skipped', reason, ...(detail ? { detail } : {}) };
278
284
  }
279
- function noteUnresolvable(ledger, sectionId, variants, code) {
280
- const entry = ledger.unresolvable.get(sectionId) ?? { variants, codes: new Set() };
285
+ function noteUnresolvable(ledger, sectionId, variants, code, detail) {
286
+ const entry = ledger.unresolvable.get(sectionId) ?? { variants, codes: new Set(), ...(detail ? { detail } : {}) };
281
287
  entry.codes.add(code);
282
288
  ledger.unresolvable.set(sectionId, entry);
283
289
  }
@@ -287,26 +293,50 @@ function orderedCoverage(ledger) {
287
293
  for (const code of CROSS_CHECK_RULE_IDS) {
288
294
  const entry = ledger.coverage[code];
289
295
  if (entry)
290
- out[code] = entry;
296
+ out[code] = { ...entry, ...(ledger.resolutions[code] ? { resolutions: ledger.resolutions[code] } : {}) };
291
297
  }
292
298
  return out;
293
299
  }
294
- function resolveCrossCheckSection(parsed, sectionId, preferred = []) {
300
+ function resolveCrossCheckSection(parsed, sectionId, preferred = [], code, ledger) {
301
+ const r = resolveRoleBlock(parsed.sections[sectionId], sectionId, preferred, code);
302
+ if (r.state === 'resolved' && r.evidence && code && ledger) {
303
+ ledger.resolutions[code] ??= {};
304
+ ledger.resolutions[code][sectionId] = r.evidence;
305
+ }
306
+ return r;
307
+ }
308
+ /** Preserve legacy direct-read behavior unless the section opts into roles. */
309
+ function roleAwareDirectRead(parsed, sectionId, code, ledger) {
295
310
  const entry = parsed.sections[sectionId];
296
- if (!entry)
297
- return { state: 'absent', block: null };
298
- if (!isVariantMap(entry))
299
- return { state: 'resolved', block: entry };
300
- const map = entry;
301
- for (const key of [...preferred, ...CROSS_CHECK_VARIANT_PREFERENCE]) {
302
- const block = map[key];
303
- if (block)
304
- return { state: 'resolved', block, variant: key };
305
- }
306
- const keys = Object.keys(map);
307
- if (keys.length === 1)
308
- return { state: 'resolved', block: map[keys[0]], variant: keys[0] };
309
- return { state: 'unresolvable', block: null, variants: keys.sort() };
311
+ const blocks = !entry ? [] : isVariantMap(entry) ? Object.values(entry) : [entry];
312
+ if (!blocks.some(hasBlockRole))
313
+ return getSection(parsed, sectionId);
314
+ const r = resolveCrossCheckSection(parsed, sectionId, [], code, ledger);
315
+ if (r.state === 'unresolvable' && code && ledger) {
316
+ noteUnresolvable(ledger, sectionId, r.variants, code, r.detail);
317
+ markSkipped(ledger, code, 'variant_unresolvable', sectionId);
318
+ }
319
+ return r.block;
320
+ }
321
+ function checkBlockRoles(parsed, issues) {
322
+ const check = (section, block) => {
323
+ if (hasBlockRole(block) && !isBlockRole(block.content['_role']))
324
+ issues.push({
325
+ code: 'ROLE-01', severity: 'error', section, field: '_role',
326
+ message: `ROLE-01: ${section}${block.annotation.variant ? ` variant=${block.annotation.variant}` : ''} must declare one valid scalar _role`,
327
+ value: block.content['_role'],
328
+ });
329
+ };
330
+ for (const [section, entry] of Object.entries(parsed.sections)) {
331
+ for (const block of isVariantMap(entry) ? Object.values(entry) : [entry])
332
+ check(section, block);
333
+ }
334
+ for (const [section, block] of Object.entries(parsed.extensions))
335
+ check(section, block);
336
+ for (const section of ['pipeline_log', 'custom_calculations', 'custom_scenarios']) {
337
+ for (const block of parsed[section])
338
+ check(section, block);
339
+ }
310
340
  }
311
341
  /**
312
342
  * Resolve every section a rule reads. On any failure the rule's skip is
@@ -319,13 +349,13 @@ function requireSections(ledger, parsed, code, needs) {
319
349
  for (const need of needs) {
320
350
  const sectionId = need[0];
321
351
  const preferred = need.length > 1 ? need[1] : [];
322
- const r = resolveCrossCheckSection(parsed, sectionId, preferred);
352
+ const r = resolveCrossCheckSection(parsed, sectionId, preferred, code, ledger);
323
353
  if (r.state === 'resolved') {
324
354
  out[sectionId] = { block: r.block, ...(r.variant ? { variant: r.variant } : {}) };
325
355
  continue;
326
356
  }
327
357
  if (r.state === 'unresolvable')
328
- noteUnresolvable(ledger, sectionId, r.variants, code);
358
+ noteUnresolvable(ledger, sectionId, r.variants, code, r.detail);
329
359
  skip ??= { reason: r.state === 'absent' ? 'section_absent' : 'variant_unresolvable', detail: sectionId };
330
360
  }
331
361
  if (skip) {
@@ -389,12 +419,12 @@ function checkCrossSectionConsistency(parsed, issues, ledger) {
389
419
  // once and agreeing everywhere (RFC 0026 §4.24). Two legs; the rule is
390
420
  // evaluated when either leg compared.
391
421
  {
392
- const su = resolveCrossCheckSection(parsed, 'sources_uses');
393
- const ds = resolveCrossCheckSection(parsed, 'debt_structure');
422
+ const su = resolveCrossCheckSection(parsed, 'sources_uses', [], 'CC-03', ledger);
423
+ const ds = resolveCrossCheckSection(parsed, 'debt_structure', [], 'CC-03', ledger);
394
424
  if (su.state === 'unresolvable')
395
- noteUnresolvable(ledger, 'sources_uses', su.variants, 'CC-03');
425
+ noteUnresolvable(ledger, 'sources_uses', su.variants, 'CC-03', su.detail);
396
426
  if (ds.state === 'unresolvable')
397
- noteUnresolvable(ledger, 'debt_structure', ds.variants, 'CC-03');
427
+ noteUnresolvable(ledger, 'debt_structure', ds.variants, 'CC-03', ds.detail);
398
428
  const suLoan = su.block ? (num(deepGet(su.block.content, 'sources.loan_amount')) ?? num(deepGet(su.block.content, 'sources.debt_proceeds'))) : undefined;
399
429
  const dsLoan = ds.block ? num(deepGet(ds.block.content, 'loan_amount')) : undefined;
400
430
  let evaluated = false;
@@ -404,7 +434,7 @@ function checkCrossSectionConsistency(parsed, issues, ledger) {
404
434
  issues.push({ code: 'CC-03', severity: 'error', section: 'sources_uses', field: 'sources.loan_amount', message: `CC-03: Loan amount in Sources & Uses ($${suLoan.toLocaleString()}) does not match Debt Structure loan amount ($${dsLoan.toLocaleString()})`, value: Math.abs(suLoan - dsLoan) });
405
435
  }
406
436
  }
407
- const senior = seniorDebtTranche(parsed);
437
+ const senior = seniorDebtTranche(parsed, ledger);
408
438
  if (senior) {
409
439
  const seniorAmount = num(senior['amount']);
410
440
  const reference = dsLoan ?? suLoan;
@@ -421,6 +451,7 @@ function checkCrossSectionConsistency(parsed, issues, ledger) {
421
451
  markSkipped(ledger, 'CC-03', 'variant_unresolvable', 'sources_uses');
422
452
  else if (ds.state === 'unresolvable')
423
453
  markSkipped(ledger, 'CC-03', 'variant_unresolvable', 'debt_structure');
454
+ else if (ledger.coverage['CC-03']?.reason === 'variant_unresolvable') { /* A role-bearing capital stack could not resolve. */ }
424
455
  else if (su.state === 'absent')
425
456
  markSkipped(ledger, 'CC-03', 'section_absent', 'sources_uses');
426
457
  else if (ds.state === 'absent')
@@ -616,7 +647,12 @@ const ADMISSIBLE_COMPONENT_CLASSES = new Set([
616
647
  // footing check (property NOI == Σ component NOI), and the section-internal
617
648
  // MU-* rules. A no-op for any document without a components section.
618
649
  function checkComponents(parsed, issues, ledger) {
619
- const components = getSection(parsed, 'components');
650
+ const components = roleAwareDirectRead(parsed, 'components', 'CC-11', ledger);
651
+ if (ledger.coverage['CC-11']?.reason === 'variant_unresolvable') {
652
+ markSkipped(ledger, 'CC-12', 'variant_unresolvable', 'components');
653
+ ledger.unresolvable.get('components')?.codes.add('CC-12');
654
+ return;
655
+ }
620
656
  if (!components) {
621
657
  markSkipped(ledger, 'CC-11', 'not_applicable', 'no components section');
622
658
  markSkipped(ledger, 'CC-12', 'not_applicable', 'no components section');
@@ -700,8 +736,12 @@ function checkComponents(parsed, issues, ledger) {
700
736
  // CC-12: property NOI must equal the sum of component NOIs. Only assert footing
701
737
  // when every admissible component states a numeric NOI — a missing one is
702
738
  // MU-04's job, not a footing mismatch.
703
- const noiModel = getSection(parsed, 'noi_model');
704
- if (!noiModel)
739
+ const noiModel = roleAwareDirectRead(parsed, 'noi_model', 'CC-12', ledger);
740
+ if (ledger.resolutions['CC-11']?.['components']) {
741
+ ledger.resolutions['CC-12'] ??= {};
742
+ ledger.resolutions['CC-12']['components'] = ledger.resolutions['CC-11']['components'];
743
+ }
744
+ if (!noiModel && !ledger.coverage['CC-12'])
705
745
  markSkipped(ledger, 'CC-12', 'section_absent', 'noi_model');
706
746
  if (noiModel) {
707
747
  const propNOI = deepGet(noiModel.content, 'net_operating_income');
@@ -743,8 +783,8 @@ const WATERFALL_MARKERS = [
743
783
  'tiers', 'promote', 'hurdle', 'hurdles', 'catch_up', 'carried_interest',
744
784
  ];
745
785
  /** The capital_stack `senior_debt` tranche, if the section and tranche exist. */
746
- function seniorDebtTranche(parsed) {
747
- const cs = getSection(parsed, 'capital_stack');
786
+ function seniorDebtTranche(parsed, ledger) {
787
+ const cs = roleAwareDirectRead(parsed, 'capital_stack', 'CC-03', ledger);
748
788
  if (!cs)
749
789
  return null;
750
790
  const tranches = cs.content['tranches'];
@@ -866,7 +906,12 @@ function checkSizeIntensive(parsed, issues, ledger) {
866
906
  }
867
907
  // 4. A property section exists — a missing section is a different defect
868
908
  // with a different remedy (RFC 0027, unresolved question 5).
869
- const property = getSection(parsed, 'property') ?? getSectionVariant(parsed, 'property', 'default');
909
+ const propertyEntry = parsed.sections['property'];
910
+ const roleBearing = propertyEntry && (isVariantMap(propertyEntry) ? Object.values(propertyEntry) : [propertyEntry]).some(hasBlockRole);
911
+ const property = roleBearing ? roleAwareDirectRead(parsed, 'property', 'CC-13', ledger)
912
+ : getSection(parsed, 'property') ?? getSectionVariant(parsed, 'property', 'default');
913
+ if (ledger.coverage['CC-13']?.reason === 'variant_unresolvable')
914
+ return;
870
915
  if (!property) {
871
916
  markSkipped(ledger, 'CC-13', 'section_absent', 'property');
872
917
  return;
@@ -971,65 +1016,6 @@ function checkCapitalStack(parsed, issues) {
971
1016
  // variant is checked structurally — a downside scenario with a gapped schedule
972
1017
  // is just as malformed as a base one. CC-15 reads only the base variant: a
973
1018
  // downside scenario is *supposed* to disagree with stabilized NOI.
974
- function checkLeaseUpContent(content, variant, issues) {
975
- const section = 'lease_up_schedule';
976
- const label = variant === 'default' ? '' : ` (variant "${variant}")`;
977
- const granRaw = content['period_granularity'];
978
- const granularity = granRaw === 'monthly' || granRaw === 'quarterly' ? granRaw : 'quarterly';
979
- if (granRaw !== 'monthly' && granRaw !== 'quarterly') {
980
- issues.push({
981
- code: 'LU-01', severity: 'error', section, field: 'period_granularity',
982
- message: `LU-01: period_granularity${label} must be "monthly" or "quarterly", not ${JSON.stringify(granRaw)}`,
983
- });
984
- }
985
- const schedule = content['schedule'];
986
- const rows = Array.isArray(schedule) ? schedule : [];
987
- if (!Array.isArray(schedule) || rows.length === 0) {
988
- issues.push({
989
- code: 'LU-03', severity: 'error', section, field: 'schedule',
990
- message: `LU-03: schedule${label} must be a non-empty array of periods`,
991
- });
992
- return;
993
- }
994
- // LU-01 (grammar) / LU-02 (strictly increasing, gap-free). One diagnostic
995
- // per defect: a row outside the grammar is not also reported as a gap.
996
- let prev = null;
997
- let grammarBroken = false;
998
- for (let i = 0; i < rows.length; i++) {
999
- const row = rows[i];
1000
- const period = row && typeof row === 'object' ? row['period'] : undefined;
1001
- const ordinal = typeof period === 'string' ? leaseUpPeriodOrdinal(period, granularity) : null;
1002
- if (ordinal === null) {
1003
- grammarBroken = true;
1004
- issues.push({
1005
- code: 'LU-01', severity: 'error', section, field: `schedule[${i}].period`,
1006
- message: `LU-01: period ${JSON.stringify(period)}${label} is outside the ${granularity} grammar (${granularity === 'quarterly' ? 'YYYY-Qn' : 'YYYY-MM'})`,
1007
- });
1008
- prev = null;
1009
- continue;
1010
- }
1011
- if (prev !== null && ordinal !== prev + 1) {
1012
- issues.push({
1013
- code: 'LU-02', severity: 'error', section, field: `schedule[${i}].period`,
1014
- message: `LU-02: periods${label} must be strictly increasing and gap-free; ${String(period)} does not immediately follow the prior period`,
1015
- });
1016
- }
1017
- prev = ordinal;
1018
- }
1019
- // LU-03 (second arm): a stabilization_target earlier than the first period.
1020
- const target = content['stabilization_target'];
1021
- if (!grammarBroken && typeof target === 'string') {
1022
- const first = rows[0]['period'];
1023
- const targetOrd = leaseUpPeriodOrdinal(target, granularity);
1024
- const firstOrd = typeof first === 'string' ? leaseUpPeriodOrdinal(first, granularity) : null;
1025
- if (targetOrd !== null && firstOrd !== null && targetOrd < firstOrd) {
1026
- issues.push({
1027
- code: 'LU-03', severity: 'error', section, field: 'stabilization_target',
1028
- message: `LU-03: stabilization_target ${target}${label} is earlier than the first schedule period ${String(first)}`,
1029
- });
1030
- }
1031
- }
1032
- }
1033
1019
  function checkLeaseUpSchedule(parsed, issues, ledger) {
1034
1020
  const entry = parsed.sections['lease_up_schedule'];
1035
1021
  if (!entry) {
@@ -1065,12 +1051,28 @@ function checkLeaseUpSchedule(parsed, issues, ledger) {
1065
1051
  const base = isVariantMap(entry)
1066
1052
  ? (entry['base'] ?? entry['default'])
1067
1053
  : entry;
1068
- if (!base)
1054
+ if (base && hasBlockRole(base)) {
1055
+ const r = resolveRoleBlock(base, 'lease_up_schedule');
1056
+ if (r.state === 'unresolvable') {
1057
+ noteUnresolvable(ledger, 'lease_up_schedule', r.variants, 'CC-15', r.detail);
1058
+ markSkipped(ledger, 'CC-15', 'variant_unresolvable', 'lease_up_schedule');
1059
+ return;
1060
+ }
1061
+ ledger.resolutions['CC-15'] ??= {};
1062
+ ledger.resolutions['CC-15']['lease_up_schedule'] = {
1063
+ ...(base.annotation.variant ? { variant: base.annotation.variant } : {}), via: 'preference',
1064
+ };
1065
+ }
1066
+ if (!base) {
1069
1067
  markSkipped(ledger, 'CC-15', 'not_applicable', 'no base variant');
1068
+ return;
1069
+ }
1070
1070
  const stabilizedNoi = base
1071
1071
  ? deepGet(base.content, 'stabilized_summary.annualized_noi')
1072
1072
  : undefined;
1073
- const noiModel = getSection(parsed, 'noi_model');
1073
+ const noiModel = roleAwareDirectRead(parsed, 'noi_model', 'CC-15', ledger);
1074
+ if (ledger.coverage['CC-15']?.reason === 'variant_unresolvable')
1075
+ return;
1074
1076
  const modelNoi = deepGet(noiModel?.content, 'net_operating_income');
1075
1077
  if (base && !noiModel)
1076
1078
  markSkipped(ledger, 'CC-15', 'section_absent', 'noi_model');