55import hashlib
66import json
77import re
8+ from typing import Sequence
89from urllib .parse import quote
910
1011from rdflib import Graph
@@ -30,12 +31,14 @@ def _iso_utc(ts: float) -> str:
3031
3132from ....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
3233from ....shared_kernel .json_safety import dump_ast_summary
34+ from ....shared_kernel .shacl_spec import NodeShapeSpec , PropertySpec , render_shapes
3335from ....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+
158269def 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
286275def build_ontology_mapping_graph () -> Graph :
287276 """A small RDFS/OWL document mapping cbm: terms to SPDX 3.0.1.
0 commit comments