@uwmd/batch 0.2.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 +18 -0
- package/bin/uwmd-batch.mjs +6 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +163 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @uwmd/batch
|
|
2
|
+
|
|
3
|
+
A local, deterministic collection indexer for `.uw.md` files. It validates every deal, records a semantic digest, and emits `uwmd-collection.json` plus a spreadsheet-safe CSV index.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @uwmd/batch deals --out batch-output
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The index is a projection over canonical deal files, not a storage protocol. A host may import it into any database without changing `.uw.md` semantics.
|
|
10
|
+
|
|
11
|
+
## Read-only workflow projections
|
|
12
|
+
|
|
13
|
+
`filterUWMDCollection(index, filters)` narrows an existing index by asset class,
|
|
14
|
+
deal stage, flag, or a named quick-metric comparison. `summarizeUWMDCollection(index)`
|
|
15
|
+
returns deterministic asset-class, stage, and flag summaries. `projectUnderwritingQueue(index)`
|
|
16
|
+
returns every matching candidate ordered by `blocking_flags`, then error count,
|
|
17
|
+
warning count, and path. Invalid candidates stay visible in the index, summary,
|
|
18
|
+
and unfiltered queue; none of these helpers changes a deal file.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { indexUWMDDirectory, writeUWMDCollectionIndex } from '../dist/index.js';
|
|
3
|
+
const [input, ...args] = process.argv.slice(2);
|
|
4
|
+
const out = args[0] === '--out' ? args[1] : undefined;
|
|
5
|
+
if (!input || !out) { console.error('Usage: uwmd-batch <input-directory> --out <output-directory>'); process.exitCode = 2; }
|
|
6
|
+
else { const index = await indexUWMDDirectory(input); const files = await writeUWMDCollectionIndex(index, out); console.log(JSON.stringify({ ...index, outputs: files }, null, 2)); if (index.invalid_deals > 0) process.exitCode = 1; }
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export declare const UWMD_BATCH_INDEX_VERSION: "0.2";
|
|
2
|
+
export type MetricComparison = 'gt' | 'gte' | 'lt' | 'lte' | 'eq';
|
|
3
|
+
export interface BatchMetricFilter {
|
|
4
|
+
metric: string;
|
|
5
|
+
comparison: MetricComparison;
|
|
6
|
+
value: number;
|
|
7
|
+
}
|
|
8
|
+
export interface BatchDealFilters {
|
|
9
|
+
asset_class?: string | readonly string[];
|
|
10
|
+
deal_stage?: string | readonly string[];
|
|
11
|
+
flag?: string;
|
|
12
|
+
metric?: BatchMetricFilter;
|
|
13
|
+
}
|
|
14
|
+
export interface BatchDealIndexEntry {
|
|
15
|
+
path: string;
|
|
16
|
+
deal_id: string | null;
|
|
17
|
+
deal_name: string | null;
|
|
18
|
+
asset_class: string | null;
|
|
19
|
+
deal_stage: string | null;
|
|
20
|
+
semantic_digest: string | null;
|
|
21
|
+
valid: boolean;
|
|
22
|
+
error_count: number;
|
|
23
|
+
warning_count: number;
|
|
24
|
+
flags: string[];
|
|
25
|
+
blocking_flags: string[];
|
|
26
|
+
quick_metrics: Record<string, number>;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface UWMDCollectionIndex {
|
|
30
|
+
index_version: typeof UWMD_BATCH_INDEX_VERSION;
|
|
31
|
+
files_scanned: number;
|
|
32
|
+
valid_deals: number;
|
|
33
|
+
invalid_deals: number;
|
|
34
|
+
deals: BatchDealIndexEntry[];
|
|
35
|
+
}
|
|
36
|
+
export interface BatchGroupSummary {
|
|
37
|
+
key: string;
|
|
38
|
+
deals: number;
|
|
39
|
+
valid_deals: number;
|
|
40
|
+
error_count: number;
|
|
41
|
+
warning_count: number;
|
|
42
|
+
}
|
|
43
|
+
export interface BatchFlagSummary {
|
|
44
|
+
flag: string;
|
|
45
|
+
deals: number;
|
|
46
|
+
blocking: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface UWMDCollectionSummary {
|
|
49
|
+
deals: number;
|
|
50
|
+
valid_deals: number;
|
|
51
|
+
invalid_deals: number;
|
|
52
|
+
error_count: number;
|
|
53
|
+
warning_count: number;
|
|
54
|
+
by_asset_class: BatchGroupSummary[];
|
|
55
|
+
by_deal_stage: BatchGroupSummary[];
|
|
56
|
+
flags: BatchFlagSummary[];
|
|
57
|
+
}
|
|
58
|
+
export interface UnderwritingQueueProjection {
|
|
59
|
+
ordering: 'blocking_flags desc, error_count desc, warning_count desc, path asc';
|
|
60
|
+
deals: BatchDealIndexEntry[];
|
|
61
|
+
}
|
|
62
|
+
export declare class BatchError extends Error {
|
|
63
|
+
readonly code: string;
|
|
64
|
+
constructor(code: string, message: string);
|
|
65
|
+
}
|
|
66
|
+
export declare function indexUWMDDirectory(inputDirectory: string): Promise<UWMDCollectionIndex>;
|
|
67
|
+
export declare function writeUWMDCollectionIndex(index: UWMDCollectionIndex, outputDirectory: string): Promise<{
|
|
68
|
+
json: string;
|
|
69
|
+
csv: string;
|
|
70
|
+
}>;
|
|
71
|
+
/** Returns a deterministic read-only subset of a collection index. */
|
|
72
|
+
export declare function filterUWMDCollection(index: UWMDCollectionIndex, filters?: BatchDealFilters): BatchDealIndexEntry[];
|
|
73
|
+
/** Summarises a collection index without changing the canonical source files. */
|
|
74
|
+
export declare function summarizeUWMDCollection(index: UWMDCollectionIndex): UWMDCollectionSummary;
|
|
75
|
+
/**
|
|
76
|
+
* Produces the underwriting queue in a stated, stable order. Invalid files are
|
|
77
|
+
* retained: they sort with the highest error count instead of disappearing.
|
|
78
|
+
*/
|
|
79
|
+
export declare function projectUnderwritingQueue(index: UWMDCollectionIndex, filters?: BatchDealFilters): UnderwritingQueueProjection;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { computeEnvelopeDigest, parseUWFile, toUWEnvelope, validateUWFile } from '@uwmd/core';
|
|
4
|
+
export const UWMD_BATCH_INDEX_VERSION = '0.2';
|
|
5
|
+
export class BatchError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(`[${code}] ${message}`);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'BatchError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function indexUWMDDirectory(inputDirectory) {
|
|
14
|
+
const root = resolve(inputDirectory);
|
|
15
|
+
const files = (await discoverUWMD(root)).sort();
|
|
16
|
+
const deals = await Promise.all(files.map((file) => indexFile(root, file)));
|
|
17
|
+
const validDeals = deals.filter((deal) => deal.valid).length;
|
|
18
|
+
return { index_version: UWMD_BATCH_INDEX_VERSION, files_scanned: deals.length, valid_deals: validDeals, invalid_deals: deals.length - validDeals, deals };
|
|
19
|
+
}
|
|
20
|
+
export async function writeUWMDCollectionIndex(index, outputDirectory) {
|
|
21
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
22
|
+
const json = resolve(outputDirectory, 'uwmd-collection.json');
|
|
23
|
+
const csv = resolve(outputDirectory, 'uwmd-collection.csv');
|
|
24
|
+
await writeFile(json, `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
|
25
|
+
const columns = ['path', 'deal_id', 'deal_name', 'asset_class', 'deal_stage', 'semantic_digest', 'valid', 'error_count', 'warning_count', 'flags', 'blocking_flags', 'quick_metrics', 'error'];
|
|
26
|
+
const rows = [columns.join(','), ...index.deals.map((deal) => columns.map((column) => csvCell(deal[column])).join(','))];
|
|
27
|
+
await writeFile(csv, `${rows.join('\n')}\n`, 'utf8');
|
|
28
|
+
return { json, csv };
|
|
29
|
+
}
|
|
30
|
+
/** Returns a deterministic read-only subset of a collection index. */
|
|
31
|
+
export function filterUWMDCollection(index, filters = {}) {
|
|
32
|
+
const assetClasses = normaliseFilter(filters.asset_class);
|
|
33
|
+
const dealStages = normaliseFilter(filters.deal_stage);
|
|
34
|
+
return index.deals.filter((deal) => {
|
|
35
|
+
if (assetClasses && (!deal.asset_class || !assetClasses.has(deal.asset_class)))
|
|
36
|
+
return false;
|
|
37
|
+
if (dealStages && (!deal.deal_stage || !dealStages.has(deal.deal_stage)))
|
|
38
|
+
return false;
|
|
39
|
+
if (filters.flag && !deal.flags.includes(filters.flag) && !deal.blocking_flags.includes(filters.flag))
|
|
40
|
+
return false;
|
|
41
|
+
return !filters.metric || matchesMetric(deal.quick_metrics[filters.metric.metric], filters.metric);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Summarises a collection index without changing the canonical source files. */
|
|
45
|
+
export function summarizeUWMDCollection(index) {
|
|
46
|
+
const validDeals = index.deals.filter((deal) => deal.valid).length;
|
|
47
|
+
return {
|
|
48
|
+
deals: index.deals.length,
|
|
49
|
+
valid_deals: validDeals,
|
|
50
|
+
invalid_deals: index.deals.length - validDeals,
|
|
51
|
+
error_count: index.deals.reduce((total, deal) => total + deal.error_count, 0),
|
|
52
|
+
warning_count: index.deals.reduce((total, deal) => total + deal.warning_count, 0),
|
|
53
|
+
by_asset_class: summarizeBy(index.deals, (deal) => deal.asset_class ?? '(unknown)'),
|
|
54
|
+
by_deal_stage: summarizeBy(index.deals, (deal) => deal.deal_stage ?? '(unknown)'),
|
|
55
|
+
flags: summarizeFlags(index.deals),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Produces the underwriting queue in a stated, stable order. Invalid files are
|
|
60
|
+
* retained: they sort with the highest error count instead of disappearing.
|
|
61
|
+
*/
|
|
62
|
+
export function projectUnderwritingQueue(index, filters = {}) {
|
|
63
|
+
const deals = [...filterUWMDCollection(index, filters)].sort((left, right) => right.blocking_flags.length - left.blocking_flags.length
|
|
64
|
+
|| right.error_count - left.error_count
|
|
65
|
+
|| right.warning_count - left.warning_count
|
|
66
|
+
|| left.path.localeCompare(right.path));
|
|
67
|
+
return {
|
|
68
|
+
ordering: 'blocking_flags desc, error_count desc, warning_count desc, path asc',
|
|
69
|
+
deals,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function discoverUWMD(directory) {
|
|
73
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
74
|
+
const found = await Promise.all(entries.map(async (entry) => {
|
|
75
|
+
const path = resolve(directory, entry.name);
|
|
76
|
+
if (entry.isDirectory())
|
|
77
|
+
return discoverUWMD(path);
|
|
78
|
+
return entry.isFile() && entry.name.endsWith('.uw.md') ? [path] : [];
|
|
79
|
+
}));
|
|
80
|
+
return found.flat();
|
|
81
|
+
}
|
|
82
|
+
async function indexFile(root, file) {
|
|
83
|
+
const path = relative(root, file).split(sep).join('/');
|
|
84
|
+
try {
|
|
85
|
+
const parsed = parseUWFile(await readFile(file, 'utf8'));
|
|
86
|
+
if (!hasUWEnvelope(parsed.frontmatter)) {
|
|
87
|
+
throw new BatchError('MISSING_UW_ENVELOPE', 'File does not contain the required UW Markdown frontmatter envelope.');
|
|
88
|
+
}
|
|
89
|
+
const validation = validateUWFile(parsed);
|
|
90
|
+
const digest = await computeEnvelopeDigest(toUWEnvelope(parsed));
|
|
91
|
+
const frontmatter = parsed.frontmatter;
|
|
92
|
+
const count = (severity) => validation.issues.filter((issue) => issue.severity === severity).length;
|
|
93
|
+
return {
|
|
94
|
+
path,
|
|
95
|
+
deal_id: stringOrNull(frontmatter.deal_id),
|
|
96
|
+
deal_name: stringOrNull(frontmatter.deal_name),
|
|
97
|
+
asset_class: stringOrNull(frontmatter.asset_class),
|
|
98
|
+
deal_stage: stringOrNull(frontmatter.deal_stage),
|
|
99
|
+
semantic_digest: digest,
|
|
100
|
+
valid: count('error') === 0,
|
|
101
|
+
error_count: count('error'),
|
|
102
|
+
warning_count: count('warning'),
|
|
103
|
+
flags: stringArray(frontmatter.flags),
|
|
104
|
+
blocking_flags: stringArray(frontmatter.blocking_flags),
|
|
105
|
+
quick_metrics: numericRecord(frontmatter.quick_metrics),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return { path, deal_id: null, deal_name: null, asset_class: null, deal_stage: null, semantic_digest: null, valid: false, error_count: 1, warning_count: 0, flags: [], blocking_flags: [], quick_metrics: {}, error: error instanceof Error ? error.message : String(error) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function stringOrNull(value) { return typeof value === 'string' ? value : null; }
|
|
113
|
+
function stringArray(value) { return Array.isArray(value) ? value.filter((item) => typeof item === 'string').sort() : []; }
|
|
114
|
+
function numericRecord(value) {
|
|
115
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
116
|
+
return {};
|
|
117
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === 'number' && Number.isFinite(item)));
|
|
118
|
+
}
|
|
119
|
+
function hasUWEnvelope(frontmatter) {
|
|
120
|
+
return ['uw_version', 'deal_id', 'deal_name', 'created', 'last_modified', 'property_address', 'city', 'state', 'zip', 'asset_class']
|
|
121
|
+
.every((field) => typeof frontmatter[field] === 'string' && frontmatter[field].length > 0);
|
|
122
|
+
}
|
|
123
|
+
function csvCell(value) { const text = value == null ? '' : String(value); return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; }
|
|
124
|
+
function normaliseFilter(value) {
|
|
125
|
+
if (value === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
return new Set(typeof value === 'string' ? [value] : value);
|
|
128
|
+
}
|
|
129
|
+
function matchesMetric(value, filter) {
|
|
130
|
+
if (value === undefined)
|
|
131
|
+
return false;
|
|
132
|
+
switch (filter.comparison) {
|
|
133
|
+
case 'gt': return value > filter.value;
|
|
134
|
+
case 'gte': return value >= filter.value;
|
|
135
|
+
case 'lt': return value < filter.value;
|
|
136
|
+
case 'lte': return value <= filter.value;
|
|
137
|
+
case 'eq': return value === filter.value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function summarizeBy(deals, keyFor) {
|
|
141
|
+
const groups = new Map();
|
|
142
|
+
for (const deal of deals) {
|
|
143
|
+
const key = keyFor(deal);
|
|
144
|
+
groups.set(key, [...(groups.get(key) ?? []), deal]);
|
|
145
|
+
}
|
|
146
|
+
return [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([key, entries]) => ({
|
|
147
|
+
key,
|
|
148
|
+
deals: entries.length,
|
|
149
|
+
valid_deals: entries.filter((deal) => deal.valid).length,
|
|
150
|
+
error_count: entries.reduce((total, deal) => total + deal.error_count, 0),
|
|
151
|
+
warning_count: entries.reduce((total, deal) => total + deal.warning_count, 0),
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
function summarizeFlags(deals) {
|
|
155
|
+
const flags = new Map();
|
|
156
|
+
for (const deal of deals) {
|
|
157
|
+
for (const [flag, blocking] of [...deal.flags.map((flag) => [flag, false]), ...deal.blocking_flags.map((flag) => [flag, true])]) {
|
|
158
|
+
const prior = flags.get(flag) ?? { flag, deals: 0, blocking: false };
|
|
159
|
+
flags.set(flag, { flag, deals: prior.deals + 1, blocking: prior.blocking || blocking });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [...flags.values()].sort((left, right) => left.flag.localeCompare(right.flag));
|
|
163
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uwmd/batch",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Deterministic local batch indexing for UW Markdown deal files.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
|
|
9
|
+
"bin": { "uwmd-batch": "bin/uwmd-batch.mjs" },
|
|
10
|
+
"files": ["bin", "dist", "README.md"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"typecheck:tests": "tsc -p tsconfig.test.json"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": { "@uwmd/core": "1.3.0" },
|
|
17
|
+
"devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.4.0", "vitest": "^3.2.6" },
|
|
18
|
+
"engines": { "node": ">=18.0.0" },
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|