feat(browse): unify the .zddc lint onto the JSON schema (baked, single source)

The .zddc lint used a hand-kept TOP_KEYS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS list
with bespoke type tags — a parallel grammar that drifted from the Go structs
(we'd already had to patch in party_source/history/etc.). Replace it with one
schema-driven validator over the same JSON Schema that now drives completion +
hover, so lint/completion/hover/form all share ONE grammar.

- Bake zddc.schema.json into the bundle at build time (window.__ZDDC_SCHEMA__),
  the exact file the server serves at /.api/zddc-schema. Synchronous + works
  offline (file://), where that endpoint is unreachable — drops the runtime
  fetch entirely.
- validateZddc() now walks the parsed doc against the schema: type, enum,
  pattern, properties, additionalProperties (false|schema), patternProperties,
  items, and the recursive $ref:"#" (paths:). Same {keyPath,severity,message}
  shape, so findLine + the CM lint helper are unchanged.
- Delete TOP_KEYS/ALLOWED_TOOLS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS + walkObject/
  checkValue/addTypeErr. preview-yaml completion now reads getZddcSchema too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-08 09:30:17 -05:00
parent a13ce12a75
commit d44e1b01bf
2 changed files with 84 additions and 210 deletions

View file

@ -14,9 +14,21 @@ ensure_exists "$src_html"
css_temp=$(mktemp)
js_raw=$(mktemp)
js_temp=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
# Generated schema lives under dist/ (gitignored); concat_files resolves paths
# relative to $root_dir, so we pass the relative form.
schema_rel="dist/.zddc-schema.gen.js"
schema_js="$root_dir/$schema_rel"
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$schema_js"; }
trap cleanup EXIT
# Bake the .zddc JSON Schema into the bundle so the lint + completion + hover
# all share ONE grammar (no hand-kept key list to drift from the Go structs)
# AND work offline (file://), where /.api/zddc-schema is unreachable. This is
# the exact file the server serves at that endpoint.
schema_src="$root_dir/../zddc/internal/zddc/zddc.schema.json"
ensure_exists "$schema_src"
{ printf 'window.__ZDDC_SCHEMA__ = '; cat "$schema_src"; printf ';\n'; } > "$schema_js"
# CSS files: shared base first, then browse-specific. Toast UI's CSS
# is bundled because the markdown plugin uses Toast UI inside the
# preview pane (.md files render as a full editor).
@ -67,6 +79,7 @@ concat_files \
"../shared/icons.js" \
"../shared/zddc-source.js" \
"js/init.js" \
"$schema_rel" \
"js/util.js" \
"js/yaml-complete.js" \
"js/conflict.js" \

View file

@ -25,23 +25,6 @@
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
// Cached .zddc JSON Schema (the machine grammar) — fetched once from
// /.api/zddc-schema and used to drive completion + hover on .zddc files.
// null = not loaded; {} on fetch failure. The promise dedupes fetches.
var zddcSchema = null;
var zddcSchemaPromise = null;
function loadZddcSchema() {
if (zddcSchema !== null) return;
if (!zddcSchemaPromise) {
zddcSchemaPromise = fetch('/.api/zddc-schema', {
headers: { 'Accept': 'application/schema+json, application/json' },
credentials: 'same-origin'
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { zddcSchema = j || {}; })
.catch(function () { zddcSchema = {}; });
}
}
// ── Filename routing ────────────────────────────────────────────────────
// True for .zddc cascade files — `.zddc` (literal name, no ext)
@ -107,49 +90,10 @@
// any level surface as warnings — typos like `defaul_tool` are
// common and the cascade silently ignores them.
var ALLOWED_TOOLS = {
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
tables: 1, form: 1
};
var TOP_KEYS = {
title: 'string',
acl: 'acl',
admins: 'string[]',
roles: 'rolemap',
available_tools: 'tools[]',
default_tool: 'tool',
dir_tool: 'tool',
auto_own: 'bool',
auto_own_fenced: 'bool',
virtual: 'bool',
drop_target: 'bool',
worm: 'string[]',
paths: 'pathmap',
display: 'stringmap',
tables: 'stringmap',
views: 'viewmap',
convert: 'convert',
created_by: 'string',
inherit: 'bool',
// Keys the Go decoder (zddc/internal/zddc/file.go) accepts that the
// lint was missing — flagged valid configs as "unknown key".
party_source: 'string',
history: 'bool',
history_globs: 'string[]',
records: 'object',
auto_own_roles: 'string[]',
received_path: 'string',
planned_response_date: 'string',
planned_review_date: 'string',
field_codes: 'object'
};
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
allow: 'string[]', deny: 'string[]' };
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
var CONVERT_KEYS = { client: 'string', project: 'string',
contractor: 'string', project_number: 'string' };
// The valid keys, types, enums and nesting are NOT hand-listed here any
// more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__,
// the same grammar the server serves at /.api/zddc-schema and that drives
// completion + hover). One source, no drift. See validateZddcSchema below.
function typeOf(v) {
if (v === null || v === undefined) return 'null';
@ -157,170 +101,89 @@
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
}
// Collect schema issues for a parsed .zddc document. Each issue is
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
// keyPath is used by findLine() to locate the offending source line.
// The .zddc JSON Schema, baked into the bundle at build time
// (window.__ZDDC_SCHEMA__ — the same file the server serves at
// /.api/zddc-schema). Single source for lint, completion and hover; works
// offline. Synchronous, so the lint helper can use it directly.
function getZddcSchema() {
return (window.__ZDDC_SCHEMA__ && window.__ZDDC_SCHEMA__.properties)
? window.__ZDDC_SCHEMA__ : {};
}
// Validate a parsed .zddc document against the JSON Schema, producing
// { keyPath, severity, message } issues (mapped to source lines by
// findLine). Covers the draft-2020-12 subset .zddc uses: type, enum,
// properties, additionalProperties (false | schema), patternProperties,
// items, pattern, and the recursive $ref:"#" (paths:).
function validateZddc(doc) {
var schema = getZddcSchema();
var issues = [];
if (!schema || !schema.properties) return issues; // schema unavailable
if (typeOf(doc) === 'null') return issues;
if (typeOf(doc) !== 'object') {
issues.push({ keyPath: [], severity: 'error',
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
return issues;
}
walkObject(doc, TOP_KEYS, [], issues);
function deref(n) { return (n && n.$ref === '#') ? schema : n; }
function typeOk(t, want) {
if (Array.isArray(want)) {
for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true;
return false;
}
if (want === 'integer' || want === 'number') return t === 'number';
return t === want;
}
function walk(value, sch, path) {
sch = deref(sch);
if (!sch) return;
var t = typeOf(value);
if (t === 'null') return; // empty value mid-edit — don't flag
if (sch.type && !typeOk(t, sch.type)) {
issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type)
+ ', got ' + t + '.' });
return;
}
if (sch.enum && sch.enum.map(String).indexOf(String(value)) === -1) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown value "' + value + '". Allowed: ' + sch.enum.join(', ') + '.' });
}
if (sch.pattern && t === 'string' && !new RegExp(sch.pattern).test(value)) {
issues.push({ keyPath: path, severity: 'error',
message: 'Value "' + value + '" must match ' + sch.pattern + '.' });
}
if (t === 'object') {
var props = sch.properties || {};
for (var k in value) {
if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
var kp = path.concat([k]);
if (props[k]) { walk(value[k], props[k], kp); continue; }
var ap = sch.additionalProperties;
if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; }
if (sch.patternProperties) {
var matched = null;
for (var p in sch.patternProperties) {
if (Object.prototype.hasOwnProperty.call(sch.patternProperties, p)
&& new RegExp(p).test(k)) { matched = sch.patternProperties[p]; break; }
}
if (matched) { walk(value[k], matched, kp); continue; }
}
if (ap === false) {
issues.push({ keyPath: kp, severity: 'warning',
message: 'Unknown key "' + k + '" — not in the .zddc schema; it will be ignored.' });
}
}
} else if (t === 'array' && sch.items) {
for (var i = 0; i < value.length; i++) {
walk(value[i], sch.items, path.concat([String(i)]));
}
}
}
walk(doc, schema, []);
return issues;
}
function walkObject(obj, schema, path, issues) {
for (var key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
var here = path.concat([key]);
var kind = schema[key];
if (!kind) {
issues.push({ keyPath: here, severity: 'warning',
message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
continue;
}
checkValue(obj[key], kind, here, issues);
}
}
function checkValue(val, kind, path, issues) {
var t = typeOf(val);
switch (kind) {
case 'string':
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'bool':
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'string[]':
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'tools[]':
if (t !== 'array' && t !== 'null') {
addTypeErr(path, kind, t, issues); return;
}
if (t === 'array') {
for (var i = 0; i < val.length; i++) {
if (typeOf(val[i]) !== 'string') {
issues.push({ keyPath: path, severity: 'error',
message: 'available_tools[' + i + '] must be a string.' });
} else if (!ALLOWED_TOOLS[val[i]]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val[i]
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
}
}
return;
case 'tool':
if (t === 'null') return;
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
if (!ALLOWED_TOOLS[val]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val + '". Known: '
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
return;
case 'stringmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var k in val) {
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
if (typeOf(val[k]) !== 'string') {
issues.push({ keyPath: path.concat([k]), severity: 'error',
message: 'Value must be a string (got '
+ typeOf(val[k]) + ').' });
}
}
return;
case 'pathmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var seg in val) {
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
if (seg.indexOf('/') !== -1) {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'Path keys must be a single segment — '
+ 'nest blocks instead of using "' + seg + '".' });
}
var v = val[seg];
if (typeOf(v) === 'null') continue;
if (typeOf(v) !== 'object') {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'paths.' + seg + ' must be a map of cascade rules.' });
continue;
}
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
}
return;
case 'viewmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var shape in val) {
if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
issues.push({ keyPath: path.concat([shape]), severity: 'warning',
message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
}
var vv = val[shape];
if (typeOf(vv) !== 'object') {
issues.push({ keyPath: path.concat([shape]), severity: 'error',
message: 'views.' + shape + ' must be a map ({tool, config}).' });
continue;
}
if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
message: 'views.' + shape + '.tool should be a known tool ('
+ Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
}
if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
message: 'views.' + shape + '.config must be a filename string.' });
}
}
return;
case 'rolemap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var rn in val) {
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
var rv = val[rn];
if (typeOf(rv) === 'null') continue;
if (typeOf(rv) !== 'object') {
issues.push({ keyPath: path.concat([rn]), severity: 'error',
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
continue;
}
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
}
return;
case 'acl':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, ACL_KEYS, path, issues);
return;
case 'convert':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, CONVERT_KEYS, path, issues);
return;
case 'object':
// Free-form map (records, field_codes) — the server accepts any
// nested shape, so we only check it's a mapping, not its keys.
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
return;
}
}
function addTypeErr(path, expected, got, issues) {
issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + expected + ', got ' + got + '.' });
}
// Locate the source line for a key path. .zddc files are
// block-style YAML in practice (no flow style, no anchors), so a
// simple indent-aware scan works: for each segment, find a line
@ -557,9 +420,7 @@
// Plain .yaml gets no schema (lint + highlighting only).
var yc = window.app.modules.yamlComplete;
if (yc && isZddcFile(node.name)) {
loadZddcSchema();
yc.attach(editor, yc.schemaProvider(function () { return zddcSchema || {}; }),
{ readOnly: !writable });
yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable });
}
currentEditor = editor;
currentNodeRef = node;