Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/flake.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: flake

# Builds the Nix derivation — the reproducible-CLI artifact this kit ships.
#
# WHY THIS EXISTS
# ---------------
# flake.nix declares every ck-* gate as a hash-pinned CLI, and `npmDepsHash`
# content-addresses the ENTIRE dependency tree. That is what makes a consuming
# site's vendored gate deterministic: the deps are fixed by hash, not re-resolved
# at install time.
#
# Nothing built it. The consequences were already visible when this job was
# added: flake.nix said version 0.8.0 while package.json said 0.11.0 — three
# minors of drift — and any change to package-lock.json silently invalidated
# npmDepsHash with nothing to report it. A reproducible-build path that is never
# built is a claim, not a guarantee, and it decays in exactly one direction.
#
# This job is the forcing function. `nix build` fails loudly on a stale
# npmDepsHash and prints the correct one, so a dependency change either updates
# the hash or does not merge.
on:
pull_request:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: flake-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22

- name: Version agreement (flake.nix vs package.json)
# Cheap, and it is the drift that revealed nobody was building this.
run: |
flake_v=$(sed -n 's/.*version = "\([^"]*\)".*/\1/p' flake.nix | head -1)
pkg_v=$(node -p "require('./package.json').version")
echo "flake.nix=$flake_v package.json=$pkg_v"
if [ "$flake_v" != "$pkg_v" ]; then
echo "::error::flake.nix version ($flake_v) != package.json version ($pkg_v)"
exit 1
fi

- name: nix build
# Fails on a stale npmDepsHash and prints the correct value, so a
# lockfile change cannot land while leaving the derivation unbuildable.
run: nix build --print-build-logs .

- name: The built CLI actually runs
# Building is not evidence the artifact works — the same distinction the
# gates' own fixtures draw. Exercises the shipped binary, including the
# SHACL-SPARQL path, against the repo's fixtures.
run: |
./result/bin/ck-shacl-runner fixtures/jsonld.shapes.ttl --turtle fixtures/dataset.conforming.ttl
./result/bin/ck-shacl-runner fixtures/sparql.shapes.ttl --turtle fixtures/sparql.conforming.ttl
# And it must still be able to FAIL from the built artifact, not only
# from the source tree.
if ./result/bin/ck-shacl-runner fixtures/sparql.shapes.ttl --turtle fixtures/sparql.violating.ttl; then
echo "::error::built ck-shacl-runner did not reject a cyclic graph — SHACL-SPARQL is not enabled in the artifact"
exit 1
fi
echo "built CLI: conforming passes, cyclic is rejected"
4 changes: 4 additions & 0 deletions .release/shacl-engine-swap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
bump: minor
---
`ck-shacl-runner` now executes SHACL-SPARQL constraints. The previous validator (`rdf-validate-shacl`) threw `Cannot find validator for constraint component sh#SPARQLConstraintComponent` on any `sh:sparql` shape at every version, so cross-row invariants — referential agreement, acyclicity, "this status implies that relationship" — could not be enforced at all; only per-node structure could. Swapped to `shacl-engine` (same RDF/JS stack, same `@zazuko/env-node` the kit already uses) with its SPARQL validations and target resolvers enabled, plus a required-to-fail cyclic fixture so that opt-in cannot be dropped silently. Report output and exit codes are unchanged.
25 changes: 25 additions & 0 deletions fixtures/sparql.conforming.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# An acquaintance chain with no cycle — satisfies fixtures/sparql.shapes.ttl.
#
# This fixture's job is to prove the SPARQL constraint RUNS rather than merely
# that it can fail. Before the engine swap the runner THREW on any sh:sparql
# shape, so this graph would have exited non-zero with an infrastructure error
# rather than reporting conforms: true.
#
# Three hops, so the transitive path has something to traverse: a → b → c.

@prefix schema: <https://schema.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<https://fixture.example/person/a> a schema:Person ;
schema:name "Ada"^^xsd:string ;
schema:url <https://fixture.example/a> ;
schema:knows <https://fixture.example/person/b> .

<https://fixture.example/person/b> a schema:Person ;
schema:name "Bea"^^xsd:string ;
schema:url <https://fixture.example/b> ;
schema:knows <https://fixture.example/person/c> .

<https://fixture.example/person/c> a schema:Person ;
schema:name "Cai"^^xsd:string ;
schema:url <https://fixture.example/c> .
40 changes: 40 additions & 0 deletions fixtures/sparql.shapes.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# A SHACL-SPARQL contract — shapes that CORE SHACL CANNOT EXPRESS.
#
# The constraint below walks a transitive property path (`schema:knows+`) to find
# nodes reachable from themselves. Core SHACL has no way to say that: sh:disjoint,
# sh:not and friends compare values, they cannot traverse. Cross-row invariants
# like referential agreement, acyclicity, and "this status implies that
# relationship" all land here — which is why a runner that cannot execute
# sh:sparql can enforce only per-node structure.
#
# This file is a FIXTURE, not a kit file: it exists so the suite can prove the
# runner executes SPARQL-based constraints at all. `rdf-validate-shacl`, which
# this gate used before, threw on it at every version:
# Error: Cannot find validator for constraint component sh#SPARQLConstraintComponent
#
# NOTE the sh:prefixes block. SHACL-SPARQL requires a SPARQL-based constraint to
# declare its own prefixes — the @prefix lines below are Turtle syntax and do not
# reach inside the sh:select string. Some engines (pyshacl) resolve them from the
# shapes graph anyway; shacl-engine, correctly, does not: without this it fails
# with `Unknown prefix: schema`.

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix schema: <https://schema.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

schema:prefixes
sh:declare [
sh:prefix "schema" ;
sh:namespace "https://schema.org/"^^xsd:anyURI ;
] .

schema:NoAcquaintanceCycleShape
a sh:NodeShape ;
sh:targetClass schema:Person ;
sh:severity sh:Violation ;
sh:sparql [
sh:prefixes schema:prefixes ;
sh:message "Person is reachable from themselves via schema:knows" ;
sh:select """
SELECT $this WHERE { $this schema:knows+ $this }""" ;
] .
36 changes: 36 additions & 0 deletions fixtures/sparql.violating.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# An acquaintance CYCLE — violates fixtures/sparql.shapes.ttl.
#
# THIS IS THE FIXTURE THAT MUST FAIL, and it guards a specific, measured hazard.
#
# shacl-engine ships core validations only; SHACL-SPARQL is opt-in via
# `targetResolvers` and `validations` from `shacl-engine/sparql.js`. Drop those
# two constructor options and the engine does not warn and does not throw — it
# silently skips every sh:sparql shape. Measured on exactly this shape of graph:
#
# default config → conforms: TRUE ← the cycle is invisible
# with the sparql opt-in → conforms: false, cycle reported
#
# A gate that reports conforms:true on a cyclic graph is worse than no gate: it
# launders the absence of checking into a pass. So the suite requires this file
# to be REJECTED, which makes removing the opt-in impossible to do quietly.
#
# Three-node cycle rather than two, so it cannot be caught by a naive
# one-hop/self-reference check that never exercises the transitive path.

@prefix schema: <https://schema.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<https://fixture.example/person/x> a schema:Person ;
schema:name "Xan"^^xsd:string ;
schema:url <https://fixture.example/x> ;
schema:knows <https://fixture.example/person/y> .

<https://fixture.example/person/y> a schema:Person ;
schema:name "Yol"^^xsd:string ;
schema:url <https://fixture.example/y> ;
schema:knows <https://fixture.example/person/z> .

<https://fixture.example/person/z> a schema:Person ;
schema:name "Zed"^^xsd:string ;
schema:url <https://fixture.example/z> ;
schema:knows <https://fixture.example/person/x> .
6 changes: 4 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
version = "0.8.0";
version = "0.11.0";

# Every ck-* bin the package.json declares (kept in sync with "bin").
bins = [
Expand Down Expand Up @@ -49,7 +49,9 @@
pname = "conformance-kit";
inherit version;
src = ./.;
npmDepsHash = "sha256-cnzJA3NEG9ZkB2dZzIyXFJKjJmX8czariuTi7NjYg40=";
# Recomputed when package-lock.json changes; the `flake` CI job fails
# with the correct value if this is stale.
npmDepsHash = "sha256-MltNN6K9iTNhpgOJLUlt8JtJRJLmnPEbdUWElVFwqww=";
dontNpmBuild = true; # the kit has no build step (pure .mjs)

nativeBuildInputs = [ pkgs.makeWrapper ];
Expand Down
44 changes: 35 additions & 9 deletions gates/shacl-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ import { join, resolve } from "node:path";

import jsonld from "jsonld";
import { Parser as N3Parser } from "n3";
import rdf from "@zazuko/env-node"; // RDF/JS env with .dataset() + clownface (required by rdf-validate-shacl)
import SHACLValidator from "rdf-validate-shacl";
import rdf from "@zazuko/env-node"; // RDF/JS env with .dataset() + clownface
import { Validator as SHACLValidator } from "shacl-engine";
// SHACL-SPARQL support is OPT-IN and silently absent without these two options —
// see the constructor below. Importing them here so the coupling is visible.
import { targetResolvers, validations } from "shacl-engine/sparql.js";

const USAGE = "usage: shacl-runner <shapes.ttl> ( <htmlDir> | --turtle <file> | --jsonld <file> )";

Expand Down Expand Up @@ -130,14 +133,31 @@ async function turtleToDataset(ttl) {
return rdf.dataset(quads);
}

/**
* Render a result's property path.
*
* shacl-engine reports a path as an array of segments carrying `predicates`,
* where rdf-validate-shacl reported a single term. Both are handled so the
* printed line is unchanged by the engine swap — consumers read these logs.
*/
function pathLabel(path) {
if (!path) return "(node)";
if (path.value) return path.value;
if (Array.isArray(path)) {
const segs = path.map((s) => (s.predicates ?? []).map((t) => t.value).join("|")).filter(Boolean);
if (segs.length) return segs.join("/");
}
return "(node)";
}

/** Print one violation per line, identically for whichever input produced it. */
function printViolations(report) {
for (const r of report.results) {
const path = r.path?.value ?? "(node)";
const focus = r.focusNode?.value ?? "(?)";
const shape = r.sourceShape?.value ?? "";
const msg = r.message?.map((m) => m.value).join("; ") || r.sourceConstraintComponent?.value || "violation";
console.log(` ✗ ${focus} [${path}] ${msg} <${shape}>`);
const shape = r.shape?.ptr?.value ?? r.sourceShape?.value ?? "";
const component = r.constraintComponent?.value ?? r.sourceConstraintComponent?.value;
const msg = r.message?.map((m) => m.value).join("; ") || component || "violation";
console.log(` ✗ ${focus} [${pathLabel(r.path)}] ${msg} <${shape}>`);
}
}

Expand All @@ -151,7 +171,7 @@ async function validateDataset(validator) {
const raw = await readFile(DIST, "utf8");
const data = mode === "turtle" ? await turtleToDataset(raw) : await jsonLdToDataset(JSON.parse(raw));

const report = validator.validate(data);
const report = await validator.validate({ dataset: data });
const rel = inputPath;
if (report.conforms) {
console.log(` ${rel}: ${data.size} quad(s) — conforms: true`);
Expand All @@ -176,7 +196,13 @@ async function main() {

const shapesTtl = await readFile(SHAPES, "utf8");
const shapes = await turtleToDataset(shapesTtl);
const validator = new SHACLValidator(shapes, { factory: rdf });
// `targetResolvers` and `validations` enable SHACL-SPARQL. WITHOUT THEM the
// engine does not warn and does not throw — it silently SKIPS every sh:sparql
// shape and reports conforms: true. Measured on a graph whose only defect was
// a dependency cycle: default config returned conforms:true, with the opt-in
// it returned 4 results. fixtures/sparql.* + the required-to-fail test exist
// to make dropping these two options impossible to do quietly.
const validator = new SHACLValidator(shapes, { factory: rdf, targetResolvers, validations });

if (mode !== "html") return validateDataset(validator);

Expand All @@ -200,7 +226,7 @@ async function main() {
for (const q of ds) data.add(q);
}

const report = validator.validate(data);
const report = await validator.validate({ dataset: data });
if (report.conforms) {
console.log(` ${rel}: ${blocks.length} block(s) — conforms: true`);
} else {
Expand Down
Loading
Loading