diff --git a/.github/workflows/main-pr-validation.yml b/.github/workflows/main-pr-validation.yml index 5fdff49..912ce0b 100644 --- a/.github/workflows/main-pr-validation.yml +++ b/.github/workflows/main-pr-validation.yml @@ -8,57 +8,19 @@ on: - 'shapes/**/*.ttl' jobs: - main-pr-validation: + validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - with: - fetch-depth: 0 # needed to compare with origin/main - - name: Set up Python - uses: actions/setup-python@v5 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - python-version: "3.11" + node-version-file: .nvmrc - name: Install dependencies - run: | - pip install pyshacl rdflib rich - - - name: Determine changed TTL files - id: changes - run: | - git fetch origin main:main || true - BASE=$(git merge-base HEAD main || echo "main") - - changed_files=$(git diff --name-only "$BASE"...HEAD | grep '^shapes/.*\.ttl$' || true) - - echo "Changed TTL files:" - echo "$changed_files" - # Output to GitHub Actions - echo "changed_files<> $GITHUB_OUTPUT - echo "$changed_files" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Validate SHACL shapes for changed files - if: steps.changes.outputs.changed_files != '' - run: | - echo "Validating changed TTL files:" - echo "${{ steps.changes.outputs.changed_files }}" - ok=true - for file in ${{ steps.changes.outputs.changed_files }}; do - echo "───────────────────────────────" - echo "Validating $file..." - python scripts/validate-shacl-shapes-file.py "$file" || ok=false - done - $ok || exit 1 - - - name: Metadata & immutability checks for changed files - if: steps.changes.outputs.changed_files != '' - run: | - python scripts/check-metadata-and-immutability-file.py ${{ steps.changes.outputs.changed_files }} + run: npm install - - name: Namespace & naming checks for changed files - if: steps.changes.outputs.changed_files != '' - run: | - python scripts/check-namespaces-and-names-file.py ${{ steps.changes.outputs.changed_files }} \ No newline at end of file + - name: Run tests + run: npm test diff --git a/.github/workflows/main-push-validation.yml b/.github/workflows/main-push-validation.yml index 94e1859..65d6a82 100644 --- a/.github/workflows/main-push-validation.yml +++ b/.github/workflows/main-push-validation.yml @@ -6,39 +6,29 @@ on: - main paths: - 'shapes/**/*.ttl' - workflow_dispatch: # allows manual triggering + workflow_dispatch: jobs: - main-push-validation: + validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - with: - fetch-depth: 0 # needed to compare with origin/main - name: Skip validation for Dependabot if: github.actor == 'dependabot[bot]' run: echo "Skipping SHACL validation for Dependabot PR" - - name: Set up Python + - name: Setup Node.js if: github.actor != 'dependabot[bot]' - uses: actions/setup-python@v5 + uses: actions/setup-node@v4 with: - python-version: '3.11' + node-version-file: .nvmrc - name: Install dependencies if: github.actor != 'dependabot[bot]' - run: pip install pyshacl rdflib rich - - - name: Validate all TTL files - if: github.actor != 'dependabot[bot]' - run: bash scripts/validate-shacl-shapes-dir.sh - - - name: Metadata & immutability checks - if: github.actor != 'dependabot[bot]' - run: python scripts/check-metadata-and-immutability-dir.py + run: npm install - - name: Namespace & naming checks + - name: Run tests if: github.actor != 'dependabot[bot]' - run: python scripts/check-namespaces-and-names-dir.py \ No newline at end of file + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3170230..e7e8215 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,8 +20,7 @@ When contributing: ## Pull Request Process * Create branch -* Ensure the shape validates before opening a PR - * [validation scripts](./scripts) are available +* Ensure the shape validates before opening a PR — run `npm test` locally (see [Validation](#validation) below) * Open a PR and complete the questions in the PR template provided * Respond to review feedback and iterate as needed @@ -62,7 +61,7 @@ address-shape:AddressMinimalShape -Shape names should use PascalCase abd end with `Shape`, e.g.: +Shape names should use PascalCase and end with `Shape`, e.g.: ``` address-shape:AddressShape @@ -71,6 +70,24 @@ address-shape:AddressShape +## Validation + +Run validation locally before opening a PR: + +```bash +npm install +npm test +``` + +This checks all shapes in the `shapes/` directory for: + +- **Turtle syntax** — the file parses as valid RDF +- **Required metadata** — each `sh:NodeShape` must have `sh:name`, `dct:created` (ISO 8601 date), and `vs:term_status` (one of `unstable`, `testing`, `stable`, `archaic`) +- **Namespace conventions** — shape URIs must use `https://solidproject.org/shapes/` and end with `Shape` in PascalCase +- **Prefix conventions** — prefix labels must be lowercase and contain only `[a-z0-9-_]` + +The same checks run automatically in CI on every PR and push to `main`. + ## Validation Rules A goal of solid/shapes is to support interoperability in the Solid ecosystem, and with this in mind, it is recommended that shapes should have constraints that maximise their potential reuse. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c204b51 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,236 @@ +{ + "name": "@solid-data/models", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@solid-data/models", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@rdfjs/wrapper": "0.34.0" + }, + "devDependencies": { + "@types/node": "^20", + "n3": "^1.23.1", + "typescript": "^5" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@rdfjs/wrapper": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@rdfjs/wrapper/-/wrapper-0.34.0.tgz", + "integrity": "sha512-psGw4IAH0E27P7Nlro0SzjqPJJ+aE3MkYKWfNbBk+vwT2Zt+gJJyuznuYg54peZs4rAF9FLoqCe4JdvQNDTzMw==", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ff2b96f --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "description": "SHACL shapes for Solid", + "type": "module", + "scripts": { + "test": "node --test" + }, + "devDependencies": { + "n3": "^1.23.1" + }, + "license": "MIT" +} diff --git a/scripts/check-metadata-and-immutability-dir.py b/scripts/check-metadata-and-immutability-dir.py deleted file mode 100644 index a2e786e..0000000 --- a/scripts/check-metadata-and-immutability-dir.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 -""" -check_metadata_and_immutability.py - -Validates SHACL shapes for: -- Required metadata (sh:name, dct:created, vs:term_status) -- Correct metadata formats -- Status validity -- Immutability rules (placeholder for future logic) -""" - -import sys -import glob -from rdflib import Graph, Namespace -from rdflib.namespace import RDF - -# Namespaces -SH = Namespace("http://www.w3.org/ns/shacl#") -DCT = Namespace("http://purl.org/dc/terms/") -VS = Namespace("http://www.w3.org/2003/06/sw-vocab-status/ns#") - -# Allowed status values -VALID_STATUS = {"unstable", "testing", "stable", "archaic"} - -# Paths -SHAPES_DIR = "shapes/" - -exit_code = 0 - - -def report_error(file, shape, property_name, message, value=None, expected=None): - """Print a structured validation error.""" - global exit_code - exit_code = 1 - - print("\n[SHACL METADATA ERROR]") - print(f"File: {file}") - print(f"Shape: {shape}") - print(f"Property: {property_name}") - - if value is not None: - print(f"Found: {value}") - - if expected is not None: - print(f"Expected: {expected}") - - print(f"Problem: {message}") - - -def check_shape_metadata(g, file, shape_uri): - - label = g.value(shape_uri, SH.name) - created = g.value(shape_uri, DCT.created) - status = g.value(shape_uri, VS.term_status) - - # sh:name - if not label: - report_error( - file, - shape_uri, - "sh:name", - "Missing required name for SHACL NodeShape.", - expected="Human readable name e.g. sh:name \"Person Shape\"" - ) - - # dct:created - if not created: - report_error( - file, - shape_uri, - "dct:created", - "Creation date missing.", - expected="ISO 8601 date literal e.g. \"2025-02-10\"^^xsd:date" - ) - else: - try: - created.toPython() - except Exception: - report_error( - file, - shape_uri, - "dct:created", - "Invalid date literal.", - value=created, - expected="ISO 8601 xsd:date (YYYY-MM-DD)" - ) - - # vs:term_status - if not status: - report_error( - file, - shape_uri, - "vs:term_status", - "Status missing.", - expected=f"One of {sorted(VALID_STATUS)}" - ) - else: - status_str = str(status) - - if status_str not in VALID_STATUS: - report_error( - file, - shape_uri, - "vs:term_status", - "Invalid status value.", - value=status_str, - expected=f"One of {sorted(VALID_STATUS)}" - ) - - -def check_immutability(g, file, shape_uri): - """ - Placeholder for immutability validation. - - In production you would: - - Compare against previous committed version - - Detect structural changes - - Ensure version increment - """ - - status = g.value(shape_uri, VS.term_status) - - if status and str(status) in {"stable", "archaic"}: - # Future logic example: - # compare shape hash with previous commit - pass - - -def main(): - global exit_code - - ttl_files = glob.glob(f"{SHAPES_DIR}/*.ttl") - - if not ttl_files: - print(f"[ERROR] No Turtle files found in '{SHAPES_DIR}'") - sys.exit(1) - - for file in ttl_files: - - g = Graph() - - try: - g.parse(file, format="turtle") - except Exception as e: - print("\n[PARSE ERROR]") - print(f"File: {file}") - print(f"Problem: Failed to parse Turtle file") - print(f"Details: {e}") - exit_code = 1 - continue - - shapes_found = False - - for shape_uri in g.subjects(RDF.type, SH.NodeShape): - shapes_found = True - check_shape_metadata(g, file, shape_uri) - check_immutability(g, file, shape_uri) - - if not shapes_found: - print(f"\n[WARNING] No sh:NodeShape found in {file}") - - if exit_code: - print("\n Validation FAILED") - print("Fix the above issues before committing shapes.") - else: - print("\n Metadata and immutability validation passed.") - - sys.exit(exit_code) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/check-metadata-and-immutability-file.py b/scripts/check-metadata-and-immutability-file.py deleted file mode 100644 index 03dba7f..0000000 --- a/scripts/check-metadata-and-immutability-file.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -""" -check_selected_metadata_and_immutability.py - -Validates metadata and immutability of selected SHACL shapes. -- Accepts files passed as command-line arguments (for PR workflows) -- Performs the same checks as the original script -""" - -import sys -from rdflib import Graph, Namespace -from rdflib.namespace import RDF - -# Namespaces -SH = Namespace("http://www.w3.org/ns/shacl#") -DCT = Namespace("http://purl.org/dc/terms/") -VS = Namespace("http://www.w3.org/2003/06/sw-vocab-status/ns#") - -VALID_STATUS = {"unstable", "testing", "stable", "archaic"} - -exit_code = 0 - - -def report_error(file, shape, property_name, message, value=None, expected=None): - global exit_code - exit_code = 1 - print("\n[SHACL METADATA ERROR]") - print(f"File: {file}") - print(f"Shape: {shape}") - print(f"Property: {property_name}") - if value is not None: - print(f"Found: {value}") - if expected is not None: - print(f"Expected: {expected}") - print(f"Problem: {message}") - - -def check_shape_metadata(g, file, shape_uri): - label = g.value(shape_uri, SH.name) - created = g.value(shape_uri, DCT.created) - status = g.value(shape_uri, VS.term_status) - - if not label: - report_error(file, shape_uri, "sh:name", - "Missing required name.", - expected="Human readable name") - if not created: - report_error(file, shape_uri, "dct:created", - "Creation date missing.", - expected="ISO 8601 xsd:date") - else: - try: - created.toPython() - except Exception: - report_error(file, shape_uri, "dct:created", - "Invalid date literal.", - value=created, - expected="ISO 8601 xsd:date") - if not status: - report_error(file, shape_uri, "vs:term_status", - "Status missing.", - expected=f"One of {sorted(VALID_STATUS)}") - elif str(status) not in VALID_STATUS: - report_error(file, shape_uri, "vs:term_status", - "Invalid status value.", - value=str(status), - expected=f"One of {sorted(VALID_STATUS)}") - - -def check_immutability(g, file, shape_uri): - status = g.value(shape_uri, VS.term_status) - if status and str(status) in {"stable", "archaic"}: - # Placeholder for future immutability logic - pass - - -def main(): - global exit_code - - # Accept files from command-line arguments - ttl_files = sys.argv[1:] - if not ttl_files: - print("Usage: python check_selected_metadata_and_immutability.py file1.ttl [file2.ttl ...]") - sys.exit(1) - - for file in ttl_files: - g = Graph() - try: - g.parse(file, format="turtle") - except Exception as e: - print("\n[PARSE ERROR]") - print(f"File: {file}") - print(f"Problem: Failed to parse Turtle file") - print(f"Details: {e}") - exit_code = 1 - continue - - shapes_found = False - for shape_uri in g.subjects(RDF.type, SH.NodeShape): - shapes_found = True - check_shape_metadata(g, file, shape_uri) - check_immutability(g, file, shape_uri) - - if not shapes_found: - print(f"\n[WARNING] No sh:NodeShape found in {file}") - - if exit_code: - print("\nValidation FAILED") - sys.exit(exit_code) - else: - print("\nMetadata and immutability validation passed.") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/check-namespaces-and-names-dir.py b/scripts/check-namespaces-and-names-dir.py deleted file mode 100644 index e81bb1a..0000000 --- a/scripts/check-namespaces-and-names-dir.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -""" -check-namespaces-and-names-dir.py - -Validates SHACL shapes for: -- Namespace correctness -- Shape naming conventions -- Prefix labels -""" - -import sys -from rdflib import Graph, Namespace -from rdflib.namespace import RDF, RDFS - -# Namespaces -SH = Namespace("http://www.w3.org/ns/shacl#") -EXPECTED_NS_PREFIX = "https://solidproject.org/shapes/" - -# Paths -SHAPES_DIR = "shapes/" - -# Exit code -exit_code = 0 - - - -def check_shape_names(shape_uri): - global exit_code - - # Skip blank nodes - from rdflib.term import BNode - if isinstance(shape_uri, BNode) or (hasattr(shape_uri, "n3") and shape_uri.n3().startswith("_:")): - return - - uri = str(shape_uri) - - # Namespace check - if not uri.startswith(EXPECTED_NS_PREFIX): - print(f"[NAMESPACE ERROR] Shape {uri} does not start with {EXPECTED_NS_PREFIX}") - exit_code = 1 - - # Local name check - local_name = uri.split("#")[-1] - if not local_name.endswith("Shape"): - print(f"[NAME ERROR] Shape {uri} local name '{local_name}' should end with 'Shape'") - exit_code = 1 - if not local_name[0].isupper(): - print(f"[NAME ERROR] Shape {uri} local name '{local_name}' should start with uppercase letter (PascalCase)") - exit_code = 1 - -def check_prefix_labels(g): - global exit_code - for prefix, ns in g.namespaces(): - if prefix: # skip default namespace - if not prefix[0].islower(): - print(f"[PREFIX ERROR] Prefix '{prefix}' should start with lowercase") - exit_code = 1 - if any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-_" for c in prefix): - print(f"[PREFIX ERROR] Prefix '{prefix}' contains invalid characters") - exit_code = 1 - -def main(): - global exit_code - import glob - - ttl_files = glob.glob(f"{SHAPES_DIR}/*.ttl") - if not ttl_files: - print(f"No .ttl files found in {SHAPES_DIR}") - sys.exit(1) - - for f in ttl_files: - g = Graph() - g.parse(f, format="turtle") - check_prefix_labels(g) - for shape_uri in g.subjects(RDF.type, SH.NodeShape): - check_shape_names(shape_uri) - - if exit_code: - print("\nValidation failed. Please fix the above errors.") - else: - print("Namespace and naming validation passed.") - - sys.exit(exit_code) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/check-namespaces-and-names-file.py b/scripts/check-namespaces-and-names-file.py deleted file mode 100644 index 0e004fc..0000000 --- a/scripts/check-namespaces-and-names-file.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -""" -shapes/shapes/scripts/check-namespaces-and-names-file.py - -Validates SHACL shapes for: -- Namespace correctness -- Shape naming conventions -- Prefix labels - -Usage: - python check_namespaces_and_names_selected.py file1.ttl [file2.ttl ...] -""" - -import sys -from rdflib import Graph, Namespace -from rdflib.namespace import RDF -from rdflib.term import BNode - -# Namespaces -SH = Namespace("http://www.w3.org/ns/shacl#") -EXPECTED_NS_PREFIX = "https://solidproject.org/shapes/" - -# Exit code -exit_code = 0 - - -def check_shape_names(g, shape_uri): - """Check namespace and local name conventions of a single shape.""" - global exit_code - - # Skip blank nodes (BNodes or _:) - if isinstance(shape_uri, BNode) or (hasattr(shape_uri, "n3") and shape_uri.n3().startswith("_:")): - return - - uri = str(shape_uri) - - # Namespace check - if not uri.startswith(EXPECTED_NS_PREFIX): - print(f"[NAMESPACE ERROR] Shape {uri} does not start with {EXPECTED_NS_PREFIX}") - exit_code = 1 - - # Local name check - local_name = uri.split("#")[-1] - if not local_name.endswith("Shape"): - print(f"[NAME ERROR] Shape {uri} local name '{local_name}' should end with 'Shape'") - exit_code = 1 - if not local_name[0].isupper(): - print(f"[NAME ERROR] Shape {uri} local name '{local_name}' should start with uppercase letter (PascalCase)") - exit_code = 1 - - -def check_prefix_labels(g): - """Check that all prefixes are lowercase and contain only allowed characters.""" - global exit_code - for prefix, ns in g.namespaces(): - if prefix: # skip default namespace - if not prefix[0].islower(): - print(f"[PREFIX ERROR] Prefix '{prefix}' should start with lowercase") - exit_code = 1 - if any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-_" for c in prefix): - print(f"[PREFIX ERROR] Prefix '{prefix}' contains invalid characters") - exit_code = 1 - - -def main(): - global exit_code - - # Get files from command-line arguments - ttl_files = sys.argv[1:] - if not ttl_files: - print("Usage: python check_namespaces_and_names_selected.py file1.ttl [file2.ttl ...]") - sys.exit(1) - - for f in ttl_files: - g = Graph() - try: - g.parse(f, format="turtle") - except Exception as e: - print(f"\n[PARSE ERROR] Failed to parse {f}") - print(f"Details: {e}") - exit_code = 1 - continue - - check_prefix_labels(g) - for shape_uri in g.subjects(RDF.type, SH.NodeShape): - check_shape_names(g, shape_uri) - - if exit_code: - print("\nValidation failed. Please fix the above errors.") - else: - print("Namespace and naming validation passed.") - - sys.exit(exit_code) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/validate-shacl-shapes-dir.sh b/scripts/validate-shacl-shapes-dir.sh deleted file mode 100755 index 0c63907..0000000 --- a/scripts/validate-shacl-shapes-dir.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/bin/bash -# ----------------------------------------------- -# Script: validate-shacl-shapes-dir.sh -# Purpose: Validate all Turtle (TTL) files in a directory against SHACL shapes using pySHACL. -# ----------------------------------------------- - -set -o pipefail -set +e - -SHAPES_DIR="./shapes" - -# Initialize counters -total=0 -valid=0 -invalid=0 - -echo "Searching for TTL files in $SHAPES_DIR..." - -# Ensure directory exists -if [ ! -d "$SHAPES_DIR" ]; then - echo "ERROR: Directory '$SHAPES_DIR' does not exist." - echo "Current repository structure:" - ls -R - exit 1 -fi - -# Prevent *.ttl from expanding to literal string if no files exist -shopt -s nullglob - -files=($SHAPES_DIR/*.ttl) - -# Check if there are any TTL files -if [ ${#files[@]} -eq 0 ]; then - echo "ERROR: No TTL files found in '$SHAPES_DIR'." - echo "Directory contents:" - ls -l "$SHAPES_DIR" - exit 1 -fi - -echo "Found ${#files[@]} TTL files." - -# Loop over each TTL file -for file in "${files[@]}"; do - ((total++)) - echo "--------------------------------" - echo "Validating file: $file" - - if output=$(python3 - <&1 -import sys -from pyshacl import validate -from rdflib.plugins.parsers.notation3 import BadSyntax - -ttl_file = "$file" - -try: - conforms, v_graph, v_text = validate( - ttl_file, - shacl_graph=None, - inference='rdfs', - abort_on_first=True, - advanced=True, - meta_shacl=True, - debug=False - ) - - if conforms: - print("RESULT: CONFORMS") - print("File:", ttl_file) - sys.exit(0) - - else: - print("RESULT: VALIDATION FAILED") - print("File:", ttl_file) - print("Validation report (first 15 lines):") - print("\\n".join(v_text.splitlines()[:15])) - sys.exit(1) - -except BadSyntax as e: - msg = str(e) - - print("RESULT: SYNTAX ERROR") - print("File:", ttl_file) - - if "Prefix" in msg: - try: - prefix = msg.split('"')[1] - print("Missing prefix declaration:", prefix) - print("Suggested fix: add '@prefix {}: .' near the top of the file.".format(prefix)) - except: - pass - - print("Parser message:", msg) - sys.exit(1) - -except Exception as e: - print("RESULT: UNEXPECTED ERROR") - print("File:", ttl_file) - print("Error message:", str(e)) - sys.exit(1) - -EOF -); then - echo "$output" - ((valid++)) - else - echo "$output" - ((invalid++)) - fi -done - -echo "--------------------------------" -echo "Validation Summary" -echo "Total files processed: $total" -echo "Files conforming: $valid" -echo "Files failed: $invalid" - -if [ $invalid -gt 0 ]; then - exit 1 -else - exit 0 -fi \ No newline at end of file diff --git a/scripts/validate-shacl-shapes-file.py b/scripts/validate-shacl-shapes-file.py deleted file mode 100644 index 072df53..0000000 --- a/scripts/validate-shacl-shapes-file.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -# ----------------------------------------------- -# Script: validate-shacl-shapes-file.py -# Purpose: Validate Turtle (TTL) files against SHACL shapes using pySHACL. -# Features: -# - Parses TTL files using rdflib -# - Runs SHACL validation (meta-SHACL included) -# - Prints nice colored output using Rich -# - Returns exit code 0 if all files valid, 1 if any file fails -# ----------------------------------------------- -# HOW TO USE: -# 1. Install dependencies: -# pip install pyshacl rdflib rich -# -# 2. Run the script on one or more TTL files: -# python ./scripts/validate-shacl-shapes-file.py shapes/shape1.ttl shapes/shape2.ttl -# -# 3. Exit codes: -# 0 → all files valid -# 1 → at least one file failed -# ----------------------------------------------- - -import sys -from pyshacl import validate -from rdflib import Graph -from rich.console import Console - -# Create a Rich console for colorful output -console = Console() - - -def validate_file(path): - """ - Validate a single TTL file against SHACL shapes. - - Args: - path (str): Path to the TTL file to validate. - - Returns: - bool: True if the file passes SHACL validation, False otherwise. - """ - console.print(f"\n[bold]Checking[/bold] {path}") - - try: - # Parse the TTL file into an RDF graph - data_graph = Graph() - data_graph.parse(path, format="turtle") - - # Run SHACL validation - # - shacl_graph=data_graph: using the same graph for data and shapes - # - inference="none": no RDFS or OWL inference - # - meta_shacl=True: enable meta-SHACL validation - conforms, _, report = validate( - data_graph, - shacl_graph=data_graph, - inference="none", - meta_shacl=True - ) - - if conforms: - console.print("[green]✓ SHACL is valid[/green]") - return True - - # If validation fails, print the report - console.print("[red]✗ SHACL validation failed[/red]") - console.print(report) - return False - - except Exception as e: - # Catch parsing errors or unexpected exceptions - console.print("[red]Parsing error[/red]") - console.print(str(e)) - return False - - -if __name__ == "__main__": - # Track overall validation result - ok = True - - # Loop over each TTL file provided as command-line arguments - for file in sys.argv[1:]: - if not validate_file(file): - ok = False - - # Exit code 0 if all files passed, 1 if any failed - sys.exit(0 if ok else 1) \ No newline at end of file diff --git a/shapes/recipe.ttl b/shapes/recipe.ttl new file mode 100644 index 0000000..4cad3ae --- /dev/null +++ b/shapes/recipe.ttl @@ -0,0 +1,298 @@ +@prefix sh: . +@prefix schema: . +@prefix xsd: . +@prefix dct: . +@prefix dc: . +@prefix vs: . +@prefix prov: . +@prefix recipe_shape: . + +recipe_shape:RecipeShape + a sh:NodeShape ; + sh:targetClass schema:Recipe ; + sh:name "Recipe" ; + sh:description "A Schema.org Recipe with ingredients, instructions, timing, nutrition, and metadata" ; + sh:codeIdentifier "Recipe" ; + dct:created "2026-05-08"^^xsd:date ; + vs:term_status "testing" ; + dc:source ; + prov:wasDerivedFrom ; + + sh:property [ + sh:path schema:name ; + sh:datatype xsd:string ; + sh:name "Name" ; + sh:description "Human-readable title of the recipe" ; + sh:codeIdentifier "name" ; + sh:minCount 1 ; + sh:maxCount 1 ; + ] ; + + sh:property [ + sh:path schema:description ; + sh:datatype xsd:string ; + sh:name "Description" ; + sh:description "Short summary describing the recipe" ; + sh:codeIdentifier "description" ; + sh:minCount 1 ; + ] ; + + sh:property [ + sh:path schema:image ; + sh:nodeKind sh:IRI ; + sh:name "Image" ; + sh:description "Image representing the recipe" ; + sh:codeIdentifier "image" ; + ] ; + + sh:property [ + sh:path schema:url ; + sh:nodeKind sh:IRI ; + sh:name "URL" ; + sh:description "Canonical URL for the recipe" ; + sh:codeIdentifier "url" ; + ] ; + + sh:property [ + sh:path schema:sameAs ; + sh:nodeKind sh:IRI ; + sh:name "Same As" ; + sh:description "Reference URL for the recipe" ; + sh:codeIdentifier "sameAs" ; + ] ; + + sh:property [ + sh:path schema:author ; + sh:nodeKind sh:BlankNodeOrIRI ; + sh:name "Author" ; + sh:description "Author of the recipe" ; + sh:codeIdentifier "author" ; + ] ; + + sh:property [ + sh:path schema:datePublished ; + sh:datatype xsd:date ; + sh:name "Date Published" ; + sh:description "Publication date of the recipe" ; + sh:codeIdentifier "datePublished" ; + ] ; + + sh:property [ + sh:path schema:prepTime ; + sh:datatype xsd:string ; + sh:name "Preparation Time" ; + sh:description "Preparation time in ISO 8601 duration format" ; + sh:codeIdentifier "prepTime" ; + sh:maxCount 1 ; + ] ; + + sh:property [ + sh:path schema:cookTime ; + sh:datatype xsd:string ; + sh:name "Cook Time" ; + sh:description "Cooking time in ISO 8601 duration format" ; + sh:codeIdentifier "cookTime" ; + sh:maxCount 1 ; + ] ; + + sh:property [ + sh:path schema:totalTime ; + sh:datatype xsd:string ; + sh:name "Total Time" ; + sh:description "Total time required in ISO 8601 duration format" ; + sh:codeIdentifier "totalTime" ; + sh:maxCount 1 ; + ] ; + + sh:property [ + sh:path schema:recipeYield ; + sh:datatype xsd:string ; + sh:name "Yield" ; + sh:description "Quantity produced by the recipe" ; + sh:codeIdentifier "recipeYield" ; + ] ; + + sh:property [ + sh:path schema:recipeCategory ; + sh:datatype xsd:string ; + sh:name "Recipe Category" ; + sh:description "Category of the recipe such as dessert or main course" ; + sh:codeIdentifier "recipeCategory" ; + ] ; + + sh:property [ + sh:path schema:recipeCuisine ; + sh:datatype xsd:string ; + sh:name "Cuisine" ; + sh:description "Cuisine associated with the recipe" ; + sh:codeIdentifier "recipeCuisine" ; + ] ; + + sh:property [ + sh:path schema:keywords ; + sh:datatype xsd:string ; + sh:name "Keywords" ; + sh:description "Keywords associated with the recipe" ; + sh:codeIdentifier "keywords" ; + ] ; + + sh:property [ + sh:path schema:cookingMethod ; + sh:datatype xsd:string ; + sh:name "Cooking Method" ; + sh:description "Technique used to prepare the recipe" ; + sh:codeIdentifier "cookingMethod" ; + ] ; + + sh:property [ + sh:path schema:suitableForDiet ; + sh:nodeKind sh:IRI ; + sh:name "Suitable Diet" ; + sh:description "Dietary suitability classification" ; + sh:codeIdentifier "suitableForDiet" ; + ] ; + + sh:property [ + sh:path schema:recipeIngredient ; + sh:datatype xsd:string ; + sh:name "Ingredient" ; + sh:description "Ingredient required for the recipe" ; + sh:codeIdentifier "recipeIngredient" ; + sh:minCount 1 ; + ] ; + + sh:property [ + sh:path schema:recipeInstructions ; + sh:node recipe_shape:HowToStepShape ; + sh:name "Instructions" ; + sh:description "Preparation instructions for the recipe" ; + sh:codeIdentifier "recipeInstructions" ; + sh:minCount 1 ; + ] ; + + sh:property [ + sh:path schema:tool ; + sh:datatype xsd:string ; + sh:name "Tool" ; + sh:description "Tool used to prepare the recipe" ; + sh:codeIdentifier "tool" ; + ] ; + + sh:property [ + sh:path schema:supply ; + sh:datatype xsd:string ; + sh:name "Supply" ; + sh:description "Supply needed for preparation" ; + sh:codeIdentifier "supply" ; + ] ; + + sh:property [ + sh:path schema:nutrition ; + sh:node recipe_shape:NutritionInformationShape ; + sh:name "Nutrition" ; + sh:description "Nutritional information for the recipe" ; + sh:codeIdentifier "nutrition" ; + ] . + +recipe_shape:HowToStepShape + a sh:NodeShape ; + sh:name "How To Step" ; + sh:description "Single instructional step in a recipe" ; + sh:codeIdentifier "HowToStep" ; + + sh:property [ + sh:path schema:position ; + sh:datatype xsd:integer ; + sh:name "Position" ; + sh:description "Step order in the instructions" ; + sh:codeIdentifier "position" ; + sh:minCount 1 ; + sh:maxCount 1 ; + ] ; + + sh:property [ + sh:path schema:text ; + sh:datatype xsd:string ; + sh:name "Text" ; + sh:description "Instructional text for the step" ; + sh:codeIdentifier "text" ; + sh:minCount 1 ; + ] ; + + sh:property [ + sh:path schema:name ; + sh:datatype xsd:string ; + sh:name "Step Name" ; + sh:description "Optional title for the instruction step" ; + sh:codeIdentifier "name" ; + ] ; + + sh:property [ + sh:path schema:url ; + sh:nodeKind sh:IRI ; + sh:name "Step URL" ; + sh:description "URL for the instruction step" ; + sh:codeIdentifier "url" ; + ] . + +recipe_shape:NutritionInformationShape + a sh:NodeShape ; + sh:name "Nutrition Information" ; + sh:description "Nutritional values associated with a recipe" ; + sh:codeIdentifier "NutritionInformation" ; + + sh:property [ + sh:path schema:calories ; + sh:datatype xsd:string ; + sh:name "Calories" ; + sh:description "Calories in the recipe" ; + sh:codeIdentifier "calories" ; + ] ; + + sh:property [ + sh:path schema:fatContent ; + sh:datatype xsd:string ; + sh:name "Fat Content" ; + sh:description "Amount of fat in the recipe" ; + sh:codeIdentifier "fatContent" ; + ] ; + + sh:property [ + sh:path schema:proteinContent ; + sh:datatype xsd:string ; + sh:name "Protein Content" ; + sh:description "Amount of protein in the recipe" ; + sh:codeIdentifier "proteinContent" ; + ] ; + + sh:property [ + sh:path schema:carbohydrateContent ; + sh:datatype xsd:string ; + sh:name "Carbohydrate Content" ; + sh:description "Amount of carbohydrates in the recipe" ; + sh:codeIdentifier "carbohydrateContent" ; + ] ; + + sh:property [ + sh:path schema:fiberContent ; + sh:datatype xsd:string ; + sh:name "Fiber Content" ; + sh:description "Amount of fiber in the recipe" ; + sh:codeIdentifier "fiberContent" ; + ] ; + + sh:property [ + sh:path schema:sugarContent ; + sh:datatype xsd:string ; + sh:name "Sugar Content" ; + sh:description "Amount of sugar in the recipe" ; + sh:codeIdentifier "sugarContent" ; + ] ; + + sh:property [ + sh:path schema:sodiumContent ; + sh:datatype xsd:string ; + sh:name "Sodium Content" ; + sh:description "Amount of sodium in the recipe" ; + sh:codeIdentifier "sodiumContent" ; + ] . \ No newline at end of file diff --git a/test/shapes.test.js b/test/shapes.test.js new file mode 100644 index 0000000..c852350 --- /dev/null +++ b/test/shapes.test.js @@ -0,0 +1,119 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Parser, Store, DataFactory } from 'n3' + +const { namedNode } = DataFactory + +const SHAPES_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '../shapes') + +const RDF_TYPE = namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type') +const SH_NODE_SHAPE = namedNode('http://www.w3.org/ns/shacl#NodeShape') +const SH_NAME = namedNode('http://www.w3.org/ns/shacl#name') +const DCT_CREATED = namedNode('http://purl.org/dc/terms/created') +const VS_STATUS = namedNode('http://www.w3.org/2003/06/sw-vocab-status/ns#term_status') + +const EXPECTED_NS = 'https://solidproject.org/shapes/' +const VALID_STATUS = new Set(['unstable', 'testing', 'stable', 'archaic']) + +const files = readdirSync(SHAPES_DIR) + .filter(f => f.endsWith('.ttl')) + .map(f => ({ name: f, path: join(SHAPES_DIR, f) })) + +// Parse once per file; cache result or error +const cache = new Map() +function parseFile(filePath) { + if (cache.has(filePath)) return cache.get(filePath) + const p = new Promise((res, rej) => { + const quads = [] + new Parser().parse(readFileSync(filePath, 'utf-8'), (err, quad, prefixes) => { + if (err) rej(err) + else if (quad) quads.push(quad) + else res({ store: new Store(quads), prefixes: prefixes ?? {} }) + }) + }) + cache.set(filePath, p) + return p +} + +describe('shapes/', () => { + test('directory contains TTL files', () => { + assert.ok(files.length > 0, 'No .ttl files found in shapes/') + }) + + for (const { name, path } of files) { + describe(name, () => { + + test('valid Turtle syntax', async () => { + await assert.doesNotReject(parseFile(path), `${name}: Turtle syntax error`) + }) + + test('NodeShapes have required metadata', async () => { + const { store } = await parseFile(path) + for (const shape of store.getSubjects(RDF_TYPE, SH_NODE_SHAPE, null)) { + const id = shape.value + + assert.ok( + store.getObjects(shape, SH_NAME, null).length > 0, + `${id}: missing sh:name` + ) + + const created = store.getObjects(shape, DCT_CREATED, null) + assert.ok(created.length > 0, `${id}: missing dct:created`) + if (created.length > 0) { + assert.match( + created[0].value, + /^\d{4}-\d{2}-\d{2}$/, + `${id}: dct:created must be ISO 8601 date (YYYY-MM-DD), got "${created[0].value}"` + ) + } + + const statuses = store.getObjects(shape, VS_STATUS, null) + assert.ok(statuses.length > 0, `${id}: missing vs:term_status`) + if (statuses.length > 0) { + const s = statuses[0].value + assert.ok( + VALID_STATUS.has(s), + `${id}: vs:term_status "${s}" must be one of ${[...VALID_STATUS].sort().join(', ')}` + ) + } + } + }) + + test('NodeShape URIs follow namespace and naming conventions', async () => { + const { store } = await parseFile(path) + for (const shape of store.getSubjects(RDF_TYPE, SH_NODE_SHAPE, null)) { + if (shape.termType === 'BlankNode') continue + const uri = shape.value + + assert.ok( + uri.startsWith(EXPECTED_NS), + `<${uri}>: must start with ${EXPECTED_NS}` + ) + + const local = uri.includes('#') ? uri.split('#').pop() : uri.split('/').pop() + assert.ok(local.endsWith('Shape'), `<${uri}>: local name "${local}" must end with "Shape"`) + assert.ok( + local[0] === local[0].toUpperCase(), + `<${uri}>: local name "${local}" must start with uppercase (PascalCase)` + ) + } + }) + + test('namespace prefixes use lowercase and valid characters', async () => { + const { prefixes } = await parseFile(path) + for (const prefix of Object.keys(prefixes)) { + if (!prefix) continue + assert.match( + prefix, + /^[a-z][a-z0-9\-_]*$/, + `${name}: prefix "${prefix}" must start with lowercase and contain only [a-z0-9-_]` + ) + } + }) + + }) + } +})