Skip to content

Commit ecbfc8d

Browse files
pedroanisioclaude
andcommitted
refactor(shapes): canonical Pydantic SHACL spec — five constructors become one model
shapes.shacl.ttl previously had five sources of truth: an imperative constructor in rdflib_emitter plus four per-plugin _add_prop variants with mutually incompatible property-shape hashing. The canonical model is now declarative: NodeShapeSpec/PropertySpec (pydantic, frozen, extra=forbid, cross-field invariants validated at import time) in shared_kernel/shacl_spec.py, rendered by the single render_shapes() code path. The emitter and every plugin shape contributor declare specs; none build RDF by hand. Behavior-preserving by proof: tests/test_shacl_spec.py pins semantic equality (constraint-level, sh:in lists resolved) against tests/fixtures/shapes_golden.ttl — a snapshot of the pre-refactor graph across all five tiers. Improvement: rendering is now fully byte-deterministic (the legacy concept-graph sh:in lists were fresh BNodes and churned every emit). Verified end-to-end: golden-repo bundles conform under both the fast-structural and pyshacl engines; full unit, backend, drift, and offline-LLM suites pass. pydantic moves into base dependencies with the same >=2.7,<3.0 pin the Docker mirror uses (H8). Also fixes the pre-existing untyped possible_import_edges parameter in build_inventory_graph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3cb107e commit ecbfc8d

8 files changed

Lines changed: 1122 additions & 444 deletions

File tree

codebase_mapper/emission/infrastructure/rdf/rdflib_emitter.py

Lines changed: 118 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import hashlib
66
import json
77
import re
8+
from typing import Sequence
89
from urllib.parse import quote
910

1011
from rdflib import Graph
@@ -30,12 +31,14 @@ def _iso_utc(ts: float) -> str:
3031

3132
from ....shared_kernel.constants import CBM, CBMI, CBMI_NS, CBMP, CBMP_NS, CBMT, CBMT_NS, CBM_NS, PHASE_VOCABULARY, SH, SPDX_CORE_NS, SPDX_SOFTWARE_NS, TYPE_VOCABULARY
3233
from ....shared_kernel.json_safety import dump_ast_summary
34+
from ....shared_kernel.shacl_spec import NodeShapeSpec, PropertySpec, render_shapes
3335
from ....inspection.models import (
3436
DeclaresDependencyEdge,
3537
FileRecord,
3638
ImportEdge,
3739
ImportExternalEdge,
3840
PinsDependencyEdge,
41+
PossibleImportEdge,
3942
TestsEdge,
4043
)
4144

@@ -67,7 +70,7 @@ def build_inventory_graph(
6770
dep_edges: list[DeclaresDependencyEdge], pin_edges: list[PinsDependencyEdge],
6871
tests_edges: list[TestsEdge],
6972
truncated_ast_paths: list[str] | None = None,
70-
possible_import_edges: list = (),
73+
possible_import_edges: Sequence[PossibleImportEdge] = (),
7174
) -> Graph:
7275
g = Graph()
7376
g.bind("cbm", CBM); g.bind("cbmt", CBMT); g.bind("cbmp", CBMP)
@@ -115,10 +118,11 @@ def build_inventory_graph(
115118

116119
for e in import_edges:
117120
g.add((file_iri(e.src_path), CBM.imports, file_iri(e.dst_path)))
118-
for e in possible_import_edges:
121+
for pe_edge in possible_import_edges:
119122
# Disclosed candidates of an ambiguous include (plan E4): a separate
120123
# property so hard cbm:imports consumers keep 100% precision.
121-
g.add((file_iri(e.src_path), CBM.possibleImport, file_iri(e.dst_path)))
124+
g.add((file_iri(pe_edge.src_path), CBM.possibleImport,
125+
file_iri(pe_edge.dst_path)))
122126
for te in tests_edges:
123127
g.add((file_iri(te.test_path), CBM.tests, file_iri(te.subject_path)))
124128

@@ -155,133 +159,118 @@ def build_inventory_graph(
155159
g.add((phase_iri(p), RDF.type, CBM.Phase))
156160
return g
157161

162+
def _core_shape_specs() -> tuple[NodeShapeSpec, ...]:
163+
"""The canonical model of every core (cbm:) node shape.
164+
165+
This declaration — not the emitted shapes.shacl.ttl — is the source of
166+
truth for the L1 validation contract. Plugins declare their tiers the
167+
same way in their graph_writer modules; render_shapes() is the only
168+
spec→RDF code path.
169+
"""
170+
xsd_string = str(XSD.string)
171+
xsd_datetime = str(XSD.dateTime)
172+
173+
file_shape = NodeShapeSpec(
174+
iri=f"{CBM_NS}FileShape", target_class=str(CBM.File), properties=(
175+
PropertySpec(path=str(CBM.path), datatype=xsd_string, min_count=1, max_count=1),
176+
PropertySpec(path=str(CBM.contentSha256), min_count=1, max_count=1,
177+
datatype=str(XSD.hexBinary),
178+
pattern="^[0-9a-f]{64}$"),
179+
PropertySpec(path=str(CBM.gitBlobSha), datatype=xsd_string,
180+
min_count=1, max_count=1),
181+
PropertySpec(path=str(CBM.sizeBytes), min_count=1, max_count=1,
182+
datatype=str(XSD.integer), min_inclusive=0),
183+
PropertySpec(path=str(CBM.language), datatype=xsd_string,
184+
max_count=1),
185+
# ast_summary is optional (only emitted when the analyzer
186+
# produces one), a canonical JSON literal of unbounded length.
187+
PropertySpec(path=str(CBM.astSummary), datatype=xsd_string,
188+
max_count=1),
189+
# extraction_errors: one literal per error, no count bound.
190+
PropertySpec(path=str(CBM.extractionError),
191+
datatype=xsd_string),
192+
# Manifest/lockfile edges are optional on cbm:File but their
193+
# object class is constrained when present.
194+
PropertySpec(path=str(CBM.declaresDependency),
195+
klass=str(CBM.ExternalPackage)),
196+
PropertySpec(path=str(CBM.pinsDependency),
197+
klass=str(CBM.PackageRelease)),
198+
# Filesystem + git commit times: optional single dateTimes
199+
# (None on a non-HEAD map / shallow clone).
200+
PropertySpec(path=str(CBM.atime), datatype=xsd_datetime,
201+
max_count=1),
202+
PropertySpec(path=str(CBM.mtime), datatype=xsd_datetime,
203+
max_count=1),
204+
PropertySpec(path=str(CBM.ctime), datatype=xsd_datetime,
205+
max_count=1),
206+
PropertySpec(path=str(CBM.gitCommitTime),
207+
datatype=xsd_datetime, max_count=1),
208+
PropertySpec(path=str(CBM.type), name="_typeProp",
209+
list_name="_typeList", min_count=1, max_count=1,
210+
in_iris=tuple(str(type_iri(t))
211+
for t in TYPE_VOCABULARY)),
212+
PropertySpec(path=str(CBM.hasPhase), name="_phaseProp",
213+
list_name="_phaseList", min_count=1,
214+
in_iris=tuple(str(phase_iri(p))
215+
for p in PHASE_VOCABULARY)),
216+
PropertySpec(path=str(CBM.imports), name="_importsProp",
217+
klass=str(CBM.File)),
218+
PropertySpec(path=str(CBM.possibleImport),
219+
name="_possibleImportProp", klass=str(CBM.File)),
220+
PropertySpec(path=str(CBM.importsExternal),
221+
name="_importsExtProp",
222+
klass=str(CBM.ExternalPackage)),
223+
))
224+
225+
tests_shape = NodeShapeSpec(
226+
iri=f"{CBM_NS}TestsSubjectShape",
227+
target_subjects_of=str(CBM.tests), properties=(
228+
PropertySpec(path=str(CBM.type), name="_testsTypeProp",
229+
has_value=str(type_iri("test_code"))),
230+
))
231+
232+
repo_shape = NodeShapeSpec(
233+
iri=f"{CBM_NS}RepositoryShape", target_class=str(CBM.Repository),
234+
properties=(
235+
PropertySpec(path=str(CBM.atCommit), klass=str(CBM.Commit),
236+
min_count=1, max_count=1),
237+
# Repository → File edges. No minCount (an empty repo emits no
238+
# files but the Repository node is still valid).
239+
PropertySpec(path=str(CBM.hasFile), klass=str(CBM.File)),
240+
))
241+
242+
# The Commit node carries its own SHA. The emitter writes the plain hex
243+
# string, so xsd:string with a hex pattern rather than xsd:hexBinary.
244+
commit_shape = NodeShapeSpec(
245+
iri=f"{CBM_NS}CommitShape", target_class=str(CBM.Commit),
246+
properties=(
247+
PropertySpec(path=str(CBM.commitSha), datatype=xsd_string,
248+
pattern="^[0-9a-f]+$", min_count=1, max_count=1),
249+
))
250+
251+
release_shape = NodeShapeSpec(
252+
iri=f"{CBM_NS}PackageReleaseShape",
253+
target_class=str(CBM.PackageRelease), properties=(
254+
PropertySpec(path=str(CBM.packageName), datatype=xsd_string,
255+
min_count=1, max_count=1),
256+
PropertySpec(path=str(CBM.packageVersion),
257+
datatype=xsd_string, min_count=1, max_count=1),
258+
PropertySpec(path=str(CBM.releaseOf),
259+
klass=str(CBM.ExternalPackage), min_count=1, max_count=1),
260+
))
261+
262+
return (file_shape, tests_shape, repo_shape, commit_shape,
263+
release_shape)
264+
265+
266+
CORE_SHAPE_SPECS = _core_shape_specs()
267+
268+
158269
def build_shacl_graph() -> Graph:
159-
g = Graph()
160-
g.bind("sh", SH); g.bind("cbm", CBM)
161-
g.bind("cbmt", CBMT); g.bind("cbmp", CBMP); g.bind("xsd", XSD)
162-
163-
def prop_uri(kwargs: dict) -> URIRef:
164-
key = "|".join(f"{k}={kwargs[k]}" for k in sorted(kwargs))
165-
return URIRef(f"{CBM_NS}_ps_{hashlib.sha1(key.encode()).hexdigest()[:16]}")
166-
167-
def add_prop(parent: URIRef, **kwargs) -> URIRef:
168-
b = prop_uri(kwargs)
169-
g.add((parent, SH.property, b))
170-
for k, v in kwargs.items():
171-
g.add((b, URIRef(SH + k), v))
172-
return b
173-
174-
file_shape = URIRef(f"{CBM_NS}FileShape")
175-
g.add((file_shape, RDF.type, SH.NodeShape))
176-
g.add((file_shape, SH.targetClass, CBM.File))
177-
178-
add_prop(file_shape, path=CBM.path,
179-
minCount=Literal(1), maxCount=Literal(1), datatype=XSD.string)
180-
add_prop(file_shape, path=CBM.contentSha256,
181-
minCount=Literal(1), maxCount=Literal(1),
182-
datatype=XSD.hexBinary, pattern=Literal("^[0-9a-f]{64}$"))
183-
add_prop(file_shape, path=CBM.gitBlobSha,
184-
minCount=Literal(1), maxCount=Literal(1), datatype=XSD.string)
185-
add_prop(file_shape, path=CBM.sizeBytes,
186-
minCount=Literal(1), maxCount=Literal(1),
187-
datatype=XSD.integer, minInclusive=Literal(0))
188-
add_prop(file_shape, path=CBM.language,
189-
maxCount=Literal(1), datatype=XSD.string)
190-
# ast_summary is optional (only emitted when the analyzer produces one)
191-
# and serialized as a canonical JSON literal. Length is not bounded — some
192-
# analyzers emit large AST blobs.
193-
add_prop(file_shape, path=CBM.astSummary,
194-
maxCount=Literal(1), datatype=XSD.string)
195-
# extraction_errors is a list, recorded as one literal per error. No count
196-
# bound; xsd:string covers the analyzer-emitted message format.
197-
add_prop(file_shape, path=CBM.extractionError, datatype=XSD.string)
198-
# Dependency-manifest files declare external packages; lockfile records
199-
# pin them to specific releases. Both predicates are optional on
200-
# cbm:File (most files are neither manifest nor lockfile) but when
201-
# present the object class is constrained.
202-
add_prop(file_shape, path=CBM.declaresDependency,
203-
**{"class": CBM.ExternalPackage})
204-
add_prop(file_shape, path=CBM.pinsDependency,
205-
**{"class": CBM.PackageRelease})
206-
# Filesystem + git commit times are optional (None on a non-HEAD map);
207-
# when present, they're single xsd:dateTime literals.
208-
for pred in (CBM.atime, CBM.mtime, CBM.ctime, CBM.gitCommitTime):
209-
add_prop(file_shape, path=pred,
210-
maxCount=Literal(1), datatype=XSD.dateTime)
211-
212-
from rdflib.collection import Collection
213-
type_list = URIRef(f"{CBM_NS}_typeList")
214-
Collection(g, type_list, [type_iri(t) for t in TYPE_VOCABULARY])
215-
type_prop = URIRef(f"{CBM_NS}_typeProp")
216-
g.add((file_shape, SH.property, type_prop))
217-
g.add((type_prop, SH.path, CBM.type))
218-
g.add((type_prop, SH.minCount, Literal(1)))
219-
g.add((type_prop, SH.maxCount, Literal(1)))
220-
g.add((type_prop, URIRef(SH + "in"), type_list))
221-
222-
phase_list = URIRef(f"{CBM_NS}_phaseList")
223-
Collection(g, phase_list, [phase_iri(p) for p in PHASE_VOCABULARY])
224-
phase_prop = URIRef(f"{CBM_NS}_phaseProp")
225-
g.add((file_shape, SH.property, phase_prop))
226-
g.add((phase_prop, SH.path, CBM.hasPhase))
227-
g.add((phase_prop, SH.minCount, Literal(1)))
228-
g.add((phase_prop, URIRef(SH + "in"), phase_list))
229-
230-
imports_prop = URIRef(f"{CBM_NS}_importsProp")
231-
g.add((file_shape, SH.property, imports_prop))
232-
g.add((imports_prop, SH.path, CBM.imports))
233-
g.add((imports_prop, URIRef(SH + "class"), CBM.File))
234-
235-
possible_imp_prop = URIRef(f"{CBM_NS}_possibleImportProp")
236-
g.add((file_shape, SH.property, possible_imp_prop))
237-
g.add((possible_imp_prop, SH.path, CBM.possibleImport))
238-
g.add((possible_imp_prop, URIRef(SH + "class"), CBM.File))
239-
240-
imports_ext_prop = URIRef(f"{CBM_NS}_importsExtProp")
241-
g.add((file_shape, SH.property, imports_ext_prop))
242-
g.add((imports_ext_prop, SH.path, CBM.importsExternal))
243-
g.add((imports_ext_prop, URIRef(SH + "class"), CBM.ExternalPackage))
244-
245-
tests_shape = URIRef(f"{CBM_NS}TestsSubjectShape")
246-
g.add((tests_shape, RDF.type, SH.NodeShape))
247-
g.add((tests_shape, URIRef(SH + "targetSubjectsOf"), CBM.tests))
248-
tests_type_prop = URIRef(f"{CBM_NS}_testsTypeProp")
249-
g.add((tests_shape, SH.property, tests_type_prop))
250-
g.add((tests_type_prop, SH.path, CBM.type))
251-
g.add((tests_type_prop, URIRef(SH + "hasValue"), type_iri("test_code")))
252-
253-
repo_shape = URIRef(f"{CBM_NS}RepositoryShape")
254-
g.add((repo_shape, RDF.type, SH.NodeShape))
255-
g.add((repo_shape, SH.targetClass, CBM.Repository))
256-
add_prop(repo_shape, path=CBM.atCommit,
257-
minCount=Literal(1), maxCount=Literal(1),
258-
**{"class": CBM.Commit})
259-
# Repository → File edges. min_count=0 (an empty repo emits no files but
260-
# the Repository node is still valid); object class fixed to cbm:File.
261-
add_prop(repo_shape, path=CBM.hasFile,
262-
**{"class": CBM.File})
263-
264-
# The Commit node carries its own SHA. Single value, hexBinary-shaped
265-
# (40 chars for SHA-1; the emitter currently writes the plain hex
266-
# string so we keep xsd:string and constrain the pattern).
267-
commit_shape = URIRef(f"{CBM_NS}CommitShape")
268-
g.add((commit_shape, RDF.type, SH.NodeShape))
269-
g.add((commit_shape, SH.targetClass, CBM.Commit))
270-
add_prop(commit_shape, path=CBM.commitSha,
271-
minCount=Literal(1), maxCount=Literal(1),
272-
datatype=XSD.string, pattern=Literal("^[0-9a-f]+$"))
273-
274-
rel_shape = URIRef(f"{CBM_NS}PackageReleaseShape")
275-
g.add((rel_shape, RDF.type, SH.NodeShape))
276-
g.add((rel_shape, SH.targetClass, CBM.PackageRelease))
277-
add_prop(rel_shape, path=CBM.packageName,
278-
minCount=Literal(1), maxCount=Literal(1), datatype=XSD.string)
279-
add_prop(rel_shape, path=CBM.packageVersion,
280-
minCount=Literal(1), maxCount=Literal(1), datatype=XSD.string)
281-
add_prop(rel_shape, path=CBM.releaseOf,
282-
minCount=Literal(1), maxCount=Literal(1),
283-
**{"class": CBM.ExternalPackage})
284-
return g
270+
return render_shapes(
271+
Graph(), CORE_SHAPE_SPECS,
272+
bind={"cbm": CBM_NS, "cbmt": CBMT_NS, "cbmp": CBMP_NS,
273+
"xsd": str(XSD)})
285274

286275
def build_ontology_mapping_graph() -> Graph:
287276
"""A small RDFS/OWL document mapping cbm: terms to SPDX 3.0.1.

0 commit comments

Comments
 (0)