diff --git a/.cargo/config.toml b/.cargo/config.toml index 7fec978..2e07606 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,8 +1,2 @@ -# Required so the `getrandom` crate (transitively pulled in by `uuid` -# with the `v7` feature in quire-rs) selects its WASM/JS backend when -# we compile this crate's cdylib for wasm32-unknown-unknown. Without -# the cfg flag, getrandom 0.3+ errors out at compile time on wasm32. -# -# Mirrors https://docs.rs/getrandom/0.3/getrandom/#webassembly-support [target.wasm32-unknown-unknown] rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/Cargo.toml b/Cargo.toml index d3f62cc..99d1bfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib", "rlib"] # (no sibling checkout in CI). The default feature set already excludes # pyo3 (see quire-rs Cargo.toml `default = []`), so the wasm cdylib # compiles without bringing in libpython. -quire-rs = { git = "https://github.com/agent-ix/quire-rs", tag = "v0.11.0", default-features = false, features = ["wasm"] } +quire-rs = { git = "https://github.com/agent-ix/quire-rs", branch = "task-9-10-canonical-filament-extraction", default-features = false, features = ["wasm"] } wasm-bindgen = "0.2" serde-wasm-bindgen = "0.6" @@ -49,3 +49,6 @@ panic-hook = ["dep:console_error_panic_hook"] lto = "thin" codegen-units = 1 opt-level = "s" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/Makefile b/Makefile index 5109bc1..3a05332 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ help: @echo " make fmt-check - Verify formatting (CI gate)" @echo " make lint - Clippy with -D warnings" @echo " make test - wasm-pack test --node (parity tests)" + @echo " make test-filament - compare generated WASM extraction with PyO3 quire" @echo " make build - wasm-pack build --target web --release" @echo " make build-node - wasm-pack build --target nodejs --release" @echo " make clean - cargo clean + rm -rf pkg/" @@ -39,6 +40,10 @@ lint: test: QUIRE_WASM_TEST_MODULE_ROOT=$(QUIRE_WASM_TEST_MODULE_ROOT) $(WASM_PACK) test --node +.PHONY: test-filament +test-filament: + node tests/filament_core_parity.mjs + .PHONY: build build: $(WASM_PACK) build --target web --release diff --git a/package.json b/package.json index 6493411..cb0b14d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ ], "scripts": { "build": "wasm-pack build --target web --release", - "test": "wasm-pack test --node" + "test": "wasm-pack test --node", + "test:filament-parity": "node tests/filament_core_parity.mjs" }, "publishConfig": { "registry": "https://registry.npmjs.org/", diff --git a/src/lib.rs b/src/lib.rs index 53e9baa..e6dbb38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,11 +25,14 @@ use std::collections::BTreeMap; +use serde::Serialize; use serde_json::Value; use wasm_bindgen::prelude::*; use quire_rs::{ - extract as rs_extract, parse_document as rs_parse_document, validate, ExtractionDsl, Registry, + extract as rs_extract, extract_filament_core as rs_extract_filament_core, + parse_document as rs_parse_document, validate, ExtractionDsl, FilamentExtractionInput, + Registry, }; /// Install a panic hook that surfaces Rust panics as console.error in JS. @@ -61,7 +64,8 @@ fn data_from_js(data: JsValue) -> Result { } fn value_to_js(value: &Value) -> Result { - serde_wasm_bindgen::to_value(value) + value + .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) .map_err(|e| JsError::new(&format!("serialization failed: {e}"))) } @@ -156,6 +160,42 @@ pub fn validate_from_blob( validate_with_registry(®istry, archetype, data) } +/// Run canonical Filament core-data extraction for one markdown document. +/// +/// Request shape: +/// ```json +/// { +/// "projectId": "project", +/// "documentId": "doc", +/// "artifactId": "artifact-or-null", +/// "relPath": "spec/FR-001.md", +/// "repoName": "repo", +/// "markdown": "---\n...", +/// "objectTypes": [{ "name": "domain", "schema": { "type": "object" } }] +/// } +/// ``` +#[wasm_bindgen(js_name = extractFilamentCore)] +pub fn extract_filament_core(request: JsValue) -> Result { + let input: FilamentExtractionInput = serde_wasm_bindgen::from_value(request) + .map_err(|e| JsError::new(&format!("invalid core extraction request: {e}")))?; + let result = rs_extract_filament_core(input); + let value = serde_json::to_value(result) + .map_err(|e| JsError::new(&format!("core extraction serialization failed: {e}")))?; + value_to_js(&value) +} + +/// Compatibility alias for hosts that prefer the core-data naming. +#[wasm_bindgen(js_name = extractCoreData)] +pub fn extract_core_data(request: JsValue) -> Result { + extract_filament_core(request) +} + +/// Compatibility alias for hosts migrating from parser-lib wording. +#[wasm_bindgen(js_name = extractCoreDataFromMarkdown)] +pub fn extract_core_data_from_markdown(request: JsValue) -> Result { + extract_filament_core(request) +} + // ============================================================================ // Shared adapters // ============================================================================ diff --git a/tests/extract_validate.rs b/tests/extract_validate.rs index d19920d..0a6c3c8 100644 --- a/tests/extract_validate.rs +++ b/tests/extract_validate.rs @@ -142,3 +142,39 @@ fn parse_document_roundtrip() { "missing sections in parse output" ); } + +#[wasm_bindgen_test] +fn extract_filament_core_returns_core_data_shape() { + use serde::Serialize as _; + let request = json!({ + "projectId": "project", + "documentId": "doc-domain", + "artifactId": "artifact-domain", + "relPath": "spec/Domain.md", + "repoName": "filament-ide", + "markdown": "---\nid: DOM-001\ntitle: Payments\ntype: FR\nobject: domain\ndepends_on:\n - ix://agent-ix/filament-ide/FR-001\n---\n\n# Payments\n", + "objectTypes": [{ + "name": "domain", + "schema": {"type": "object", "additionalProperties": true}, + "allowedLinks": {}, + "bodyExtraction": null, + "hasPlugin": false, + "moduleId": null + }] + }); + let js_request: JsValue = request.serialize(&serializer()).unwrap(); + let out = quire_wasm::extract_filament_core(js_request).expect("extract core ok"); + let value: serde_json::Value = serde_wasm_bindgen::from_value(out).unwrap(); + assert_eq!(value["documentId"], "doc-domain"); + assert_eq!(value["artifactId"], "artifact-domain"); + assert_eq!(value["objectTypes"][0]["name"], "domain"); + assert_eq!(value["nodes"][0]["objectType"], "domain"); + assert_eq!( + value["nodes"][0]["ref"], + "ix://agent-ix/filament-ide/Domain" + ); + assert_eq!( + value["edges"][0]["targetRef"], + "ix://agent-ix/filament-ide/FR-001" + ); +} diff --git a/tests/filament_core_parity.mjs b/tests/filament_core_parity.mjs new file mode 100644 index 0000000..92e452e --- /dev/null +++ b/tests/filament_core_parity.mjs @@ -0,0 +1,81 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import init, { + extractCoreData, + extractCoreDataFromMarkdown, + extractFilamentCore, +} from "../pkg/quire_wasm.js"; + +const wasmPath = fileURLToPath(new URL("../pkg/quire_wasm_bg.wasm", import.meta.url)); +await init({ module_or_path: readFileSync(wasmPath) }); + +const fixturesPath = resolve( + fileURLToPath(new URL("..", import.meta.url)), + "../quire-rs/tests/fixtures/filament_core/graph_cases.json", +); +const fixtures = JSON.parse(readFileSync(fixturesPath, "utf8")); + +const python = process.env.QUIRE_PYTHON ?? "python3"; +const pythonCode = ` +import json +import sys +import quire + +payload = json.load(sys.stdin) +result = quire.extract_core_data(payload) +json.dump(result, sys.stdout, sort_keys=True, separators=(",", ":")) +`; + +function sortJson(value) { + if (Array.isArray(value)) { + return value.map(sortJson); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sortJson(item)]), + ); + } + return value; +} + +function stable(value) { + return JSON.stringify(sortJson(value)); +} + +for (const fixture of fixtures) { + const wasm = extractFilamentCore(fixture.input); + const pythonResult = spawnSync(python, ["-c", pythonCode], { + input: JSON.stringify(fixture.input), + encoding: "utf8", + }); + + if (pythonResult.status !== 0) { + throw new Error( + `${fixture.name}: Python extraction failed\n${pythonResult.stderr}`, + ); + } + + const py = JSON.parse(pythonResult.stdout); + if (stable(wasm) !== stable(py)) { + throw new Error( + `${fixture.name}: WASM/Python output mismatch\nWASM: ${stable(wasm)}\nPython: ${stable(py)}`, + ); + } +} + +const aliasRequest = fixtures[0].input; +if (stable(extractCoreData(aliasRequest)) !== stable(extractFilamentCore(aliasRequest))) { + throw new Error("extractCoreData alias diverges from extractFilamentCore"); +} +if ( + stable(extractCoreDataFromMarkdown(aliasRequest)) !== stable(extractFilamentCore(aliasRequest)) +) { + throw new Error("extractCoreDataFromMarkdown alias diverges from extractFilamentCore"); +} + +console.log(`filament core WASM/Python parity passed (${fixtures.length} fixtures)`);