Skip to content

Repository files navigation

kSTEP

kSTEP

Type-safe Kotlin DSL for the STEP standard (ISO 10303).

Warning
kSTEP is in early, pre-release development (M2). The API is unstable and no artifacts are published yet. See Status below for what actually works today.

Licensed under the Apache License, Version 2.0. See LICENSE.

Why kSTEP

LLMs are strong in popular, high-resource languages (Kotlin, Java, Python) but measurably weak at generating external, low-resource DSLs — and STEP’s native exchange format (Part 21, graphs of #123-style entity references) is a particularly hostile one to read or write directly.

kSTEP applies Typed Domain Grounding (TDG) — the pattern already validated by its sibling project, kUML: expose a domain as a type-safe, embedded Kotlin DSL with semantic validation and structured errors, so that both human developers and LLM agents can work in it reliably. The syntax stays Kotlin (high-resource); only the vocabulary is domain-specific, enforced by the type system. A strict type system, a compiler, and structured validation errors form a feedback loop the LLM can act on directly, without a separate benchmark or fine-tuning step. kSTEP compiles that DSL down to the neutral, standardized ISO 10303 exchange format.

Vision

kSTEP is not "just another CAD tool." The ambition is to cover the entire STEP standard (ISO 10303) — CAD is the entry point, not the boundary. STEP spans far more than mechanical geometry: manufacturing (STEP-NC), electrical and electronics (assemblies, PCBs, wiring), kinematics, composites, FEM/simulation, and plant/process data are all part of the standard, each carved out as its own Application Protocol (AP242, AP238, AP210, AP209, and others). Those APs are the building blocks kSTEP intends to grow into over time.

The CAD market today splits into two camps: classic CAD suites (SolidWorks, CATIA, Siemens NX, Fusion 360, Inventor, Onshape) — GUI- centric, click-based, proprietary formats, AI bolted on after the fact — and code-first CAD (OpenSCAD, CadQuery, Build123d, Replicad, JSCAD) — scriptable and Git-friendly, but niche and without real AI integration. kSTEP is the deliberate third way: code-first like the second camp, AI-first like neither camp, and STEP-native instead of proprietary. It is the same thesis as its sibling project kUML in the UML/SysML space, applied across the full breadth of engineering rather than just software architecture — kUML covers the IT domain, kSTEP covers the engineering domain, both grounded in the same Typed Domain Grounding (TDG) principle described above.

V1 deliberately starts narrow — a single Application Protocol (AP242), scoped down to product structure and metadata with no geometry and no PMI (see Roadmap below and kSTEP-ADR-0001) — but the architecture is meant to carry the wider vision from the start, not to box it in.

Status

The project has completed M1 (Headless MVP — EXPRESS parser, semantic model, Kotlin code generation, WHERE-rule evaluation, the kstep-core runtime DSL surface, STEP Part 21 export/import, and DERIVE/INVERSE/ UNIQUE clause capture, six waves) and has started M2 (AI Layer) with an MCP server exposing the M1 DSL as LLM tool-calling tools (M2 Welle 1, below). Concretely, as of this writing:

  • An ANTLR4-based parser for the EXPRESS schema language (ISO 10303-11) exists and successfully parses a hand-written AP242-subset fixture in kstep-tests, plus (M1 Welle 4) a small excerpt of the real, official AP242 schema containing the six V1 entities and their supporting declarations — see below.

  • A semantic model walks the ANTLR parse tree into a typed Kotlin AST (dev.kstep.express.semantic): entities with their explicit attributes (declared type, optional-ness), supertype/subtype relationships, and WHERE-rule clauses. Attribute types resolve against a per-schema, case-insensitive symbol table built in a two-pass walk, so forward references (an entity referencing an entity declared later in the same schema) resolve correctly. WHERE-rule bodies are captured verbatim as unparsed source text.

  • A KotlinPoet-based code generator (dev.kstep.express.codegen.ExpressKotlinCodeGenerator) turns the semantic model into idiomatic Kotlin data class`es, one per entity, with named (and, for `OPTIONAL attributes, defaulted) constructor parameters mirroring the EXPRESS declaration order. Verified end-to-end against all six entities of the AP242-subset fixture, and (M1 Welle 4) against the real six V1 entities extracted from the official AP242 schema — see below. SUBTYPE OF is handled by attribute-inheritance flattening, not Kotlin inheritance (see the SUBTYPE OF inheritance-flattening entry below); constructs the generator still doesn’t turn into Kotlin — redeclared attributes, LOGICAL/NUMBER/BINARY-typed attributes, and zero-attribute entities — raise a structured CodeGenException instead of emitting a silently wrong or partial class. A TYPE reference (DefinedTypeRef) resolves only the single-level simple-alias case (TYPE x = STRING; and the other five simple EXPRESS types — ExpressDefinedType’s `underlyingSimpleType) to its underlying Kotlin type; anything needing further indirection — an aggregation, a SELECT/ENUMERATION, or a TYPE that itself references another TYPE — still raises CodeGenException, deliberately not resolved transitively (M1 Welle 4).

  • (M1 Welle 4) The generator has now run against real AP242 ground truth for the first time, not just the hand-written AP242-subset fixture. A curated excerpt of the official ap242ed2_dis2_mim_lf_v1.101.exp (ISO TS 10303-442 AP242 EXPRESS MIM Long Form, v1.101, 2019 — see NOTICE for full provenance, which parts are verbatim vs. deliberately adapted, and the precedent this excerpt’s inclusion rests on) — the six V1 entities plus the supporting TYPE/ENTITY declarations their attribute types reference, directly or (as of the SUBTYPE OF inheritance-flattening wave below) via SUBTYPE OF — is vendored at kstep-express/src/main/resources/dev/kstep/express/codegen/ap242-v1-entities.exp and regenerated by dev.kstep.express.codegen.Ap242V1CodeGen (see the generateExpressKotlin Gradle task below). The complete, unmodified official schema (49,942 lines, 2,122 entities) is not vendored in this repository — this wave’s real-schema verification is scoped to this excerpt, not a full-schema-scale exercise of the parser/semantic model. Against this real-schema ground truth, codegen now succeeds for all six V1 entities, including next_assembly_usage_occurrence — see the SUBTYPE OF inheritance-flattening entry below for how.

  • SUBTYPE OF attribute-inheritance flattening (M2, following M1 Welle 4’s real-schema cross-check above): dev.kstep.express.semantic. InheritanceResolver flattens a SUBTYPE OF chain into a ResolvedEntity — every ancestor’s explicit attributes prepended (supertype-most-general first, matching STEP Part 21 instance encoding) to the entity’s own — because a generated Kotlin data class cannot itself extend another data class; Kotlin inheritance was considered and rejected in favor of this flattening approach (see ExpressKotlinCodeGenerator’s KDoc for the full rejected-alternatives writeup). An `ABSTRACT SUPERTYPE contributes attributes to its subtypes but is never itself emitted as a Kotlin class. Structured failures (SemanticModelException): a SUBTYPE OF cycle, a chain deeper than 32 levels, an unresolved supertype name, more than one SUBTYPE OF ancestor (EXPRESS AND/ANDOR multiple inheritance, out of scope for V1), a SELF...RENAMED redeclared attribute encountered while flattening, and a flattened property-name collision between two different ancestors. Against real-schema ground truth, next_assembly_usage_occurrence’s actual SUBTYPE OF chain is `product_definition_relationshipproduct_definition_usageassembly_component_usagenext_assembly_usage_occurrence; none of the first three declare ABSTRACT SUPERTYPE in the real schema, so per EXPRESS semantics they are themselves instantiable too — but nothing in the six V1 entities' generated Kotlin references them as an attribute type, so Ap242V1CodeGen deliberately does not emit Kotlin classes for them (see that file’s SUPPORT_ENTITY_NAMES comment). NextAssemblyUsageOccurrence’s flattened constructor is `id, name, description?, relatingProductDefinition, relatedProductDefinition, referenceDesignator? (6 parameters). This also incidentally resolves the two entities flagged as a limitation in M1 Welle 4: product_context/product_definition_context are themselves SUBTYPE OF (application_context_element), and once that chain flattens cleanly they codegen too — so Product’s and `ProductDefinition’s generated Kotlin is now fully self-contained, with no dangling class references anywhere in the emitted file (see `Ap242V1CodeGenTest).

  • A real correctness fix from this cross-check: the real AP242 MIM’s approval.level attribute is typed label (a TYPE label = STRING; alias), not INTEGER as Welle 3 had assumed without a real schema to verify against. ap242-subset.exp’s `approval entity and kstep-core’s `Approval/ApprovalBuilder now use String; the SELF.level >= 0 WHERE rule (meaningless for a string) is replaced with SELF.level <> '', mirroring the non-empty-string pattern already used by product.id/product_definition.id.

  • A WHERE-rule evaluator (dev.kstep.express.validation) interprets the actually-occurring subset of EXPRESS WHERE-rule expressions: comparisons (>, >=, <, , =, <>), SELF.attribute references (and the equivalent bare attribute form) resolved against an instance attribute value bag, string/integer/real literals, and AND/OR/NOT boolean combinators. WhereRuleExpressionBuilder re-parses the verbatim expression text via a new ExpressParserFactory.parseExpression entry point and walks the expression parse tree into a small AST; WhereRuleEvaluator evaluates that AST against a Map<String, WhereRuleValue>; WhereRuleValidator ties both together into a (entityName, rules, attributeValues) → List<WhereRuleViolation> API. Anything outside the supported subset (EXISTS(), SIZEOF(), other function calls, aggregate/set operations, QUERY, arithmetic operators, the tri-state LOGICAL type, …​) raises a structured UnsupportedWhereExpressionException instead of silently doing the wrong thing; a genuine evaluation-time problem (a missing attribute, a non-boolean result, an incompatible-type comparison) raises WhereRuleEvaluationException. Both the re-parse and the AST walk are depth-guarded against pathologically deep (but syntactically valid) expressions, mirroring the existing StackOverflowError guard at the ANTLR-parse boundary and the MAX_TYPE_NESTING_DEPTH guard in the semantic model. The ap242-subset.exp fixture now carries a WHERE rule on five of its six entities (product_definition_formation deliberately has none), exercised end-to-end from parse through evaluation.

  • kstep-core now has hand-authored, type-safe Kotlin builders for all six V1 entities (dev.kstep.core.ap242): product, personAndOrganization, approval, productDefinitionFormation, productDefinition, nextAssemblyUsageOccurrence. Each is a named-parameter, lambda-with-receiver DSL function returning dev.kstep.core.ValidationResult<T>Valid(value) or Invalid(violations), never a thrown exception for a validation failure. Building an instance runs the WHERE-rule evaluator against the built values and also checks that every mandatory entity-typed reference attribute and every mandatory primitive attribute was actually set, collecting all violations (never stopping at the first) as structured DslViolation`s with `KSTEP-W-001 (WHERE rule not satisfied), KSTEP-M-001 (missing mandatory reference), or (M2 Welle 7) KSTEP-M-002 (missing mandatory primitive attribute) codes, analogous to kUML’s KUML-E-xxx structured errors — see Roadmap above for what KSTEP-M-002 covers and what it deliberately doesn’t. These six types are hand-authored independently of ExpressKotlinCodeGenerator’s generated output, and (M2 Welle 8) that relationship is now precisely characterized rather than a vague "future work" placeholder: (i) codegen already produces schema-faithful equivalents of all six types plus their six support entities in the generated `dev.kstep.generated.ap242v1 package (see Status above and Ap242V1CodeGenTest) — the generator needed no changes; (ii) kstep-core is a deliberately ergonomic layer aligned to the ap242-subset.exp fixture, which simplifies the real excerpt in specific, now-enumerated ways (entity references modeled as String, a few real attributes omitted, one optionality narrowed, one attribute invented, several WHERE rules synthesized — see each type’s KDoc in dev.kstep.core.ap242 for the per-attribute rationale); (iii) this wave adds dev.kstep.tests.Ap242CoreSchemaConsistencyTest, which re-derives the real shape live from ap242-v1-entities.exp on every run and fails the build on any divergence not explicitly named in its allowlist — so the gap is now bounded and guarded, not open-ended. See Roadmap for the full divergence inventory and what remains deliberately deferred.

  • (M2 Welle 7) A correctness fix in the same spirit as the approval.level one above: Product.kt’s doc comment claimed `id, name, description, all STRING, none OPTIONAL, which contradicted the real AP242 MIM (description : OPTIONAL text;, ap242-v1-entities.exp line 138) and the builder’s own description handling — corrected to state plainly that only id/name are non-OPTIONAL. This wave also closes the mandatory-primitive-attribute-presence gap: product.name and next_assembly_usage_occurrence.name are non-OPTIONAL label attributes with no WHERE rule, previously left silently defaultable to "" by both kstep-core’s builders and `kstep-mcp’s `build_product/build_next_assembly_usage_occurrence tools (which used to write name = args.name ?: ""). Both now use a nullable presence sentinel and surface KSTEP-M-002 — including through the MCP tool path, since that is exactly the "compiler as oracle for the LLM" surface this project exists to demonstrate — when name is never assigned. An explicitly empty name is unaffected and stays Valid.

  • (M1 Welle 5) kstep-step21 now has a real STEP Part 21 (ISO 10303-21 physical file exchange format) writer and reader for the six V1 AP242 entities, in dev.kstep.step21. Part 21 is a different, much simpler ISO 10303 sub-format than EXPRESS (the physical #123=ENTITY_NAME(…​); exchange syntax, not a schema language), so this wave deliberately does not reuse the ANTLR EXPRESS grammar/parser — it is a small, independent, hand-rolled scanner (Part21Tokenizer) plus a two-pass graph resolver (Part21GraphResolver). Part21Writer.write(header, roots) serializes a graph of already-validated kstep-core instances, deduplicating shared references by object identity (not structural equality) so a single shared Product gets exactly one #N no matter how many ProductDefinitionFormation`s reference it. `Part21Reader.read(source) parses Part-21 text back, resolving forward references (an entity may reference a #N defined later in the file) via an iterative, non-recursive topological sort, and reconstructs each instance through its kstep-core builder function — so WHERE-rule validation runs on read too, not only on write. Genuine structural malformation (missing semicolon, malformed #N=, unknown entity name, wrong argument arity/kind, a reference pointing at the wrong target entity type, a dangling reference, a reference cycle, or a DoS-guard trip on source length/instance count/nesting depth/reference-chain depth) raises one of six structured exceptions (Part21SyntaxException, Part21EncodingException, Part21DanglingReferenceException, Part21CycleException, Part21LimitExceededException, Part21WriteException). A WHERE-rule failure while reconstructing a parsed instance is not thrown — it surfaces as a DslViolation in the returned Part21ReadResult.violations, with any instance that (directly or transitively) depended on a failed instance recorded in Part21ReadResult.skipped instead of being attempted, mirroring kstep-core’s own "a validation failure is structured data, not a thrown exception" philosophy. V1 does not implement ISO 10303-21’s `\X\/\X2\/\X4\ non-ASCII escape mechanism — string values are scoped to printable ASCII plus the ''-doubling convention for an embedded '; anything else raises Part21EncodingException rather than being silently mis-encoded. Acceptance-criterion honesty: kSTEP-ADR-0001 names a lossless roundtrip through an external CAD/PLM tool (e.g. FreeCAD) as the real-world bar for Part 21 export/import. No such tool is available in this development environment, so that external validation has not been done — this wave’s acceptance test is a self-roundtrip only (Part21Reader.read(Part21Writer.write(header, model)) == model, verified in Part21RoundtripTest). This closes the kSTEP-own-format half of the ADR-0001 bar, not the external-interop half; see Roadmap below, which carries the FreeCAD/external-tool gap forward explicitly rather than overclaiming it.

  • kstep-step21 has no CLI wiring yet (no kstep render/kstep import command) — it is a library API only, callable from tests, matching how Wave 2’s codegen and Wave 3’s WHERE-rule evaluator were introduced as plain Kotlin APIs before any CLI wiring.

  • (M1 Welle 6) The semantic model now captures DERIVE, INVERSE, and UNIQUE entity-body clauses — a gap flagged across three separate wave reviews (ExpressEntity’s own "losslessly captured" KDoc was, until now, aspirational rather than true). Three new data classes in `dev.kstep.express.semantic: ExpressDerivedAttribute (name, declared type via the same parameterType resolution explicit attributes already use, verbatim initializer expressionText — DERIVE expressions are captured, not evaluated, exactly like WHERE rules), ExpressInverseAttribute (name, optional SET/BAG InverseAggregationKind + bounds, unresolved raw targetEntity, optional forEntity qualifier, forAttribute), and ExpressUniqueRule (optional label, verbatim referencedAttributes list, covering both bare and SELF\entity.attr-qualified forms). ExpressEntity gained matching derivedAttributes/inverseAttributes/uniqueRules fields, defaulting to empty lists exactly like whereRules when a clause is absent. DERIVE and UNIQUE assertions are cross-checked against the real product_definition, product_definition_formation, next_assembly_usage_occurrence, and person_and_organization entities in ap242-v1-entities.exp; no V1 entity has an INVERSE clause, so that capture is proven against a small, hand-written synthetic fixture instead. A redeclared (SELF\entity.attr) DERIVE or INVERSE name throws a structured SemanticModelException rather than silently dropping the clause or NPE-ing, mirroring mapParameterType’s existing precedent for out-of-scope constructs. Evaluation of `DERIVE/INVERSE/UNIQUE and ExpressKotlinCodeGenerator codegen support remain out of scope — this wave is capture only, exactly like WHERE-rule capture (Wave 2) preceded WHERE-rule evaluation (Wave 3).

  • (M2 Welle 1 — start of the "AI Layer" milestone) A new module, kstep-mcp (dev.kstep.mcp), exposes the six kstep-core V1 entity builders and kstep-step21’s Part 21 export as MCP (Model Context Protocol) tools over stdio, built on the official `io.modelcontextprotocol:kotlin-sdk-server:0.14.0 (MIT-licensed, see License and attribution below). Nine tools: build_product, build_person_and_organization, build_product_definition_formation, build_product_definition, build_next_assembly_usage_occurrence, build_approval, export_part21, list_entities, get_entity. Because every MCP tool call is stateless (JSON in, JSON out) but five of the six V1 builders take entity-typed Kotlin references (ProductDefinitionFormation.ofProduct: Product, etc.), kstep-mcp adds a session-scoped, in-memory EntityStore: each successful build_* call stores its validated entity under a caller-supplied id/handle (the entity’s own natural id where one exists — product, product_definition_formation, product_definition, next_assembly_usage_occurrence — or an arbitrary handle string for the two entities that don’t — person_and_organization, approval), and later calls reference earlier ones by that string. The store is scoped to one Server process for its lifetime (reset only on restart) — the kotlin-sdk’s own transport model (stdio, one process per session; `ChannelTransport supports multiple concurrent sessions against one Server, exercised directly in the test suite) offers no finer-grained session boundary worth adding complexity for at this wave’s scope. A ConcurrentHashMap backs the store, with put’s capacity check and insert made atomic under a single lock (a bare `size()-then-put() sequence would race under concurrent sessions); the store is capped at 512 entities and every string field/list is length/size-bounded, so a malformed or adversarial tool call cannot grow memory unboundedly or crash the server process. Every tool returns one of five structured CallToolResult error shapes instead of an opaque MCP protocol error — malformed_input, unknown_reference (an id/handle that was never built, or was built as the wrong entity type — collected for every bad reference in one call, not just the first), validation_failed (a lossless JSON mirror of kstep-core’s own `DslViolation list — the same structured "compiler as oracle" feedback loop a Kotlin caller already gets), store_capacity_exceeded, and export_failed — and no raw exception message or stack trace ever reaches the caller (every handler catches its own exceptions before the SDK’s own top-level handler, which does interpolate e.message verbatim, gets a chance to). Tested end-to-end (not just handler-level) through the SDK’s own ChannelTransport in-memory client/server transport (io.modelcontextprotocol:kotlin-sdk-testing, @ExperimentalMcpApi) — a real Client drives a real Server with all nine tools registered, including a full multi-tool-call build-and-export sequence whose Part 21 output is parsed back with kstep-step21’s own `Part21Reader and compared for equality, proving the MCP layer round-trips genuinely, not just that individual calls don’t crash. Explicitly out of scope this wave: any actual LLM API call or benchmark (README roadmap item 5’s second half — a separate, larger piece of future work), kstep-cli wiring (no kstep mcp command yet — runStdioServer() is fully wired and ready for a future one-line CLI subcommand to call), and any transport other than stdio.

  • (M2 Welle 3) kstep-cli is no longer a placeholder skeleton: kstep mcp starts the kstep-mcp server over stdio by calling its existing, unmodified runStdioServer(). No arguments, help, or --help print a short usage message and exit 0; an unknown subcommand (or mcp with extra trailing arguments) prints the same usage message and exits 1. The argument-dispatch logic lives in a pure resolveCommand(args: Array<String>): CliCommand function, kept deliberately separate from main()’s side effects (`println/exitProcess/runBlocking) so it’s directly unit-testable — main() itself calls exitProcess on the error path, which would tear down a test JVM if invoked in-process, so it is intentionally never called from a test. Tests live in kstep-tests (CliMainTest), not a kstep-cli-local test source set, matching this project’s one-test-module pattern (see Building below). Verified empirically (not assumed) what happens once runStdioServer() returns: at that point the JVM has exactly one non-daemon thread left (main, parked in runBlocking), so the process exits on its own with code 0 — no explicit exitProcess(0) needed on that path. Also verified: an immediate stdin EOF (e.g. < /dev/null) does not reliably make runStdioServer() return on its own — a kotlin-sdk-server:0.14.0 / StdioServerTransport behavior, not introduced by this wave and out of scope to fix here (would mean changing kstep-mcp’s own transport handling). In practice this matches how MCP hosts actually manage stdio subprocesses: SIGTERM/SIGKILL the child directly rather than relying on it observing a clean stdin close, and a SIGTERM does terminate the process immediately, verified the same way. No new CLI subcommand beyond `mcp, no argument-parsing library — a plain when over args is enough at this scope.

  • (M2 Welle 4) DERIVE-expression evaluation and UNIQUE-constraint enforcement now exist, building on M1 Welle 6’s capture-only clauses. dev.kstep.express.validation gained WhereRuleEvaluator.evaluateToValue (a thin additive entry point returning the raw WhereRuleValue an expression reduces to, without evaluate’s top-level boolean requirement) and a new `DerivedAttributeEvaluator, which re-parses an ExpressDerivedAttribute’s initializer text and evaluates it via the same `WhereRuleExpressionBuilder/WhereRuleEvaluator machinery WHERE rules already use — DERIVE’s initializer and WHERE’s domainRule are the identical expression grammar production, so this is deliberately not a second parser/evaluator. Verified against all three real DERIVE clauses in ap242-v1-entities.exp: product_definition’s and `person_and_organization’s (`get_name_value(SELF), get_description_value(SELF)) and next_assembly_usage_occurrence’s (a two-hop `SELF\entity.attr\entity.attr chain) all correctly throw UnsupportedWhereExpressionException — function calls and multi-hop qualifier chains are outside the supported subset, exactly like WHERE rules using the same constructs, and that is this wave’s expected, complete outcome for those three clauses, not a gap. A small synthetic fixture (DERIVE canonical_id : STRING := SELF.id; and similar) proves positive evaluation actually works when the expression is within the supported subset. INVERSE evaluation remains explicitly out of scope — no real V1 entity has an INVERSE clause, and kstep-core has no bidirectional-relationship modeling to evaluate against.

    Separately, kstep-mcp’s `build_next_assembly_usage_occurrence tool now enforces the real AP242 next_assembly_usage_occurrence UNIQUE UR1 rule — (reference_designator, relating_product_definition) must be unique across every next_assembly_usage_occurrence already in the EntityStore — comparing relating_product_definition by object identity (mirroring EntityStore.keyOf’s existing precedent for "no natural id" entity comparisons). A conflict returns a new structured `unique_constraint_violated tool error (added to McpToolError.kt, following the file’s existing shape/conventions) naming the rule, the conflicting id, and the duplicated field values, instead of silently allowing the duplicate or crashing. The rule constant and the scan logic live in kstep-mcp’s tool file, not in `kstep-core — UNIQUE is fundamentally cross-instance, and kstep-core’s builders are pure, single-instance constructors with no visibility into other instances; the `EntityStore is the only place in this codebase with that visibility. The scan is O(n) over the store’s current entries, bounded by the store’s existing maxEntities cap, so this does not introduce unbounded work. Two rules are deliberately not newly enforced, both documented in code and here rather than faked or approximated: NAUO’s own UNIQUE UR2 (product_definition_occurrence_id, relating_product_definition), because product_definition_occurrence_id is itself a DERIVE value chained through product_definition_occurrence, an entity nowhere modeled among kstep-core’s six V1 types; and `product_definition_formation’s own `UNIQUE UR1 (id, of_product), because the EntityStore already keys every product_definition_formation by that same id, so the composite key can never actually collide without id itself colliding first — a claim proven empirically by a test (KStepMcpServerTest), not just asserted in prose. The UR1 scan and the store write run atomically under EntityStore’s existing `capacityLock (a new putIfNoConflict, alongside the plain put used by every other tool), so two concurrent, conflicting build_next_assembly_usage_occurrence calls can no longer both pass the scan before either lands.

  • (M2 Welle 6) A new module, kstep-script (dev.kstep.script), adds a Kotlin-scripting DSL surface: .kstep.kts scripts author kSTEP models with the same six kstep-core builders (available without explicit imports, via KStepScriptCompilationConfiguration’s `defaultImports) and end with a stepFile(fileName = "…​") { …​ } call whose result — a KStepModel — becomes the script’s return value. KStepModelBuilder.root(…​) accepts either an already-unwrapped entity (the concise getOrThrow() pattern) or a raw ValidationResult — the latter *aggregates every violation across every registered root into KStepModel.violations instead of aborting the script at the first bad entity, the preferred form for LLM/JSON consumption (kSTEP-ADR-0001 acceptance criterion #3). KStepScriptHost.eval (a File or inline String overload) compiles and runs a script and maps every outcome — never a thrown exception or a raw stack trace — into a structured KStepScriptOutcome: Success, CompilationError (KSTEP-S-001, a Kotlin syntax/type error, with source line/column), NoModelProduced (KSTEP-S-002, the last expression wasn’t a KStepModel), ValidationErrors (the aggregated DslViolation list, or — belt-and- braces — a single synthetic KSTEP-S-004 violation when a script uses getOrThrow() directly and it throws), and RuntimeError (KSTEP-S-003, any other script-thrown exception, exception class
    message only). A blank timestamp in stepFile(…​) is defaulted to the current time by the host (not the builder, keeping the DSL surface itself pure/deterministic) once a model is known to be fully valid. kstep-cli gained a new kstep export <script.kstep.kts> [--out <file.step>] [--output json] subcommand: --out defaults to the script’s own name with .kstep.kts replaced by .step; --output json renders the same success/error information as a JSON document instead of human-readable text (both are the command’s own stdout output, so both stay plain println, matching this module’s existing reasoning for USAGE_TEXT). resolveCommand’s argument parsing for `export is a small hand-rolled flag loop, no argument-parsing library, same stance as the mcp/help dispatch above. kstep-script is deliberately not sandboxed — KStepScriptCompilationConfiguration uses dependenciesFromCurrentContext(wholeClasspath = true) with no curated/allowlisted classpath, mirroring kUML’s trusted in-process script path (kUML’s sandboxed path exists only for its hosted-portal "compile someone else’s script" scenario, which kSTEP has no equivalent of yet — see KStepScriptHost’s KDoc for the full reasoning). Verified against the real `kstep-cli distribution, not just the test JVM: ./gradlew :kstep-cli:installDist followed by running the built bin/kstep-cli export binary against both fixtures below reproduces the exact JSON/text/exit-code behavior asserted in the test suite — kotlin-compiler-embeddable and the rest of the scripting toolchain ride along automatically on kstep-cli’s `runtimeClasspath (and so into installDist’s `lib/) via the ordinary implementation project(":kstep-script") dependency, no jlink/native-image wiring needed for this. Two fixture scripts (hello-assembly.kstep.kts, hello-invalid.kstep.kts) live in kstep-tests/src/test/resources — not under kstep-script itself, matching this project’s established single-shared-test-module convention (see Building below) — and double as the README usage examples below.

See Roadmap for what’s planned next.

Modules

Module Purpose Current state

kstep-core

Core DSL types (schema-independent runtime support for the generated Kotlin DSL): dev.kstep.core.ValidationResult/DslViolation, and hand-authored named-parameter builders for the six V1 AP242 entities under dev.kstep.core.ap242

Working: product/personAndOrganization/approval/ productDefinitionFormation/productDefinition/ nextAssemblyUsageOccurrence builder functions, each running WHERE-rule, mandatory-reference (KSTEP-M-001), and — (M2 Welle 7) product.name/next_assembly_usage_occurrence.name — mandatory- primitive-attribute (KSTEP-M-002) validation and returning a ValidationResult. Depends on kstep-express for dev.kstep.express.validation only (no ANTLR types on its own compile classpath).

kstep-express

ANTLR4-generated EXPRESS parser (dev.kstep.express.ExpressParserFactory), semantic model (dev.kstep.express.semantic), Kotlin code generator (dev.kstep.express.codegen.ExpressKotlinCodeGenerator, built on KotlinPoet), real-schema six-V1-entity regeneration (dev.kstep.express.codegen.Ap242V1CodeGen), and WHERE-rule validation (dev.kstep.express.validation)

Working: parses EXPRESS source into an ANTLR parse tree (verified against a real-schema excerpt, not just the AP242-subset fixture); walks it into a semantic AST; generates Kotlin data class`es from that AST, including single-level simple `TYPE-alias resolution; evaluates the supported WHERE-rule expression subset against instance attribute values. (M2 Welle 4) Also evaluates DERIVE initializer expressions within that same supported subset, via DerivedAttributeEvaluator and the new WhereRuleEvaluator.evaluateToValue entry point — reusing the WHERE-rule builder/evaluator, not a second parser. The generateExpressKotlin Gradle task regenerates the six V1 entities from a real-schema extraction as a build artifact (wired into check, not into this or any other module’s own compile classpath — see Building below).

kstep-step21

STEP Part 21 (ISO 10303-21 physical file exchange format) reader/writer for the six V1 AP242 entities (dev.kstep.step21)

Working: Part21Writer.write/Part21Reader.read, a hand-rolled scanner and two-pass graph resolver (independent of the ANTLR EXPRESS grammar — Part 21 is a different sub-format). Verified via a self-roundtrip test suite in kstep-tests; no external CAD/PLM tool (e.g. FreeCAD) validation yet — see Status above. (M2 Welle 6) Now wired into the CLI via kstep-script/kstep export — see the kstep-script/kstep-cli rows below.

kstep-script

Kotlin-scripting DSL surface for *.kstep.kts scripts (dev.kstep.script)

Working (M2 Welle 6): KStepScript/KStepScriptCompilationConfiguration (the @KotlinScript template + defaultImports), stepFile { } / KStepModel / KStepModelBuilder (the DSL entry point a script ends with), and KStepScriptHost.eval (File or inline String), which maps every compile/runtime outcome into a structured KStepScriptOutcome — never a thrown exception. See Status above for the full breakdown. Deliberately unsandboxed (trusted in-process path only, wholeClasspath = true) — see Status above and KStepScriptHost’s KDoc. Depends on `kstep-core and kstep-step21 only (both api, so their types resolve inside scripts).

kstep-cli

Command-line entry point (dev.kstep.cli.MainKt)

Working (M2 Welle 3): kstep mcp starts the MCP server (kstep-mcp’s `runStdioServer()); no-args, help, and --help all print a short usage message and exit 0; an unknown subcommand exits 1. (M2 Welle 6) kstep export <script.kstep.kts> [--out <file.step>] [--output json] compiles and runs a *.kstep.kts script via kstep-script’s `KStepScriptHost and writes the resulting Part 21 file via kstep-step21’s `Part21Writer — see Status above and Usage below. Depends on kstep-mcp and kstep-script.

kstep-mcp

MCP server exposing the six V1 entity builders and Part-21 export as LLM tool-calling tools (dev.kstep.mcp), built on the official io.modelcontextprotocol:kotlin-sdk-server

Working (M2 Welle 1): nine tools — build_product, build_person_and_organization, build_product_definition_formation, build_product_definition, build_next_assembly_usage_occurrence, build_approval, export_part21, list_entities, get_entity — over stdio transport, with a session-scoped in-memory EntityStore (bounded, thread-safe) resolving entity-typed references by caller-supplied id/handle. No HTTP/SSE transport. Depends on kstep-core and kstep-step21 only. (M2 Welle 2) Server lifecycle and every tool call’s outcome are logged via kotlin-logging over an explicit slf4j-simple backend. (M2 Welle 3) kstep-cli now wraps runStdioServer() behind a kstep mcp subcommand — see the kstep-cli row above. (M2 Welle 4) build_next_assembly_usage_occurrence now also enforces NAUO’s real UNIQUE UR1 rule against the EntityStore’s current entries, returning a new `unique_constraint_violated structured error on conflict (tool count stays at nine — no new tool added).

kstep-tests

Cross-module integration tests (Kotest)

Smoke test for the EXPRESS parser; semantic-model, naming-convention, and code-generation tests; WHERE-rule expression-builder, evaluator, and validator tests (unit-level and an end-to-end fixture integration test); kstep-core DSL builder tests; real-schema six-V1-entity codegen tests; (M1 Welle 5) Part 21 writer, reader, and roundtrip tests (Part21WriterTest, Part21ReaderTest, Part21RoundtripTest); (M2 Welle 1) an end-to-end kstep-mcp test suite (KStepMcpServerTest) driving a real MCP Server through the SDK’s own in-memory ChannelTransport; (M2 Welle 2) McpLoggingTest, which captures System.err around one MCP tool call and asserts the configured slf4j-simple backend actually emitted the tool-outcome log line; (M2 Welle 3) CliMainTest, unit-testing kstep-cli’s pure `resolveCommand argument-dispatch function and its USAGE_TEXT (not main() itself, which calls exitProcess on its error path); and (M2 Welle 4) DerivedAttributeEvaluatorTest (DERIVE-expression evaluation, including all three real ap242-v1-entities.exp DERIVE clauses and a synthetic supported-subset positive case) plus new KStepMcpServerTest cases for NAUO’s UNIQUE UR1 enforcement (conflict, non-conflict, self-overwrite, and the product_definition_formation redundancy proof) — all exercised against either the hand-written AP242-subset fixture or the real-schema excerpt; (M2 Welle 6) KStepScriptHostTest (every KStepScriptOutcome case: a valid multi-part-assembly script, the aggregating root(ValidationResult) form, a structural KSTEP-M-001 violation, a getOrThrow() abort, a Kotlin syntax error, an unresolved reference, a wrong-last-expression- type script, a plain runtime exception, and empty/whitespace-only scripts — none of them ever throw out of the host), KStepScriptExportTest (the hello-assembly.kstep.kts/hello-invalid.kstep.kts fixtures driven end-to-end through KStepScriptHost + Part21Writer, with a self-roundtrip via Part21Reader), and new CliMainTest cases for resolveCommand’s `export argument parsing (--out, --output json, every malformed-flag combination, and USAGE_TEXT’s new `kstep export entry)

Building

Requires JDK 21. The Gradle wrapper pins Gradle 9.6.1.

./gradlew clean check

check runs ktlint (now including kstep-mcp, kstep-cli, and kstep-script), kstep-express’s `generateExpressKotlin task (regenerates the six V1 entities from real-schema ground truth — see Status above — and fails the build if its success/skip counts ever drift from the documented boundary), and the Kotest test suite in kstep-tests: the EXPRESS parser smoke test, semantic-model tests, naming-convention tests, code-generation tests (including real-schema six-V1-entity regeneration), WHERE-rule expression-builder/evaluator/ validator tests, kstep-core DSL builder tests, (M1 Welle 5) kstep-step21 Part 21 writer/reader/roundtrip tests, (M2 Welle 1) the end-to-end kstep-mcp MCP server test suite (KStepMcpServerTest), (M2 Welle 3) CliMainTest for kstep-cli’s argument-dispatch logic, and (M2 Welle 6) `KStepScriptHostTest/KStepScriptExportTest for kstep-script’s scripting host, plus `CliMainTest’s new `export argument-parsing cases. kstep-mcp, kstep-cli, and kstep-script themselves compile and are ktlint-checked as part of check; none of them has tests of its own — their tests live in kstep-tests, matching every other module’s pattern (the two *.kstep.kts test fixtures are resources under kstep-tests/src/test/resources, loaded via getResourceAsStream — not under kstep-script itself, for the same reason).

kSTEP logs through kotlin-logging (io.github.oshai:kotlin-logging-jvm), an idiomatic Kotlin wrapper over SLF4J. kstep-mcp’s server lifecycle and tool-call outcomes are the first real usage of it (M2 Welle 2); `kstep-core, kstep-express, and kstep-step21 remain untouched, since their existing structured- exception/ValidationResult error model already covers their diagnostic needs. (M2 Welle 6) kstep-script’s `KStepScriptHost is the second real usage: it logs a warning if host.eval(…​) itself throws before producing a scripting result at all (the one case its own KStepScriptOutcome mapping can’t attribute to the script source) — every expected outcome (compile error, validation failure, runtime exception) is structured data, not a log line. The SLF4J backend is slf4j-simple (MIT-licensed, zero-config, prints to stderr) — kstep-mcp depends on it at runtimeOnly, and kstep-tests carries a matching testImplementation, so ./gradlew clean check now shows real log output instead of SLF4J’s "No SLF4J providers were found …​ Defaulting to no-operation (NOP) logger" warning. Ap242V1CodeGen’s `generateExpressKotlin console report and kstep-cli’s `main() — including its USAGE_TEXT help output (M2 Welle 3) and (M2 Welle 6) kstep export’s own success/error rendering, text or JSON — remain plain `println deliberately: that is Gradle-task/CLI stdout output, not diagnostic logging, and stays outside kotlin-logging’s scope.

generateExpressKotlin can also be run on its own:

./gradlew :kstep-express:generateExpressKotlin

It writes generated Kotlin source under kstep-express/build/generated/expressKotlin/main. That output is a build artifact only, not added to any module’s sourceSet — two of the five entities it successfully generates (Product, ProductDefinition) reference support entity types (ProductContext, ProductDefinitionContext) that themselves cannot be code-generated in V1 (see Status above), so wiring the output into an actual compilation would break it.

Usage (current capability)

Parsing an EXPRESS schema and generating Kotlin `data class`es from it works end-to-end today:

import dev.kstep.express.codegen.ExpressKotlinCodeGenerator
import dev.kstep.express.semantic.ExpressSemanticModelBuilder

val schema = """
    SCHEMA example_schema;
      ENTITY product;
        id   : STRING;
        name : STRING;
      END_ENTITY;
    END_SCHEMA;
""".trimIndent()

val model = ExpressSemanticModelBuilder.build(schema)
val kotlinSource = ExpressKotlinCodeGenerator.generateFileSource(
    model.schemas.single(),
    "dev.kstep.generated.example",
)
println(kotlinSource)
// package dev.kstep.generated.example
//
// public data class Product(
//   public val id: String,
//   public val name: String,
// )

ExpressParserFactory.parse (used internally by ExpressSemanticModelBuilder.build) throws ExpressSyntaxException on the first syntax error instead of returning a partial tree. ExpressSemanticModelBuilder throws SemanticModelException for a named type that resolves to neither a known entity nor a known TYPE in the same schema. ExpressKotlinCodeGenerator throws CodeGenException for EXPRESS constructs it doesn’t yet turn into Kotlin (see Status above). None of this is wired into the CLI yet; the generateExpressKotlin Gradle task (see Building above) is the one place it is wired into a Gradle task today, scoped to the six V1 entities.

Building a kstep-core DSL entity runs WHERE-rule validation and returns a structured result instead of throwing for a validation failure:

import dev.kstep.core.ValidationResult
import dev.kstep.core.ap242.product

val result = product(id = "BRK-001") {
    name = "Bracket"
    description = "Mounting bracket"
}
when (result) {
    is ValidationResult.Valid -> println(result.value)
    is ValidationResult.Invalid -> println(result.violations)
}

// An empty id violates product's WHERE rule (SELF.id <> ''):
val invalid = product(id = "") { name = "Bracket" }
println((invalid as ValidationResult.Invalid).violations)
// [DslViolation(code=KSTEP-W-001, entityName=product, ruleLabel=wr1,
//   expressionText=SELF.id <> '', message=WHERE rule wr1 not satisfied: SELF.id <> '')]

WhereRuleValidator.validate — the lower-level API kstep-core’s builders call internally — re-parses WHERE-rule expression text and evaluates it against a `Map<String, WhereRuleValue>:

import dev.kstep.express.validation.WhereRuleSpec
import dev.kstep.express.validation.WhereRuleValidator
import dev.kstep.express.validation.WhereRuleValue

val violations = WhereRuleValidator.validate(
    entityName = "approval",
    rules = listOf(WhereRuleSpec(label = "wr1", expressionText = "SELF.level <> ''")),
    attributeValues = mapOf("level" to WhereRuleValue.StringValue("")),
)
println(violations)
// [WhereRuleViolation(entityName=approval, ruleLabel=wr1, expressionText=SELF.level <> '', sourceLine=0)]

A WHERE-rule expression using a construct outside the supported subset (EXISTS(), QUERY, arithmetic, …​) throws UnsupportedWhereExpressionException; a genuine evaluation-time problem (a missing attribute, a non-boolean result, an incompatible-type comparison) throws WhereRuleEvaluationException. Neither is ever silently swallowed into an empty violation list.

(M1 Welle 5) Exporting a kstep-core product structure to a STEP Part 21 physical file, then reading it back:

import dev.kstep.core.ap242.product
import dev.kstep.core.ap242.productDefinition
import dev.kstep.core.ap242.productDefinitionFormation
import dev.kstep.core.getOrThrow
import dev.kstep.step21.Part21Header
import dev.kstep.step21.Part21Reader
import dev.kstep.step21.Part21Writer

val bracket = product("BRK-001") { name = "Bracket" }.getOrThrow()
val builtFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") { formation = builtFormation }.getOrThrow()

val header = Part21Header(
    fileName = "bracket.step",
    timestamp = "2026-07-19T12:00:00",
    schemaIdentifiers = listOf("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF"),
)
val exported = Part21Writer.write(header, listOf(definition))
println(exported)
// ISO-10303-21;
// HEADER;
// FILE_DESCRIPTION((),'2;1');
// FILE_NAME('bracket.step','2026-07-19T12:00:00',(),(),'','kSTEP','');
// FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF'));
// ENDSEC;
// DATA;
// #1=PRODUCT('BRK-001','Bracket','');
// #2=PRODUCT_DEFINITION_FORMATION('BRK-001-F','',#1);
// #3=PRODUCT_DEFINITION('BRK-001-D','',#2);
// ENDSEC;
// END-ISO-10303-21;

val result = Part21Reader.read(exported)
println(result.isFullySuccessful) // true

Part21Reader.read throws Part21SyntaxException, Part21EncodingException, Part21DanglingReferenceException, Part21CycleException, or Part21LimitExceededException for structurally malformed input (see Status above). A WHERE-rule failure while reconstructing a parsed instance is never thrown — it surfaces in Part21ReadResult.violations, with any dependent instance recorded in Part21ReadResult.skipped instead of attempted:

// A hand-edited file with an empty product id (violates product's WHERE rule):
val handEdited = """
    ISO-10303-21;
    HEADER;
    FILE_DESCRIPTION((),'2;1');
    FILE_NAME('n','t',(),(),'','','');
    FILE_SCHEMA(('S'));
    ENDSEC;
    DATA;
    #1=PRODUCT('','','');
    #2=PRODUCT_DEFINITION_FORMATION('PDF-001','',#1);
    ENDSEC;
    END-ISO-10303-21;
""".trimIndent()

val badResult = Part21Reader.read(handEdited)
println(badResult.violations) // {1=[DslViolation(code=KSTEP-W-001, ...)]}
println(badResult.skipped)    // {2=[1]}

Part21Writer/Part21Reader are called directly here as a library API; (M2 Welle 6) below, the kstep export CLI subcommand wraps this same pair via the kstep-script scripting DSL instead of hand-written Kotlin.

(M2 Welle 1) Driving the kstep-mcp server end-to-end from an MCP client — the same tool-call sequence an LLM agent would make, here shown via the SDK’s own in-memory ChannelTransport rather than a real stdio subprocess:

import dev.kstep.mcp.buildServer
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation

val server = buildServer() // registers all nine tools on a fresh EntityStore
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
server.createSession(serverTransport)

val client = Client(clientInfo = Implementation(name = "example-client", version = "1.0"))
client.connect(clientTransport)

val built = client.callTool("build_product", mapOf("id" to "BRK-001", "name" to "Bracket"))
println(built.structuredContent) // {"id":"BRK-001","name":"Bracket","description":"","entityType":"product"}

// A validation failure comes back as structured content, not a protocol-level error.
// Both the empty id (WHERE rule) and the never-set name (mandatory attribute, (M2 Welle 7))
// are collected in the same response, not just the first one found:
val invalid = client.callTool("build_product", mapOf("id" to ""))
println(invalid.isError) // true
println(invalid.structuredContent)
// {"errorKind":"validation_failed","violations":[
//   {"code":"KSTEP-W-001","entityName":"product", ...},
//   {"code":"KSTEP-M-002","entityName":"product","message":"required attribute 'name' ..."}
// ]}

To run the server for real, kstep-cli wraps this directly (M2 Welle 3):

kstep mcp

This blocks on stdin/stdout until the session closes, exactly like runStdioServer() above — kstep mcp is a one-line call to it, nothing more. In practice, expect the process to be terminated by its MCP host (SIGTERM/SIGKILL) rather than to exit on its own from a clean stdin close: an immediate stdin EOF does not reliably make runStdioServer() return in the underlying kotlin-sdk-server:0.14.0 transport, a pre-existing SDK behavior out of scope for kstep-cli to work around (see Status above). Once runStdioServer() does return — whichever way the session actually closes — the process exits on its own with code 0; no explicit shutdown call is needed on that path.

(M2 Welle 4) Evaluating a DERIVE initializer expression works exactly like WhereRuleValidator.validate above, via DerivedAttributeEvaluator:

import dev.kstep.express.validation.DerivedAttributeEvaluator
import dev.kstep.express.validation.WhereRuleValue

val value = DerivedAttributeEvaluator.evaluate(
    expressionText = "SELF.id",
    attributeValues = mapOf("id" to WhereRuleValue.StringValue("W-1")),
)
println(value) // StringValue(value=W-1)

// The real AP242 product_definition.name DERIVE (get_name_value(SELF)) is a function
// call, outside the supported subset — this throws, exactly like an equivalent WHERE
// rule would:
// DerivedAttributeEvaluator.evaluate("get_name_value(SELF)", emptyMap())
// -> UnsupportedWhereExpressionException

build_next_assembly_usage_occurrence now also enforces NAUO’s real UNIQUE UR1 rule — a second NAUO sharing an already-used (reference_designator, relating_product_definition) pair is rejected:

// after building NAUO-1 with reference_designator "RD-1" and
// relating_product_definition_id "PD-A":
val conflict = client.callTool(
    "build_next_assembly_usage_occurrence",
    mapOf(
        "id" to "NAUO-2",
        "relating_product_definition_id" to "PD-A",
        "related_product_definition_id" to "PD-OTHER",
        "reference_designator" to "RD-1",
    ),
)
println(conflict.isError) // true
println(conflict.structuredContent)
// {"errorKind":"unique_constraint_violated","entityType":"next_assembly_usage_occurrence",
//  "ruleLabel":"UR1","conflictingId":"NAUO-1","fields":[...]}

By contrast, build_product_definition_formation deliberately has no such UNIQUE check — and needs none. Its real UR1 is (id, of_product), and because the EntityStore already keys every formation by id, two formations can only share a composite key by sharing id, which is a re-build (overwrite), not a second instance. Two formations of the same product under different ids both succeed (a product legitimately has many formations); re-building under the same id overwrites. No unique_constraint_violated is reachable for this entity — verified by test, not just asserted:

// after building product "SHARED-PRODUCT":
val first = client.callTool(
    "build_product_definition_formation",
    mapOf("id" to "PDF-A", "of_product_id" to "SHARED-PRODUCT"),
)
val second = client.callTool(
    "build_product_definition_formation",
    mapOf("id" to "PDF-B", "of_product_id" to "SHARED-PRODUCT"),
)
println(first.isError)  // null — different id, so (id, of_product) cannot collide
println(second.isError) // null — same story, even though of_product is shared

// rebuilding PDF-A under the SAME id overwrites, it does not conflict:
val rebuilt = client.callTool(
    "build_product_definition_formation",
    mapOf("id" to "PDF-A", "of_product_id" to "SHARED-PRODUCT"),
)
println(rebuilt.isError) // null

(M2 Welle 6) Exporting a *.kstep.kts script directly to a STEP Part 21 file via kstep export, no hand-written Kotlin caller needed. A script ends with stepFile(fileName = "…​") { …​ }; the six kstep-core builders and stepFile/root are available without any import (KStepScriptCompilationConfiguration’s `defaultImports):

// bracket.kstep.kts
val bracket = product("BRK-001") { name = "Bracket" }.getOrThrow()
val bracketFormation = productDefinitionFormation("BRK-001-F") { ofProduct = bracket }.getOrThrow()
val definition = productDefinition("BRK-001-D") { formation = bracketFormation }.getOrThrow()

stepFile(fileName = "bracket.step") {
    root(definition)
}
kstep export bracket.kstep.kts
# Exported 1 root(s) to bracket.step

--out overrides the derived output path; --output json renders the same information as a JSON document instead of human-readable text — useful for tool/LLM consumption (kSTEP-ADR-0001 acceptance criterion #3). The two root(…​) forms differ in what happens to a validation failure: root(entity) takes an already-getOrThrow()-unwrapped entity, so an Invalid result aborts the script immediately; passing the raw ValidationResult to root(…​) instead aggregates every violation across every registered root, which is what the second fixture below demonstrates:

// hello-invalid.kstep.kts — an empty product id violates product's WHERE rule
stepFile(fileName = "hello-invalid.step") {
    root(product(id = "") { name = "Nameless" })
}
kstep export --output json hello-invalid.kstep.kts
{"status":"error","errorKind":"validation_failed","violations":[
  {"code":"KSTEP-W-001","entityName":"product","ruleLabel":"wr1",
   "expressionText":"SELF.id <> ''","message":"WHERE rule wr1 not satisfied: SELF.id <> ''"}
]}

A Kotlin syntax error, an unresolved reference, a script whose last expression isn’t a KStepModel, and a plain runtime exception all produce their own structured KSTEP-S-xxx error document the same way (compilation_error, no_model_produced, runtime_error) — see Status above for the full KStepScriptOutcome breakdown — never a raw Kotlin stack trace on stdout/stderr. Both fixture scripts above are the actual kstep-tests test resources (hello-assembly.kstep.kts is the 3-part-assembly variant of the first example), not just README prose — see the kstep-tests Modules row above.

Roadmap (V1 scope)

Per the project’s scope-reduction decision (kSTEP-ADR-0001, analogous to kUML-ADR-0004), V1 deliberately excludes B-rep geometry and PMI, and focuses on a semantic core that a type-safe DSL benefits from most:

  1. EXPRESS parser (done) + EXPRESS-to-Kotlin code generation (done, see above — semantic model and KotlinPoet-based generator, verified end-to-end against the six-entity AP242-subset fixture, and, as of M1 Welle 4, against the real, official AP242 schema and its six V1 entities — see Status and Building above). Supertype/subtype attribute inheritance (needed for next_assembly_usage_occurrence and most real AP242 entities, and for the two support entities product_context/ product_definition_context that product/product_definition reference) is now done too — see the SUBTYPE OF inheritance-flattening entry in Status above (dev.kstep.express.semantic.InheritanceResolver). Still pending: DERIVE/INVERSE/UNIQUE clause codegen (still not started), and SELECT/ENUMERATION-type and transitive-TYPE-alias resolution. DERIVE/UNIQUE evaluation (as opposed to codegen) is now partially done — capture was M1 Welle 6, and M2 Welle 4 added evaluation for the WHERE-rule- supported expression subset (DerivedAttributeEvaluator) plus next_assembly_usage_occurrence’s `UNIQUE UR1 enforcement in kstep-mcp — see Status above. Still open: INVERSE evaluation (no real V1 entity has an INVERSE clause, and kstep-core has no bidirectional-relationship modeling to evaluate against) and NAUO’s UNIQUE UR2 (depends on product_definition_occurrence, an entity not modeled in kstep-core) — both carried forward explicitly as named limitations, not silently dropped.

  2. A semantic AP242 core: product structure and metadata, without B-rep geometry and without PMI (PMI references geometry shape aspects and moves with the geometry milestone). The Kotlin type layer for its six core entities (PRODUCT, PRODUCT_DEFINITION
    revision, PRODUCT_DEFINITION_FORMATION, NEXT_ASSEMBLY_USAGE_OCCURRENCE, APPROVAL, PERSON_AND_ORGANIZATION) now generates correctly from the fixture and — all six, see Status above — from the real AP242 schema, and kstep-core now has a runtime construction/ validation API on top of hand-authored equivalents of those six types (see Status above). (M2 Welle 8 — codegen reconciliation, resolved-this-wave vs. deliberately-deferred:)

    Resolved this wave: every divergence between kstep-core’s hand-authored shape and the real AP242 excerpt is now enumerated, documented in each type’s KDoc (`dev.kstep.core.ap242), and pinned by dev.kstep.tests.Ap242CoreSchemaConsistencyTest (re-derives the real shape live from ap242-v1-entities.exp via InheritanceResolver on every run, fails the build on any new, undocumented drift). The full inventory:

    Entity Divergence Status

    approval

    status is entity-typed (approval_status) in the real schema; String in kstep-core

    accepted, guarded

    approval

    authorized_by does not exist on the real entity at all — a kSTEP-invented convenience linkage

    accepted, guarded; removal deferred (see below)

    approval

    real entity has no WHERE rule; kstep-core’s `wr1 is synthesized

    documented (WHERE rules are out of the guard’s scope, see below)

    person_and_organization

    the_person/the_organization are entity-typed (person/organization) in the real schema; String in kstep-core

    accepted, guarded

    person_and_organization

    real WR1/WR2 use SIZEOF(USEDIN(…​)) (unsupported); `kstep-core’s rule is synthesized

    documented

    product

    real entity has a mandatory frame_of_reference : SET [1:?] OF product_context; kstep-core omits it

    accepted, guarded

    product

    real WR1 uses SIZEOF(USEDIN(…​)) (unsupported); `kstep-core’s rule is synthesized

    documented

    product_definition

    real entity has a mandatory frame_of_reference : product_definition_context; kstep-core omits it

    accepted, guarded

    product_definition

    real WR1 uses SIZEOF(USEDIN(…​)) (unsupported); `kstep-core’s rule is synthesized

    documented

    product, product_definition, product_definition_formation

    real OPTIONAL text description; kstep-core models it as non-null String defaulting to ""

    accepted, guarded (named convention, "OptionalStringAsEmptyDefault")

    product_definition_formation

    real UNIQUE UR1: id, of_product not enforced anywhere in kstep-core

    resolved by construction — id is the EntityStore’s primary key, so `(id, of_product) cannot collide unless id alone does; store id-keying already guarantees UR1, proven by KStepMcpServerTest ("product_definition_formation’s UNIQUE UR1 needs no enforcement: store id-keying already guarantees it"). Contrast NAUO’s UNIQUE UR1 above, whose fields exclude the store key and so needed a real scan.

    next_assembly_usage_occurrence

    real flattened shape inherits an OPTIONAL text description (product_definition_relationship); kstep-core omits it

    accepted, guarded

    next_assembly_usage_occurrence

    real inherited reference_designator (assembly_component_usage) is OPTIONAL identifier; kstep-core narrows it to non-null String defaulting to ""

    accepted, guarded

    next_assembly_usage_occurrence

    real WR1 is acyclic_product_definition_relationship(…​) (unsupported); `kstep-core’s rule is synthesized

    documented

    Deliberately deferred, not attempted this wave: entity-typed modeling of kstep-core’s `status/the_person/the_organization (i.e. actually building ApprovalStatus/Person/Organization/ProductContext/ ProductDefinitionContext/ApplicationContext into the hand-authored layer, and dropping authorizedBy). This needs capabilities the hand-authored layer does not have yet — LIST/aggregation support (person’s `middle_names/prefix_titles/suffix_titles) and EXISTS() WHERE-rule evaluation (person’s `WR1) — and would, without those, only re-derive by hand what ExpressKotlinCodeGenerator already generates faithfully (see Status above). It is therefore a milestone-scoped architecture decision, not a wave: the real fork is whether to rebuild kstep-core’s ergonomic layer on top of the generated `dev.kstep.generated.ap242v1 types (no duplication, but couples the ergonomic API to generated shapes and needs a builder/ validation layer over generated data classes) or to keep hand-authoring and extend the hand layer with LIST + EXISTS support — an explicit decision for the project owner, not pre-committed here. authorizedBy removal, status-as-entity modeling, and the Part-21 APPROVAL real-arity alignment (kstep-step21’s `Part21EntityKind, currently 3 positional args, matching ap242-subset.exp, not the real 2-attribute approval) all travel together in that future wave — removing authorizedBy alone would leave status a bare String, which matches neither the fixture nor the real schema, an incoherent half-step.

    WHERE-rule divergences are documentation-only, not covered by the drift test: every real WHERE rule on these six entities uses SIZEOF/USEDIN/EXISTS/acyclic_…​, all outside WhereRuleValidator’s supported expression subset, so there is no supported real rule to mechanically align a synthesized one to. `UNIQUE/DERIVE/INVERSE clause enforcement is likewise out of the drift test’s scope. The test guards attribute shape only (field set, primitive-vs-entity-vs-aggregation category, optionality) — where silent structural drift would actually hide.

  3. Validation: EXPRESS WHERE rules surfaced as structured errors. Done for the supported expression subset (comparisons, SELF.attribute references, AND/OR/NOT, string/integer/real literals) — see Status above. Constructs outside that subset (EXISTS(), SIZEOF(), other function calls, aggregate/set operations, QUERY, arithmetic operators, the tri-state LOGICAL type) are not evaluated and raise a structured exception instead. Note that WHERE-rule evaluation is not the same thing as EXPRESS mandatory-attribute- presence enforcement (the $-token / non-OPTIONAL mechanism) — (M2 Welle 7) this is now enforced for non-OPTIONAL primitive attributes too, not only entity-typed references: kstep-core’s builders model such an attribute as a nullable presence sentinel (mirroring the existing entity-reference pattern) and emit a structured `KSTEP-M-002 (missing mandatory attribute) when one is left unset, alongside KSTEP-M-001 for references. In the six V1 entities this closes the two real instances — product.name and next_assembly_usage_occurrence.name — both non-OPTIONAL label attributes that previously carried no WHERE rule and so silently accepted an unset value as an empty string. Enforcement is presence, not non-emptiness: an explicitly assigned empty string is a legal value for a non-OPTIONAL STRING that no WHERE rule constrains, and stays Valid. Still deliberately out of scope: non-OPTIONAL attributes on the three hand-authored entities whose Kotlin shape currently diverges from the real schema (approval.status, person_and_organization.the_person/ the_organization are hand-modeled as strings where the real schema has entity-typed references; reconciling those with generated output is the entity-typed-reconciliation item explicitly deferred in the "codegen reconciliation" roadmap entry above), and the general model-driven form (auto-deriving presence checks from ResolvedEntity/ExpressAttribute.isOptional instead of a per- builder null-check) which awaits that reconciliation. No non-STRING (INTEGER/REAL) primitive presence case exists among the six V1 entities, so that reasoning is not yet needed either.

  4. STEP Part 21 export/import. (M1 Welle 5) Done for the kSTEP-own-format half: Part21Writer/Part21Reader in kstep-step21 losslessly roundtrip the six V1 entities through ISO 10303-21 physical file text (Part21Reader.read(Part21Writer.write(header, model)) == model, verified in Part21RoundtripTest) — see Status and Usage above. Still open, and not to be conflated with the above: kSTEP-ADR-0001’s actual acceptance bar is a lossless roundtrip through an external CAD/PLM tool (e.g. FreeCAD), which has not been attempted — no such tool is available in this development environment. A self-roundtrip proves internal consistency (the writer and reader agree with each other); it does not prove kSTEP’s Part 21 output is actually interoperable with real-world STEP tooling, nor that this reader can parse real-world Part 21 files beyond the six V1 entity shapes it targets. Closing that gap is future work, not assumed by this wave. (M2 Welle 6, done) CLI wiring now exists: kstep export <script.kstep.kts> (via the new kstep-script module’s *.kstep.kts scripting DSL and KStepScriptHost) compiles and runs a script and writes its Part21Writer output to disk — see Status and Usage above. The external-CAD/PLM-tool roundtrip gap above is unchanged by this — `kstep export’s output still needs the same manual FreeCAD import to close acceptance criterion #2.

  5. An MCP server and an LLM benchmark comparing raw STEP, CadQuery, and the kSTEP DSL as generation targets. (M2 Welle 1) Split into two halves, only the first of which is done: the MCP server itself (kstep-mcp — nine tools over stdio wrapping the six V1 builders and Part-21 export, a bounded session-scoped EntityStore, structured tool-call errors, tested end-to-end via the SDK’s in-memory transport — see Status and Usage above) is complete and gives an LLM agent the same "compiler/validator as oracle" structured feedback loop a Kotlin caller already gets, just over MCP tool calls instead of a Kotlin compiler error. The LLM benchmark half — actually calling an LLM API and comparing raw STEP, CadQuery, and the kSTEP DSL (via kstep-mcp) as generation targets — is real API cost and experiment-design work, deliberately not started, and deferred to an explicit discussion with the project owner rather than assumed by this wave. (M2 Welle 3, done) kstep-cli now wraps the MCP server behind a kstep mcp subcommand — see Status and Usage above. (M2 Welle 4, done) build_next_assembly_usage_occurrence now enforces NAUO’s real UNIQUE UR1 rule against the `EntityStore’s current entries — see Status and Usage above.

  6. Later: geometry via an OpenCascade (OCCT) bridge, additional STEP Application Protocols as needed.

License and attribution

kSTEP is licensed under the Apache License, Version 2.0 — see LICENSE.

The bundled EXPRESS grammar (kstep-express/src/main/antlr/dev/kstep/express/grammar/Express.g4) is vendored from lutaml/express-grammar and is BSD-2-Clause licensed (Ribose Inc.); see NOTICE for full attribution.

A small excerpt of the real AP242 EXPRESS schema (kstep-express/src/main/resources/dev/kstep/express/codegen/ap242-v1-entities.exp — nineteen declarations, mostly verbatim (two spots deliberately adapted), not the complete 2,122-entity schema) is copied from the official CAx-IF/MBx-IF-published ap242ed2_dis2_mim_lf_v1.101.exp (ISO TS 10303-442 AP242 EXPRESS MIM Long Form, v1.101, 2019); see NOTICE for full provenance, which parts are verbatim vs. adapted, and the precedent this rests on.

kstep-mcp depends on the official Model Context Protocol Kotlin SDK (io.modelcontextprotocol:kotlin-sdk-server, maintained by Anthropic in collaboration with JetBrains), which is MIT licensed — compatible with kSTEP’s Apache 2.0 license, not vendored or modified, pulled in as a normal Gradle dependency.

About

Kotlin DSL for STEP

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages