Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fbf96c9
feat: Enhance enum value handling: sanitize values for GraphQL compli…
jdacoello Feb 5, 2026
55b5b61
feat: Implement instance dimension enum sanitization and metadata ann…
jdacoello Feb 6, 2026
cb5e6ae
feat(s2dm): add extended attributes metadata annotations
jdacoello Feb 6, 2026
1853cd3
feat(s2dm): enhance pluralization handling and naming conventions in …
jdacoello Feb 6, 2026
ee038de
refactor(s2dm): move GraphQL utilities into s2dm exporter
jdacoello Feb 9, 2026
1c7e2be
feat: Implement progressive qualification for GraphQL type naming in …
jdacoello Feb 9, 2026
dcedf38
feat(s2dm): enhance name collision detection including tree and structs
jdacoello Feb 12, 2026
2e1b21f
fix(s2dm): Simplify directive handling in modular mode and resolve my…
jdacoello Feb 18, 2026
aeb345d
feat(s2dm): add unit enums metadata handling in modular mode
jdacoello Feb 18, 2026
0789fbf
fix(s2dm): Use the correct output type
jdacoello Feb 19, 2026
bba6f30
feat(s2dm): implement handling for skipped empty branches during export
jdacoello Mar 2, 2026
9a6d57d
feat(s2dm): simplify vspec metadata annotation with sidecar lookup sp…
jdacoello Mar 11, 2026
e1c85ba
feat(s2dm): fix struct processing and remove hoisted properties
jdacoello Apr 14, 2026
7ba0a63
refactor(s2dm): Simplify vspec directive and include instantiate meta…
jdacoello Apr 17, 2026
3a275fa
feat(s2dm): map units to qudt references
jdacoello Apr 28, 2026
3cba567
fix(s2dm): Update test for instance tag check without id fields
jdacoello Apr 28, 2026
4d16f73
fix(s2dm): Add alias for old vspec unit keys for qudt mappings
jdacoello Apr 29, 2026
8df6a55
fix(s2dm): Export allowed values inside struct as enums
jdacoello Jun 8, 2026
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
249 changes: 243 additions & 6 deletions docs/s2dm.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,242 @@ type Vehicle_Cabin @vspec(element: BRANCH, fqn: "Vehicle.Cabin") {
In this example, the metadata shows that the `Vehicle_Cabin` type was derived from the Fully-Qualified Name (FQN) `Vehicle.Cabin`, and that is was a `BRANCH`.
Likewise, the `driverPosition` was derived from `Vehicle.Cabin.DriverPosition` and it was an `ATTRIBUTE`.

The `@vspec` directive is also used to annotate original names when they have been modified for GraphQL compliance (for example, see [Enum Value Sanitization](#enum-value-sanitization)).

#### Extended Attributes

If the VSpec model uses extended attributes (custom metadata), the exporter add them to `@vspec` metadata annotations.

**Example VSS with extended attributes `source` and `quality`:**
```yaml
Vehicle.Speed:
datatype: float
type: sensor
unit: km/h
source: ecu0xAA # Extended attribute
quality: 100 # Extended attribute
```

**Generated GraphQL:**
```graphql
type Vehicle {
speed(unit: VelocityUnitEnum = KILOMETERS_PER_HOUR): Float
@vspec(
element: SENSOR,
fqn: "Vehicle.Speed",
metadata: [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not a dict as value, so taking 1:1 what is in the source?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This metadata can hold more than only the fields reported by the vss model. For example, I am now annotating when a name was modified. For instance, a leaf name in VSS is PascalCase, whereas in the export it becomes a field inside a type, which in GraphQL conventions is done with camelCase. However, I think I can assign to metadata:

Dict found in VSS model + Dict of extra stuff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, a dictionary is not a built-in scalar in GraphQL. There are other community driven scalars specified. But, they don't offer the dictionary scalar. The closest one would be to say it's a JSON.

"""Annotation directive for Vspec mapping information."""
directive @vspec(
  """The element of the Vspec language to which the item in the GraphQL schema maps."""
  element: VspecElement,
  """The Fully Qualified Name (FQN) of the related element in Vspec (aka., path). Example: Vehicle.Cabin.Door.Window.position"""
  fqn: String
  """Additional metadata associated with the mapping."""
  metadata: JSON
) on OBJECT | FIELD_DEFINITION | ENUM | ENUM_VALUE
@vspec(
  element: SENSOR,
  fqn: "Vehicle.Speed",
  metadata: '{"source": "ecu0xAA", "quality": "100"}'
)

Is that what you mean? It seems feasible. But, I see a few limitations with that:

  • Less GraphQL-idiomatic (clients must parse JSON string), where the JSON can have multiple nested objects
  • No type safety
  • GraphQL introspection will provide metadata as a string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's what I meant but encoding JSON into a string is not that great... then let's stick with the typed key vale pair. Although there exists the same problem that all values are forced to be strings and we are limiting ourselves to non complex metadata values, which in turn means that complex vales could be JSON strings...

{key: "source", value: "ecu0xAA"},
{key: "quality", value: "100"}
]
)
}
```

Extended attributes work on all VSS elements: branches, sensors, actuators, attributes, and structs.

### VSS Data Types Support
The exporter handles all `vspec` data types as follows:
- **Strings** → GraphQL String
- **Numbers** → GraphQL Int, Float, or custom scalars (Int8, UInt16, etc.)
- **Booleans** → GraphQL Boolean
- **Arrays** → GraphQL Lists
- **Arrays** → GraphQL Lists (with natural plural field names)
- **Allowed values** → GraphQL Enums

#### List Field Names (Automatic Pluralization)

When VSS branches have instances (like `Seat` with multiple rows/positions), the parent type gets a list field. The S2DM exporter automatically generates **natural plural names** using the inflect library:

- `seat` → `seats: [Vehicle_Cabin_Seat]`
- `door` → `doors: [Vehicle_Cabin_Door]`
- `window` → `windows: [Vehicle_Cabin_Window]`
- `battery` → `batteries: [Vehicle_Battery]`
- `mirror` → `mirrors: [Vehicle_Body_Mirror]`

This improves GraphQL schema readability by following common naming conventions instead of using mechanical suffixes like `_s`.

**Example:**
```graphql
type Vehicle_Cabin {
"""Cabin seats for passengers."""
seats: [Vehicle_Cabin_Seat]

"""Cabin doors."""
doors: [Vehicle_Cabin_Door]
}
```

**Tracking:** All pluralized field names are logged to `vspec_reference/pluralized_field_names.yaml` showing the original VSS FQN, the plural field name used, and its location in the GraphQL schema.

#### Enum Value Sanitization

GraphQL enum values must follow strict naming rules (alphanumeric + underscore only, cannot start with a digit). The S2DM exporter automatically sanitizes VSS enum values to comply with GraphQL requirements:

- **Spaces & special characters** → Converted to underscores (`"some value"` → `SOME_VALUE`)
- **CamelCase** → Converted to SCREAMING_SNAKE_CASE (`"HTTPSProtocol"` → `HTTPS_PROTOCOL`)
- **Leading digits** → Prefixed with underscore (`"123abc"` → `_123ABC`)

When enum values are modified, the original VSS value is preserved using `@vspec` metadata for complete traceability. This applies to both **allowed value enums** and **instance dimension enums**.

**Allowed Value Enum Example:**
```graphql
enum Vehicle_Connection_Protocol_Enum @vspec(element: SENSOR, fqn: "Vehicle.Connection.Protocol", metadata: [{key: "allowed", value: "['HTTPSProtocol', 'TCPProtocol']"}]) {
HTTPS_PROTOCOL @vspec(metadata: [{key: "originalName", value: "HTTPSProtocol"}])
TCP_PROTOCOL @vspec(metadata: [{key: "originalName", value: "TCPProtocol"}])
}
```

**Instance Dimension Enum Example:**
```graphql
enum Vehicle_Cabin_Seat_InstanceTag_Dimension2 {
DRIVER_SIDE @vspec(metadata: [{key: "originalName", value: "DriverSide"}])
PASSENGER_SIDE @vspec(metadata: [{key: "originalName", value: "PassengerSide"}])
}
```

This ensures complete traceability between the VSS source and the generated GraphQL schema.

### GraphQL Type Naming: Short Names with Collision Resolution

**By default**, the S2DM exporter generates **clean, short GraphQL type names** using the last segment of the VSS path:

- `Vehicle.Cabin.Door.Window` → `type Window`
- `Vehicle.Body.Lights` → `type Lights`
- `Vehicle.Chassis.Axle` → `type Axle`

This improves readability and follows GraphQL best practices for concise type names.

#### Progressive Qualification for Name Collisions

Since VSS does not enforce branch name uniqueness (only Fully Qualified Names are unique), the exporter uses **progressive qualification** when collisions are detected:

1. **Try short name first**: `Window`
2. **If collision, add parent**: `Door_Window`, `Windshield_Window`
3. **If still collision, add more ancestors**: `Cabin_Door_Window`, `Body_Windshield_Window`
4. **Last resort: use full FQN**: `Vehicle_Cabin_Door_Window`

**Example from real VSS collisions:**

```graphql
# Vehicle.Cabin.Door.Shade and Vehicle.Cabin.Sunroof.Shade both have short name "Shade"
# Resolution: qualify with parent name
type Door_Shade @vspec(element: BRANCH, fqn: "Vehicle.Cabin.Door.Shade") {
position: Int
}

type Sunroof_Shade @vspec(element: BRANCH, fqn: "Vehicle.Cabin.Sunroof.Shade") {
position: Int
}
```

**Console Output:** During export, collision statistics are logged:

```
[INFO] Short name collision resolution:
✓ 245 types use short names (no collisions)
⚠ 8 types qualified with parent (e.g., Parent_Name)
⚠ 2 types qualified with multiple ancestors (e.g., GrandParent_Parent_Name)
→ See vspec_reference/short_name_collisions.yaml for 5 collision groups
```

**Collision Report:** The `vspec_reference/short_name_collisions.yaml` file documents all name collisions and resolutions in a compact format:

```yaml
# Short Name Collision Resolution Report
#
# This file documents how VSS branch names were converted to GraphQL type names.
# Resolution strategy: Progressive parent qualification
# 1. Try short name (e.g., 'Window')
# 2. If collision: add parent (e.g., 'Door_Window', 'Windshield_Window')
# 3. If still collision: add more ancestors (e.g., 'Cabin_Door_Window')
# 4. Last resort: use full FQN with underscores

summary:
total_branches: 137
no_collisions: 120
parent_qualified: 15
multi_ancestor_qualified: 2
full_fqn_fallback: 0

# Collisions resolved by adding immediate parent name (e.g., Parent_Name)
parent_qualified:
- { fqn: Vehicle.Body.Windshield.Shade, type: Windshield_Shade }
- { fqn: Vehicle.Cabin.Door.Shade, type: Door_Shade }
- { fqn: Vehicle.Cabin.Sunroof.Shade, type: Sunroof_Shade }

# Collisions resolved by adding multiple ancestor names (e.g., GrandParent_Parent_Name)
multi_ancestor_qualified:
- { fqn: Vehicle.Powertrain.Engine.Brake, type: Engine_Brake }
- { fqn: Vehicle.Chassis.Axle.Wheel.Brake, type: Axle_Wheel_Brake }

# Detailed collision groups: branches that share the same short name
collision_groups:
Axle: # 4 branches share this name
- { fqn: Vehicle.Chassis.Axle, type: Chassis_Axle }
- { fqn: Vehicle.Trailer.Axle, type: Trailer_Axle }
- { fqn: Vehicle.Chassis.Axle.Wheel.Axle, type: Wheel_Axle }
- { fqn: Vehicle.Body.Hood.Axle, type: Hood_Axle }
Shade: # 2 branches share this name
- { fqn: Vehicle.Cabin.Door.Shade, type: Door_Shade }
- { fqn: Vehicle.Cabin.Sunroof.Shade, type: Sunroof_Shade }
```

#### Opting Out: Full FQN Names

To use traditional fully-qualified names with underscores (legacy behavior), use the `--fqn-type-names` flag:

```bash
vspec export s2dm --vspec spec.vspec --output myOutput/ --fqn-type-names
```

This generates:
- `Vehicle.Cabin.Door.Window` → `type Vehicle_Cabin_Door_Window`
- `Vehicle.Body.Lights` → `type Vehicle_Body_Lights`

**When to use `--fqn-type-names`:**
- You need backward compatibility with existing schemas
- Your tooling expects the old naming convention
- You prefer explicit full paths over short names

### GraphQL Naming Convention Warnings

GraphQL best practices recommend using **singular names for types** (e.g., `User` not `Users`, `Product` not `Products`). The S2DM exporter automatically detects VSS branches with plural names and generates warnings to help identify potential naming convention violations.

**Detection:** The exporter uses the inflect library to identify potential plural type names. It maintains a whitelist of known exceptions (acronyms like "ADAS", "ABS", Latin words like "Status", "Chassis") to reduce false positives.

**Warning Output:** When plural type names are detected, two files are generated in `vspec_reference/`:

1. **`plural_type_warnings.yaml`** - Lists all detected plural type names:
```yaml
# WARNING: These elements in the reference model seem to have a plural name...

Vehicle.Cabin.Lights:
singular: Light
currentNameInGraphQLModel: Lights # or Vehicle_Cabin_Lights if using --fqn-type-names

Vehicle.Body.Mirrors:
singular: Mirror
currentNameInGraphQLModel: Mirrors # or Vehicle_Body_Mirrors if using --fqn-type-names

# Whitelisted words (excluded from plural detection):
whitelisted_non_plurals:
- ADAS
- ABS
- Status
- Chassis
# ... etc
```

2. **Console warnings:** During export, warnings are logged for immediate visibility:
```
WARNING: Type 'Lights' uses potential plural name 'Lights'.
Suggested singular: 'Light' (VSS FQN: Vehicle.Cabin.Lights)
```

**Review Process:** These warnings help VSS maintainers identify:
- **True plurals** - VSS branches that should be renamed to singular form
- **False positives** - Words that end in 's' but aren't actually plural (add to whitelist)
- **Acceptable exceptions** - Cases where plural names are intentional

### VSS Instances Become GraphQL Structures
When your `vspec` has instances (like multiple seats), the exporter creates proper GraphQL types:

Expand All @@ -63,13 +291,13 @@ type Vehicle_Cabin_Seat_InstanceTag @instanceTag {
}

enum Vehicle_Cabin_Seat_InstanceTag_Dimension1 {
Row1
Row2
ROW1 @vspec(metadata: [{key: "originalName", value: "Row1"}])
ROW2 @vspec(metadata: [{key: "originalName", value: "Row2"}])
}

enum Vehicle_Cabin_Seat_InstanceTag_Dimension2 {
DriverSide
PassengerSide
DRIVER_SIDE @vspec(metadata: [{key: "originalName", value: "DriverSide"}])
PASSENGER_SIDE @vspec(metadata: [{key: "originalName", value: "PassengerSide"}])
}
```
Such an structure is then usable by any other type like:
Expand Down Expand Up @@ -125,7 +353,10 @@ myOutput/
├── README.md # Documentation and provenance info
├── vspec_lookup_spec.yaml # Complete VSS tree (fully expanded)
├── vspec_units.yaml # Units used (if provided via -u or implicit)
└── vspec_quantities.yaml # Quantities used (if provided via -q or implicit)
├── vspec_quantities.yaml # Quantities used (if provided via -q or implicit)
├── short_name_collisions.yaml # Name collision resolutions (if any detected)
├── plural_type_warnings.yaml # Plural type names detected (if any)
└── pluralized_field_names.yaml # Fields changed to plural form (if any instances)
```

### VSS Reference Files
Expand All @@ -136,11 +367,15 @@ The `vspec_reference/` directory provides complete traceability:
- **vspec_lookup_spec.yaml** - Complete VSS specification tree (fully processed and expanded) in YAML format
- **vspec_units.yaml** - Unit definitions used during generation (included if units were provided via `-u` flag or implicitly loaded)
- **vspec_quantities.yaml** - Quantity definitions used during generation (included if quantities were provided via `-q` flag or implicitly loaded)
- **short_name_collisions.yaml** - Branch name collisions and progressive qualification resolutions (generated if collisions detected)
- **plural_type_warnings.yaml** - VSS branches with plural type names that may violate GraphQL naming conventions (generated if any detected)
- **pluralized_field_names.yaml** - Fields whose names were changed to plural form for list fields (generated if any instances exist)

These files allow you to:
1. Trace GraphQL elements back to their VSS source using the FQN in `@vspec` directives
2. Reproduce the exact GraphQL schema by re-running the exporter
3. Understand which input files were used for generation
4. Review naming convention warnings and pluralization changes



Expand Down Expand Up @@ -183,6 +418,8 @@ enum VelocityUnitEnum @vspec(element: QUANTITY_KIND, metadata: [{key: "quantity"
}
```

**Note:** Type names use short names by default (`Vehicle` instead of `Vehicle`). For types deeper in the tree like `Vehicle.Cabin.Door.Window`, the short name `Window` is used unless a collision is detected. Use `--fqn-type-names` to get fully-qualified names like `Vehicle_Cabin_Door_Window`.

## Output Structures

The S2DM exporter supports three output modes. All modes generate a `vspec_reference/` directory for traceability.
Expand Down
7 changes: 7 additions & 0 deletions src/vss_tools/cli_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,10 @@ def validate_attribute(value):
default=True, # Default to flat for simplicity
show_default=True,
)

fqn_type_names_opt = option(
"--fqn-type-names/--short-type-names",
help="Use fully qualified names for GraphQL types (default: use short names with collision resolution).",
default=False,
show_default=True,
)
14 changes: 9 additions & 5 deletions src/vss_tools/exporters/s2dm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
@clo.types_opt
@clo.modular_opt
@clo.flat_domains_opt
@clo.fqn_type_names_opt
@clo.strict_exceptions_opt
def cli(
vspec: Path,
Expand All @@ -60,6 +61,7 @@ def cli(
types: tuple[Path, ...],
modular: bool,
flat_domains: bool,
fqn_type_names: bool,
strict_exceptions: Path | None,
) -> None:
"""
Expand Down Expand Up @@ -109,29 +111,31 @@ def cli(
log.info("Generating S2DM GraphQL schema...")

# Generate the schema
schema, unit_enums_metadata, allowed_enums_metadata, vspec_comments = generate_s2dm_schema(
tree, data_type_tree, extended_attributes=extended_attributes
schema, unit_enums_metadata, allowed_enums_metadata, mapping_metadata = generate_s2dm_schema(
tree, data_type_tree, extended_attributes=extended_attributes, use_short_names=not fqn_type_names
)

if modular:
# Write modular files
write_modular_schema(
schema, unit_enums_metadata, allowed_enums_metadata, vspec_comments, output_dir, flat_domains
schema, unit_enums_metadata, allowed_enums_metadata, mapping_metadata, output_dir, flat_domains
)
log.info(f"Modular GraphQL schema written to {output_dir}/")
else:
# Single file export: write to outputDir/outputDir.graphql
graphql_file = output_dir / f"{output_dir.name}.graphql"
full_schema_str = print_schema_with_vspec_directives(
schema, unit_enums_metadata, allowed_enums_metadata, vspec_comments
schema, unit_enums_metadata, allowed_enums_metadata, mapping_metadata
)
with open(graphql_file, "w", encoding="utf-8") as outfile:
outfile.write(full_schema_str)

log.info(f"GraphQL schema written to {graphql_file}")

# Generate VSS reference files (will check for implicit files)
generate_vspec_reference(tree, data_type_tree, output_dir, extended_attributes, vspec, units, quantities)
generate_vspec_reference(
tree, data_type_tree, output_dir, extended_attributes, vspec, units, quantities, mapping_metadata
)

except S2DMExporterException as e:
log.error(e)
Expand Down
6 changes: 4 additions & 2 deletions src/vss_tools/exporters/s2dm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@

from caseconverter import DELIMITERS, pascalcase

from vss_tools.utils.graphql_utils import (
from vss_tools.utils.string_conversion_utils import handle_fqn_conversion

from .graphql_utils import (
DEFAULT_CONVERSIONS,
GraphQLElementType,
load_predefined_schema_elements,
)
from vss_tools.utils.string_conversion_utils import handle_fqn_conversion

# VSS leaf types to track in field metadata (corresponds to VspecElement enum in directives.graphql)
# Note: BRANCH is excluded as it's handled separately for object types
Expand Down Expand Up @@ -62,6 +63,7 @@ def get_s2dm_conversions() -> Dict[GraphQLElementType, Callable[[str], str]]:
VSpecDirective = CUSTOM_DIRECTIVES["vspec"]
RangeDirective = CUSTOM_DIRECTIVES["range"]
InstanceTagDirective = CUSTOM_DIRECTIVES["instanceTag"]
ReferenceDirective = CUSTOM_DIRECTIVES["reference"]


class S2DMExporterException(Exception):
Expand Down
Loading
Loading