Files
stack/packages/tasks/src/fields.mjs
T
jason.woltjeandClaude Opus 5.5 2a9b77b56b tasks, webui: field table check, line length and bidi escapes (#1596)
Row 102, the row 88 follow-up. Built by a Haiku subagent under
Researcher (decision 129), packet agents/researcher/work/queue-102/,
row102.patch in candidate manifest
8dedcec601ee79a08b4421842af694c0acdae6af3da31b26505c581d3dbc5558.
Filbert approved on #1596, comment 28251. Mos gave the go for the one
public/ line, bus.js's UNSEEN pattern (decision 123, sage-0140).

Sage's gate rerun on 3a1e11b83 with rows 85, 102 and 103 applied,
Docker up: node suites bus 128, business 67, cli 165, control-board
124, conversation 182, discord 178, harness 73, ledger 78, mosaic 69,
queue 155, runs 43, seat 113, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:34:47 -05:00

80 lines
4.4 KiB
JavaScript

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 Field table in
// docs/plans/2026-10-04_slice-1.md is the reviewed prose it must match, and tests/fields.test.mjs
// checks that prose and this table against what the verbs do. For the prose, it reads the rows of
// the Field table and requires that every field name in a row is a label here and that the
// task verbs a row names are exactly the verbs this table lists for that field.
// 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));
}