@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.
- package/README.md +12 -1
- package/dist/block-roles.d.ts +20 -0
- package/dist/block-roles.d.ts.map +1 -0
- package/dist/block-roles.js +54 -0
- package/dist/block-roles.js.map +1 -0
- package/dist/browser.d.ts +11 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +6 -1
- package/dist/browser.js.map +1 -1
- package/dist/calc/dependencies.d.ts +3 -0
- package/dist/calc/dependencies.d.ts.map +1 -1
- package/dist/calc/dependencies.js +14 -1
- package/dist/calc/dependencies.js.map +1 -1
- package/dist/calc/errors.d.ts +1 -1
- package/dist/calc/errors.d.ts.map +1 -1
- package/dist/calc/errors.js.map +1 -1
- package/dist/calc/evaluator.d.ts +1 -1
- package/dist/calc/evaluator.d.ts.map +1 -1
- package/dist/calc/evaluator.js +10 -0
- package/dist/calc/evaluator.js.map +1 -1
- package/dist/calc/parser.d.ts +10 -0
- package/dist/calc/parser.d.ts.map +1 -1
- package/dist/calc/parser.js +25 -1
- package/dist/calc/parser.js.map +1 -1
- package/dist/calc/prng.d.ts.map +1 -1
- package/dist/calc/prng.js +6 -14
- package/dist/calc/prng.js.map +1 -1
- package/dist/calculation-context.d.ts +6 -0
- package/dist/calculation-context.d.ts.map +1 -0
- package/dist/calculation-context.js +52 -0
- package/dist/calculation-context.js.map +1 -0
- package/dist/cli.js +65 -3
- package/dist/cli.js.map +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +23 -1
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lease-up-cash-flows.d.ts +10 -0
- package/dist/lease-up-cash-flows.d.ts.map +1 -0
- package/dist/lease-up-cash-flows.js +143 -0
- package/dist/lease-up-cash-flows.js.map +1 -0
- package/dist/lease-up-structure.d.ts +3 -0
- package/dist/lease-up-structure.d.ts.map +1 -0
- package/dist/lease-up-structure.js +76 -0
- package/dist/lease-up-structure.js.map +1 -0
- package/dist/packs/excel-emit.d.ts +3 -0
- package/dist/packs/excel-emit.d.ts.map +1 -1
- package/dist/packs/excel-emit.js +32 -1
- package/dist/packs/excel-emit.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +12 -3
- package/dist/parser.js.map +1 -1
- package/dist/period-path.d.ts +23 -0
- package/dist/period-path.d.ts.map +1 -0
- package/dist/period-path.js +100 -0
- package/dist/period-path.js.map +1 -0
- package/dist/period-validation.d.ts +4 -0
- package/dist/period-validation.d.ts.map +1 -0
- package/dist/period-validation.js +84 -0
- package/dist/period-validation.js.map +1 -0
- package/dist/periods.d.ts +17 -0
- package/dist/periods.d.ts.map +1 -0
- package/dist/periods.js +92 -0
- package/dist/periods.js.map +1 -0
- package/dist/protocol.d.ts +97 -3
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +45 -1
- package/dist/protocol.js.map +1 -1
- package/dist/refinement.d.ts +5 -1
- package/dist/refinement.d.ts.map +1 -1
- package/dist/refinement.js +98 -25
- package/dist/refinement.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +5 -0
- package/dist/runner.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +98 -96
- package/dist/validator.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 =
|
|
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 =
|
|
704
|
-
if (
|
|
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 =
|
|
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
|
|
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 (
|
|
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 =
|
|
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');
|