Files
natural-language-report-ser…/src/runner/check.js
2026-09-27 15:56:31 +02:00

426 lines
17 KiB
JavaScript

import { startShape, fieldsOf } from './profile.js';
import { NUMERIC_DATATYPES } from './assemble.js';
import { sharedSteps } from './spec.js';
const SH = 'http://www.w3.org/ns/shacl#';
const REP = 'http://mu.semte.ch/vocabularies/reporting/';
const XSD = 'http://www.w3.org/2001/XMLSchema#';
const XSD_DATE = XSD + 'date';
const XSD_DATETIME = XSD + 'dateTime';
// The validator. checkSpec runs the checks in order and returns the first
// error, or null when the spec is fine. Every message names the thing that
// is wrong in the profile's own words and says what to write instead. No
// database call anywhere in this file.
const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
const ANYOF_MAX_TERMS = 24;
const ANYOF_MAX_TERM_LENGTH = 100;
// null when the profile exists, else what to write instead.
export function profileError(profiles, uri) {
if (profiles.has(uri)) return null;
return `no profile <${uri}>. Available: ${[...profiles.values()].map(p => p.title).join(', ')}.`;
}
// The spec names an entity we have: by rep:entity, or by a targetClass only
// one entity carries. A class two entities share is refused with both names,
// so the model can add rep:entity instead of guessing.
function startEntity(spec, profile) {
if (!spec.targetClass) {
return `no sh:targetClass. In "${profile.title}" you can list: ${shapeList(profile)}.`;
}
if (spec.entity) {
const s = profile.shapes.find(x => x.uri === spec.entity);
if (!s) {
return `no entity <${spec.entity}> in "${profile.title}". It has: ${shapeList(profile)}.`;
}
if (s.targetClass !== spec.targetClass) {
return `rep:entity <${spec.entity}> is a <${s.targetClass}>, but sh:targetClass says <${spec.targetClass}>. Make them agree.`;
}
return null;
}
const matches = profile.shapes.filter(s => s.targetClass === spec.targetClass);
if (matches.length === 1) return null;
if (!matches.length) {
return `"${profile.title}" cannot list <${spec.targetClass}>. It has: ${shapeList(profile)}.`;
}
const names = matches.map(m => `${m.label || m.uri} <${m.uri}>`).join(', ');
return `<${spec.targetClass}> is the class of ${matches.length} entities: ${names}. Add rep:entity <...> to say which one.`;
}
function shapeList(profile) {
return profile.shapes.map(s => s.label || s.targetClass).join(', ');
}
// Every condition in the spec: the filters, and the rep:where conditions on
// filters and columns, with the words that name each one in a message. A
// rep:where also carries its host, the filter or column it hangs on.
function conditions(spec) {
const out = spec.filters.map((f, i) => ({ what: `filter ${i + 1}`, cond: f }));
spec.filters.forEach((f, i) => f.where.forEach((w, j) => out.push({
what: `rep:where ${j + 1} on filter ${i + 1}`, cond: w,
host: { what: `filter ${i + 1}`, path: f.path },
})));
spec.columns.forEach(c => c.where.forEach((w, j) => out.push({
what: `rep:where ${j + 1} on column "${c.label}"`, cond: w,
host: { what: `column "${c.label}"`, path: c.path },
})));
return out;
}
// Columns exist
function columnsPresent(spec) {
if (!spec.columns.length) return 'no columns. Add at least one rep:columns entry.';
return null;
}
// Every column has a label
function columnLabel(spec) {
for (const col of spec.columns) {
if (!col.label) return 'a column has no rdfs:label.';
}
return null;
}
// Labels are unique
function columnLabelsUnique(spec) {
const seen = new Set();
for (const col of spec.columns) {
if (seen.has(col.label)) {
return `two columns are called "${col.label}". Labels must differ.`;
}
seen.add(col.label);
}
return null;
}
// Every path resolves, columns end on a value, depth capped
function pathsResolve(spec, profile) {
const startShapeUri = startShape(profile, spec).uri;
for (const col of spec.columns) {
const err = walk(profile, startShapeUri, col.path,
`column "${col.label}"`, true, col.nodeKind === SH + 'IRI');
if (err) return err;
}
for (const [i, filter] of spec.filters.entries()) {
const err = walk(profile, startShapeUri, filter.path,
`filter ${i + 1}`, false);
if (err) return err;
}
return null;
}
// sh:min/sh:max only on numbers and dates
function collectMinMaxTyped(spec, profile) {
const startShapeUri = startShape(profile, spec).uri;
for (const col of spec.columns) {
if (col.collect !== SH + 'min' && col.collect !== SH + 'max') continue;
const name = col.collect === SH + 'min' ? 'sh:min' : 'sh:max';
if (!col.path.length) return `${name} on a rep:self column does not apply. A subject URI is not a number or date.`;
const last = col.path[col.path.length - 1];
// no end field: pathsResolve already reported the real error
const f = endFields(profile, startShapeUri, col.path)
.find(x => x.datatype !== XSD_DATETIME && x.datatype !== XSD_DATE && !NUMERIC_DATATYPES.has(x.datatype));
if (f) {
return `"${f.name || last.predicate}" is text, so ${name} does not apply. Use rep:row (same as leaving rep:collect out: every value gets its own row).`;
}
}
return null;
}
// At most one rep:row column
function oneRowColumn(spec) {
const rowCols = spec.columns.filter(c => c.collect === REP + 'row');
if (rowCols.length > 1) {
const names = rowCols.map(c => `"${c.label}"`).join(' and ');
return `only one column may use rep:row. ${names} both do.`;
}
return null;
}
// Columns carry no constraints; known collect mode
function columnPure(spec) {
for (const col of spec.columns) {
if (Object.keys(col.constraints || {}).length) {
const which = Object.keys(col.constraints)[0];
return `a column carries ${which}. Constraints belong in sh:property, or in rep:where for a condition on a step of this column.`;
}
if (col.nodeKind && col.nodeKind !== SH + 'IRI') {
return `column "${col.label}" has sh:nodeKind <${col.nodeKind}>. A column takes only sh:nodeKind sh:IRI, to show the URI of the node its path ends on.`;
}
if (col.collect && ![SH + 'groupConcat', SH + 'min', SH + 'max', REP + 'row'].includes(col.collect)) {
return `column "${col.label}" has an unknown rep:collect <${col.collect}>. Use sh:groupConcat (joins the values), rep:row or nothing (each value gets its own row), sh:min or sh:max.`;
}
}
return null;
}
// No label inside a filter or rep:where
function filterLabel(spec) {
for (const { what, cond } of conditions(spec)) {
if ((cond.constraints || {}).label) {
return `${what} carries rdfs:label. Move it to rep:columns to show it.`;
}
}
return null;
}
// A filter with a path but no constraint selects everything; the model
// almost certainly meant to filter. Refuse and say what to add.
function filterConstraint(spec) {
const MEANINGFUL = ['minCount', 'maxCount', 'hasValue', 'in', 'minInclusive',
'maxInclusive', 'minExclusive', 'maxExclusive', 'pattern', 'flags', 'anyOf'];
for (const { what, cond } of conditions(spec)) {
const c = cond.constraints || {};
if (!MEANINGFUL.some(k => c[k] !== undefined && c[k] !== null)) {
const p = cond.path.map(h => h.predicate).join(' → ');
return `${what} (path ${p}) has no condition. Add one: sh:hasValue for an exact value, sh:in for a list, rep:anyOf for words, or remove the filter.`;
}
}
return null;
}
// sh:maxCount above 0 needs counting
function maxCountZero(spec) {
for (const { cond } of conditions(spec)) {
const c = cond.constraints;
if (c.maxCount > 0) {
return `sh:maxCount ${c.maxCount} needs counting, which this service cannot do. Use sh:maxCount 0 for "has none", or drop it.`;
}
if (c.minCount > 2) {
return `sh:minCount ${c.minCount} needs counting. Use sh:minCount 1 (at least one) or sh:minCount 2 (two distinct values); anything higher is beyond this service.`;
}
}
return null;
}
// rep:anyOf size limits
function anyOfLimits(spec) {
for (const { cond } of conditions(spec)) {
const c = cond.constraints;
if (!c.anyOf) continue;
if (c.anyOf.length > ANYOF_MAX_TERMS) {
return `rep:anyOf has ${c.anyOf.length} terms. The limit is ${ANYOF_MAX_TERMS}. Narrow the words, or split the filter into two sh:property blocks.`;
}
for (const term of c.anyOf) {
if (term.type === 'uri') {
return 'a rep:anyOf term is a URI. rep:anyOf takes Dutch words, not URIs.';
}
if (term.value && term.value.length > ANYOF_MAX_TERM_LENGTH) {
return `a rep:anyOf term is ${term.value.length} characters. The limit is ${ANYOF_MAX_TERM_LENGTH}.`;
}
}
}
return null;
}
// sh:in must not mix URIs and literals
function inHomogeneous(spec) {
for (const { cond } of conditions(spec)) {
const c = cond.constraints;
if (!c.in) continue;
const uris = c.in.filter(t => t.type === 'uri').length;
if (uris > 0 && uris < c.in.length) {
return 'sh:in mixes URIs and text. Pick one.';
}
}
return null;
}
// Typed literals must parse
function literalsParse(spec) {
const constraintTerms = [];
for (const { cond } of conditions(spec)) {
const c = cond.constraints;
for (const key of ['hasValue', 'minInclusive', 'maxInclusive', 'minExclusive', 'maxExclusive']) {
if (c[key]) constraintTerms.push(c[key]);
}
if (c.in) constraintTerms.push(...c.in);
}
for (const t of constraintTerms) {
if (t.type !== 'literal' || !t.datatype) continue;
if (t.datatype === XSD_DATETIME && !parsesAsDate(t.value, true)) {
return `"${t.value}" is not a valid xsd:dateTime.`;
}
if (t.datatype === XSD_DATE && !parsesAsDate(t.value, false)) {
return `"${t.value}" is not a valid xsd:date.`;
}
}
return null;
}
// rep:where: walks from the row through the profile, shares at least one
// step with its filter or column, and holds no rep:where of its own.
function whereConditions(spec, profile) {
const startShapeUri = startShape(profile, spec).uri;
for (const { what, cond, host } of conditions(spec)) {
if (!host) continue;
if (cond.where.length) {
return `${what} holds a rep:where of its own. Put every rep:where directly on the filter or column.`;
}
const err = walk(profile, startShapeUri, cond.path, what, false);
if (err) return err;
const k = sharedSteps(host.path, cond.path);
if (!k) {
return `${what} shares no step with ${host.what}. Start its path with the same steps as ${host.what}; a condition on the row itself goes in sh:property.`;
}
const c = cond.constraints;
if (k === cond.path.length && (c.maxCount === 0 || c.minCount === 2)) {
return `${what} ends on a step of ${host.what}, so ${c.maxCount === 0 ? 'sh:maxCount 0' : 'sh:minCount 2'} has nothing to count. Add the step that must be missing or repeated.`;
}
}
for (const [i, filter] of spec.filters.entries()) {
if (filter.where.length && filter.constraints.minCount === 2) {
return `filter ${i + 1} combines sh:minCount 2 with rep:where. Use one of the two.`;
}
}
return null;
}
// A date or number condition compares values. On a link (an entity or a
// code) it compares a URI and never matches, so its path must end on a field
// with a datatype.
function rangeOnValue(spec, profile) {
const startShapeUri = startShape(profile, spec).uri;
for (const { what, cond } of conditions(spec)) {
const key = ['minInclusive', 'maxInclusive', 'minExclusive', 'maxExclusive']
.find(k => cond.constraints[k]);
if (!key || !cond.path.length) continue;
// pathsResolve reports a missing step
const f = endFields(profile, startShapeUri, cond.path).find(x => !x.datatype);
if (!f) continue;
const target = f.nodes.length
? f.nodes.map(uri => shapeName(profile, uri)).join(' or ')
: `the code "${f.name}"`;
const steps = [...new Set(f.nodes.flatMap(uri => fieldsOf(profile, uri))
.filter(x => x.datatype === XSD_DATE || x.datatype === XSD_DATETIME || NUMERIC_DATATYPES.has(x.datatype))
.map(x => `${x.name} (${x.path})`))];
const add = steps.length ? `Add a step: ${steps.join(' or ')}.` : 'End the path on a date or number field.';
return `${what} ends on ${target}, not on a value, so sh:${key} never matches. ${add}`;
}
return null;
}
// Where each rep:where applies, in the profile's words, so the proposal can
// say it. Profile only, no database.
export function whereNotes(spec, profile) {
const startShapeUri = startShape(profile, spec).uri;
return conditions(spec).filter(x => x.host).map(({ what, cond, host }) => {
const k = sharedSteps(host.path, cond.path);
return `${what} applies at ${nodeName(profile, startShapeUri, host.path.slice(0, k))}, after step ${k} of ${host.what}.`;
});
}
// The entity a path lands on, or the value field when it ends on one. A
// step that leads to several entities names them all.
function nodeName(profile, startShapeUri, hops) {
if (!hops.length) return shapeName(profile, startShapeUri);
const ends = endFields(profile, startShapeUri, hops);
if (!ends.length) return `step ${hops.length}`;
const value = ends.find(f => !f.nodes.length);
if (value) return `the value "${value.name}"`;
return [...new Set(ends.flatMap(f => f.nodes))].map(uri => shapeName(profile, uri)).join(' or ');
}
function shapeName(profile, shapeUri) {
return `"${profile.shapes.find(s => s.uri === shapeUri)?.label || shapeUri}"`;
}
function parsesAsDate(value, withTime) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return false;
return withTime ? value.includes('T') : /^\d{4}-\d{2}-\d{2}$/.test(value);
}
// In this order: startEntity comes first, the checks after it rely on a
// start shape.
const CHECKS = [
startEntity,
columnsPresent,
columnLabel,
columnLabelsUnique,
columnPure,
filterLabel,
filterConstraint,
maxCountZero,
anyOfLimits,
inHomogeneous,
literalsParse,
oneRowColumn,
pathsResolve,
whereConditions,
rangeOnValue,
collectMinMaxTyped,
];
// checkSpec(spec, profiles) → the first error, or null.
export function checkSpec(spec, profiles) {
const error = profileError(profiles, spec.profileUri);
if (error) return error;
const profile = profiles.get(spec.profileUri);
for (const check of CHECKS) {
const err = check(spec, profile);
if (err) return err;
}
return null;
}
// The fields a hop takes from any of the shapes a path has reached. A field
// may link to several entities (sh:or of sh:node: an eenheid's political and
// leidinggevende organen): the walk follows them all and the next steps
// decide. The query follows predicates only, so this is what the data does.
function hopFields(profile, shapeUris, hop) {
return shapeUris.flatMap(uri => fieldsOf(profile, uri)
.filter(f => f.path === hop.predicate && !!f.inverse === !!hop.inverse));
}
// The fields a path can end on: follow sh:node through every hop but the
// last. walk() has already validated the path, so none here means the walk
// reported the real error and the caller can bail.
function endFields(profile, startShapeUri, hops) {
let shapeUris = [startShapeUri];
for (const hop of hops.slice(0, -1)) {
shapeUris = [...new Set(hopFields(profile, shapeUris, hop).flatMap(f => f.nodes))];
}
return hopFields(profile, shapeUris, hops[hops.length - 1]);
}
function walk(profile, startShapeUri, hops, what, isColumn, wantsIri = false) {
if (hops.length > MAX_PATH_DEPTH) {
return `${what} walks ${hops.length} hops. The limit is ${MAX_PATH_DEPTH}.`;
}
if (!hops.length) return null;
let shapeUris = [startShapeUri];
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
const found = hopFields(profile, shapeUris, hop);
if (!found.length) {
const have = [...new Set(shapeUris.flatMap(uri => fieldsOf(profile, uri))
.map(x => (x.inverse ? `${x.name} (${x.path}, inverse)` : `${x.name} (${x.path})`)))].join(', ');
const shapeLabel = shapeUris.map(uri => shapeName(profile, uri)).join(' or ');
return `at ${shapeLabel} there is no "${hop.predicate}". It has: ${have || '(nothing)'}.`;
}
if (i < hops.length - 1) {
const links = found.filter(f => f.nodes.length);
if (!links.length) {
return `${what} hop ${i + 1} <${hop.predicate}> holds a value, not a link. You cannot walk past it.`;
}
shapeUris = [...new Set(links.flatMap(f => f.nodes))];
} else if (isColumn) {
// every field the path can end on must suit the column
for (const f of found) {
if (wantsIri && f.datatype) {
return `${what} has sh:nodeKind sh:IRI but ends on the value "${f.name}", which is no URI. Drop sh:nodeKind.`;
}
if (!wantsIri && !f.datatype && !f.class) {
const valueFields = [...new Set(f.nodes.flatMap(uri => fieldsOf(profile, uri))
.filter(x => x.datatype || x.class)
.map(x => `${x.name} (${x.path})`))];
return `${what} ends on a link. Add a hop: ${valueFields.join(' or ')}. Only if the user asked for the URI of that node itself, add sh:nodeKind sh:IRI to the column instead.`;
}
}
}
}
return null;
}