Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

## [Unreleased]

### Added

- Property icons: `task icons` (`bin/property_icons.py`) gives every attribute of the package an icon for its meaning - mapped in `icons/property-icons.json`, else the kind of its value, read from its declared range alone - and every mapped relation an icon for its kind with an arrow for its direction, coloured after the domain or range class, embedded in the vocabulary; the shared glyphs ship in `bin/glyphs/`, a package draws its own into `icons/glyphs/`, every glyph is refused unless it is drawing only, and `task check:offline` fails on a missing or stale icon; the `vocabulary` skill's depictions reference describes it, and the template's `check:icons:case` holds it on a fixture

### Changed

- The `vocabulary` skill orders a term's predicates as the OWL API writes them (measured with ROBOT 1.10), so a vocabulary saved in Protégé or ROBOT shows no reordering; label casing is the organisation's
- The `vocabulary` skill names every relation (object property) as a verb phrase read from its domain to its range - a verb such as `manages`, or `has` / `is … of` with the role the range plays, such as `hasProductManager` - never as a bare class or role name
- The `shapes` skill says that a class the package does not own - from a vocabulary it ships unchanged or depends on, such as QUDT or FOAF - gets its icon from a `foaf:depiction` on the package's node shape, so its resources show an icon in Explore without the package changing the source's vocabulary; the `vocabulary` skill's depictions reference points there

## [1.8.0] 2026-10-18
Expand Down
15 changes: 15 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ tasks:
cmds:
- cd {{.TEST_CASE}}_dir && ../scripts/check-offline.py

check:icons:case:
desc: assert the property icons a generated package ships are drawn as the vocabulary skill says
summary: |
A generated package maps no property to an icon, so its own check
passes whatever bin/property_icons.py does. This drives the rendered
package's script over tests/fixtures/icons: icons written, coloured
after their classes, with and without the arrow, value kinds for
unmapped attributes, hand-drawn depictions left alone, and three planted
faults caught - an edited icon, a glyph with a script, a property of
another namespace.
cmds:
- cd {{.TEST_CASE}}_dir && ../scripts/check-property-icons.py

check:validate:case:
desc: validate a project test case
preconditions:
Expand All @@ -131,6 +144,8 @@ tasks:
vars: {TEST_CASE: '{{.TEST_CASE}}'}
- task: check:offline:case
vars: {TEST_CASE: '{{.TEST_CASE}}'}
- task: check:icons:case
vars: {TEST_CASE: '{{.TEST_CASE}}'}
- cd {{.TEST_CASE}}_dir && task check

check:validate:cases:
Expand Down
1 change: 1 addition & 0 deletions scripts/check-offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"check_paths.py",
"check_placeholders.py",
"check_skill_map.py",
"property_icons.py",
)

CUSTOM_MAP = Path(".claude") / "skill-map.custom.json"
Expand Down
160 changes: 160 additions & 0 deletions scripts/check-property-icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["rdflib>=7"]
# ///
"""Assert that the property icons a generated package ships are drawn, coloured and checked as described.

Run from inside a generated package directory; `check:icons:case` does that.

A generated package maps no property to an icon, so its own `task icons:check` passes whatever
`bin/property_icons.py` does. This drives the rendered package's script over a copy of
`tests/fixtures/icons` instead - two classes with icons of their own, relations with a shared glyph,
with "range" and without a mapping, attributes with a mapped glyph, a glyph of the package's own and
none, one depiction drawn by hand:

* written, every icon is in the vocabulary, the check passes, and a second run changes nothing;
* a relation is drawn in its range class's colour with the arrow, an attribute in its domain
class's colour without one; an unmapped relation and the hand-drawn depiction are left alone;
* an attribute without a mapped glyph shows the kind of its value (date, yes/no, count);
* an icon edited by hand fails the check, a glyph that is not drawing only is refused, and a
property of another namespace in the mapping is refused.

Exits non-zero with a one-line reason on the first failure.
"""

import base64
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

from rdflib import Graph, URIRef
from rdflib.namespace import FOAF

SCRIPT = Path("bin") / "property_icons.py"
FIXTURE = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "icons"
EX = "https://example.org/icons/"
BAND, VENUE = "#b8325a", "#2a7f62"
ARROW = "M2 12 H8.6"


def fail(message):
print(f"check-property-icons: {message}", file=sys.stderr)
sys.exit(1)


def run(root, *args):
result = subprocess.run([str(SCRIPT.resolve()), *args, str(root)], capture_output=True, text=True)
return result.returncode, result.stdout + result.stderr


def icons(root):
"""Each property's embedded icon SVG, by local name."""
graph = Graph().parse(root / "pkg" / "graphs" / "vocab.ttl")
found = {}
for prop, value in graph.subject_objects(FOAF.depiction):
if str(prop).startswith(EX) and str(value).startswith("data:image/svg+xml;base64,"):
found.setdefault(str(prop)[len(EX):], []).append(
base64.b64decode(str(value).split(",", 1)[1]).decode())
return found


def main():
if not SCRIPT.is_file():
fail(f"{SCRIPT} does not exist - the template did not ship it")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "fixture"
shutil.copytree(FIXTURE, root)
vocab = root / "pkg" / "graphs" / "vocab.ttl"

code, output = run(root, "--check")
if code == 0:
fail(f"the check passed before any icon was written:\n{output}")
code, output = run(root)
if code != 0:
fail(f"writing the icons failed:\n{output}")
written = vocab.read_text()
code, output = run(root, "--check")
if code != 0:
fail(f"the check failed right after writing:\n{output}")
run(root)
if vocab.read_text() != written:
fail("a second run changed the vocabulary")

drawn = icons(root)
expect = {
"isBasedAt": lambda s: VENUE in s and ARROW in s,
"hasHomeVenue": lambda s: VENUE in s and ARROW in s and "translate(9.6 5.4)" in s,
"email": lambda s: BAND in s and ARROW not in s,
"foundingDate": lambda s: BAND in s and "M4 6 H20 V20.5 H4 Z" in s,
"isTouring": lambda s: "M8 12.5 L11 15.5 L16.5 9" in s,
"memberCount": lambda s: "M4 15 L20 9" in s,
"capacity": lambda s: VENUE in s and "M6 9 A2 2" in s,
}
for name, test in expect.items():
if len(drawn.get(name, [])) != 1 or not test(drawn[name][0]):
fail(f"{name}: icon missing, doubled or not drawn as described")
if "supports" in drawn:
fail("an unmapped relation got an icon; it keeps the built-in one")
if len(drawn.get("stageName", [])) != 1 or 'class="property-icon"' in drawn["stageName"][0]:
fail("the depiction drawn by hand was replaced or doubled")

# the icon line closes its block, where the OWL API writes foaf:depiction (vocabulary skill)
for line in written.splitlines():
if "# property-icons:" in line and not re.sub(r"\s+#.*$", "", line).rstrip().endswith("."):
fail(f"an icon line is not the last of its block: {line[:60]}...")

# planted: an icon line an older release wrote in the middle of its block
lines = written.split("\n")
at = next(i for i, line in enumerate(lines) if "# property-icons: email" in line)
icon = re.sub(r"\s*\.(\s*#)", r" ;\1", lines[at])
moved = lines[:at] + lines[at + 1:]
moved[at - 1] = re.sub(r"\s*;(\s*(#.*)?)$", r" .\1", moved[at - 1])
start = max(i for i in range(at) if moved[i] and not moved[i][0].isspace())
moved.insert(start + 1, icon)
vocab.write_text("\n".join(moved))
if run(root, "--check")[0] == 0:
fail("an icon line in the middle of its block passed the check")
run(root)
if vocab.read_text() != written:
fail("task icons did not move an icon line back to the end of its block")
vocab.write_text(written)

# planted: an icon edited by hand
start = written.index("# property-icons: email")
line_start = written.rindex("\n", 0, start) + 1
tampered = written[:line_start] + written[line_start:start].replace("base64,", "base64,PHN2Zy8+", 1) \
+ written[start:]
vocab.write_text(tampered)
if run(root, "--check")[0] == 0:
fail("an icon edited by hand passed the check")
vocab.write_text(written)

# planted: a glyph that is not drawing only
glyph = root / "icons" / "glyphs" / "attributes" / "audience.svg"
glyph.write_text('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
'<script>alert(1)</script></svg>')
code, output = run(root)
if code == 0 or "element <script>" not in output or vocab.read_text() != written:
fail(f"a glyph with a script was not refused, or something was written:\n{output}")
shutil.copy(FIXTURE / "icons" / "glyphs" / "attributes" / "audience.svg", glyph)

# planted: a property of another namespace
mapping = root / "icons" / "property-icons.json"
data = json.loads(mapping.read_text())
data["relations"]["https://example.org/other/sponsor"] = "supplied-by"
mapping.write_text(json.dumps(data))
code, output = run(root)
if code == 0 or "not a property of this package" not in output:
fail(f"an icon on another namespace's property was not refused:\n{output}")

print(f"check-property-icons: ok ({len(expect)} icons drawn as described, hand-drawn and unmapped "
"left alone, 3 planted faults caught)")


if __name__ == "__main__":
main()
25 changes: 21 additions & 4 deletions src/Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ tasks:
summary: |
Runs the checks in bin/ over the package directory: dangling shape,
group and query references, the query placeholder declarations, every
property shape's shacl:path against the vocabulary, and the shipped
queries against the documenting-a-query convention.
property shape's shacl:path against the vocabulary, the shipped
queries against the documenting-a-query convention, and the names of the
package's own attributes against the vocabulary skill's naming rules.

These need no instance, no credentials and no install - and two of the
mistakes they catch stay invisible until after one. A dangling
Expand All @@ -60,15 +61,31 @@ tasks:
every cardinality on that row with nothing to check, so validation passes
and proves nothing.

A package with no shape catalog passes with a note: every check here is
catalog-driven. The project's own skill map, .claude/skill-map.custom.json,
A package with no shape catalog passes with a note once its names are
checked: every other check here is catalog-driven. The project's own skill map, .claude/skill-map.custom.json,
is validated too, so a broken rule cannot switch its reminders off unseen.
preconditions:
- sh: command -v uv
msg: "The checks in bin/ run through uv (PEP 723 scripts). Install it: https://docs.astral.sh/uv/"
cmds:
- bin/check_all.py {{.PACKAGE_DIR}}

icons:
desc: Write the property icons the project maps in icons/property-icons.json into the vocabulary
summary: |
The Business Knowledge Editor draws a property's foaf:depiction on its
box. bin/property_icons.py gives every attribute an icon for its meaning
(or the kind of its value) and every mapped relation an icon for its kind
and direction, coloured after the classes, from the shared glyphs in
bin/glyphs/ and the project's own in icons/glyphs/ - each checked to be
drawing only. The vocabulary skill's depictions reference describes it.
task check:offline checks the result.
preconditions:
- sh: command -v uv
msg: "bin/property_icons.py runs through uv (PEP 723). Install it: https://docs.astral.sh/uv/"
cmds:
- bin/property_icons.py .

build:
desc: Build package archive
preconditions:
Expand Down
21 changes: 16 additions & 5 deletions src/bin/check_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# ///
"""Run every offline check in this toolkit over a Corporate Memory package directory.

The four checks answer questions that a SHACL validation run cannot, because the catalog has
The checks answer questions that a SHACL validation run cannot, because the catalog has
to be installed before validation can say anything — and two of these mistakes make the
install itself useless: a dangling reference turns every graph's validation into an HTTP 500,
and a path that matches nothing turns every constraint on its row into a no-op that reports
Expand All @@ -29,6 +29,10 @@
A file no parser accepts is the exception: that fails, because `cmemc package build` ignores
RDF syntax entirely and such a graph installs only as an error.

Where the project maps its properties to icons, it also checks that `property_icons.py` finds every
icon written and drawing only; that needs the vocabulary alone, so a vocabulary package is checked
too.

It also validates the project's own skill map, `.claude/skill-map.custom.json`, with
`check_skill_map.py`, and names it in the combined summary: a broken rule there takes
the skill check hook's reminders away without a word in the session.
Expand Down Expand Up @@ -156,11 +160,18 @@ def check(directory, quiet=False):
print(f"FAIL: {len(unparseable)} file(s) no parser accepts: {names}")
return 1

# The icon check needs only the vocabulary, so it runs for a vocabulary
# package too, before the shape-catalog checks below.
if vocab:
# Property icons are opt-in: without icons/property-icons.json it passes with a note.
code, _ = run("property_icons.py", ["--check", root], quiet)
extra.append(("property_icons.py", code))

if not shapes:
# Every check here is driven by a shape catalog, so a package without
# one - a plain vocabulary package, or one not yet written - has
# nothing to answer for. This runs as a build gate, so that is a pass.
return passed_early(extra, "no shape catalog found, so there is nothing to check")
# Every other check here is driven by a shape catalog, so a package
# without one - a plain vocabulary package, or one not yet written - has
# nothing more to answer for. This runs as a build gate, so that is a pass.
return passed_early(extra, "no shape catalog found, so there is nothing more to check")

plan = [
("check_dangling.py", shapes),
Expand Down
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/address.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/amount.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/count.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/date.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/due-date.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/email.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/identifier.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/link.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/location.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/name.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/number.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/quantity.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/text.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/time.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/title.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/year.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/attributes/yes-no.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/relations/categorised-as.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/bin/glyphs/relations/copy-of.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading