@uwmd/core 2.4.0 → 2.5.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/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +19 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +36 -1
- package/dist/protocol.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validator.d.ts +8 -1
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +385 -104
- 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,7 +2,7 @@
|
|
|
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 } 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, CROSS_CHECK_VARIANT_PREFERENCE, 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';
|
|
@@ -91,15 +91,17 @@ function resolveSectionFieldPath(parsed, path) {
|
|
|
91
91
|
export function validateUWFile(parsed, thresholdOverrides) {
|
|
92
92
|
const thresholds = { ...DEFAULT_THRESHOLDS, ...thresholdOverrides };
|
|
93
93
|
const issues = [];
|
|
94
|
+
const ledger = newCoverageLedger();
|
|
94
95
|
checkFinancialValidity(parsed, thresholds, issues);
|
|
95
|
-
checkCrossSectionConsistency(parsed, issues);
|
|
96
|
-
checkComponents(parsed, issues);
|
|
96
|
+
checkCrossSectionConsistency(parsed, issues, ledger);
|
|
97
|
+
checkComponents(parsed, issues, ledger);
|
|
97
98
|
checkCapitalStack(parsed, issues);
|
|
98
|
-
checkLeaseUpSchedule(parsed, issues);
|
|
99
|
+
checkLeaseUpSchedule(parsed, issues, ledger);
|
|
99
100
|
checkCashFlowSeries(parsed, issues);
|
|
100
101
|
checkWaterfall(parsed, issues);
|
|
101
|
-
checkSizeIntensive(parsed, issues);
|
|
102
|
-
checkSectionReadiness(parsed, issues);
|
|
102
|
+
checkSizeIntensive(parsed, issues, ledger);
|
|
103
|
+
checkSectionReadiness(parsed, issues, ledger);
|
|
104
|
+
checkReturnsTaxBasis(parsed, issues);
|
|
103
105
|
checkLocale(parsed, issues);
|
|
104
106
|
checkAssetClassIdentifier(parsed, issues);
|
|
105
107
|
checkMetaIntegrity(parsed, issues);
|
|
@@ -107,6 +109,23 @@ export function validateUWFile(parsed, thresholdOverrides) {
|
|
|
107
109
|
checkSourceVocabulary(parsed, issues);
|
|
108
110
|
checkScopeReadiness(parsed, issues);
|
|
109
111
|
checkDataQuality(parsed, issues);
|
|
112
|
+
// CC-16: one info issue per section that was present as a variant map no
|
|
113
|
+
// cross-check could resolve (§5.3, RFC 0037). Emitted after every check has
|
|
114
|
+
// run so the message can name every rule the map silenced.
|
|
115
|
+
for (const [sectionId, entry] of ledger.unresolvable) {
|
|
116
|
+
const codes = [...entry.codes].sort();
|
|
117
|
+
issues.push({
|
|
118
|
+
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)`,
|
|
120
|
+
value: entry.variants,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// Every registered rule owes a coverage entry; a rule no check reached is
|
|
124
|
+
// reported rather than silently missing.
|
|
125
|
+
for (const code of CROSS_CHECK_RULE_IDS) {
|
|
126
|
+
if (!ledger.coverage[code])
|
|
127
|
+
markSkipped(ledger, code, 'not_applicable', 'not reached');
|
|
128
|
+
}
|
|
110
129
|
// Enrich every issue with BUILTIN_REMEDIATIONS title/remediation/spec_ref
|
|
111
130
|
// when the code matches the registry. Keeps inline messages (which carry
|
|
112
131
|
// deal-specific values) and adds canonical remediation copy.
|
|
@@ -127,6 +146,7 @@ export function validateUWFile(parsed, thresholdOverrides) {
|
|
|
127
146
|
errors,
|
|
128
147
|
warnings,
|
|
129
148
|
info,
|
|
149
|
+
coverage: orderedCoverage(ledger),
|
|
130
150
|
};
|
|
131
151
|
}
|
|
132
152
|
// ─── §5.2 Financial validity thresholds ──────────────────────────────────────
|
|
@@ -245,132 +265,346 @@ function checkFinancialValidity(parsed, t, issues) {
|
|
|
245
265
|
}
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
268
|
+
function newCoverageLedger() {
|
|
269
|
+
return { coverage: {}, unresolvable: new Map() };
|
|
270
|
+
}
|
|
271
|
+
function markEvaluated(ledger, code) {
|
|
272
|
+
ledger.coverage[code] = { status: 'evaluated' };
|
|
273
|
+
}
|
|
274
|
+
function markSkipped(ledger, code, reason, detail) {
|
|
275
|
+
if (ledger.coverage[code]?.status === 'evaluated')
|
|
276
|
+
return;
|
|
277
|
+
ledger.coverage[code] = { status: 'skipped', reason, ...(detail ? { detail } : {}) };
|
|
278
|
+
}
|
|
279
|
+
function noteUnresolvable(ledger, sectionId, variants, code) {
|
|
280
|
+
const entry = ledger.unresolvable.get(sectionId) ?? { variants, codes: new Set() };
|
|
281
|
+
entry.codes.add(code);
|
|
282
|
+
ledger.unresolvable.set(sectionId, entry);
|
|
283
|
+
}
|
|
284
|
+
/** Coverage in registered-rule order, so `--json` output is stable. */
|
|
285
|
+
function orderedCoverage(ledger) {
|
|
286
|
+
const out = {};
|
|
287
|
+
for (const code of CROSS_CHECK_RULE_IDS) {
|
|
288
|
+
const entry = ledger.coverage[code];
|
|
289
|
+
if (entry)
|
|
290
|
+
out[code] = entry;
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
function resolveCrossCheckSection(parsed, sectionId, preferred = []) {
|
|
295
|
+
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() };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Resolve every section a rule reads. On any failure the rule's skip is
|
|
313
|
+
* recorded (the first failure's reason wins; every unresolvable section is
|
|
314
|
+
* noted for CC-16) and `null` is returned.
|
|
315
|
+
*/
|
|
316
|
+
function requireSections(ledger, parsed, code, needs) {
|
|
317
|
+
const out = {};
|
|
318
|
+
let skip = null;
|
|
319
|
+
for (const need of needs) {
|
|
320
|
+
const sectionId = need[0];
|
|
321
|
+
const preferred = need.length > 1 ? need[1] : [];
|
|
322
|
+
const r = resolveCrossCheckSection(parsed, sectionId, preferred);
|
|
323
|
+
if (r.state === 'resolved') {
|
|
324
|
+
out[sectionId] = { block: r.block, ...(r.variant ? { variant: r.variant } : {}) };
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (r.state === 'unresolvable')
|
|
328
|
+
noteUnresolvable(ledger, sectionId, r.variants, code);
|
|
329
|
+
skip ??= { reason: r.state === 'absent' ? 'section_absent' : 'variant_unresolvable', detail: sectionId };
|
|
330
|
+
}
|
|
331
|
+
if (skip) {
|
|
332
|
+
markSkipped(ledger, code, skip.reason, skip.detail);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
function num(v) {
|
|
338
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
339
|
+
}
|
|
340
|
+
function checkCrossSectionConsistency(parsed, issues, ledger) {
|
|
262
341
|
// CC-01: Rent roll GPR must match OS GPR within 3%
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
if (
|
|
271
|
-
|
|
342
|
+
{
|
|
343
|
+
const got = requireSections(ledger, parsed, 'CC-01', [['rent_roll'], ['operating_statement', ['t12']]]);
|
|
344
|
+
if (got) {
|
|
345
|
+
const rentRoll = got['rent_roll'].block;
|
|
346
|
+
const os = got['operating_statement'].block;
|
|
347
|
+
const rrGPR = num(deepGet(rentRoll.content, 'gross_potential_rent')) ?? num(deepGet(rentRoll.content, 'totals.gross_potential_rent'));
|
|
348
|
+
const osGPR = num(deepGet(os.content, 'gross_potential_rent')) ?? num(deepGet(os.content, 'income.gross_potential_rent'));
|
|
349
|
+
if (rrGPR == null)
|
|
350
|
+
markSkipped(ledger, 'CC-01', 'field_absent', 'rent_roll.gross_potential_rent');
|
|
351
|
+
else if (osGPR == null || osGPR === 0)
|
|
352
|
+
markSkipped(ledger, 'CC-01', 'field_absent', 'operating_statement.gross_potential_rent');
|
|
353
|
+
else {
|
|
354
|
+
markEvaluated(ledger, 'CC-01');
|
|
355
|
+
const pctDiff = Math.abs(rrGPR - osGPR) / osGPR;
|
|
356
|
+
if (pctDiff > 0.03) {
|
|
357
|
+
issues.push({ code: 'CC-01', severity: 'warning', section: 'rent_roll', field: 'gross_potential_rent', message: `CC-01: Rent roll GPR ($${rrGPR.toLocaleString()}) differs from Operating Statement GPR ($${osGPR.toLocaleString()}) by ${(pctDiff * 100).toFixed(1)}% (threshold: 3%)`, value: pctDiff });
|
|
358
|
+
}
|
|
272
359
|
}
|
|
273
360
|
}
|
|
274
361
|
}
|
|
275
362
|
// CC-02: UW value in valuation must match LTV denominator in debt_structure
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
285
|
-
|
|
363
|
+
{
|
|
364
|
+
const got = requireSections(ledger, parsed, 'CC-02', [['valuation'], ['debt_structure']]);
|
|
365
|
+
if (got) {
|
|
366
|
+
const valuation = got['valuation'].block;
|
|
367
|
+
const debtStructure = got['debt_structure'].block;
|
|
368
|
+
const uwValue = num(deepGet(valuation.content, 'underwritten_value')) ?? num(deepGet(valuation.content, 'purchase_price'));
|
|
369
|
+
const loanAmt = num(deepGet(debtStructure.content, 'loan_amount'));
|
|
370
|
+
const ltvInDebt = num(deepGet(debtStructure.content, 'ltv'));
|
|
371
|
+
if (uwValue == null || uwValue <= 0)
|
|
372
|
+
markSkipped(ledger, 'CC-02', 'field_absent', 'valuation.underwritten_value');
|
|
373
|
+
else if (loanAmt == null)
|
|
374
|
+
markSkipped(ledger, 'CC-02', 'field_absent', 'debt_structure.loan_amount');
|
|
375
|
+
else if (ltvInDebt == null)
|
|
376
|
+
markSkipped(ledger, 'CC-02', 'field_absent', 'debt_structure.ltv');
|
|
377
|
+
else {
|
|
378
|
+
markEvaluated(ledger, 'CC-02');
|
|
379
|
+
const impliedLTV = loanAmt / uwValue;
|
|
380
|
+
const diff = Math.abs(impliedLTV - ltvInDebt);
|
|
381
|
+
if (diff > 0.005) {
|
|
382
|
+
issues.push({ code: 'CC-02', severity: 'warning', section: 'debt_structure', field: 'ltv', message: `CC-02: Implied LTV (${(impliedLTV * 100).toFixed(2)}%) from loan/value does not match stated LTV (${(ltvInDebt * 100).toFixed(2)}%) — check valuation.underwritten_value vs debt_structure.ltv`, value: diff });
|
|
383
|
+
}
|
|
286
384
|
}
|
|
287
385
|
}
|
|
288
386
|
}
|
|
289
387
|
// CC-03: the senior loan reconciles across sources_uses, debt_structure, and
|
|
290
388
|
// (when present) the capital_stack senior_debt tranche — one senior view stated
|
|
291
|
-
// once and agreeing everywhere (RFC 0026 §4.24).
|
|
389
|
+
// once and agreeing everywhere (RFC 0026 §4.24). Two legs; the rule is
|
|
390
|
+
// evaluated when either leg compared.
|
|
292
391
|
{
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
392
|
+
const su = resolveCrossCheckSection(parsed, 'sources_uses');
|
|
393
|
+
const ds = resolveCrossCheckSection(parsed, 'debt_structure');
|
|
394
|
+
if (su.state === 'unresolvable')
|
|
395
|
+
noteUnresolvable(ledger, 'sources_uses', su.variants, 'CC-03');
|
|
396
|
+
if (ds.state === 'unresolvable')
|
|
397
|
+
noteUnresolvable(ledger, 'debt_structure', ds.variants, 'CC-03');
|
|
398
|
+
const suLoan = su.block ? (num(deepGet(su.block.content, 'sources.loan_amount')) ?? num(deepGet(su.block.content, 'sources.debt_proceeds'))) : undefined;
|
|
399
|
+
const dsLoan = ds.block ? num(deepGet(ds.block.content, 'loan_amount')) : undefined;
|
|
400
|
+
let evaluated = false;
|
|
401
|
+
if (suLoan != null && dsLoan != null) {
|
|
402
|
+
evaluated = true;
|
|
403
|
+
if (Math.abs(suLoan - dsLoan) > 100) {
|
|
404
|
+
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
|
+
}
|
|
298
406
|
}
|
|
299
407
|
const senior = seniorDebtTranche(parsed);
|
|
300
408
|
if (senior) {
|
|
301
|
-
const seniorAmount =
|
|
409
|
+
const seniorAmount = num(senior['amount']);
|
|
302
410
|
const reference = dsLoan ?? suLoan;
|
|
303
|
-
if (seniorAmount != null && reference != null
|
|
304
|
-
|
|
411
|
+
if (seniorAmount != null && reference != null) {
|
|
412
|
+
evaluated = true;
|
|
413
|
+
if (Math.abs(seniorAmount - reference) > 100) {
|
|
414
|
+
issues.push({ code: 'CC-03', severity: 'error', section: 'capital_stack', field: 'tranches.senior_debt.amount', message: `CC-03: the capital_stack senior_debt tranche ($${seniorAmount.toLocaleString()}) does not match the senior loan amount ($${reference.toLocaleString()})`, value: Math.abs(seniorAmount - reference) });
|
|
415
|
+
}
|
|
305
416
|
}
|
|
306
417
|
}
|
|
418
|
+
if (evaluated)
|
|
419
|
+
markEvaluated(ledger, 'CC-03');
|
|
420
|
+
else if (su.state === 'unresolvable')
|
|
421
|
+
markSkipped(ledger, 'CC-03', 'variant_unresolvable', 'sources_uses');
|
|
422
|
+
else if (ds.state === 'unresolvable')
|
|
423
|
+
markSkipped(ledger, 'CC-03', 'variant_unresolvable', 'debt_structure');
|
|
424
|
+
else if (su.state === 'absent')
|
|
425
|
+
markSkipped(ledger, 'CC-03', 'section_absent', 'sources_uses');
|
|
426
|
+
else if (ds.state === 'absent')
|
|
427
|
+
markSkipped(ledger, 'CC-03', 'section_absent', 'debt_structure');
|
|
428
|
+
else
|
|
429
|
+
markSkipped(ledger, 'CC-03', 'field_absent', suLoan == null ? 'sources_uses.sources.loan_amount' : 'debt_structure.loan_amount');
|
|
307
430
|
}
|
|
308
431
|
// CC-04: Sources must equal uses in sources_uses (within $1)
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
432
|
+
{
|
|
433
|
+
const got = requireSections(ledger, parsed, 'CC-04', [['sources_uses']]);
|
|
434
|
+
if (got) {
|
|
435
|
+
const sourcesUses = got['sources_uses'].block;
|
|
436
|
+
const totalSources = num(deepGet(sourcesUses.content, 'total_sources'));
|
|
437
|
+
const totalUses = num(deepGet(sourcesUses.content, 'total_uses'));
|
|
438
|
+
if (totalSources == null)
|
|
439
|
+
markSkipped(ledger, 'CC-04', 'field_absent', 'sources_uses.total_sources');
|
|
440
|
+
else if (totalUses == null)
|
|
441
|
+
markSkipped(ledger, 'CC-04', 'field_absent', 'sources_uses.total_uses');
|
|
442
|
+
else {
|
|
443
|
+
markEvaluated(ledger, 'CC-04');
|
|
444
|
+
if (Math.abs(totalSources - totalUses) > 1) {
|
|
445
|
+
issues.push({ code: 'CC-04', severity: 'error', section: 'sources_uses', field: 'total_sources', message: `CC-04: Sources ($${totalSources.toLocaleString()}) do not equal Uses ($${totalUses.toLocaleString()}) — difference: $${Math.abs(totalSources - totalUses).toLocaleString()}`, value: Math.abs(totalSources - totalUses) });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
314
448
|
}
|
|
315
449
|
}
|
|
316
450
|
// CC-05: NOI used for DSCR must match noi_model.net_operating_income within 1%
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
451
|
+
{
|
|
452
|
+
const got = requireSections(ledger, parsed, 'CC-05', [['noi_model'], ['debt_structure']]);
|
|
453
|
+
if (got) {
|
|
454
|
+
const noiModel = got['noi_model'].block;
|
|
455
|
+
const debtStructure = got['debt_structure'].block;
|
|
456
|
+
const modelNOI = num(deepGet(noiModel.content, 'net_operating_income'));
|
|
457
|
+
const debtNOI = num(deepGet(debtStructure.content, 'underwritten_noi')) ?? num(deepGet(debtStructure.content, 'noi_used_for_dscr'));
|
|
458
|
+
if (modelNOI == null || modelNOI <= 0)
|
|
459
|
+
markSkipped(ledger, 'CC-05', 'field_absent', 'noi_model.net_operating_income');
|
|
460
|
+
else if (debtNOI == null)
|
|
461
|
+
markSkipped(ledger, 'CC-05', 'field_absent', 'debt_structure.underwritten_noi');
|
|
462
|
+
else {
|
|
463
|
+
markEvaluated(ledger, 'CC-05');
|
|
464
|
+
const pctDiff = Math.abs(modelNOI - debtNOI) / modelNOI;
|
|
465
|
+
if (pctDiff > 0.01) {
|
|
466
|
+
issues.push({ code: 'CC-05', severity: 'warning', section: 'debt_structure', field: 'underwritten_noi', message: `CC-05: NOI used for DSCR ($${debtNOI.toLocaleString()}) differs from noi_model NOI ($${modelNOI.toLocaleString()}) by ${(pctDiff * 100).toFixed(2)}% (threshold: 1%)`, value: pctDiff });
|
|
467
|
+
}
|
|
325
468
|
}
|
|
326
469
|
}
|
|
327
470
|
}
|
|
328
471
|
// CC-06: DCF Year 1 NOI must be consistent with noi_model projections
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
472
|
+
{
|
|
473
|
+
const got = requireSections(ledger, parsed, 'CC-06', [['noi_model'], ['dcf']]);
|
|
474
|
+
if (got) {
|
|
475
|
+
const noiModel = got['noi_model'].block;
|
|
476
|
+
const dcf = got['dcf'].block;
|
|
477
|
+
const modelNOI = num(deepGet(noiModel.content, 'net_operating_income'));
|
|
478
|
+
const dcfY1NOI = num(deepGet(dcf.content, 'annual_cash_flows[0].noi')) ?? num(deepGet(dcf.content, 'annual_cash_flows[0].net_operating_income'));
|
|
479
|
+
if (modelNOI == null || modelNOI <= 0)
|
|
480
|
+
markSkipped(ledger, 'CC-06', 'field_absent', 'noi_model.net_operating_income');
|
|
481
|
+
else if (dcfY1NOI == null)
|
|
482
|
+
markSkipped(ledger, 'CC-06', 'field_absent', 'dcf.annual_cash_flows[0].net_operating_income');
|
|
483
|
+
else {
|
|
484
|
+
markEvaluated(ledger, 'CC-06');
|
|
485
|
+
const pctDiff = Math.abs(modelNOI - dcfY1NOI) / modelNOI;
|
|
486
|
+
if (pctDiff > 0.02) {
|
|
487
|
+
issues.push({ code: 'CC-06', severity: 'warning', section: 'dcf', field: 'annual_cash_flows[0].noi', message: `CC-06: DCF Year 1 NOI ($${dcfY1NOI.toLocaleString()}) deviates from noi_model NOI ($${modelNOI.toLocaleString()}) by ${(pctDiff * 100).toFixed(2)}%`, value: pctDiff });
|
|
488
|
+
}
|
|
337
489
|
}
|
|
338
490
|
}
|
|
339
491
|
}
|
|
340
492
|
// CC-07: Exit cap rate in dcf must be consistent with stress test cap rate scenarios
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
493
|
+
{
|
|
494
|
+
const got = requireSections(ledger, parsed, 'CC-07', [['dcf'], ['stress_tests']]);
|
|
495
|
+
if (got) {
|
|
496
|
+
const dcf = got['dcf'].block;
|
|
497
|
+
const stressTests = got['stress_tests'].block;
|
|
498
|
+
const dcfExitCap = num(deepGet(dcf.content, 'assumptions.exit_cap_rate')) ?? num(deepGet(dcf.content, 'exit_cap_rate'));
|
|
499
|
+
const stressExitCap = num(deepGet(stressTests.content, 'base_case.exit_cap_rate'));
|
|
500
|
+
if (dcfExitCap == null)
|
|
501
|
+
markSkipped(ledger, 'CC-07', 'field_absent', 'dcf.assumptions.exit_cap_rate');
|
|
502
|
+
else if (stressExitCap == null)
|
|
503
|
+
markSkipped(ledger, 'CC-07', 'field_absent', 'stress_tests.base_case.exit_cap_rate');
|
|
504
|
+
else {
|
|
505
|
+
markEvaluated(ledger, 'CC-07');
|
|
506
|
+
if (Math.abs(dcfExitCap - stressExitCap) > 0.005) {
|
|
507
|
+
issues.push({ code: 'CC-07', severity: 'warning', section: 'stress_tests', field: 'base_case.exit_cap_rate', message: `CC-07: Exit cap rate in DCF (${(dcfExitCap * 100).toFixed(2)}%) differs from stress test base case (${(stressExitCap * 100).toFixed(2)}%)`, value: Math.abs(dcfExitCap - stressExitCap) });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
347
510
|
}
|
|
348
511
|
}
|
|
349
|
-
// CC-08: Appraised value in due_diligence must match valuation.appraised_value
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
512
|
+
// CC-08: Appraised value in due_diligence must match valuation.appraised_value.
|
|
513
|
+
// due_diligence's variants are sub-documents (§2.8), so the `appraisal`
|
|
514
|
+
// variant is preferred and, when it is the resolved block, the value may
|
|
515
|
+
// sit at its root rather than under an `appraisal` key.
|
|
516
|
+
{
|
|
517
|
+
const got = requireSections(ledger, parsed, 'CC-08', [['due_diligence', ['appraisal']], ['valuation']]);
|
|
518
|
+
if (got) {
|
|
519
|
+
const dd = got['due_diligence'];
|
|
520
|
+
const valuation = got['valuation'].block;
|
|
521
|
+
const ddAppraisedVal = num(deepGet(dd.block.content, 'appraisal.appraised_value'))
|
|
522
|
+
?? (dd.variant === 'appraisal' ? num(deepGet(dd.block.content, 'appraised_value')) : undefined);
|
|
523
|
+
const valAppraisedVal = num(deepGet(valuation.content, 'appraised_value'));
|
|
524
|
+
if (ddAppraisedVal == null)
|
|
525
|
+
markSkipped(ledger, 'CC-08', 'field_absent', 'due_diligence.appraisal.appraised_value');
|
|
526
|
+
else if (valAppraisedVal == null)
|
|
527
|
+
markSkipped(ledger, 'CC-08', 'field_absent', 'valuation.appraised_value');
|
|
528
|
+
else {
|
|
529
|
+
markEvaluated(ledger, 'CC-08');
|
|
530
|
+
if (Math.abs(ddAppraisedVal - valAppraisedVal) > 1000) {
|
|
531
|
+
issues.push({ code: 'CC-08', severity: 'warning', section: 'due_diligence', field: 'appraisal.appraised_value', message: `CC-08: Appraised value in due_diligence ($${ddAppraisedVal.toLocaleString()}) does not match valuation.appraised_value ($${valAppraisedVal.toLocaleString()})`, value: Math.abs(ddAppraisedVal - valAppraisedVal) });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
355
534
|
}
|
|
356
535
|
}
|
|
357
536
|
// CC-09: Annual debt service in stress_tests base case must match debt_structure.annual_debt_service
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
537
|
+
{
|
|
538
|
+
const got = requireSections(ledger, parsed, 'CC-09', [['stress_tests'], ['debt_structure']]);
|
|
539
|
+
if (got) {
|
|
540
|
+
const stressTests = got['stress_tests'].block;
|
|
541
|
+
const debtStructure = got['debt_structure'].block;
|
|
542
|
+
const stressADS = num(deepGet(stressTests.content, 'base_case.annual_debt_service'));
|
|
543
|
+
const debtADS = num(deepGet(debtStructure.content, 'annual_debt_service'));
|
|
544
|
+
if (stressADS == null)
|
|
545
|
+
markSkipped(ledger, 'CC-09', 'field_absent', 'stress_tests.base_case.annual_debt_service');
|
|
546
|
+
else if (debtADS == null)
|
|
547
|
+
markSkipped(ledger, 'CC-09', 'field_absent', 'debt_structure.annual_debt_service');
|
|
548
|
+
else {
|
|
549
|
+
markEvaluated(ledger, 'CC-09');
|
|
550
|
+
if (Math.abs(stressADS - debtADS) > 500) {
|
|
551
|
+
issues.push({ code: 'CC-09', severity: 'warning', section: 'stress_tests', field: 'base_case.annual_debt_service', message: `CC-09: Annual debt service in stress test base case ($${stressADS.toLocaleString()}) differs from debt_structure ($${debtADS.toLocaleString()}) by $${Math.abs(stressADS - debtADS).toLocaleString()}`, value: Math.abs(stressADS - debtADS) });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
363
554
|
}
|
|
364
555
|
}
|
|
365
556
|
// CC-10: Purchase price in sources_uses.uses must match valuation.purchase_price
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
557
|
+
{
|
|
558
|
+
const got = requireSections(ledger, parsed, 'CC-10', [['sources_uses'], ['valuation']]);
|
|
559
|
+
if (got) {
|
|
560
|
+
const sourcesUses = got['sources_uses'].block;
|
|
561
|
+
const valuation = got['valuation'].block;
|
|
562
|
+
const suPP = num(deepGet(sourcesUses.content, 'uses.purchase_price'));
|
|
563
|
+
const valPP = num(deepGet(valuation.content, 'purchase_price'));
|
|
564
|
+
if (suPP == null)
|
|
565
|
+
markSkipped(ledger, 'CC-10', 'field_absent', 'sources_uses.uses.purchase_price');
|
|
566
|
+
else if (valPP == null)
|
|
567
|
+
markSkipped(ledger, 'CC-10', 'field_absent', 'valuation.purchase_price');
|
|
568
|
+
else {
|
|
569
|
+
markEvaluated(ledger, 'CC-10');
|
|
570
|
+
if (Math.abs(suPP - valPP) > 100) {
|
|
571
|
+
issues.push({ code: 'CC-10', severity: 'error', section: 'sources_uses', field: 'uses.purchase_price', message: `CC-10: Purchase price in Sources & Uses ($${suPP.toLocaleString()}) does not match valuation.purchase_price ($${valPP.toLocaleString()})`, value: Math.abs(suPP - valPP) });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
371
574
|
}
|
|
372
575
|
}
|
|
373
576
|
}
|
|
577
|
+
// ─── §4.9 dcf.returns.tax_basis (RFC 0038) ───────────────────────────────────
|
|
578
|
+
/**
|
|
579
|
+
* The tax basis every metric in `dcf.returns` is stated on: the declared
|
|
580
|
+
* `returns.tax_basis` when it is a registered value, else the spec default
|
|
581
|
+
* (`pre_tax`). Consumers comparing return metrics across documents call this
|
|
582
|
+
* rather than re-deriving the default.
|
|
583
|
+
*/
|
|
584
|
+
export function getReturnTaxBasis(parsed) {
|
|
585
|
+
const dcf = resolveCrossCheckSection(parsed, 'dcf').block;
|
|
586
|
+
const v = dcf ? deepGet(dcf.content, 'returns.tax_basis') : undefined;
|
|
587
|
+
return typeof v === 'string' && RETURN_TAX_BASES.includes(v)
|
|
588
|
+
? v
|
|
589
|
+
: DEFAULT_RETURN_TAX_BASIS;
|
|
590
|
+
}
|
|
591
|
+
// RT-01: a stated tax_basis outside the closed set. Absent (or null) means
|
|
592
|
+
// the default and is not an issue.
|
|
593
|
+
function checkReturnsTaxBasis(parsed, issues) {
|
|
594
|
+
const dcf = resolveCrossCheckSection(parsed, 'dcf').block;
|
|
595
|
+
if (!dcf)
|
|
596
|
+
return;
|
|
597
|
+
const v = deepGet(dcf.content, 'returns.tax_basis');
|
|
598
|
+
if (v === undefined || v === null)
|
|
599
|
+
return;
|
|
600
|
+
if (typeof v === 'string' && RETURN_TAX_BASES.includes(v))
|
|
601
|
+
return;
|
|
602
|
+
issues.push({
|
|
603
|
+
code: 'RT-01', severity: 'error', section: 'dcf', field: 'returns.tax_basis',
|
|
604
|
+
message: `RT-01: dcf.returns.tax_basis must be one of ${RETURN_TAX_BASES.join(', ')} (found ${JSON.stringify(v)}); omit it to mean ${DEFAULT_RETURN_TAX_BASIS} (format §4.9, RFC 0038)`,
|
|
605
|
+
value: v,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
374
608
|
// ─── §4.23 Mixed-use components (RFC 0019) ───────────────────────────────────
|
|
375
609
|
// The eight income classes admissible as a mixed-use component. `land` is
|
|
376
610
|
// excluded (its NOI model nets negative) and `mixed_use` cannot nest in itself.
|
|
@@ -381,13 +615,18 @@ const ADMISSIBLE_COMPONENT_CLASSES = new Set([
|
|
|
381
615
|
// Validates the `components` section: the CC-11 asset-class gate, the CC-12
|
|
382
616
|
// footing check (property NOI == Σ component NOI), and the section-internal
|
|
383
617
|
// MU-* rules. A no-op for any document without a components section.
|
|
384
|
-
function checkComponents(parsed, issues) {
|
|
618
|
+
function checkComponents(parsed, issues, ledger) {
|
|
385
619
|
const components = getSection(parsed, 'components');
|
|
386
|
-
if (!components)
|
|
620
|
+
if (!components) {
|
|
621
|
+
markSkipped(ledger, 'CC-11', 'not_applicable', 'no components section');
|
|
622
|
+
markSkipped(ledger, 'CC-12', 'not_applicable', 'no components section');
|
|
387
623
|
return;
|
|
624
|
+
}
|
|
388
625
|
const assetClass = parsed.frontmatter.asset_class;
|
|
389
626
|
// CC-11: a components section is only valid under asset_class mixed_use.
|
|
627
|
+
markEvaluated(ledger, 'CC-11');
|
|
390
628
|
if (assetClass !== 'mixed_use') {
|
|
629
|
+
markSkipped(ledger, 'CC-12', 'not_applicable', 'CC-11 fired');
|
|
391
630
|
issues.push({
|
|
392
631
|
code: 'CC-11', severity: 'error', section: 'components', field: 'asset_class',
|
|
393
632
|
message: `CC-11: a components section is only valid when asset_class is mixed_use (found "${String(assetClass)}")`,
|
|
@@ -462,6 +701,8 @@ function checkComponents(parsed, issues) {
|
|
|
462
701
|
// when every admissible component states a numeric NOI — a missing one is
|
|
463
702
|
// MU-04's job, not a footing mismatch.
|
|
464
703
|
const noiModel = getSection(parsed, 'noi_model');
|
|
704
|
+
if (!noiModel)
|
|
705
|
+
markSkipped(ledger, 'CC-12', 'section_absent', 'noi_model');
|
|
465
706
|
if (noiModel) {
|
|
466
707
|
const propNOI = deepGet(noiModel.content, 'net_operating_income');
|
|
467
708
|
const compNOIs = admissible
|
|
@@ -469,7 +710,12 @@ function checkComponents(parsed, issues) {
|
|
|
469
710
|
? raw['net_operating_income']
|
|
470
711
|
: undefined)
|
|
471
712
|
.filter((v) => typeof v === 'number');
|
|
713
|
+
if (propNOI == null)
|
|
714
|
+
markSkipped(ledger, 'CC-12', 'field_absent', 'noi_model.net_operating_income');
|
|
715
|
+
else if (admissible.length === 0 || compNOIs.length !== admissible.length)
|
|
716
|
+
markSkipped(ledger, 'CC-12', 'field_absent', 'components[*].net_operating_income');
|
|
472
717
|
if (propNOI != null && admissible.length > 0 && compNOIs.length === admissible.length) {
|
|
718
|
+
markEvaluated(ledger, 'CC-12');
|
|
473
719
|
const sum = compNOIs.reduce((a, b) => a + b, 0);
|
|
474
720
|
if (Math.abs(sum - propNOI) > 1) {
|
|
475
721
|
issues.push({
|
|
@@ -590,36 +836,49 @@ function checkStackContent(content, section, prefix, issues) {
|
|
|
590
836
|
// hard gate has INCOMPLETE_DATA_POLICIES. The five applicability
|
|
591
837
|
// preconditions are normative (§5.3): each guards against the rule judging a
|
|
592
838
|
// document whose missing size is some *other* rule's diagnosis.
|
|
593
|
-
function checkSizeIntensive(parsed, issues) {
|
|
839
|
+
function checkSizeIntensive(parsed, issues, ledger) {
|
|
594
840
|
// 1. UWX record, not a compiled UW Lite summary — Lite states size in its
|
|
595
841
|
// own grammar, and its compiled envelope carries the x_uw_lite_source
|
|
596
842
|
// extension (surfaced under `extensions` by fromUWEnvelope, under
|
|
597
843
|
// `sections` by a re-parse of the serialized UWX).
|
|
598
|
-
if (parsed.sections[UW_LITE_SOURCE_EXTENSION] || parsed.extensions?.[UW_LITE_SOURCE_EXTENSION])
|
|
844
|
+
if (parsed.sections[UW_LITE_SOURCE_EXTENSION] || parsed.extensions?.[UW_LITE_SOURCE_EXTENSION]) {
|
|
845
|
+
markSkipped(ledger, 'CC-13', 'not_applicable', 'compiled UW Lite summary');
|
|
599
846
|
return;
|
|
847
|
+
}
|
|
600
848
|
// 2. Deal-record profile only. An absent profile is the plain underwriting
|
|
601
849
|
// record; any other declared profile has no property section by design.
|
|
602
850
|
const profile = parsed.frontmatter['document_profile'];
|
|
603
|
-
if (profile != null && profile !== DEAL_UNDERWRITING_PROFILE)
|
|
851
|
+
if (profile != null && profile !== DEAL_UNDERWRITING_PROFILE) {
|
|
852
|
+
markSkipped(ledger, 'CC-13', 'not_applicable', `document_profile ${String(profile)}`);
|
|
604
853
|
return;
|
|
854
|
+
}
|
|
605
855
|
// 3. Recognized class with a primary size field (not mixed_use, §XIII.2;
|
|
606
856
|
// not an unrecognized class, §XIII.3).
|
|
607
857
|
const assetClass = parsed.frontmatter.asset_class;
|
|
608
|
-
if (typeof assetClass !== 'string')
|
|
858
|
+
if (typeof assetClass !== 'string') {
|
|
859
|
+
markSkipped(ledger, 'CC-13', 'not_applicable', 'no asset_class');
|
|
609
860
|
return;
|
|
861
|
+
}
|
|
610
862
|
const intensive = getSizeIntensive(assetClass);
|
|
611
|
-
if (!intensive)
|
|
863
|
+
if (!intensive) {
|
|
864
|
+
markSkipped(ledger, 'CC-13', 'not_applicable', `${assetClass} has no primary size field`);
|
|
612
865
|
return;
|
|
866
|
+
}
|
|
613
867
|
// 4. A property section exists — a missing section is a different defect
|
|
614
868
|
// with a different remedy (RFC 0027, unresolved question 5).
|
|
615
869
|
const property = getSection(parsed, 'property') ?? getSectionVariant(parsed, 'property', 'default');
|
|
616
|
-
if (!property)
|
|
870
|
+
if (!property) {
|
|
871
|
+
markSkipped(ledger, 'CC-13', 'section_absent', 'property');
|
|
617
872
|
return;
|
|
873
|
+
}
|
|
618
874
|
// 5. Not externalized (RFC 0021) — the directive is not the section.
|
|
619
875
|
// Presence of the key is the whole test, as in the Lite projection.
|
|
620
876
|
if (EXTERNAL_ANNOTATION_KEY in property.annotation ||
|
|
621
|
-
EXTERNAL_ANNOTATION_KEY in property.content)
|
|
877
|
+
EXTERNAL_ANNOTATION_KEY in property.content) {
|
|
878
|
+
markSkipped(ledger, 'CC-13', 'not_applicable', 'property is externalized');
|
|
622
879
|
return;
|
|
880
|
+
}
|
|
881
|
+
markEvaluated(ledger, 'CC-13');
|
|
623
882
|
// §VIII.2's unwrap rule, exactly as the calc evaluator applies it: a block
|
|
624
883
|
// storing the envelope shape keeps its payload one level down at `content`.
|
|
625
884
|
// CC-13 judges the payload the pack divides by — never the wrapper.
|
|
@@ -649,7 +908,7 @@ function checkSizeIntensive(parsed, issues) {
|
|
|
649
908
|
// same scan shows deal_stage declarations state where a deal is going, not
|
|
650
909
|
// what the file contains (all twelve worked examples fail their declared
|
|
651
910
|
// stage's list); info reports the gap without refusing or nagging.
|
|
652
|
-
function checkSectionReadiness(parsed, issues) {
|
|
911
|
+
function checkSectionReadiness(parsed, issues, ledger) {
|
|
653
912
|
// CC-14 preconditions mirror CC-13's 1 and 2 (RFC 0028 §1). Precondition 3
|
|
654
913
|
// (not externalized) is satisfied structurally: an externalized-but-
|
|
655
914
|
// unresolved section still parses as a block, so it is present here.
|
|
@@ -657,6 +916,12 @@ function checkSectionReadiness(parsed, issues) {
|
|
|
657
916
|
const profile = parsed.frontmatter['document_profile'];
|
|
658
917
|
const isDealRecord = profile == null || profile === DEAL_UNDERWRITING_PROFILE;
|
|
659
918
|
const hasProperty = hasStageSection(parsed, 'property');
|
|
919
|
+
if (isCompiledLite)
|
|
920
|
+
markSkipped(ledger, 'CC-14', 'not_applicable', 'compiled UW Lite summary');
|
|
921
|
+
else if (!isDealRecord)
|
|
922
|
+
markSkipped(ledger, 'CC-14', 'not_applicable', 'not a deal record');
|
|
923
|
+
else
|
|
924
|
+
markEvaluated(ledger, 'CC-14');
|
|
660
925
|
let cc14Fired = false;
|
|
661
926
|
if (!isCompiledLite && isDealRecord && !hasProperty) {
|
|
662
927
|
cc14Fired = true;
|
|
@@ -765,10 +1030,12 @@ function checkLeaseUpContent(content, variant, issues) {
|
|
|
765
1030
|
}
|
|
766
1031
|
}
|
|
767
1032
|
}
|
|
768
|
-
function checkLeaseUpSchedule(parsed, issues) {
|
|
1033
|
+
function checkLeaseUpSchedule(parsed, issues, ledger) {
|
|
769
1034
|
const entry = parsed.sections['lease_up_schedule'];
|
|
770
|
-
if (!entry)
|
|
1035
|
+
if (!entry) {
|
|
1036
|
+
markSkipped(ledger, 'CC-15', 'not_applicable', 'no lease_up_schedule');
|
|
771
1037
|
return;
|
|
1038
|
+
}
|
|
772
1039
|
const variants = isVariantMap(entry)
|
|
773
1040
|
? Object.entries(entry)
|
|
774
1041
|
: [['default', entry]];
|
|
@@ -791,15 +1058,29 @@ function checkLeaseUpSchedule(parsed, issues) {
|
|
|
791
1058
|
// within LEASE_UP_STABILIZED_TOLERANCE. Tolerance-checked, not exact: the
|
|
792
1059
|
// trajectory endpoint and the stabilized-year projection are two different
|
|
793
1060
|
// models of stabilization (RFC 0008).
|
|
1061
|
+
// CC-15 reads the base variant ONLY (RFC 0008: non-base variants are
|
|
1062
|
+
// exempt by design — a downside is supposed to disagree). So the §5.3
|
|
1063
|
+
// lone-variant fallback does not apply here, and a map with no base or
|
|
1064
|
+
// default is `not_applicable`, not unresolvable: nothing was silenced.
|
|
794
1065
|
const base = isVariantMap(entry)
|
|
795
1066
|
? (entry['base'] ?? entry['default'])
|
|
796
1067
|
: entry;
|
|
1068
|
+
if (!base)
|
|
1069
|
+
markSkipped(ledger, 'CC-15', 'not_applicable', 'no base variant');
|
|
797
1070
|
const stabilizedNoi = base
|
|
798
1071
|
? deepGet(base.content, 'stabilized_summary.annualized_noi')
|
|
799
1072
|
: undefined;
|
|
800
|
-
const
|
|
1073
|
+
const noiModel = getSection(parsed, 'noi_model');
|
|
1074
|
+
const modelNoi = deepGet(noiModel?.content, 'net_operating_income');
|
|
1075
|
+
if (base && !noiModel)
|
|
1076
|
+
markSkipped(ledger, 'CC-15', 'section_absent', 'noi_model');
|
|
1077
|
+
else if (base && !(typeof stabilizedNoi === 'number' && Number.isFinite(stabilizedNoi)))
|
|
1078
|
+
markSkipped(ledger, 'CC-15', 'field_absent', 'lease_up_schedule.stabilized_summary.annualized_noi');
|
|
1079
|
+
else if (base && !(typeof modelNoi === 'number' && Number.isFinite(modelNoi) && modelNoi !== 0))
|
|
1080
|
+
markSkipped(ledger, 'CC-15', 'field_absent', 'noi_model.net_operating_income');
|
|
801
1081
|
if (typeof stabilizedNoi === 'number' && Number.isFinite(stabilizedNoi) &&
|
|
802
1082
|
typeof modelNoi === 'number' && Number.isFinite(modelNoi) && modelNoi !== 0) {
|
|
1083
|
+
markEvaluated(ledger, 'CC-15');
|
|
803
1084
|
const drift = Math.abs(stabilizedNoi - modelNoi) / Math.abs(modelNoi);
|
|
804
1085
|
if (drift > LEASE_UP_STABILIZED_TOLERANCE) {
|
|
805
1086
|
issues.push({
|