From 76e96c70f9c1adb18bfbd628199f6ce9528900d5 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Mon, 29 Jun 2026 14:15:35 -0700 Subject: [PATCH 1/2] Expose Filament extraction from quire-wasm --- .cargo/config.toml | 6 -- Cargo.toml | 5 +- Makefile | 5 + package.json | 3 +- src/lib.rs | 44 +++++++- tests/extract_validate.rs | 36 +++++++ tests/filament_core_parity.mjs | 180 +++++++++++++++++++++++++++++++++ 7 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 tests/filament_core_parity.mjs 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..7b9d6f7 --- /dev/null +++ b/tests/filament_core_parity.mjs @@ -0,0 +1,180 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +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 objectTypes = { + capability: { + name: "capability", + schema: { type: "object", additionalProperties: true }, + allowedLinks: {}, + bodyExtraction: null, + hasPlugin: false, + moduleId: null, + }, + endpoint: { + name: "endpoint", + schema: { + type: "object", + required: ["method", "target"], + properties: { + method: { type: "string" }, + target: { type: "string" }, + }, + }, + allowedLinks: {}, + bodyExtraction: { + yield_pattern: { + match: { + method: { + from: "section_body", + after_heading: "Endpoint", + regex: "^(GET)", + required: true, + }, + target: { + from: "section_body", + after_heading: "Endpoint", + regex: "(/payments)", + required: true, + }, + }, + }, + emit_edges: [{ type: "serves", target: "ix://agent-ix/example/API" }], + }, + hasPlugin: false, + moduleId: null, + }, + plugin: { + name: "plugin", + schema: { type: "object", additionalProperties: true }, + allowedLinks: {}, + bodyExtraction: null, + hasPlugin: true, + moduleId: null, + }, +}; + +function request(markdown, objectTypesForFixture = [objectTypes.capability]) { + return { + projectId: "project", + documentId: "doc-1", + artifactId: "artifact-1", + relPath: "spec/FR-001.md", + markdown, + repoName: "example", + objectTypes: objectTypesForFixture, + }; +} + +const fixtures = [ + { + name: "tier1-frontmatter", + request: request( + "---\nid: FR-001\ntitle: Pay vendors\nobject: capability\npriority: P0\n---\n# Body\n", + ), + }, + { + name: "tier2-dsl", + request: request( + "---\nid: API-001\ntitle: Payments API\nobject: endpoint\n---\n## Endpoint\nGET /payments\n", + [objectTypes.endpoint], + ), + }, + { + name: "unknown-object", + request: request("---\nid: FR-001\nobject: missing\n---\n# Body\n"), + }, + { + name: "relationship-and-body-links", + request: request( + "---\nid: FR-001\nobject: capability\ndepends_on:\n - FR-002\n---\nSee [US-001](ix://agent-ix/example/US-001).\n", + ), + }, + { + name: "duplicate-edge", + request: request( + "---\nid: FR-001\nobject: capability\nrelationships:\n - target: ix://agent-ix/example/FR-002\n type: references\n---\nSee [FR-002](ix://agent-ix/example/FR-002).\n", + ), + }, + { + name: "malformed-ix-uri", + request: request( + "---\nid: FR-001\nobject: capability\nrelationships:\n - target: ix://agent-ix\n type: references\n---\n# Body\n", + ), + }, + { + name: "plugin-error", + request: request("---\nid: PLUG-001\nobject: plugin\n---\n# Body\n", [objectTypes.plugin]), + }, +]; + +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.request); + const pythonResult = spawnSync(python, ["-c", pythonCode], { + input: JSON.stringify(fixture.request), + 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].request; +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)`); From 172c7cf2469696e0ede053af292d3469da9583bc Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Mon, 29 Jun 2026 17:50:10 -0700 Subject: [PATCH 2/2] Use shared Filament graph parity fixtures --- tests/filament_core_parity.mjs | 117 +++------------------------------ 1 file changed, 9 insertions(+), 108 deletions(-) diff --git a/tests/filament_core_parity.mjs b/tests/filament_core_parity.mjs index 7b9d6f7..92e452e 100644 --- a/tests/filament_core_parity.mjs +++ b/tests/filament_core_parity.mjs @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import init, { @@ -11,111 +12,11 @@ import init, { const wasmPath = fileURLToPath(new URL("../pkg/quire_wasm_bg.wasm", import.meta.url)); await init({ module_or_path: readFileSync(wasmPath) }); -const objectTypes = { - capability: { - name: "capability", - schema: { type: "object", additionalProperties: true }, - allowedLinks: {}, - bodyExtraction: null, - hasPlugin: false, - moduleId: null, - }, - endpoint: { - name: "endpoint", - schema: { - type: "object", - required: ["method", "target"], - properties: { - method: { type: "string" }, - target: { type: "string" }, - }, - }, - allowedLinks: {}, - bodyExtraction: { - yield_pattern: { - match: { - method: { - from: "section_body", - after_heading: "Endpoint", - regex: "^(GET)", - required: true, - }, - target: { - from: "section_body", - after_heading: "Endpoint", - regex: "(/payments)", - required: true, - }, - }, - }, - emit_edges: [{ type: "serves", target: "ix://agent-ix/example/API" }], - }, - hasPlugin: false, - moduleId: null, - }, - plugin: { - name: "plugin", - schema: { type: "object", additionalProperties: true }, - allowedLinks: {}, - bodyExtraction: null, - hasPlugin: true, - moduleId: null, - }, -}; - -function request(markdown, objectTypesForFixture = [objectTypes.capability]) { - return { - projectId: "project", - documentId: "doc-1", - artifactId: "artifact-1", - relPath: "spec/FR-001.md", - markdown, - repoName: "example", - objectTypes: objectTypesForFixture, - }; -} - -const fixtures = [ - { - name: "tier1-frontmatter", - request: request( - "---\nid: FR-001\ntitle: Pay vendors\nobject: capability\npriority: P0\n---\n# Body\n", - ), - }, - { - name: "tier2-dsl", - request: request( - "---\nid: API-001\ntitle: Payments API\nobject: endpoint\n---\n## Endpoint\nGET /payments\n", - [objectTypes.endpoint], - ), - }, - { - name: "unknown-object", - request: request("---\nid: FR-001\nobject: missing\n---\n# Body\n"), - }, - { - name: "relationship-and-body-links", - request: request( - "---\nid: FR-001\nobject: capability\ndepends_on:\n - FR-002\n---\nSee [US-001](ix://agent-ix/example/US-001).\n", - ), - }, - { - name: "duplicate-edge", - request: request( - "---\nid: FR-001\nobject: capability\nrelationships:\n - target: ix://agent-ix/example/FR-002\n type: references\n---\nSee [FR-002](ix://agent-ix/example/FR-002).\n", - ), - }, - { - name: "malformed-ix-uri", - request: request( - "---\nid: FR-001\nobject: capability\nrelationships:\n - target: ix://agent-ix\n type: references\n---\n# Body\n", - ), - }, - { - name: "plugin-error", - request: request("---\nid: PLUG-001\nobject: plugin\n---\n# Body\n", [objectTypes.plugin]), - }, -]; +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 = ` @@ -147,9 +48,9 @@ function stable(value) { } for (const fixture of fixtures) { - const wasm = extractFilamentCore(fixture.request); + const wasm = extractFilamentCore(fixture.input); const pythonResult = spawnSync(python, ["-c", pythonCode], { - input: JSON.stringify(fixture.request), + input: JSON.stringify(fixture.input), encoding: "utf8", }); @@ -167,7 +68,7 @@ for (const fixture of fixtures) { } } -const aliasRequest = fixtures[0].request; +const aliasRequest = fixtures[0].input; if (stable(extractCoreData(aliasRequest)) !== stable(extractFilamentCore(aliasRequest))) { throw new Error("extractCoreData alias diverges from extractFilamentCore"); }