/** * Parser for the three machine-readable regions of an account's SCHEMA.md. * * Shared because two separate builds need the same answer: the admin UI server * (platform/ui/server/lib/account-root-groups.ts, which groups the /data root * into home/system) and the session manager's standing schema reconcile * (platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts, * which audits declared-versus-on-disk parity). A third reader, * platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs, ships in a separate * deploy tree and keeps its own copy; a parity test asserts the two agree. * * The regions are written by platform/scripts/lib/account-schema-owned-dirs.py * merge(). Their separators differ: * * ontology roots: - `jobs/` - one folder per Job record. (ASCII hyphen) * plugin-owned: - `pages/` — Owned by the … plugin. (em dash) * * .docs/data-portal-folder-index.md records that parsing the ontology region on * the wrong separator matches nothing and reads as "this account has no * deliverables". This parser sidesteps that failure entirely by anchoring on the * backticked `dir/` head rather than the separator, and a test pins the * behaviour so a future edit cannot reintroduce separator sensitivity. */ /** * Non-ontology buckets that are still the operator's own work, so they are home * on every brand. They carry no ontology row because they are not entity * buckets. On a vertical with no `## Top-level node types` table * (schema-knowledge-work) they are the whole of home. * * The membership test is "would the operator go looking for this?", not "who * writes it". `output`, `generated` and `extracted` hold delivered work product; * `sites` holds published websites; `uploads` holds what a client sent in. The * shipped template groups those five under "tool-owned", but that is a rule * about not reorganising their internals — a write constraint, not a reason to * file them behind the platform's own plumbing. What stays out is genuinely * internal: caches, state, logs, secrets, temp, the git and Claude control * dirs, and plugin workspaces. * * Every name here is created at provision (provision-account-dir.sh `_seed_dirs`), * so the standing home-parity audit finds them on disk rather than reporting * them missing. * * Lives here rather than in either consumer because both the /data page and the * standing reconcile audit must agree on the same set; a hand-synced copy in * each build is precisely the drift this shared module exists to prevent. */ export const HOME_FIXED: readonly string[] = Object.freeze([ 'projects', 'contacts', 'documents', 'output', 'generated', 'extracted', 'sites', 'uploads', ]) const ONT_START = '' const ONT_END = '' const PLUGIN_START = '' const PLUGIN_END = '' const ALLOWED_FENCE = '```allowed-top-level' /** * A region line declaring a directory: `- \`/\` …`. The trailing slash * inside the backticks is what excludes a nested-entity line * (`- \`Quote\` records live under \`jobs/\` …`), whose first backtick span is a * bare label. Anything after the closing backtick is ignored, so both * separators parse. */ const DIR_LINE = /^-\s+`([^`/]+)\/`/ export interface SchemaRegions { /** Root bucket dirs from the ontology region, in table order, deduped. */ ontologyRoots: string[] /** Dirs from the plugin-owned region, in file order, deduped. */ pluginOwned: string[] /** Entries of the ```allowed-top-level fence, in file order. */ allowedTopLevel: string[] /** * True when the allowed-top-level fence was found with at least one entry. * That fence is present in every valid account SCHEMA.md (it ships in * platform/templates/account-schema/SCHEMA.md), so its absence means the file * is missing or not an account schema. An EMPTY ontology region is NOT a * parse failure: a vertical with no `## Top-level node types` table (for * example schema-knowledge-work) legitimately projects no buckets. */ parsed: boolean /** * Why `parsed` is false, for the caller's log line. The two states need * different remediation — provision the account versus regenerate its schema * — so a single `status=parse-failed` cannot be acted on. */ reason: 'ok' | 'schema-absent' | 'fence-absent' } function regionLines(text: string, start: string, end: string): string[] { const from = text.indexOf(start) if (from === -1) return [] const to = text.indexOf(end, from) if (to === -1) return [] return text.slice(from + start.length, to).split('\n') } function dirsIn(lines: string[]): string[] { const out: string[] = [] for (const line of lines) { const m = DIR_LINE.exec(line.trim()) if (m && !out.includes(m[1])) out.push(m[1]) } return out } function fenceEntries(text: string): string[] { const from = text.indexOf(ALLOWED_FENCE) if (from === -1) return [] const bodyStart = text.indexOf('\n', from) if (bodyStart === -1) return [] const to = text.indexOf('```', bodyStart) if (to === -1) return [] return text .slice(bodyStart + 1, to) .split('\n') .map((s) => s.trim()) .filter((s) => s.length > 0) } export function parseSchemaRegions(schemaMd: string | null): SchemaRegions { if (schemaMd === null || schemaMd.length === 0) { return { ontologyRoots: [], pluginOwned: [], allowedTopLevel: [], parsed: false, reason: 'schema-absent' } } const allowedTopLevel = fenceEntries(schemaMd) const parsed = allowedTopLevel.length > 0 return { ontologyRoots: dirsIn(regionLines(schemaMd, ONT_START, ONT_END)), pluginOwned: dirsIn(regionLines(schemaMd, PLUGIN_START, PLUGIN_END)), allowedTopLevel, parsed, reason: parsed ? 'ok' : 'fence-absent', } }