Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -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"']
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
44 changes: 42 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -61,7 +64,8 @@ fn data_from_js(data: JsValue) -> Result<Value, JsError> {
}

fn value_to_js(value: &Value) -> Result<JsValue, JsError> {
serde_wasm_bindgen::to_value(value)
value
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
.map_err(|e| JsError::new(&format!("serialization failed: {e}")))
}

Expand Down Expand Up @@ -156,6 +160,42 @@ pub fn validate_from_blob(
validate_with_registry(&registry, 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<JsValue, JsError> {
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<JsValue, JsError> {
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<JsValue, JsError> {
extract_filament_core(request)
}

// ============================================================================
// Shared adapters
// ============================================================================
Expand Down
36 changes: 36 additions & 0 deletions tests/extract_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
81 changes: 81 additions & 0 deletions tests/filament_core_parity.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
Loading