@rubytech/create-maxy-lite 0.1.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.
@@ -0,0 +1,404 @@
1
+ # maxy-lite SCHEMA
2
+
3
+ The machine-readable declaration of the maxy-lite file-native ontology. The model and its sources are fixed in [`../../../.docs/maxy-lite-ontology.md`](../../../.docs/maxy-lite-ontology.md); this file is the field-by-field derivation that both the assistant and the validator read. It is the single source of truth — the validator parses the tables below directly, so there is no second copy to drift from.
4
+
5
+ Each entity is one file: YAML frontmatter (the typed fields declared here) plus a markdown body (free prose — note body, email text, call notes, meeting notes live in the body, not in frontmatter).
6
+
7
+ ## How the validator reads this
8
+
9
+ - **Field types** are `text`, `number`, `date`, `checkbox`, `area`, `link`, `list`. A `date` is an ISO date (`YYYY-MM-DD`) or date-time. An `area` must be one of the Areas below. A `link` is a single reference (`"[[Target]]"` or a bare basename). A `list` is a YAML list; if its field name appears in **Associations**, every item must be a link.
10
+ - **`type`** selects the entity. A file whose `type` is not declared here fails with `type:unknown`; a file with no `type` fails with `type:missing`.
11
+ - **Required** fields are marked ✓. A missing required field fails with `field:missing`. A present field whose value does not match its type fails with `field:type` (or `field:area` for a bad area).
12
+ - **Associations** are fields whose name appears in the Associations table. Each link is resolved by basename against every file in the vault. A link that resolves to no file fails with `field:dangling`; a link whose target file is the wrong entity type fails with `field:target`. A target of `any` constrains nothing but still fails on a dangling link. Resolution is by basename (the filename, as Obsidian wikilinks resolve); if two files in different folders share a basename the validator cannot distinguish them, so keep entity filenames unique.
13
+
14
+ ## Areas
15
+
16
+ The controlled vocabulary of life-areas. Every document, asset, and project must declare an `area`; contacts, events, and activities may. An `area` outside this set fails.
17
+
18
+ | Area | Covers |
19
+ |---|---|
20
+ | home | residence, household, utilities, repairs |
21
+ | work | employment, career, work contacts |
22
+ | finance | money, banking, bookkeeping, tax |
23
+ | health | medical records, providers, prescriptions |
24
+ | vehicle | cars and other vehicles |
25
+ | insurance | policies across all areas |
26
+ | travel | trips and holidays |
27
+ | education | school, courses, qualifications |
28
+ | family | family members, children, pets |
29
+ | legal | contracts, agreements, official documents |
30
+ | personal | identity documents, personal admin |
31
+
32
+ ## Contacts
33
+
34
+ ### Person (`person`)
35
+
36
+ vCard (RFC 6350). Lives in `people/`.
37
+
38
+ | Field | Type | Required | Source |
39
+ |---|---|---|---|
40
+ | type | text | ✓ | discriminator (`person`) |
41
+ | name | text | ✓ | vCard `FN` |
42
+ | firstName | text | | vCard `N` (given) |
43
+ | lastName | text | | vCard `N` (family) |
44
+ | nickname | text | | vCard `NICKNAME` |
45
+ | emails | list | | vCard `EMAIL` |
46
+ | tels | list | | vCard `TEL` |
47
+ | org | link | | vCard `ORG` (association `worksAt`) |
48
+ | title | text | | vCard `TITLE` |
49
+ | role | text | | vCard `ROLE` |
50
+ | address | text | | vCard `ADR` |
51
+ | birthday | date | | vCard `BDAY` |
52
+ | anniversary | date | | vCard `ANNIVERSARY` |
53
+ | urls | list | | vCard `URL` |
54
+ | categories | list | | vCard `CATEGORIES` |
55
+ | area | area | | association `filedUnder` |
56
+
57
+ ### Organization (`organization`)
58
+
59
+ HubSpot Company object. Lives in `organizations/`.
60
+
61
+ | Field | Type | Required | Source |
62
+ |---|---|---|---|
63
+ | type | text | ✓ | discriminator (`organization`) |
64
+ | name | text | ✓ | HubSpot `name` |
65
+ | domain | text | | HubSpot `domain` |
66
+ | website | text | | HubSpot `website` |
67
+ | industry | text | | HubSpot `industry` |
68
+ | companyType | text | | HubSpot `type` |
69
+ | employees | number | | HubSpot `numberofemployees` |
70
+ | revenue | number | | HubSpot `annualrevenue` |
71
+ | phone | text | | HubSpot `phone` |
72
+ | city | text | | HubSpot `city` |
73
+ | state | text | | HubSpot `state` |
74
+ | country | text | | HubSpot `country` |
75
+ | members | list | | HubSpot Contact→Company assoc (`employs`) |
76
+ | area | area | | association `filedUnder` |
77
+
78
+ ## Time
79
+
80
+ ### Event (`event`)
81
+
82
+ iCalendar VEVENT (RFC 5545). Lives in `calendar/`.
83
+
84
+ | Field | Type | Required | Source |
85
+ |---|---|---|---|
86
+ | type | text | ✓ | discriminator (`event`) |
87
+ | summary | text | ✓ | iCal `SUMMARY` |
88
+ | start | date | ✓ | iCal `DTSTART` |
89
+ | end | date | | iCal `DTEND` |
90
+ | location | text | | iCal `LOCATION` |
91
+ | description | text | | iCal `DESCRIPTION` |
92
+ | attendees | list | | iCal `ATTENDEE` (association `attends`) |
93
+ | organizer | link | | iCal `ORGANIZER` (association `organizes`) |
94
+ | recurrence | text | | iCal `RRULE` |
95
+ | status | text | | iCal `STATUS` |
96
+ | categories | list | | iCal `CATEGORIES` |
97
+ | url | text | | iCal `URL` |
98
+ | project | link | | association to `project` |
99
+ | area | area | | association `filedUnder` |
100
+
101
+ ## Activities
102
+
103
+ HubSpot engagements. All live in `activities/`; `type` discriminates. Long-form content is the markdown body.
104
+
105
+ ### Task (`task`)
106
+
107
+ | Field | Type | Required | Source |
108
+ |---|---|---|---|
109
+ | type | text | ✓ | discriminator (`task`) |
110
+ | title | text | ✓ | HubSpot `hs_task_subject` |
111
+ | due | date | | HubSpot `hs_timestamp` |
112
+ | priority | text | | HubSpot `hs_task_priority` |
113
+ | status | text | | HubSpot `hs_task_status` |
114
+ | taskType | text | | HubSpot `hs_task_type` |
115
+ | assignee | link | | HubSpot `hubspot_owner_id` (association `assignedTo`) |
116
+ | about | link | | engagement association |
117
+ | project | link | | association to `project` |
118
+ | area | area | | association `filedUnder` |
119
+
120
+ ### Note (`note`)
121
+
122
+ | Field | Type | Required | Source |
123
+ |---|---|---|---|
124
+ | type | text | ✓ | discriminator (`note`) |
125
+ | title | text | ✓ | label (body is HubSpot `hs_note_body`) |
126
+ | about | link | | engagement association |
127
+ | project | link | | association to `project` |
128
+ | area | area | | association `filedUnder` |
129
+
130
+ ### Email (`email`)
131
+
132
+ | Field | Type | Required | Source |
133
+ |---|---|---|---|
134
+ | type | text | ✓ | discriminator (`email`) |
135
+ | subject | text | ✓ | HubSpot `hs_email_subject` |
136
+ | timestamp | date | | HubSpot `hs_timestamp` |
137
+ | direction | text | | HubSpot `hs_email_direction` |
138
+ | participants | list | | engagement→Contact assoc (`participates`) |
139
+ | about | link | | engagement association |
140
+ | area | area | | association `filedUnder` |
141
+
142
+ ### Call (`call`)
143
+
144
+ | Field | Type | Required | Source |
145
+ |---|---|---|---|
146
+ | type | text | ✓ | discriminator (`call`) |
147
+ | subject | text | ✓ | HubSpot `hs_call_title` |
148
+ | timestamp | date | | HubSpot `hs_timestamp` |
149
+ | duration | number | | HubSpot `hs_call_duration` |
150
+ | direction | text | | HubSpot `hs_call_direction` |
151
+ | disposition | text | | HubSpot `hs_call_disposition` |
152
+ | participants | list | | engagement→Contact assoc (`participates`) |
153
+ | about | link | | engagement association |
154
+ | area | area | | association `filedUnder` |
155
+
156
+ ### Meeting (`meeting`)
157
+
158
+ | Field | Type | Required | Source |
159
+ |---|---|---|---|
160
+ | type | text | ✓ | discriminator (`meeting`) |
161
+ | subject | text | ✓ | HubSpot `hs_meeting_title` |
162
+ | start | date | | HubSpot `hs_meeting_start_time` |
163
+ | end | date | | HubSpot `hs_meeting_end_time` |
164
+ | location | text | | HubSpot `hs_meeting_location` |
165
+ | participants | list | | engagement→Contact assoc (`participates`) |
166
+ | about | link | | engagement association |
167
+ | area | area | | association `filedUnder` |
168
+
169
+ ## Assets
170
+
171
+ Things owned, that documents attach to. Filed under an area folder.
172
+
173
+ ### Property (`property`)
174
+
175
+ | Field | Type | Required | Source |
176
+ |---|---|---|---|
177
+ | type | text | ✓ | discriminator (`property`) |
178
+ | name | text | ✓ | label (e.g. "12 Oak Street") |
179
+ | address | text | | — |
180
+ | propertyType | text | | house / flat / land |
181
+ | purchaseDate | date | | — |
182
+ | value | number | | — |
183
+ | ownedBy | link | | association `ownedBy` |
184
+ | area | area | ✓ | association `filedUnder` |
185
+
186
+ ### Vehicle (`vehicle`)
187
+
188
+ | Field | Type | Required | Source |
189
+ |---|---|---|---|
190
+ | type | text | ✓ | discriminator (`vehicle`) |
191
+ | name | text | ✓ | label (e.g. "VW Golf") |
192
+ | make | text | | — |
193
+ | model | text | | — |
194
+ | year | number | | — |
195
+ | registration | text | | plate / VIN |
196
+ | purchaseDate | date | | — |
197
+ | value | number | | — |
198
+ | ownedBy | link | | association `ownedBy` |
199
+ | area | area | ✓ | association `filedUnder` |
200
+
201
+ ### Account (`account`)
202
+
203
+ A financial account.
204
+
205
+ | Field | Type | Required | Source |
206
+ |---|---|---|---|
207
+ | type | text | ✓ | discriminator (`account`) |
208
+ | name | text | ✓ | label |
209
+ | institution | link | | association to `organization` |
210
+ | accountType | text | | checking / savings / credit / investment |
211
+ | accountNumber | text | | — |
212
+ | ownedBy | link | | association `ownedBy` |
213
+ | area | area | ✓ | association `filedUnder` |
214
+
215
+ ## Financial records
216
+
217
+ Bookkeeping family (QuickBooks / Xero / Wave).
218
+
219
+ ### Invoice (`invoice`)
220
+
221
+ | Field | Type | Required | Source |
222
+ |---|---|---|---|
223
+ | type | text | ✓ | discriminator (`invoice`) |
224
+ | number | text | ✓ | invoice number |
225
+ | issueDate | date | ✓ | — |
226
+ | dueDate | date | | — |
227
+ | amount | number | ✓ | — |
228
+ | currency | text | | — |
229
+ | status | text | | paid / unpaid / overdue |
230
+ | billedTo | link | | association `billedTo` |
231
+ | about | link | | — |
232
+ | area | area | ✓ | association `filedUnder` |
233
+
234
+ ### Expense (`expense`)
235
+
236
+ A receipt — money spent.
237
+
238
+ | Field | Type | Required | Source |
239
+ |---|---|---|---|
240
+ | type | text | ✓ | discriminator (`expense`) |
241
+ | description | text | ✓ | — |
242
+ | date | date | ✓ | — |
243
+ | amount | number | ✓ | — |
244
+ | currency | text | | — |
245
+ | category | text | | — |
246
+ | paidTo | link | | association `paidTo` |
247
+ | paymentMethod | text | | — |
248
+ | about | link | | — |
249
+ | area | area | ✓ | association `filedUnder` |
250
+
251
+ ### Bill (`bill`)
252
+
253
+ A bill owed.
254
+
255
+ | Field | Type | Required | Source |
256
+ |---|---|---|---|
257
+ | type | text | ✓ | discriminator (`bill`) |
258
+ | description | text | ✓ | — |
259
+ | dueDate | date | ✓ | — |
260
+ | amount | number | ✓ | — |
261
+ | currency | text | | — |
262
+ | status | text | | — |
263
+ | issuedBy | link | | association `issuedBy` |
264
+ | recurring | checkbox | | — |
265
+ | area | area | ✓ | association `filedUnder` |
266
+
267
+ ### Statement (`statement`)
268
+
269
+ A bank or credit statement.
270
+
271
+ | Field | Type | Required | Source |
272
+ |---|---|---|---|
273
+ | type | text | ✓ | discriminator (`statement`) |
274
+ | period | text | ✓ | e.g. 2026-05 |
275
+ | date | date | | — |
276
+ | account | link | | association to `account` |
277
+ | issuedBy | link | | association `issuedBy` |
278
+ | balance | number | | — |
279
+ | area | area | ✓ | association `filedUnder` |
280
+
281
+ ### Payment (`payment`)
282
+
283
+ | Field | Type | Required | Source |
284
+ |---|---|---|---|
285
+ | type | text | ✓ | discriminator (`payment`) |
286
+ | date | date | ✓ | — |
287
+ | amount | number | ✓ | — |
288
+ | currency | text | | — |
289
+ | paidTo | link | | association `paidTo` |
290
+ | method | text | | — |
291
+ | about | link | | the invoice or bill it settles |
292
+ | area | area | ✓ | association `filedUnder` |
293
+
294
+ ## Life documents
295
+
296
+ Life-admin filing cabinet.
297
+
298
+ ### Policy (`policy`)
299
+
300
+ Insurance policy.
301
+
302
+ | Field | Type | Required | Source |
303
+ |---|---|---|---|
304
+ | type | text | ✓ | discriminator (`policy`) |
305
+ | name | text | ✓ | — |
306
+ | policyNumber | text | | — |
307
+ | insurer | link | | association to `organization` |
308
+ | covers | link | | association `covers` |
309
+ | premium | number | | — |
310
+ | startDate | date | | — |
311
+ | endDate | date | | — |
312
+ | area | area | ✓ | association `filedUnder` |
313
+
314
+ ### Contract (`contract`)
315
+
316
+ | Field | Type | Required | Source |
317
+ |---|---|---|---|
318
+ | type | text | ✓ | discriminator (`contract`) |
319
+ | name | text | ✓ | — |
320
+ | parties | list | | association `parties` |
321
+ | startDate | date | | — |
322
+ | endDate | date | | — |
323
+ | value | number | | — |
324
+ | about | link | | — |
325
+ | area | area | ✓ | association `filedUnder` |
326
+
327
+ ### Warranty (`warranty`)
328
+
329
+ | Field | Type | Required | Source |
330
+ |---|---|---|---|
331
+ | type | text | ✓ | discriminator (`warranty`) |
332
+ | name | text | ✓ | — |
333
+ | covers | link | | association `covers` |
334
+ | provider | link | | association to `organization` |
335
+ | startDate | date | | — |
336
+ | endDate | date | | — |
337
+ | area | area | ✓ | association `filedUnder` |
338
+
339
+ ### TaxDocument (`taxDocument`)
340
+
341
+ | Field | Type | Required | Source |
342
+ |---|---|---|---|
343
+ | type | text | ✓ | discriminator (`taxDocument`) |
344
+ | name | text | ✓ | — |
345
+ | taxYear | text | ✓ | — |
346
+ | documentType | text | | return / W2 / 1099 / P60 |
347
+ | issuedBy | link | | association `issuedBy` |
348
+ | area | area | ✓ | association `filedUnder` |
349
+
350
+ ### IdentityDocument (`identityDocument`)
351
+
352
+ | Field | Type | Required | Source |
353
+ |---|---|---|---|
354
+ | type | text | ✓ | discriminator (`identityDocument`) |
355
+ | name | text | ✓ | — |
356
+ | documentType | text | ✓ | passport / license / certificate |
357
+ | number | text | | — |
358
+ | issuedBy | link | | association `issuedBy` |
359
+ | issueDate | date | | — |
360
+ | expiryDate | date | | — |
361
+ | holder | link | | association `holder` |
362
+ | area | area | ✓ | association `filedUnder` |
363
+
364
+ ## Projects
365
+
366
+ ### Project (`project`)
367
+
368
+ A multi-step effort (a holiday, a house move, a renovation) that ties together tasks, events, and documents. PARA's "Projects".
369
+
370
+ | Field | Type | Required | Source |
371
+ |---|---|---|---|
372
+ | type | text | ✓ | discriminator (`project`) |
373
+ | name | text | ✓ | — |
374
+ | status | text | | active / done / someday |
375
+ | startDate | date | | — |
376
+ | dueDate | date | | — |
377
+ | about | link | | — |
378
+ | area | area | ✓ | association `filedUnder` |
379
+
380
+ ## Associations
381
+
382
+ A field whose name appears here is a typed link. `link`-typed fields are single; `list`-typed fields are many. The target is an entity type, a `|`-separated union, or `any`.
383
+
384
+ | Field | Target | Cardinality | Notes |
385
+ |---|---|---|---|
386
+ | org | organization | one | Person worksAt Organization |
387
+ | members | person | many | Organization employs Person |
388
+ | attendees | person | many | Event attends Person |
389
+ | organizer | person | one | Event organized by Person |
390
+ | assignee | person | one | Task assignedTo Person |
391
+ | about | any | one | Activity/record about any object |
392
+ | project | project | one | item belongs to a Project |
393
+ | participants | person | many | communication participants |
394
+ | ownedBy | person | one | Asset owned by Person |
395
+ | institution | organization | one | Account at an Organization |
396
+ | billedTo | organization\|person | one | Invoice billed to a customer |
397
+ | paidTo | organization\|person | one | Expense/Payment paid to a counterparty |
398
+ | issuedBy | organization | one | document issued by an Organization |
399
+ | account | account | one | Statement for an Account |
400
+ | insurer | organization | one | Policy underwritten by an Organization |
401
+ | covers | any | one | Policy/Warranty covers an Asset/Person |
402
+ | provider | organization | one | Warranty provided by an Organization |
403
+ | parties | organization\|person | many | Contract parties |
404
+ | holder | person | one | IdentityDocument held by a Person |
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // Deterministic CLI gate for a maxy-lite vault.
3
+ //
4
+ // maxy-lite-validate <vault> [--schema <path>]
5
+ //
6
+ // Emits one `[lite-schema] op=validate ...` line per file and a final
7
+ // `[lite-schema] op=summary ...` line, then exits 0 when every file conforms and
8
+ // 1 when any file violates the schema.
9
+
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, resolve, join } from 'node:path';
12
+ import { loadSchema } from './schema.mjs';
13
+ import { validateVault } from './validate.mjs';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const DEFAULT_SCHEMA = resolve(HERE, '..', 'schema', 'SCHEMA.md');
17
+
18
+ function parseArgs(argv) {
19
+ let vault = null;
20
+ let schema = DEFAULT_SCHEMA;
21
+ for (let i = 0; i < argv.length; i += 1) {
22
+ const arg = argv[i];
23
+ if (arg === '--schema') { schema = argv[i += 1]; }
24
+ else if (arg.startsWith('--schema=')) { schema = arg.slice('--schema='.length); }
25
+ else if (!vault) { vault = arg; }
26
+ }
27
+ return { vault, schema };
28
+ }
29
+
30
+ export function run(argv) {
31
+ const { vault, schema: schemaPath } = parseArgs(argv);
32
+ if (!vault) {
33
+ process.stderr.write('usage: maxy-lite-validate <vault> [--schema <path>]\n');
34
+ return 2;
35
+ }
36
+
37
+ const schema = loadSchema(schemaPath);
38
+ const { results, summary } = validateVault(resolve(vault), schema);
39
+
40
+ for (const r of results) {
41
+ process.stdout.write(
42
+ `[lite-schema] op=validate file=${r.file} entity=${r.entity} ok=${r.ok} errors=[${r.errors.join(',')}]\n`,
43
+ );
44
+ }
45
+ process.stdout.write(
46
+ `[lite-schema] op=summary files=${summary.files} valid=${summary.valid} invalid=${summary.invalid}\n`,
47
+ );
48
+
49
+ return summary.invalid > 0 ? 1 : 0;
50
+ }
51
+
52
+ const invokedDirectly = process.argv[1]
53
+ && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
54
+ if (invokedDirectly) {
55
+ process.exit(run(process.argv.slice(2)));
56
+ }
@@ -0,0 +1,37 @@
1
+ // Extract and parse the YAML frontmatter block from a markdown file's text.
2
+ // Deterministic: no LLM. Returns { frontmatter, hasFrontmatter }.
3
+
4
+ import yaml from 'js-yaml';
5
+
6
+ // Opening `---` line, lazily-captured body, closing `---` line. The newline
7
+ // before the closing fence is optional so an empty block (`---\n---`) parses.
8
+ const FRONTMATTER_RE = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n?---[ \t]*(?:\r?\n|$)/;
9
+
10
+ /**
11
+ * Parse the leading `--- ... ---` YAML block of a markdown document.
12
+ *
13
+ * @param {string} text raw file contents
14
+ * @returns {{ hasFrontmatter: boolean, frontmatter: object, error: string|null }}
15
+ * - hasFrontmatter: a frontmatter block was present
16
+ * - frontmatter: the parsed mapping (empty object when absent or not a mapping)
17
+ * - error: a parse-error message, or null
18
+ */
19
+ export function parseFrontmatter(text) {
20
+ const match = text.match(FRONTMATTER_RE);
21
+ if (!match) {
22
+ return { hasFrontmatter: false, frontmatter: {}, error: null };
23
+ }
24
+ let data;
25
+ try {
26
+ data = yaml.load(match[1]);
27
+ } catch (err) {
28
+ return { hasFrontmatter: true, frontmatter: {}, error: err.message };
29
+ }
30
+ if (data === null || data === undefined) {
31
+ return { hasFrontmatter: true, frontmatter: {}, error: null };
32
+ }
33
+ if (typeof data !== 'object' || Array.isArray(data)) {
34
+ return { hasFrontmatter: true, frontmatter: {}, error: 'frontmatter is not a mapping' };
35
+ }
36
+ return { hasFrontmatter: true, frontmatter: data, error: null };
37
+ }
@@ -0,0 +1,147 @@
1
+ // Parse SCHEMA.md's markdown tables into a schema object the validator checks
2
+ // against. SCHEMA.md is the single source of truth; this reads it directly so
3
+ // there is no second declaration to drift from. Deterministic: no LLM.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+
7
+ const FIELD_TYPES = new Set(['text', 'number', 'date', 'checkbox', 'area', 'link', 'list']);
8
+
9
+ /**
10
+ * Split a markdown table row into trimmed cells, dropping the leading/trailing
11
+ * pipes. A literal pipe inside a cell is escaped as `\|` (e.g. a `a|b` union) and
12
+ * must not be treated as a column separator.
13
+ */
14
+ function splitRow(line) {
15
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
16
+ return trimmed.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
17
+ }
18
+
19
+ function isSeparatorRow(line) {
20
+ return /^\s*\|?[\s:|-]+\|?\s*$/.test(line) && line.includes('-');
21
+ }
22
+
23
+ /**
24
+ * Collect the markdown tables that appear under a heading whose text matches
25
+ * `headingTest`. Each table is returned as an array of row objects keyed by the
26
+ * lower-cased header cell text.
27
+ */
28
+ function tablesUnderHeading(lines, headingTest) {
29
+ const out = [];
30
+ let active = false;
31
+ let header = null;
32
+ let rows = [];
33
+
34
+ const flush = () => {
35
+ if (header && rows.length) out.push(rows);
36
+ header = null;
37
+ rows = [];
38
+ };
39
+
40
+ for (const line of lines) {
41
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
42
+ if (heading) {
43
+ flush();
44
+ active = headingTest(heading[2].trim());
45
+ continue;
46
+ }
47
+ if (!active) continue;
48
+ const isTableRow = line.trim().startsWith('|');
49
+ if (!isTableRow) {
50
+ // A blank line inside a section is fine; a table ends at the next non-row line.
51
+ if (line.trim() === '') continue;
52
+ flush();
53
+ continue;
54
+ }
55
+ if (isSeparatorRow(line)) continue;
56
+ const cells = splitRow(line);
57
+ if (!header) {
58
+ header = cells.map((c) => c.toLowerCase());
59
+ } else {
60
+ const row = {};
61
+ header.forEach((key, i) => { row[key] = cells[i] ?? ''; });
62
+ rows.push(row);
63
+ }
64
+ }
65
+ flush();
66
+ return out;
67
+ }
68
+
69
+ function isRequired(cell) {
70
+ const v = (cell || '').toLowerCase();
71
+ return v.includes('✓') || v === 'yes' || v === 'required' || v === 'true';
72
+ }
73
+
74
+ /** Heading form: "Person (`person`)" → { name: 'Person', type: 'person' }. */
75
+ function parseEntityHeading(text) {
76
+ const m = text.match(/^(.+?)\s+\(`([^`]+)`\)\s*$/);
77
+ if (!m) return null;
78
+ return { name: m[1].trim(), type: m[2].trim() };
79
+ }
80
+
81
+ /**
82
+ * Read and parse SCHEMA.md.
83
+ *
84
+ * @param {string} schemaPath path to SCHEMA.md
85
+ * @returns {{
86
+ * areas: Set<string>,
87
+ * entities: Map<string, { name: string, fields: Map<string, {type: string, required: boolean}> }>,
88
+ * associations: Map<string, { targets: string[]|'any' }>
89
+ * }}
90
+ */
91
+ export function loadSchema(schemaPath) {
92
+ const text = readFileSync(schemaPath, 'utf8');
93
+ const lines = text.split(/\r?\n/);
94
+
95
+ // Areas: the first column of the table under the "Areas" heading.
96
+ const areas = new Set();
97
+ for (const table of tablesUnderHeading(lines, (h) => h.toLowerCase() === 'areas')) {
98
+ for (const row of table) {
99
+ const value = row.area ?? Object.values(row)[0];
100
+ if (value) areas.add(value);
101
+ }
102
+ }
103
+
104
+ // Associations: field -> targets, from the table under the "Associations" heading.
105
+ const associations = new Map();
106
+ for (const table of tablesUnderHeading(lines, (h) => h.toLowerCase() === 'associations')) {
107
+ for (const row of table) {
108
+ const field = row.field;
109
+ const targetCell = row.target || '';
110
+ if (!field || !targetCell) continue;
111
+ const targets = targetCell === 'any' ? 'any' : targetCell.split('|').map((t) => t.trim());
112
+ associations.set(field, { targets });
113
+ }
114
+ }
115
+
116
+ // Entities: every heading shaped "Name (`type`)" introduces a field table.
117
+ // Pair each entity heading with the field table that immediately follows it.
118
+ const entities = new Map();
119
+ let current = null;
120
+ let header = null;
121
+ for (const line of lines) {
122
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
123
+ if (heading) {
124
+ const parsed = parseEntityHeading(heading[2].trim());
125
+ current = parsed ? { ...parsed, fields: new Map() } : null;
126
+ header = null;
127
+ if (parsed) entities.set(parsed.type, current);
128
+ continue;
129
+ }
130
+ if (!current) continue;
131
+ if (!line.trim().startsWith('|')) { if (line.trim() === '') continue; header = null; continue; }
132
+ if (isSeparatorRow(line)) continue;
133
+ const cells = splitRow(line);
134
+ if (!header) {
135
+ header = cells.map((c) => c.toLowerCase());
136
+ continue;
137
+ }
138
+ const row = {};
139
+ header.forEach((key, i) => { row[key] = cells[i] ?? ''; });
140
+ const fieldName = row.field;
141
+ const fieldType = (row.type || '').toLowerCase();
142
+ if (!fieldName || !FIELD_TYPES.has(fieldType)) continue;
143
+ current.fields.set(fieldName, { type: fieldType, required: isRequired(row.required) });
144
+ }
145
+
146
+ return { areas, entities, associations };
147
+ }