diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d07cdcd..9d8d5fd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,7 @@ on: tags: - '*' jobs: - publish: + npm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -18,3 +18,42 @@ jobs: - run: npm ci - run: npm run build - run: npm publish --tag latest + + rubygems: + runs-on: ubuntu-latest + defaults: + run: + working-directory: gem + steps: + # Set up + - uses: actions/checkout@v5 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4.7' + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: ".tool-versions" + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + working-directory: . + - run: npm run build + working-directory: . + - run: npm ci + working-directory: entrypoint + - name: Install gems + run: bundle install + - name: Set version + run: sed -i "s/VERSION = '0.0.0'/VERSION = '${GITHUB_REF_NAME#v}'/" lib/triangulum/version.rb && bundle + - name: Package gems + run: bundle exec rake package + + # Release + - uses: rubygems/configure-rubygems-credentials@v1.0.0 + - name: Publish gem + run: bundle exec rake push:all + - name: Wait for release + run: gem exec rubygems-await pkg/*.gem diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 915e97d..89f83f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: - 'main' pull_request: jobs: - test: + typescript: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -16,3 +16,40 @@ jobs: - run: npm ci - run: npm run build - run: npm run test + + ruby: + runs-on: ubuntu-latest + name: ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.4.7' + defaults: + run: + working-directory: gem + + steps: + - uses: actions/checkout@v5 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: ".tool-versions" + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + working-directory: . + - run: npm run build + working-directory: . + - run: npm ci + working-directory: entrypoint + - name: Install gems + run: bundle install + - name: Run the default task + run: bundle exec rake + - name: Build all gems + run: bundle exec rake package diff --git a/.tool-versions b/.tool-versions index 1b05724..f8d866b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,3 @@ nodejs 24.13.0 +ruby 3.4.7 +bun 1.3.11 diff --git a/entrypoint/.gitignore b/entrypoint/.gitignore new file mode 100644 index 0000000..2720d9d --- /dev/null +++ b/entrypoint/.gitignore @@ -0,0 +1,2 @@ +*.iml +node_modules/ diff --git a/entrypoint/mapper.ts b/entrypoint/mapper.ts new file mode 100644 index 0000000..45a5ee8 --- /dev/null +++ b/entrypoint/mapper.ts @@ -0,0 +1,126 @@ +import {SingleValidationInputData} from "./readSingle"; +import { + DataType, + Flow, + FunctionDefinition, + LiteralValue, + NodeFunction, + NodeParameter, + NodeParameterValue, ParameterDefinition, +} from "@code0-tech/sagittarius-graphql-types"; +import { + DefinitionDataType, + NodeFunction as TucanaNodeFunction, + NodeParameter as TucanaNodeParameter, RuntimeFunctionDefinition, RuntimeParameterDefinition, + ValidationFlow, +} from "@code0-tech/tucana/shared"; +import {toAllowedValue} from "@code0-tech/tucana/helpers"; + +export type TriangulumFlowValidationInput = { + flow?: Flow, + functions?: FunctionDefinition[], + dataTypes?: DataType[] +} + +export function mapToFlowValidation(data: SingleValidationInputData): TriangulumFlowValidationInput { + return { + flow: mapFlow(data.flow!), + functions: data.functions.map(mapFunctionDefinition), + dataTypes: data.dataTypes.map(mapDataType) + } +} + +function gid(type: string, id: bigint | number) { + return `gid://sagittarius/${type}/${Number(id)}`; +} + +function mapFlow(flow: ValidationFlow): Flow { + return { + __typename: "Flow", + id: gid('Flow', flow.flowId) as Flow['id'], + inputType: flow.inputType, + returnType: flow.returnType, + startingNodeId: gid('NodeFunction', flow.startingNodeId) as NodeFunction['id'], + nodes: { + nodes: flow.nodeFunctions.map(mapNodeFunction) + } + }; +} + +function mapNodeFunction(nodeFunction: TucanaNodeFunction): NodeFunction { + return { + id: gid('NodeFunction', nodeFunction.databaseId) as NodeFunction['id'], + functionDefinition: { + identifier: nodeFunction.runtimeFunctionId + }, + nextNodeId: nodeFunction.nextNodeId ? gid('NodeFunction', nodeFunction.nextNodeId) as NodeFunction['id'] : undefined, + parameters: { + nodes: nodeFunction.parameters.map(mapNodeParameter) + } + } +} + +function mapNodeParameter(nodeParameter: TucanaNodeParameter): NodeParameter { + let value: NodeParameterValue = {} + const nodeParameterValue = nodeParameter.value?.value + + if (nodeParameterValue?.oneofKind === 'literalValue') { + value = { + __typename: 'LiteralValue', + value: toAllowedValue(nodeParameterValue.literalValue) + }; + } else if (nodeParameterValue?.oneofKind === 'referenceValue') { + const target = nodeParameterValue.referenceValue.target; + + value = { + __typename: 'ReferenceValue', + referencePath: nodeParameterValue.referenceValue.paths.map(p => ({ + __typename: 'ReferencePath', + path: p.path, + arrayIndex: Number(p.arrayIndex) + })) + } + + if (target.oneofKind === 'nodeId') { + value.nodeFunctionId = gid('NodeFunction', target.nodeId) as NodeFunction['id'] + } else if (target.oneofKind === 'inputType') { + value.nodeFunctionId = gid('NodeFunction', target.inputType.nodeId) as NodeFunction['id'] + value.parameterIndex = Number(target.inputType.parameterIndex) + value.inputIndex = Number(target.inputType.inputIndex) + } + } else if (nodeParameterValue?.oneofKind === 'nodeFunctionId') { + value = { + __typename: 'NodeFunctionIdWrapper', + id: gid('NodeFunction', nodeParameterValue.nodeFunctionId) as NodeFunction['id'] + } + } + + return { + id: gid('NodeParameter', nodeParameter.databaseId) as NodeParameter['id'], + value + } +} + +function mapFunctionDefinition(functionDefinition: RuntimeFunctionDefinition): FunctionDefinition { + return { + identifier: functionDefinition.runtimeName, + signature: functionDefinition.signature, + parameterDefinitions: { + nodes: functionDefinition.runtimeParameterDefinitions.map(mapParameterDefinition) + } + } +} + +function mapParameterDefinition(parameterDefinition: RuntimeParameterDefinition): ParameterDefinition { + return { + identifier: parameterDefinition.runtimeName, + } +} + +function mapDataType(dataType: DefinitionDataType): DataType { + return { + identifier: dataType.identifier, + type: dataType.type, + genericKeys: dataType.genericKeys + }; +} \ No newline at end of file diff --git a/entrypoint/package-lock.json b/entrypoint/package-lock.json new file mode 100644 index 0000000..3ef2b25 --- /dev/null +++ b/entrypoint/package-lock.json @@ -0,0 +1,81 @@ +{ + "name": "@code0-tech/triangulum-entrypoint", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@code0-tech/triangulum-entrypoint", + "version": "0.0.0", + "dependencies": { + "@code0-tech/triangulum": "file:../", + "@code0-tech/tucana": "0.0.62", + "@protobuf-ts/runtime": "^2.11.1" + }, + "devDependencies": { + "bun-types": "^1.3.11" + } + }, + "..": { + "name": "@code0-tech/triangulum", + "version": "0.1.0", + "devDependencies": { + "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2385560645-52d09772ef7058a833cf32edc393ce95668b8404", + "@types/node": "^25.3.2", + "@typescript/vfs": "^1.6.4", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2385560645-52d09772ef7058a833cf32edc393ce95668b8404", + "@typescript/vfs": "^1.6.4", + "typescript": "^5.9.3" + } + }, + "node_modules/@code0-tech/triangulum": { + "resolved": "..", + "link": true + }, + "node_modules/@code0-tech/tucana": { + "version": "0.0.62", + "resolved": "https://registry.npmjs.org/@code0-tech/tucana/-/tucana-0.0.62.tgz", + "integrity": "sha512-OLdGT0FSGxzlaGxVKnnvioaDovNZf1wdwofCoSx8nsT8fe7z24/IQLKSOkEYEF9ds3F8JXim7fiB+k3T2qky8Q==", + "license": "Apache-2.0" + }, + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/bun-types": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz", + "integrity": "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/entrypoint/package.json b/entrypoint/package.json new file mode 100644 index 0000000..3f37dcc --- /dev/null +++ b/entrypoint/package.json @@ -0,0 +1,13 @@ +{ + "name": "@code0-tech/triangulum-entrypoint", + "version": "0.0.0", + "private": true, + "dependencies": { + "@code0-tech/triangulum": "file:../", + "@code0-tech/tucana": "0.0.62", + "@protobuf-ts/runtime": "^2.11.1" + }, + "devDependencies": { + "bun-types": "^1.3.11" + } +} diff --git a/entrypoint/readSingle.ts b/entrypoint/readSingle.ts new file mode 100644 index 0000000..9eeb867 --- /dev/null +++ b/entrypoint/readSingle.ts @@ -0,0 +1,39 @@ +import { + DefinitionDataType, + RuntimeFunctionDefinition, + ValidationFlow +} from "@code0-tech/tucana/shared"; + +export type SingleValidationInputData = { + flow?: ValidationFlow, + functions: RuntimeFunctionDefinition[], + dataTypes: DefinitionDataType[] +}; + +export async function readSingleValidation(input: AsyncIterable) { + const data: SingleValidationInputData = { + functions: [], + dataTypes: [] + }; + + let parsingState = 0; + + for await (const line of input) { + if(line === '') { + parsingState++; + continue; + } + + const message = Uint8Array.fromBase64(line); + if(parsingState === 0) { + data.flow = ValidationFlow.fromBinary(message); + } else if(parsingState === 1) { + data.functions.push(RuntimeFunctionDefinition.fromBinary(message)); + } else if(parsingState === 2) { + data.dataTypes.push(DefinitionDataType.fromBinary(message)); + } + } + + return data; +} + diff --git a/entrypoint/single-validation.ts b/entrypoint/single-validation.ts new file mode 100644 index 0000000..c474b31 --- /dev/null +++ b/entrypoint/single-validation.ts @@ -0,0 +1,10 @@ +import {readSingleValidation} from "./readSingle"; +import {mapToFlowValidation} from "./mapper"; +import {getFlowValidation} from "@code0-tech/triangulum"; + +const data = await readSingleValidation(console); +const validationInput = mapToFlowValidation(data); + +const result = getFlowValidation(validationInput.flow, validationInput.functions, validationInput.dataTypes) + +console.info(JSON.stringify(result)); diff --git a/entrypoint/tsconfig.json b/entrypoint/tsconfig.json new file mode 100644 index 0000000..cd2d3d6 --- /dev/null +++ b/entrypoint/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": true, + "moduleResolution": "bundler", + "module": "esnext", + "target": "esnext", + "types": ["bun-types"] + } +} diff --git a/gem/.gitignore b/gem/.gitignore new file mode 100644 index 0000000..7890f7a --- /dev/null +++ b/gem/.gitignore @@ -0,0 +1,21 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# bun binaries +/exe/*/bun + +# built entrypoints +/lib/triangulum/js/ + +# intellij +/.idea/ +*.iml + +# rspec failure tracking +.rspec_status diff --git a/gem/.rspec b/gem/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/gem/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/gem/.rubocop.yml b/gem/.rubocop.yml new file mode 100644 index 0000000..f1f716b --- /dev/null +++ b/gem/.rubocop.yml @@ -0,0 +1,22 @@ +plugins: + - rubocop-rake + - rubocop-rspec + +AllCops: + TargetRubyVersion: 3.1 + NewCops: enable + +Gemspec/DevelopmentDependencies: + EnforcedStyle: gemspec + +Metrics: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/MultipleExpectations: + Enabled: false + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes diff --git a/gem/Gemfile b/gem/Gemfile new file mode 100644 index 0000000..2e0652b --- /dev/null +++ b/gem/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Specify your gem's dependencies in triangulum.gemspec +gemspec diff --git a/gem/Gemfile.lock b/gem/Gemfile.lock new file mode 100644 index 0000000..c23f0b4 --- /dev/null +++ b/gem/Gemfile.lock @@ -0,0 +1,124 @@ +PATH + remote: . + specs: + triangulum (0.0.0) + base64 (~> 0.3) + json (~> 2.19) + open3 (~> 0.2) + tucana (~> 0.0, >= 0.0.62) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.0.1) + date (3.5.1) + diff-lcs (1.6.2) + erb (6.0.2) + google-protobuf (4.34.1) + bigdecimal + rake (~> 13.3) + googleapis-common-protos-types (1.22.0) + google-protobuf (~> 4.26) + grpc (1.78.1) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.1) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + mcp (0.8.0) + json-schema (>= 4.1) + open3 (0.2.1) + parallel (1.27.0) + parser (3.3.10.2) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.85.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + mcp (~> 0.6) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-rake (0.7.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.6.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + ruby-progressbar (1.13.0) + rubyzip (2.4.1) + stringio (3.2.0) + tsort (0.2.0) + tucana (0.0.62) + grpc (~> 1.64) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + ruby + +DEPENDENCIES + irb (~> 1.17) + rake (~> 13.0) + rspec (~> 3.0) + rubocop (~> 1.21) + rubocop-rake (~> 0.7.0) + rubocop-rspec (~> 3.0) + rubyzip (~> 2.3) + triangulum! + +BUNDLED WITH + 2.6.9 diff --git a/gem/README.md b/gem/README.md new file mode 100644 index 0000000..ac29362 --- /dev/null +++ b/gem/README.md @@ -0,0 +1,77 @@ +# Triangulum Ruby Gem + +Ruby bindings for the [Triangulum](https://github.com/code0-tech/triangulum) validation layer. This gem wraps the TypeScript library and a bundled [Bun](https://bun.sh) runtime to validate flows using the TypeScript compiler. + +## Installation + +Add to your Gemfile: + +```ruby +gem 'triangulum' +``` + +Platform-specific gems are published for: + +- `arm64-darwin` (macOS Apple Silicon) +- `x86_64-darwin` (macOS Intel) +- `x86_64-linux-gnu` (Linux x64) +- `x86_64-linux-musl` (Linux x64 musl) +- `aarch64-linux-gnu` (Linux ARM64) +- `aarch64-linux-musl` (Linux ARM64 musl) + +If Bundler doesn't automatically select the correct platform gem, add your platform: + +```bash +bundle lock --add-platform x86_64-linux-gnu +``` + +## Usage + +```ruby +result = Triangulum::Validation.new(flow, runtime_function_definitions, data_types).validate + +result.valid? # => true / false +result.return_type # => "void" +result.diagnostics # => [Triangulum::Validation::Diagnostic, ...] +``` + +The arguments are [Tucana](https://github.com/code0-tech/tucana) protobuf objects: + +- `flow` — `Tucana::Shared::ValidationFlow` +- `runtime_function_definitions` — `Array` +- `data_types` — `Array` + +### Diagnostics + +Each diagnostic contains: + +| Field | Description | +|---|---| +| `message` | Human-readable error description | +| `code` | TypeScript diagnostic code | +| `severity` | `"error"` or `"warning"` | +| `node_id` | ID of the node that caused the error | +| `parameter_index` | Index of the parameter that caused the error | + +## Development + +Prerequisites: [Bun](https://bun.sh) installed locally for building the entrypoint. + +```bash +cd gem +bundle install +bundle exec rake prepare_build # downloads bun binaries + builds JS entrypoint +bundle exec rake # run tests and rubocop +``` + +### Building platform gems + +```bash +bundle exec rake package +``` + +This will download bun binaries (with SHA256 checksum verification) for all supported platforms and build a `.gem` file for each. + +## License + +See [LICENSE](../LICENSE). diff --git a/gem/Rakefile b/gem/Rakefile new file mode 100644 index 0000000..d232347 --- /dev/null +++ b/gem/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' +require 'rubocop/rake_task' + +RSpec::Core::RakeTask.new(:spec) +RuboCop::RakeTask.new + +task default: %i[prepare_build spec rubocop] +task prepare_build: %i[download build:entrypoints] +task package: %i[clobber prepare_build] diff --git a/gem/bin/console b/gem/bin/console new file mode 100755 index 0000000..1432b05 --- /dev/null +++ b/gem/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'triangulum' + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require 'irb' +IRB.start(__FILE__) diff --git a/gem/lib/triangulum.rb b/gem/lib/triangulum.rb new file mode 100644 index 0000000..a10b050 --- /dev/null +++ b/gem/lib/triangulum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'base64' +require 'json' +require 'open3' + +module Triangulum + class Error < StandardError; end +end + +require_relative 'triangulum/version' +require_relative 'triangulum/validation' diff --git a/gem/lib/triangulum/validation.rb b/gem/lib/triangulum/validation.rb new file mode 100644 index 0000000..c384e21 --- /dev/null +++ b/gem/lib/triangulum/validation.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Triangulum + # == Triangulum::Validation + # This class implements the validation using the typescript package + class Validation + class TriangulumFailed < Triangulum::Error + end + + class BunNotFound < Triangulum::Error + end + + Result = Struct.new(:valid?, :return_type, :diagnostics, keyword_init: true) + Diagnostic = Struct.new(:message, :code, :severity, :node_id, :parameter_index, keyword_init: true) + + ENTRYPOINT = File.expand_path('js/single-validation.js', __dir__) + BUN_EXE = Dir.glob(File.expand_path('../../exe/*/bun', __dir__)).find do |path| + platform = Gem::Platform.new(File.basename(File.dirname(path))) + Gem::Platform.match_gem?(platform, Gem::Platform.local.to_s) + end + + attr_reader :flow, :runtime_function_definitions, :data_types + + def initialize(flow, runtime_function_definitions, data_types) + @flow = flow + @runtime_function_definitions = runtime_function_definitions + @data_types = data_types + end + + def validate + input = serialize_input + + output = run_ts_triangulum(input) + + parse_output(output) + end + + private + + def run_ts_triangulum(input) + raise BunNotFound, "No bundled bun binary found for #{Gem::Platform.local}" if BUN_EXE.nil? + + stdout_s, stderr_s, status = Open3.capture3( + BUN_EXE, 'run', ENTRYPOINT, + stdin_data: input + ) + + raise TriangulumFailed, "OUT:\n#{stdout_s}\n\nERR:\n#{stderr_s}" unless status.success? + + stdout_s + end + + def serialize_input + input = [] + + input << Base64.strict_encode64(flow.to_proto) + input << '' + + runtime_function_definitions.each do |rfd| + input << Base64.strict_encode64(rfd.to_proto) + end + + input << '' + + data_types.each do |dt| + input << Base64.strict_encode64(dt.to_proto) + end + + input << '' + + input.join("\n") + end + + def parse_output(output) + json = JSON.parse(output, symbolize_names: true) + + Result.new( + valid?: json[:isValid], + return_type: json[:returnType], + diagnostics: json[:diagnostics].map do |diagnostic| + Diagnostic.new( + message: diagnostic[:message], + code: diagnostic[:code], + severity: diagnostic[:severity], + node_id: diagnostic[:nodeId], + parameter_index: diagnostic[:parameterIndex] + ) + end + ) + end + end +end diff --git a/gem/lib/triangulum/version.rb b/gem/lib/triangulum/version.rb new file mode 100644 index 0000000..8c585f5 --- /dev/null +++ b/gem/lib/triangulum/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Triangulum + VERSION = '0.0.0' +end diff --git a/gem/rakelib/bun.rb b/gem/rakelib/bun.rb new file mode 100644 index 0000000..7ae7372 --- /dev/null +++ b/gem/rakelib/bun.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +BUN_VERSION = 'v1.3.11' + +# rubocop:disable Layout/LineLength +# rubygems platform name => [bun release zip filename, sha256 checksum] +BUN_PLATFORMS = { + 'arm64-darwin' => %w[bun-darwin-aarch64.zip 6f5a3467ed9caec4795bf78cd476507d9f870c7d57b86c945fcb338126772ffc], + 'x86_64-darwin' => %w[bun-darwin-x64.zip c4fe2b9247218b0295f24e895aaec8fee62e74452679a9026b67eacbd611a286], + 'x86_64-linux-gnu' => %w[bun-linux-x64.zip 8611ba935af886f05a6f38740a15160326c15e5d5d07adef966130b4493607ed], + 'x86_64-linux-musl' => %w[bun-linux-x64-musl.zip b0fce3bc4fab52f26a1e0d8886dc07fd0c0eb2a274cb343b59c83a2d5997b5b1], + 'aarch64-linux-gnu' => %w[bun-linux-aarch64.zip d13944da12a53ecc74bf6a720bd1d04c4555c038dfe422365356a7be47691fdf], + 'aarch64-linux-musl' => %w[bun-linux-aarch64-musl.zip 0f5bf5dc3f276053196274bb84f90a44e2fa40c9432bd6757e3247a8d9476a3d] +}.freeze +# rubocop:enable Layout/LineLength + +def bun_download_url(filename) + "https://github.com/oven-sh/bun/releases/download/bun-#{BUN_VERSION}/#{filename}" +end diff --git a/gem/rakelib/entrypoints.rake b/gem/rakelib/entrypoints.rake new file mode 100644 index 0000000..5a312bb --- /dev/null +++ b/gem/rakelib/entrypoints.rake @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +desc 'Bundle the TypeScript entrypoints into single JS files' +task 'build:entrypoints' do + rm_rf 'lib/triangulum/js' + directory 'lib/triangulum/js' + + entrypoints = %w[single-validation] + + entrypoints.each do |entrypoint| + entrypoint_src = File.expand_path("../../entrypoint/#{entrypoint}.ts", __dir__) + entrypoint_dst = File.expand_path("../lib/triangulum/js/#{entrypoint}.js", __dir__) + + sh 'bun', 'build', entrypoint_src, '--outfile', entrypoint_dst, '--target', 'bun' + end +end diff --git a/gem/rakelib/package.rake b/gem/rakelib/package.rake new file mode 100644 index 0000000..57ee216 --- /dev/null +++ b/gem/rakelib/package.rake @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rubygems/package_task' +require 'open-uri' +require 'digest' +require_relative 'bun' + +desc 'Clean downloaded bun binaries' +task 'clobber:bun' do + BUN_PLATFORMS.each_key do |platform| + bun_path = File.join('exe', platform, 'bun') + rm bun_path if File.exist?(bun_path) + end +end + +task clobber: %i[clobber:bun] + +exepaths = [] + +TRIANGULUM_GEMSPEC = Bundler.load_gemspec('triangulum.gemspec') + +BUN_PLATFORMS.each do |platform, (zip_filename, expected_checksum)| + TRIANGULUM_GEMSPEC.dup.tap do |gemspec| + exedir = File.join('exe', platform) + exepath = File.join(exedir, 'bun') + + exepaths << exepath + + gemspec.platform = platform + gemspec.files += [exepath] + + gem_path = Gem::PackageTask.new(gemspec).define + desc "Build the #{platform} gem" + task "gem:#{platform}" => [gem_path] + + directory exedir + file exepath => [exedir] do + url = bun_download_url(zip_filename) + warn "Downloading #{exepath} from #{url} ..." + + URI.open(url) do |remote| # rubocop:disable Security/Open + zip_data = remote.read + + actual_checksum = Digest::SHA256.hexdigest(zip_data) + unless actual_checksum == expected_checksum + raise "Checksum mismatch for #{zip_filename}: expected #{expected_checksum}, got #{actual_checksum}" + end + + require 'zip' + Zip::File.open_buffer(zip_data) do |zip| + bun_entry = zip.find { |e| File.basename(e.name) == 'bun' } + raise "bun binary not found in #{zip_filename}" unless bun_entry + + File.binwrite(exepath, bun_entry.get_input_stream.read) + end + end + + FileUtils.chmod(0o755, exepath, verbose: true) + end + end +end + +desc 'Download all bun binaries' +task download: exepaths diff --git a/gem/rakelib/push.rake b/gem/rakelib/push.rake new file mode 100644 index 0000000..3aa282b --- /dev/null +++ b/gem/rakelib/push.rake @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +desc 'Push all platform gems to RubyGems' +task 'push:all' do + Dir['pkg/*.gem'].each do |gem_file| + sh 'gem', 'push', gem_file + end +end diff --git a/gem/spec/spec_helper.rb b/gem/spec/spec_helper.rb new file mode 100644 index 0000000..31056e4 --- /dev/null +++ b/gem/spec/spec_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'triangulum' +require 'tucana' + +Tucana.load_protocol(:shared) + +Dir[File.join(__dir__, 'support/**/*.rb')].each { |f| require f } + +RSpec.configure do |config| + config.example_status_persistence_file_path = '.rspec_status' + config.disable_monkey_patching! + config.include ProtobufFactories + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end diff --git a/gem/spec/support/protobuf_factories.rb b/gem/spec/support/protobuf_factories.rb new file mode 100644 index 0000000..808a51b --- /dev/null +++ b/gem/spec/support/protobuf_factories.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module ProtobufFactories + def build_flow(node_functions:, starting_node_id: 1) + Tucana::Shared::ValidationFlow.new( + flow_id: 1, + project_id: 1, + type: 'test', + starting_node_id: starting_node_id, + node_functions: node_functions, + project_slug: 'test' + ) + end + + def literal_value(value) + Tucana::Shared::NodeValue.new( + literal_value: Tucana::Shared::Value.from_ruby(value) + ) + end + + def reference_node(node_id:, paths: []) + Tucana::Shared::NodeValue.new( + reference_value: Tucana::Shared::ReferenceValue.new( + node_id: node_id, + paths: paths + ) + ) + end + + def node_function_value(node_id) + Tucana::Shared::NodeValue.new(node_function_id: node_id) + end + + def node(id:, function_id:, parameters: [], next_node_id: nil) + Tucana::Shared::NodeFunction.new( + database_id: id, + runtime_function_id: function_id, + parameters: parameters, + next_node_id: next_node_id, + definition_source: 'test' + ) + end + + def param(id:, runtime_parameter_id:, value: nil) + Tucana::Shared::NodeParameter.new( + database_id: id, + runtime_parameter_id: runtime_parameter_id, + value: value + ) + end + + def default_data_types + [ + Tucana::Shared::DefinitionDataType.new(identifier: 'LIST', type: 'T[]', generic_keys: ['T']), + Tucana::Shared::DefinitionDataType.new(identifier: 'NUMBER', type: 'number'), + Tucana::Shared::DefinitionDataType.new(identifier: 'STRING', type: 'string'), + Tucana::Shared::DefinitionDataType.new(identifier: 'CONSUMER', type: '(item:R) => void', generic_keys: ['R']), + Tucana::Shared::DefinitionDataType.new(identifier: 'RUNNABLE', type: '() => void') + ] + end + + def default_functions + [ + Tucana::Shared::RuntimeFunctionDefinition.new( + runtime_name: 'std::math::add', + signature: '(a: NUMBER, b: NUMBER): NUMBER' + ), + Tucana::Shared::RuntimeFunctionDefinition.new( + runtime_name: 'std::list::at', + signature: '(list: LIST, index: NUMBER): R' + ), + Tucana::Shared::RuntimeFunctionDefinition.new( + runtime_name: 'std::control::for_each', + signature: '(list: LIST, consumer: CONSUMER): void' + ), + Tucana::Shared::RuntimeFunctionDefinition.new( + runtime_name: 'std::control::return', + signature: '(value: R): R' + ) + ] + end +end diff --git a/gem/spec/triangulum/validation_spec.rb b/gem/spec/triangulum/validation_spec.rb new file mode 100644 index 0000000..18155c8 --- /dev/null +++ b/gem/spec/triangulum/validation_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +RSpec.describe Triangulum::Validation do + let(:data_types) { default_data_types } + let(:functions) { default_functions } + + describe '#validate' do + it 'validates a simple valid flow' do + flow = build_flow( + node_functions: [ + node( + id: 1, + function_id: 'std::math::add', + parameters: [ + param(id: 1, runtime_parameter_id: 'a', value: literal_value(1)), + param(id: 2, runtime_parameter_id: 'b', value: literal_value(0)) + ] + ) + ] + ) + + result = described_class.new(flow, functions, data_types).validate + + expect(result.valid?).to be true + expect(result.diagnostics).to be_empty + end + + it 'detects type errors in parameters' do + flow = build_flow( + node_functions: [ + node( + id: 1, + function_id: 'std::math::add', + parameters: [ + param(id: 1, runtime_parameter_id: 'a', value: literal_value('not a number')), + param(id: 2, runtime_parameter_id: 'b', value: literal_value(10)) + ] + ) + ] + ) + + result = described_class.new(flow, functions, data_types).validate + + expect(result.valid?).to be false + expect(result.diagnostics).not_to be_empty + expect(result.diagnostics.first.message).to include('number') + end + + it 'validates a flow with references between nodes' do + flow = build_flow( + node_functions: [ + node( + id: 1, + function_id: 'std::math::add', + next_node_id: 2, + parameters: [ + param(id: 1, runtime_parameter_id: 'a', value: literal_value(1)), + param(id: 2, runtime_parameter_id: 'b', value: literal_value(2)) + ] + ), + node( + id: 2, + function_id: 'std::math::add', + parameters: [ + param(id: 3, runtime_parameter_id: 'a', value: reference_node(node_id: 1)), + param(id: 4, runtime_parameter_id: 'b', value: literal_value(3)) + ] + ) + ] + ) + + result = described_class.new(flow, functions, data_types).validate + + expect(result.valid?).to be true + expect(result.diagnostics).to be_empty + end + + it 'validates a flow with nested scopes' do + flow = build_flow( + node_functions: [ + node( + id: 1, + function_id: 'std::control::for_each', + parameters: [ + param(id: 1, runtime_parameter_id: 'list', value: literal_value([1, 2, 3])), + param(id: 2, runtime_parameter_id: 'consumer', value: node_function_value(2)) + ] + ), + node( + id: 2, + function_id: 'std::math::add', + parameters: [ + param(id: 3, runtime_parameter_id: 'a', value: literal_value(1)), + param(id: 4, runtime_parameter_id: 'b', value: literal_value(2)) + ] + ) + ] + ) + + result = described_class.new(flow, functions, data_types).validate + + expect(result.valid?).to be true + end + + it 'returns diagnostics with node_id and parameter_index' do + flow = build_flow( + node_functions: [ + node( + id: 1, + function_id: 'std::math::add', + parameters: [ + param(id: 1, runtime_parameter_id: 'a', value: literal_value('string')), + param(id: 2, runtime_parameter_id: 'b', value: literal_value(10)) + ] + ) + ] + ) + + result = described_class.new(flow, functions, data_types).validate + + expect(result.valid?).to be false + diagnostic = result.diagnostics.find { |d| d.parameter_index == 0 } + expect(diagnostic).not_to be_nil + expect(diagnostic.node_id).not_to be_nil + end + + it 'returns the return type' do + flow = build_flow( + node_functions: [ + node( + id: 1, + function_id: 'std::math::add', + parameters: [ + param(id: 1, runtime_parameter_id: 'a', value: literal_value(1)), + param(id: 2, runtime_parameter_id: 'b', value: literal_value(2)) + ] + ) + ] + ) + + result = described_class.new(flow, functions, data_types).validate + + expect(result.return_type).to eq('void') + end + end +end diff --git a/gem/spec/triangulum_spec.rb b/gem/spec/triangulum_spec.rb new file mode 100644 index 0000000..2e358b7 --- /dev/null +++ b/gem/spec/triangulum_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Triangulum do + it 'has a version number' do + expect(Triangulum::VERSION).not_to be_nil + end +end diff --git a/gem/triangulum.gemspec b/gem/triangulum.gemspec new file mode 100644 index 0000000..2db7f51 --- /dev/null +++ b/gem/triangulum.gemspec @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative 'lib/triangulum/version' + +Gem::Specification.new do |spec| + spec.name = 'triangulum' + spec.version = Triangulum::VERSION + spec.authors = ['Niklas van Schrick'] + spec.email = ['mc.taucher2003@gmail.com'] + spec.license = 'MIT' + + spec.summary = 'Triangulum is the CodeZero validation layer' + spec.homepage = 'https://github.com/code0-tech/triangulum' + spec.required_ruby_version = '>= 3.1.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['changelog_uri'] = "#{spec.homepage}/releases" + spec.metadata['rubygems_mfa_required'] = 'true' + + # Specify which files should be added to the gem when it is released. + spec.files = Dir.glob('lib/**/*', base: __dir__).select { |f| File.file?(File.join(__dir__, f)) } + ['README.md'] + spec.require_paths = ['lib'] + + spec.add_dependency 'base64', '~> 0.3' + spec.add_dependency 'json', '~> 2.19' + spec.add_dependency 'open3', '~> 0.2' + spec.add_dependency 'tucana', '~> 0.0', '>= 0.0.62' + + spec.add_development_dependency 'irb', '~> 1.17' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rubyzip', '~> 2.3' + + spec.add_development_dependency 'rspec', '~> 3.0' + + spec.add_development_dependency 'rubocop', '~> 1.21' + spec.add_development_dependency 'rubocop-rake', '~> 0.7.0' + spec.add_development_dependency 'rubocop-rspec', '~> 3.0' +end