import { readFileSync } from 'node:fs'; import { TASK_VERBS } from '../../bus/src/broker.mjs'; // Row 61 (#1550): the slice 1 field table as data, in ../fields.json. The prose in // docs/plans/2026-10-04_slice-1.md stays the reviewed source; tests/fields.test.mjs checks both // against what the verbs do. Two layers, as lead decision 113 rules: // - `verbs`: the writer verbs.mjs checks before anything else. `pm` is the pm definition // (field-writer otherwise), `assigned` a role whose bot is an assignee (not-assigned // otherwise), `any` no writer check. // - Whether a write also needs a resolved decision isn't here. It comes from the role's // authority in roles/*.json, which the broker reads. // Each field lists the verbs that write it and the Vikunja calls each verb makes for it. This // function is the file's schema: it refuses any other shape. export const FIELDS_FILE = new URL('../fields.json', import.meta.url); export const WRITERS = Object.freeze(['pm', 'assigned', 'any']); const CALL = /^(POST|PUT|PATCH|DELETE) (\/(?:[a-z_]+|\{[a-z]+\}))+$/; const FIELD = /^[a-z][a-z_]{0,63}$/; export class FieldTableError extends Error {} const plain = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); const str = (x, max) => typeof x === 'string' && x.trim() !== '' && x.length <= max; function exact(x, where, allowed, required = allowed) { if (!plain(x)) throw new FieldTableError(`${where}: not an object`); for (const k of Object.keys(x)) if (!allowed.includes(k)) throw new FieldTableError(`${where}: unknown key ${k}`); for (const k of required) if (!Object.hasOwn(x, k)) throw new FieldTableError(`${where}: missing ${k}`); } function list(x, where) { if (!Array.isArray(x) || !x.length) throw new FieldTableError(`${where}: not a nonempty list`); return x; } export function checkFields(table, verbs = TASK_VERBS) { exact(table, 'table', ['source', 'verbs', 'fields']); if (!str(table.source, 256)) throw new FieldTableError('source: not a nonempty string'); exact(table.verbs, 'verbs', verbs); for (const [v, w] of Object.entries(table.verbs)) if (!WRITERS.includes(w)) throw new FieldTableError(`verbs.${v}: writer must be ${WRITERS.join(', ')}`); const seen = new Set(), used = new Set(); for (const [i, f] of list(table.fields, 'fields').entries()) { const at = `fields[${i}]`; exact(f, at, ['field', 'label', 'note', 'writes'], ['field', 'label', 'writes']); if (typeof f.field !== 'string' || !FIELD.test(f.field)) throw new FieldTableError(`${at}.field: not a field name`); if (seen.has(f.field)) throw new FieldTableError(`${at}.field: ${f.field} twice`); seen.add(f.field); if (!str(f.label, 80)) throw new FieldTableError(`${at}.label: not a nonempty string`); if (f.note !== undefined && !str(f.note, 256)) throw new FieldTableError(`${at}.note: not a nonempty string`); const mine = new Set(); for (const [j, w] of list(f.writes, `${at}.writes`).entries()) { const wat = `${at}.writes[${j}]`; exact(w, wat, ['verb', 'calls']); if (!Object.hasOwn(table.verbs, w.verb)) throw new FieldTableError(`${wat}.verb: not a task verb`); if (mine.has(w.verb)) throw new FieldTableError(`${wat}.verb: ${w.verb} twice`); mine.add(w.verb); used.add(w.verb); const calls = list(w.calls, `${wat}.calls`); for (const c of calls) if (typeof c !== 'string' || !CALL.test(c)) throw new FieldTableError(`${wat}.calls: not a Vikunja call`); if (new Set(calls).size !== calls.length) throw new FieldTableError(`${wat}.calls: a call twice`); } } for (const v of Object.keys(table.verbs)) if (!used.has(v)) throw new FieldTableError(`verbs.${v}: writes no field`); return table; } function freeze(x) { if (x && typeof x === 'object') { for (const v of Object.values(x)) freeze(v); Object.freeze(x); } return x; } // The committed table, checked and frozen. A file that fails the check refuses to load. export function loadFields(file = FIELDS_FILE) { let table; try { table = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new FieldTableError(e instanceof SyntaxError ? 'fields.json: not JSON' : `fields.json: ${e.code ?? 'unreadable'}`); } return freeze(checkFields(table)); }