@dforge-core/metadata 0.0.34 → 0.0.36

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,425 @@
1
+ // Checking one declaration of `registers.json`, and the file as a whole.
2
+ // Every rule FAILS OPEN: what cannot be resolved is not reported.
3
+
4
+ import { createEntityChecks } from "./entities";
5
+ import type { EntityChecks, RegisterEntityLookup, ResolvedRegisterEntity } from "./entities";
6
+ import { createIssueReporter } from "./issues";
7
+ import type { IssuePath, RegisterIssue, RegisterReporter } from "./issues";
8
+ import {
9
+ KIND_LABEL, hasKey, isRecord, registerCarrier, registerDimensions, registerLineMode, registerRefusedProps,
10
+ registerRequiredProps, registerResourceShape, registerResources, safeTrim, stringList,
11
+ } from "./read";
12
+ import type { Raw } from "./read";
13
+ import { REGISTER_CODE, REGISTER_COLUMN_NAME, REGISTER_TOTALS_PERIODS, isRegisterKind } from "./types";
14
+ import type { RegisterKind } from "./types";
15
+
16
+ const MAX_DIMENSIONS = 8;
17
+ const MAX_RESOURCES = 64;
18
+ const MAX_ANALYTIC_SLOTS = 8;
19
+ const MAX_REGISTERS = 100;
20
+
21
+ interface Context extends EntityChecks {
22
+ def: Raw;
23
+ kind: RegisterKind;
24
+ error: RegisterReporter;
25
+ warning: RegisterReporter;
26
+ journalCode?: string;
27
+ journal: ResolvedRegisterEntity;
28
+ carrierCode?: string;
29
+ carrier: ResolvedRegisterEntity;
30
+ dimensions: string[];
31
+ sides: Raw;
32
+ resources: ReturnType<typeof registerResources>;
33
+ }
34
+
35
+ /** Two columns that must both be named and must differ — a debit/credit pair. */
36
+ function checkColumnPair(
37
+ error: RegisterReporter,
38
+ code: string,
39
+ first: string | undefined,
40
+ second: string | undefined,
41
+ where: { missing: IssuePath; missingMessage: string; same: IssuePath; sameMessage: string },
42
+ ): void {
43
+ if (!first || !second) {
44
+ error(where.missing, code, where.missingMessage);
45
+ }
46
+ if (first && first === second) {
47
+ error(where.same, code, where.sameMessage);
48
+ }
49
+ }
50
+
51
+ /** Report each of `keys` the declaration carries, with the same message. */
52
+ function refuseKeys(def: Raw, keys: readonly string[], error: RegisterReporter, code: string, message: (key: string) => string): void {
53
+ for (const key of keys) {
54
+ if (hasKey(def, key)) {
55
+ error([key], code, message(key));
56
+ }
57
+ }
58
+ }
59
+
60
+ function checkProperties({ def, kind, error }: Pick<Context, "def" | "kind" | "error">): void {
61
+ for (const key of registerRequiredProps(kind)) {
62
+ if (safeTrim(def[key]) === undefined) {
63
+ error([key], "required", `${KIND_LABEL[kind].sentence} needs '${key}'.`);
64
+ }
65
+ }
66
+ refuseKeys(def, registerRefusedProps(kind), error, "refused", (key) => `'${key}' isn't allowed on ${KIND_LABEL[kind].inline}.`);
67
+ if ((safeTrim(def.journalDirection) || safeTrim(def.journalDate)) && !safeTrim(def.journal)) {
68
+ error(["journal"], "journal-stamps", "'journalDirection' and 'journalDate' are columns of the journal — name the journal entity.");
69
+ }
70
+ if (kind === "info" && def.recordChangesOnly === true && !safeTrim(def.recorder)) {
71
+ error(["recordChangesOnly"], "record-changes-only", "'recordChangesOnly' needs a 'recorder' — only a document's record can be compared with the slice in effect.");
72
+ }
73
+ }
74
+
75
+ function resolveEntities(
76
+ def: Raw,
77
+ kind: RegisterKind,
78
+ checks: EntityChecks,
79
+ ): Pick<Context, "journalCode" | "journal" | "carrierCode" | "carrier"> {
80
+ const { entityAt, columnAt } = checks;
81
+ const entityCode = safeTrim(def.entity);
82
+ const journalCode = safeTrim(def.journal);
83
+ const carriedByEntity = kind === "balance" || kind === "info";
84
+ const entity = carriedByEntity ? entityAt(["entity"], entityCode, "Entity") : null;
85
+ const journal = kind !== "info" ? entityAt(["journal"], journalCode, "Journal") : null;
86
+ if (kind === "accounting") {
87
+ entityAt(["chart"], safeTrim(def.chart), "Chart");
88
+ }
89
+
90
+ columnAt(["journalDirection"], journal, journalCode, def.journalDirection, "Journal direction column");
91
+ columnAt(["journalDate"], journal, journalCode, def.journalDate, "Journal date column");
92
+ columnAt(["period"], entity, entityCode, def.period, "Period column");
93
+ columnAt(["recorder"], entity, entityCode, def.recorder, "Recorder column");
94
+
95
+ return { journalCode, journal, carrierCode: registerCarrier(def), carrier: carriedByEntity ? entity : journal };
96
+ }
97
+
98
+ function checkDimensions({ def, dimensions, sides, carrier, carrierCode, columnAt, error }: Context): void {
99
+ if (!Array.isArray(def.dimensions) || dimensions.length === 0) {
100
+ error(["dimensions"], "dimensions", "A register needs at least one dimension.");
101
+ }
102
+ if (dimensions.length > MAX_DIMENSIONS) {
103
+ error(["dimensions"], "dimensions", `At most ${MAX_DIMENSIONS} dimensions.`);
104
+ }
105
+ const twice = dimensions.filter((name, index) => dimensions.indexOf(name) !== index);
106
+ if (twice.length > 0) {
107
+ error(["dimensions"], "dimensions", `Dimension listed more than once: ${[...new Set(twice)].join(", ")}.`);
108
+ }
109
+ dimensions.forEach((name, i) => {
110
+ // A two-sided dimension lives in its side columns, not under its own name.
111
+ if (!(name in sides)) {
112
+ columnAt(["dimensions", i], carrier, carrierCode, name, "Dimension");
113
+ }
114
+ });
115
+ }
116
+
117
+ /** The shape a resource's columns take, per kind and line mode. */
118
+ function checkResourceShape(ctx: Context, name: string, resource: Raw, path: (key?: string) => IssuePath): void {
119
+ const { def, kind, error } = ctx;
120
+ const field = safeTrim(resource.field);
121
+ const debit = safeTrim(resource.debitField);
122
+ const credit = safeTrim(resource.creditField);
123
+ const split = registerResourceShape(resource) === "split";
124
+ const classic = kind === "accounting" && registerLineMode(def) === "classic";
125
+ if (split && hasKey(resource, "field")) {
126
+ error(path("field"), "resource-shape", `Resource '${name}': either 'field' or 'debitField' + 'creditField', not both.`);
127
+ }
128
+ if (classic && !split) {
129
+ error(path(), "resource-shape", `Resource '${name}': on a classic accounting register every resource is split — 'debitField' and 'creditField'.`);
130
+ } else if (kind !== "accounting" && split) {
131
+ error(path(), "resource-shape", `Resource '${name}': 'debitField' / 'creditField' belong to an accounting register — name one 'field'.`);
132
+ } else if (split) {
133
+ checkColumnPair(error, "resource-shape", debit, credit, {
134
+ missing: path(),
135
+ missingMessage: `Resource '${name}' needs both 'debitField' and 'creditField'.`,
136
+ same: path("creditField"),
137
+ sameMessage: `Resource '${name}': the debit and credit columns must differ.`,
138
+ });
139
+ } else if (!field) {
140
+ error(path(), "resource-shape", `Resource '${name}' needs its column ('field').`);
141
+ }
142
+ }
143
+
144
+ function checkResources(ctx: Context): void {
145
+ const { def, kind, resources, dimensions, carrier, carrierCode, columnAt, error } = ctx;
146
+ if (!isRecord(def.resources) || resources.length === 0) {
147
+ error(["resources"], "resources", "A register needs at least one resource.");
148
+ }
149
+ if (resources.length > MAX_RESOURCES) {
150
+ error(["resources"], "resources", `At most ${MAX_RESOURCES} resources.`);
151
+ }
152
+ const takesAbsentAs = kind === "info" && !!safeTrim(def.recorder) && def.recordChangesOnly === true;
153
+ const storage = new Map<string, string>();
154
+ for (const { name, def: resource } of resources) {
155
+ const path = (key?: string): IssuePath => (key ? ["resources", name, key] : ["resources", name]);
156
+ if (!REGISTER_COLUMN_NAME.test(name)) {
157
+ error(path(), "resource-name", `Resource '${name}' isn't a name (lowercase letters, digits and '_').`);
158
+ }
159
+ checkResourceShape(ctx, name, resource, path);
160
+ for (const key of ["field", "debitField", "creditField"] as const) {
161
+ const column = safeTrim(resource[key]);
162
+ if (!column) {
163
+ continue;
164
+ }
165
+ columnAt(path(key), carrier, carrierCode, column, `Resource '${name}' column`);
166
+ const other = storage.get(column);
167
+ if (other && other !== name) {
168
+ error(path(key), "resource-column-shared", `Resources '${other}' and '${name}' both store into '${column}' — posting would add both into one column.`);
169
+ }
170
+ storage.set(column, name);
171
+ }
172
+ const field = safeTrim(resource.field);
173
+ if (field && dimensions.includes(field)) {
174
+ error(path("field"), "resource-is-dimension", `'${field}' is both a dimension and resource '${name}'.`);
175
+ }
176
+ if (hasKey(resource, "absentAs") && !takesAbsentAs) {
177
+ error(path("absentAs"), "absent-as", `Resource '${name}': 'absentAs' needs 'recordChangesOnly' on a subordinate information register.`);
178
+ }
179
+ }
180
+ }
181
+
182
+ function checkAnalyticSlotColumns(ctx: Context, key: string, columns: string[]): void {
183
+ columns.forEach((column, i) => ctx.columnAt([key, i], ctx.journal, ctx.journalCode, column, "Analytic slot column"));
184
+ }
185
+
186
+ function checkSideDimensions(ctx: Context): void {
187
+ const { dimensions, sides, journal, journalCode, columnAt, error } = ctx;
188
+ for (const [name, side] of Object.entries(sides)) {
189
+ const base: IssuePath = ["sideDimensions", name];
190
+ if (!dimensions.includes(name)) {
191
+ error(base, "side-dimension", `Two-sided dimension '${name}' must also be listed in 'dimensions'.`);
192
+ }
193
+ const sideDef = isRecord(side) ? side : {};
194
+ const debitField = safeTrim(sideDef.debitField);
195
+ const creditField = safeTrim(sideDef.creditField);
196
+ checkColumnPair(error, "side-dimension", debitField, creditField, {
197
+ missing: base,
198
+ missingMessage: `Two-sided dimension '${name}' needs 'debitField' and 'creditField'.`,
199
+ same: [...base, "creditField"],
200
+ sameMessage: `Two-sided dimension '${name}': the two columns must differ.`,
201
+ });
202
+ columnAt([...base, "debitField"], journal, journalCode, debitField, `Dimension '${name}' debit column`);
203
+ columnAt([...base, "creditField"], journal, journalCode, creditField, `Dimension '${name}' credit column`);
204
+ }
205
+ }
206
+
207
+ /** Returns whether the register declares analytic slots. */
208
+ function checkCompoundAccounts(ctx: Context): boolean {
209
+ const { def, journal, journalCode, columnAt, error } = ctx;
210
+ refuseKeys(def, ["account", "analyticFields"], error, "line-mode", (key) => `'${key}' belongs to a classic register; a compound one names 'debitAccount' and 'creditAccount'.`);
211
+ const debit = safeTrim(def.debitAccount);
212
+ const credit = safeTrim(def.creditAccount);
213
+ checkColumnPair(error, "accounts", debit, credit, {
214
+ missing: ["debitAccount"],
215
+ missingMessage: "A compound register needs 'debitAccount' and 'creditAccount'.",
216
+ same: ["creditAccount"],
217
+ sameMessage: "'debitAccount' and 'creditAccount' must differ — one column would record no correspondence.",
218
+ });
219
+ columnAt(["debitAccount"], journal, journalCode, debit, "Debit account column");
220
+ columnAt(["creditAccount"], journal, journalCode, credit, "Credit account column");
221
+
222
+ const debitSlots = stringList(def.debitAnalyticFields);
223
+ const creditSlots = stringList(def.creditAnalyticFields);
224
+ if (debitSlots.length > MAX_ANALYTIC_SLOTS || creditSlots.length > MAX_ANALYTIC_SLOTS) {
225
+ error(["debitAnalyticFields"], "analytic-slots", `At most ${MAX_ANALYTIC_SLOTS} analytic slots per side.`);
226
+ }
227
+ const shared = debitSlots.filter((column) => creditSlots.includes(column));
228
+ if (shared.length > 0) {
229
+ error(["creditAnalyticFields"], "analytic-slots", `Analytic slot column on both sides: ${shared.join(", ")}.`);
230
+ }
231
+ checkAnalyticSlotColumns(ctx, "debitAnalyticFields", debitSlots);
232
+ checkAnalyticSlotColumns(ctx, "creditAnalyticFields", creditSlots);
233
+ const needsComposition = debitSlots.length > 0 || creditSlots.length > 0;
234
+ checkSideDimensions(ctx);
235
+ return needsComposition;
236
+ }
237
+
238
+ /** Returns whether the register declares analytic slots. */
239
+ function checkClassicAccounts(ctx: Context): boolean {
240
+ const { def, journal, journalCode, columnAt, error } = ctx;
241
+ refuseKeys(def, ["debitAccount", "creditAccount", "debitAnalyticFields", "creditAnalyticFields", "sideDimensions"], error, "line-mode", (key) => `'${key}' belongs to a compound register.`);
242
+ if (!safeTrim(def.account)) {
243
+ error(["account"], "accounts", "A classic register names the posting's account column ('account').");
244
+ }
245
+ columnAt(["account"], journal, journalCode, safeTrim(def.account), "Account column");
246
+ const slots = stringList(def.analyticFields);
247
+ if (slots.length > MAX_ANALYTIC_SLOTS) {
248
+ error(["analyticFields"], "analytic-slots", `At most ${MAX_ANALYTIC_SLOTS} analytic slots.`);
249
+ }
250
+ checkAnalyticSlotColumns(ctx, "analyticFields", slots);
251
+ return slots.length > 0;
252
+ }
253
+
254
+ function checkComposition(ctx: Context, needsComposition: boolean): void {
255
+ const { def, entityAt, columnAt, error } = ctx;
256
+ const composition = isRecord(def.composition) ? def.composition : undefined;
257
+ if (needsComposition && !composition) {
258
+ error(["composition"], "composition", "Analytic slots need a 'composition' — where the chart says which analytic each account takes in which slot.");
259
+ }
260
+ if (!needsComposition && composition) {
261
+ error(["composition"], "composition", "'composition' is for a register with analytic slots; this one declares none.");
262
+ }
263
+ if (!composition) {
264
+ return;
265
+ }
266
+ for (const key of ["entity", "account", "slot", "analytic"]) {
267
+ if (!safeTrim(composition[key])) {
268
+ error(["composition", key], "composition", `'composition' needs '${key}'.`);
269
+ }
270
+ }
271
+ const code = safeTrim(composition.entity);
272
+ const entity = entityAt(["composition", "entity"], code, "Composition entity");
273
+ for (const key of ["account", "slot", "analytic"]) {
274
+ columnAt(["composition", key], entity, code, composition[key], `Composition '${key}' column`);
275
+ }
276
+ }
277
+
278
+ function checkAccounting(ctx: Context): void {
279
+ const { def, kind, error } = ctx;
280
+ if (kind !== "accounting") {
281
+ return;
282
+ }
283
+ const mode = safeTrim(def.lineMode);
284
+ if (mode && mode !== "classic" && mode !== "compound") {
285
+ error(["lineMode"], "line-mode", "'lineMode' is 'classic' or 'compound'.");
286
+ }
287
+ checkComposition(ctx, mode === "compound" ? checkCompoundAccounts(ctx) : checkClassicAccounts(ctx));
288
+ }
289
+
290
+ function checkTotals(ctx: Context): void {
291
+ const { def, kind, dimensions, resources, entityAt, columnAt, error } = ctx;
292
+ const totals = isRecord(def.totals) ? def.totals : undefined;
293
+ if (!totals || kind === "info") {
294
+ return;
295
+ }
296
+ const totalsCode = safeTrim(totals.entity);
297
+ if (!totalsCode) {
298
+ error(["totals", "entity"], "totals", "The totals cache needs its entity.");
299
+ }
300
+ const totalsEntity = entityAt(["totals", "entity"], totalsCode, "Totals entity");
301
+ const period = safeTrim(totals.period);
302
+ if (!period || !(REGISTER_TOTALS_PERIODS as readonly string[]).includes(period)) {
303
+ error(["totals", "period"], "totals", "The totals cache needs a period: day, month, quarter or year.");
304
+ }
305
+ if (!safeTrim(totals.periodField)) {
306
+ error(["totals", "periodField"], "totals", "The totals cache needs 'periodField' — the column holding each period's first date.");
307
+ }
308
+ columnAt(["totals", "periodField"], totalsEntity, totalsCode, safeTrim(totals.periodField), "Totals period column");
309
+ if (kind === "accounting") {
310
+ if (!safeTrim(totals.accountField)) {
311
+ error(["totals", "accountField"], "totals", "An accounting register's cache needs 'accountField'.");
312
+ }
313
+ columnAt(["totals", "accountField"], totalsEntity, totalsCode, safeTrim(totals.accountField), "Totals account column");
314
+ stringList(totals.dimensions).forEach((name, i) => {
315
+ if (!dimensions.includes(name)) {
316
+ error(["totals", "dimensions", i], "totals", `Totals dimension '${name}' isn't a dimension of the register.`);
317
+ }
318
+ });
319
+ } else {
320
+ if (hasKey(totals, "accountField")) {
321
+ error(["totals", "accountField"], "totals", "'accountField' belongs to an accounting register's cache.");
322
+ }
323
+ if (hasKey(totals, "dimensions")) {
324
+ error(["totals", "dimensions"], "totals", "A cache grouped by fewer dimensions is for an accounting register only.");
325
+ }
326
+ }
327
+ const cached = isRecord(totals.resources) ? totals.resources : {};
328
+ const pair = kind === "accounting" ? ["debitField", "creditField"] : ["inField", "outField"];
329
+ for (const { name } of resources) {
330
+ const entry = cached[name];
331
+ if (!isRecord(entry)) {
332
+ error(["totals", "resources"], "totals", `The totals cache must cover every resource — '${name}' is missing.`);
333
+ continue;
334
+ }
335
+ for (const key of pair) {
336
+ if (!safeTrim(entry[key])) {
337
+ error(["totals", "resources", name], "totals", `Totals resource '${name}' needs '${key}'.`);
338
+ }
339
+ columnAt(["totals", "resources", name, key], totalsEntity, totalsCode, safeTrim(entry[key]), `Totals '${name}' ${key}`);
340
+ }
341
+ }
342
+ for (const name of Object.keys(cached)) {
343
+ if (!resources.some((resource) => resource.name === name)) {
344
+ error(["totals", "resources", name], "totals", `Totals cache names '${name}', which isn't a resource of the register.`);
345
+ }
346
+ }
347
+ }
348
+
349
+ function checkWarnings({ def, kind, journalCode, warning }: Context): void {
350
+ if (!safeTrim(def.label)) {
351
+ warning(["label"], "label", "No label — the query builder shows the code.");
352
+ }
353
+ if (kind === "balance") {
354
+ if (!journalCode) {
355
+ warning(["journal"], "no-journal", "No journal: unposting has to recompute from the document, and as-of-date reads are refused.");
356
+ } else if (!safeTrim(def.journalDirection) || !safeTrim(def.journalDate)) {
357
+ warning(["journal"], "journal-stamps", "Declare both journal stamps (direction and date) from the start — rows journalled without them can't be backfilled.");
358
+ }
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Check one declaration against the schema's per-kind rules and the installer's
364
+ * — paths relative to the declaration. With a lookup, the entities it names are
365
+ * checked to exist and the columns it names to be stored on them.
366
+ */
367
+ export function checkRegister(raw: unknown, lookup?: RegisterEntityLookup): RegisterIssue[] {
368
+ const out: RegisterIssue[] = [];
369
+ const error = createIssueReporter(out, "registers", "error");
370
+ const warning = createIssueReporter(out, "registers", "warning");
371
+ if (!isRecord(raw)) {
372
+ error([], "shape", "A register declaration is a JSON object.");
373
+ return out;
374
+ }
375
+ const def = raw;
376
+ if (!isRegisterKind(def.kind)) {
377
+ error(["kind"], "kind", "Pick a kind: balance, turnover, info or accounting.");
378
+ return out;
379
+ }
380
+ const kind = def.kind;
381
+ checkProperties({ def, kind, error });
382
+ const checks = createEntityChecks(lookup, error, warning);
383
+ const ctx: Context = {
384
+ def, kind, error, warning, ...checks,
385
+ ...resolveEntities(def, kind, checks),
386
+ dimensions: registerDimensions(def),
387
+ sides: isRecord(def.sideDimensions) ? def.sideDimensions : {},
388
+ resources: registerResources(def),
389
+ };
390
+ checkDimensions(ctx);
391
+ checkResources(ctx);
392
+ checkAccounting(ctx);
393
+ checkTotals(ctx);
394
+ checkWarnings(ctx);
395
+ return out;
396
+ }
397
+
398
+ /**
399
+ * Check a whole `registers.json`: every declaration (paths prefixed by its
400
+ * code), plus the codes themselves and the file's limits.
401
+ */
402
+ export function checkRegisters(file: unknown, lookup?: RegisterEntityLookup): RegisterIssue[] {
403
+ const out: RegisterIssue[] = [];
404
+ const error = createIssueReporter(out, "registers", "error");
405
+ if (!isRecord(file)) {
406
+ error([], "shape", "registers.json is an object of register declarations.");
407
+ return out;
408
+ }
409
+ const codes = Object.keys(file).filter((code) => !code.startsWith("$"));
410
+ if (codes.length === 0) {
411
+ error([], "empty", "A module with no registers has no registers.json — the file needs at least one.");
412
+ }
413
+ if (codes.length > MAX_REGISTERS) {
414
+ error([], "limit", `A module declares at most ${MAX_REGISTERS} registers.`);
415
+ }
416
+ for (const code of codes) {
417
+ if (!REGISTER_CODE.test(code)) {
418
+ error([code], "code", `'${code}': lowercase letters, digits and '_', starting with a letter — a binding names it as module.code.`);
419
+ }
420
+ for (const issue of checkRegister(file[code], lookup)) {
421
+ out.push({ ...issue, path: [code, ...issue.path] });
422
+ }
423
+ }
424
+ return out;
425
+ }
@@ -0,0 +1,79 @@
1
+ // Resolving the entities and columns a declaration names, through the caller's lookup.
2
+
3
+ import { REGISTER_COLUMN_NAME } from "./types";
4
+ import type { IssuePath, RegisterReporter } from "./issues";
5
+
6
+ /**
7
+ * What an entity a declaration names looks like, as far as the caller knows.
8
+ * `columns` is left undefined when they can't be listed (a dependency known
9
+ * only from its contract), and no column rule runs against it.
10
+ */
11
+ export interface RegisterEntityInfo {
12
+ columns?: Iterable<string>;
13
+ }
14
+
15
+ /**
16
+ * Resolves an entity code as `registers.json` writes it — bare for the module's
17
+ * own, `module.entity` for another's. Returns `null` when nothing can be known
18
+ * (the module named isn't available: the rule stands down) and `undefined` when
19
+ * the module is known and has no such entity.
20
+ */
21
+ export type RegisterEntityLookup = (code: string) => RegisterEntityInfo | null | undefined;
22
+
23
+ export type ResolvedRegisterEntity = RegisterEntityInfo | null | undefined;
24
+
25
+ export interface EntityChecks {
26
+ /** Resolve `code`, reporting `unknown-entity` when the module is known and has no such entity. */
27
+ entityAt: (path: IssuePath, code: string | undefined, what: string) => ResolvedRegisterEntity;
28
+ /** Check a column name, and that `entity` stores it. A non-string or blank column is skipped. */
29
+ columnAt: (path: IssuePath, entity: ResolvedRegisterEntity, entityCode: string | undefined, column: unknown, what: string) => void;
30
+ }
31
+
32
+ export function createEntityChecks(
33
+ lookup: RegisterEntityLookup | undefined,
34
+ error: RegisterReporter,
35
+ warning: RegisterReporter,
36
+ ): EntityChecks {
37
+ const resolved = new Map<string, ResolvedRegisterEntity>();
38
+ const columnSets = new WeakMap<RegisterEntityInfo, Set<string> | null>();
39
+
40
+ const entityAt: EntityChecks["entityAt"] = (path, code, what) => {
41
+ if (!code || !lookup) {
42
+ return null;
43
+ }
44
+ if (!resolved.has(code)) {
45
+ resolved.set(code, lookup(code));
46
+ }
47
+ const found = resolved.get(code);
48
+ if (found === undefined) {
49
+ error(path, "unknown-entity", `${what} '${code}' isn't an entity this module can see.`);
50
+ }
51
+ return found;
52
+ };
53
+
54
+ const columnsOf = (entity: ResolvedRegisterEntity): Set<string> | null => {
55
+ if (!entity) {
56
+ return null;
57
+ }
58
+ if (!columnSets.has(entity)) {
59
+ columnSets.set(entity, entity.columns ? new Set(entity.columns) : null);
60
+ }
61
+ return columnSets.get(entity) ?? null;
62
+ };
63
+
64
+ const columnAt: EntityChecks["columnAt"] = (path, entity, entityCode, column, what) => {
65
+ if (typeof column !== "string" || !column.trim()) {
66
+ return;
67
+ }
68
+ if (!REGISTER_COLUMN_NAME.test(column)) {
69
+ error(path, "column-name", `${what} '${column}' isn't a column name (lowercase letters, digits and '_').`);
70
+ return;
71
+ }
72
+ const columns = columnsOf(entity);
73
+ if (columns && !columns.has(column)) {
74
+ warning(path, "unknown-column", `${what} '${column}' isn't a stored column of ${entityCode}.`);
75
+ }
76
+ };
77
+
78
+ return { entityAt, columnAt };
79
+ }
@@ -0,0 +1,17 @@
1
+ // Registers — the authoring shape of `registers.json` and the rules the installer
2
+ // holds a declaration and a document's binding to.
3
+ //
4
+ // The rules live here, once, so an editor can run them as the author types, a
5
+ // language server can report them on the file, and a validator can refuse a pack
6
+ // — the same way the DSL rules are shared.
7
+
8
+ export * from "./types";
9
+ export {
10
+ registerDimensions, registerResources, registerCarrier, registerResourceShape, registerLineMode,
11
+ registerRefusedProps, registerRequiredProps,
12
+ } from "./read";
13
+ export type { RegisterIssue } from "./issues";
14
+ export type { RegisterEntityInfo, RegisterEntityLookup } from "./entities";
15
+ export { checkRegister, checkRegisters } from "./check-declaration";
16
+ export { checkRegisterBinding, isPlainColumnRef } from "./check-binding";
17
+ export type { RegisterBindingTarget } from "./check-binding";
@@ -0,0 +1,25 @@
1
+ // Findings: what a check reports, and the reporters that collect them.
2
+
3
+ export type IssuePath = Array<string | number>;
4
+
5
+ /** One finding, addressed by a JSON path relative to what was checked. */
6
+ export interface RegisterIssue {
7
+ path: IssuePath;
8
+ message: string;
9
+ /** `error`: install refuses it. `warning`: worth a look. */
10
+ severity: "error" | "warning";
11
+ /** Stable id, namespaced `registers/*` for a declaration and `entity/register-*` for a binding. */
12
+ code: string;
13
+ }
14
+
15
+ export type RegisterReporter = (path: IssuePath, code: string, message: string) => void;
16
+
17
+ export function createIssueReporter(
18
+ issues: RegisterIssue[],
19
+ namespace: "registers" | "entity",
20
+ severity: RegisterIssue["severity"],
21
+ ): RegisterReporter {
22
+ return (path, code, message) => {
23
+ issues.push({ path, code: `${namespace}/${code}`, message, severity });
24
+ };
25
+ }
@@ -0,0 +1,81 @@
1
+ // Reading a declaration: the loose-JSON helpers every check shares, and what each
2
+ // kind requires and refuses.
3
+
4
+ import type { RegisterDef, RegisterKind, RegisterResourceDef } from "./types";
5
+
6
+ export type Raw = Record<string, unknown>;
7
+
8
+ export const isRecord = (value: unknown): value is Raw =>
9
+ !!value && typeof value === "object" && !Array.isArray(value);
10
+
11
+ export const safeTrim = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value : undefined);
12
+
13
+ export const stringList = (value: unknown): string[] =>
14
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
15
+
16
+ /** A key present but undefined is an editor's draft, not a declaration. */
17
+ export const hasKey = (obj: Raw, key: string): boolean => obj[key] !== undefined;
18
+
19
+ /** A declaration's dimension names, only the ones that are strings. */
20
+ export const registerDimensions = (def: RegisterDef | Raw): string[] => stringList(def.dimensions);
21
+
22
+ /** A declaration's resources in file order, each with its definition object. */
23
+ export const registerResources = (def: RegisterDef | Raw): Array<{ name: string; def: Raw }> =>
24
+ !isRecord(def.resources)
25
+ ? []
26
+ : Object.entries(def.resources).map(([name, raw]) => ({ name, def: isRecord(raw) ? raw : {} }));
27
+
28
+ /** The entity the dimensions, resources and stamps are columns of. */
29
+ export function registerCarrier(def: RegisterDef | Raw): string | undefined {
30
+ switch (def.kind) {
31
+ case "balance":
32
+ case "info":
33
+ return safeTrim(def.entity);
34
+ case "turnover":
35
+ case "accounting":
36
+ return safeTrim(def.journal);
37
+ default:
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ /** How a resource names its column: one shared column, or one per side. */
43
+ export const registerResourceShape = (resource: RegisterResourceDef | Raw): "field" | "split" =>
44
+ "debitField" in resource || "creditField" in resource ? "split" : "field";
45
+
46
+ export const registerLineMode = (def: RegisterDef | Raw): "classic" | "compound" =>
47
+ def.lineMode === "compound" ? "compound" : "classic";
48
+
49
+ const INFO_PROPERTIES = ["period", "recorder", "recordChangesOnly"] as const;
50
+ const ACCOUNTING_PROPERTIES = [
51
+ "chart", "composition", "analyticFields", "lineMode", "account",
52
+ "debitAccount", "creditAccount", "debitAnalyticFields", "creditAnalyticFields", "sideDimensions",
53
+ ] as const;
54
+ const BALANCE_PROPERTIES = ["allowNegative", "autoCreate"] as const;
55
+
56
+ /** Properties each kind refuses — the schema's `false` properties, which install fails on. */
57
+ const REFUSED: Record<RegisterKind, readonly string[]> = {
58
+ balance: [...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES],
59
+ turnover: ["entity", ...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES, ...BALANCE_PROPERTIES],
60
+ info: ["journal", "journalDirection", "journalDate", ...ACCOUNTING_PROPERTIES, "totals", ...BALANCE_PROPERTIES],
61
+ accounting: ["entity", ...INFO_PROPERTIES, "journalDirection", ...BALANCE_PROPERTIES],
62
+ };
63
+
64
+ /** Properties each kind requires beside `kind`, `dimensions` and `resources`. */
65
+ const REQUIRED: Record<RegisterKind, readonly string[]> = {
66
+ balance: ["entity"],
67
+ turnover: ["journal", "journalDirection", "journalDate"],
68
+ info: ["entity"],
69
+ accounting: ["chart", "journal", "journalDate", "lineMode"],
70
+ };
71
+
72
+ export const registerRefusedProps = (kind: RegisterKind): readonly string[] => REFUSED[kind];
73
+ export const registerRequiredProps = (kind: RegisterKind): readonly string[] => REQUIRED[kind];
74
+
75
+ /** "A balance register" / "a balance register", for the start and the middle of a message. */
76
+ export const KIND_LABEL: Record<RegisterKind, { sentence: string; inline: string }> = {
77
+ balance: { sentence: "A balance register", inline: "a balance register" },
78
+ turnover: { sentence: "A turnover register", inline: "a turnover register" },
79
+ info: { sentence: "An information register", inline: "an information register" },
80
+ accounting: { sentence: "An accounting register", inline: "an accounting register" },
81
+ };